diff --git a/BLOCKv.podspec b/BLOCKv.podspec index f1969be6..4b6e5947 100644 --- a/BLOCKv.podspec +++ b/BLOCKv.podspec @@ -12,18 +12,19 @@ Pod::Spec.new do |s| s.author = { 'BLOCKv' => 'developer.blockv.io' } s.source = { :git => 'https://github.com/BLOCKvIO/ios-sdk.git', :tag => s.version.to_s } s.social_media_url = 'https://twitter.com/blockv_io' - s.ios.deployment_target = '10.0' - s.swift_version = '4.2' + s.ios.deployment_target = '11.0' + s.swift_version = '5.0' s.default_subspecs = 'Face' s.subspec 'Core' do |s| s.source_files = 'BlockV/Core/**/*.{swift}' - s.dependency 'Alamofire', '~> 4.7' # Networking - s.dependency 'Starscream', '~> 3.0' # Web socket - s.dependency 'JWTDecode', '~> 2.1' # JWT decoding - s.dependency 'Signals', '~> 6.0' # Elegant eventing - s.dependency 'SwiftLint', '~> 0.26' # Linter - s.dependency 'GenericJSON', '~> 1.2' # JSON + s.dependency 'Alamofire', '~> 4.7' # Networking + s.dependency 'Starscream', '~> 3.0.6' # Web socket + s.dependency 'JWTDecode', '~> 2.1' # JWT decoding + s.dependency 'Signals', '~> 6.0' # Elegant eventing + s.dependency 'SwiftLint', '~> 0.26' # Linter + s.dependency 'GenericJSON', '~> 2.0' # JSON + s.dependency 'PromiseKit', '~> 6.8' # Promises #s.exclude_files = '**/Info*.plist' end diff --git a/BlockV/Core/BLOCKv.swift b/BlockV/Core/BLOCKv.swift index 6897b161..e2073313 100644 --- a/BlockV/Core/BLOCKv.swift +++ b/BlockV/Core/BLOCKv.swift @@ -12,10 +12,11 @@ import Foundation import Alamofire import JWTDecode +import Nuke /* Goal: - BLOCKv should be invariant over App ID and Environment. In other words, the properties should + BLOCKv should be invariant over App ID and Environment. In other words, the properties should not change, once set. Possibly targets for each environemnt? */ @@ -27,7 +28,7 @@ public final class BLOCKv { /// The App ID to be passed to the BLOCKv platform. /// /// Must be set once by the host app. - fileprivate static var appID: String? { + internal fileprivate(set) static var appID: String? { // willSet is only called outside of the initialisation context, i.e. // setting the appID after its init will cause a fatal error. willSet { @@ -42,7 +43,7 @@ public final class BLOCKv { /// The BLOCKv platform environment to use. /// /// Must be set by the host app. - fileprivate static var environment: BVEnvironment? { + internal fileprivate(set) static var environment: BVEnvironment? { willSet { if environment != nil { reset() } } @@ -60,31 +61,6 @@ public final class BLOCKv { public static func configure(appID: String) { self.appID = appID - // NOTE: Since `configure` is called only once in the app's lifecycle. We do not - // need to worry about multiple registrations. - NotificationCenter.default.addObserver(BLOCKv.self, - selector: #selector(handleUserAuthorisationRequired), - name: Notification.Name.BVInternal.UserAuthorizationRequried, - object: nil) - } - - // MARK: - Client - - // FIXME: Should this be nil on logout? - // FIXME: This MUST become a singleton (since only a single instance should ever exist). - private static let oauthHandler = OAuth2Handler(appID: BLOCKv.appID!, - baseURLString: BLOCKv.environment!.apiServerURLString, - refreshToken: CredentialStore.refreshToken?.token ?? "") - - /// Computes the configuration object needed to initialise clients and sockets. - fileprivate static var clientConfiguration: Client.Configuration { - // ensure host app has set an app id - let warning = """ - Please call 'BLOCKv.configure(appID:)' with your issued app ID before making network - requests. - """ - precondition(BLOCKv.appID != nil, warning) - // - CONFIGURE ENVIRONMENT // only modify if not set @@ -117,6 +93,44 @@ public final class BLOCKv { } + // NOTE: Since `configure` is called only once in the app's lifecycle. We do not + // need to worry about multiple registrations. + NotificationCenter.default.addObserver(BLOCKv.self, + selector: #selector(handleUserAuthorisationRequired), + name: Notification.Name.BVInternal.UserAuthorizationRequried, + object: nil) + + // configure in-memory cache (store processed images ready for display) + ImageCache.shared.costLimit = ImageCache.defaultCostLimit() + + // configure http cache (store unprocessed image data at the http level) + DataLoader.sharedUrlCache.memoryCapacity = 80 * 1024 * 1024 // 80 MB + DataLoader.sharedUrlCache.diskCapacity = 180 // 180 MB + + // handle session launch + if self.isLoggedIn { + self.onSessionLaunch() + } + + } + + // MARK: - Client + + // FIXME: Should this be nil on logout? + // FIXME: This MUST become a singleton (since only a single instance should ever exist). + private static let oauthHandler = OAuth2Handler(appID: BLOCKv.appID!, + baseURLString: BLOCKv.environment!.apiServerURLString, + refreshToken: CredentialStore.refreshToken?.token ?? "") + + /// Computes the configuration object needed to initialise clients and sockets. + fileprivate static var clientConfiguration: Client.Configuration { + // ensure host app has set an app id + let warning = """ + Please call 'BLOCKv.configure(appID:)' with your issued app ID before making network + requests. + """ + precondition(BLOCKv.appID != nil, warning) + // return the configuration (inexpensive object) return Client.Configuration(baseURLString: BLOCKv.environment!.apiServerURLString, appID: BLOCKv.appID!) @@ -171,7 +185,7 @@ public final class BLOCKv { fileprivate static var _socket: WebSocketManager? //TODO: What if this is accessed before the client is accessed? - //TODO: What if the viewer suscribes to an event before auth (login/reg) has occured? + //TODO: What if the viewer subscribes to an event before auth (login/reg) has occured? public static var socket: WebSocketManager { if _socket == nil { _socket = WebSocketManager(baseURLString: self.environment!.webSocketURLString, @@ -194,8 +208,10 @@ public final class BLOCKv { // disconnect and nil out socekt self._socket?.disconnect() self._socket = nil + // clear data pool + DataPool.clear() - printBV(info: "Reset") + printBV(info: "Reseting SDK") } // - Public Lifecycle @@ -223,10 +239,10 @@ public final class BLOCKv { BLOCKv.client.getAccessToken(completion: completion) } - /// Called when the networking client detects the user is unathorized. + /// Called when the networking client detects the user is unauthenticated. /// /// This method perfroms a clean up operation before notifying the viewer that the SDK requires - /// user authorization. + /// user authentication. /// /// - important: This method may be called multiple times. For example, consider the case where /// multiple requests fail due to the refresh token being invalid. @@ -245,20 +261,50 @@ public final class BLOCKv { } + /// Called when the user authenticates (logs in). + /// + /// - important: + /// This method is *not* called when the access token refreshes. + static internal func onLogin() { + + // stand up the session + self.onSessionLaunch() + + } + /// Holds a closure to call on logout public static var onLogout: (() -> Void)? - /// Sets the BLOCKv platform environment. + /// This function is called everytime a user session is launched. /// - /// By setting the environment you are informing the SDK which BLOCKv - /// platform environment to interact with. + /// A 'session launch' means the user has logged in (received a new refresh token), or the app has been cold + /// launched with an existing *valid* refresh token. /// - /// Typically, you would call `setEnvironment` in `application(_:didFinishLaunchingWithOptions:)`. - @available(*, deprecated, message: "BLOCKv now defaults to production. You may remove this call.") - public static func setEnvironment(_ environment: BVEnvironment) { - self.environment = environment + /// - note: + /// This is slightly broader than 'log in' since it includes the lifecycle of the app. This function is responsible + /// for creating objects which are depenedent on a user session, e.g. data pool. + /// + /// Its compainion `onSessionTerminated` is `onLogout` since there is no app event signalling app termination. + /// + /// Triggered by: + /// - User authentication + /// - App launch & user is authenticated + static private func onSessionLaunch() { + + guard let refreshToken = CredentialStore.refreshToken?.token else { + fatalError("Invlalid session") + } + + guard let claim = try? decode(jwt: refreshToken).claim(name: "user_id"), let userId = claim.string else { + fatalError("Invalid cliam") + } + + // standup the client & socket + _ = client + _ = socket.connect() - //FIXME: *Changing* the environment should nil out the client and access credentials. + // standup data pool + DataPool.sessionInfo = ["userID": userId] } @@ -304,3 +350,29 @@ func printBV(info string: String) { func printBV(error string: String) { print("\nBV SDK >>> Error: \(string)") } + +extension BLOCKv { + + public enum Debug { + + //// Returns the cache size of the face data resource disk caches. + public static var faceDataResourceCacheSize: UInt64? { + return try? FileManager.default.allocatedSizeOfDirectory(at: DataDownloader.recommendedCacheDirectory) + } + + /// Returns the cache size of all data pool region disk caches. + public static var regionCacheSize: UInt64? { + return try? FileManager.default.allocatedSizeOfDirectory(at: Region.recommendedCacheDirectory) + } + + /// Clears all disk caches. + public static func clearCache() { + ImageCache.shared.removeAll() + DataLoader.sharedUrlCache.removeAllCachedResponses() + try? FileManager.default.removeItem(at: DataDownloader.recommendedCacheDirectory) + try? FileManager.default.removeItem(at: Region.recommendedCacheDirectory) + } + + } + +} diff --git a/BlockV/Core/BVEnvironment.swift b/BlockV/Core/BVEnvironment.swift index b91df37b..d3a8c7d8 100644 --- a/BlockV/Core/BVEnvironment.swift +++ b/BlockV/Core/BVEnvironment.swift @@ -34,4 +34,11 @@ public enum BVEnvironment: String { } } + var oauthWebApp: String { + switch self { + case .production: return "https://login.blockv.io" + case .development: return "https:/login.blockv.net" + } + } + } diff --git a/BlockV/Core/BVError.swift b/BlockV/Core/BVError.swift index 0e9afce9..9b92b955 100644 --- a/BlockV/Core/BVError.swift +++ b/BlockV/Core/BVError.swift @@ -23,9 +23,9 @@ import Foundation /// NB: The BLOCKv platform is in the process of unifying error codes. /// BVError is subject to change in future releases. public enum BVError: Error { - + // MARK: Cases - + /// Models a native swift model decoding error. case modelDecoding(reason: String) /// Models a BLOCKv platform error. @@ -34,117 +34,127 @@ public enum BVError: Error { case networking(error: Error) /// Models a Web socket error. case webSocket(error: WebSocketErrorReason) - - //FIXME: REMOVE AT SOME POINT + /// Models a session error. + case session(reason: SessionErrorReason) /// Models a custom error. This should be used in very limited circumstances. /// A more defined error is preferred. case custom(reason: String) - + // MARK: Reasons - + + public enum SessionErrorReason: Equatable { + case invalidAuthorizationCode + case nonMatchingStates + } + /// Platform error. Associated values: `code` and `message`. public enum PlatformErrorReason: Equatable { - + + //TODO: Remove. Temporary until all error responses return a code key-value pair. + case unknownWithMissingCode(Int, String) case unknownAppId(Int, String) + case unhandledAction(Int, String) case internalServerIssue(Int, String) - // - case tokenExpired(Int, String) + case unauthorized(Int, String) + case tokenExists(Int, String) + case rateLimited(Int, String) + case invalidAppKey(Int, String) case invalidPayload(Int, String) case tokenUnavailable(Int, String) case invalidDateFormat(Int, String) - // case malformedRequestBody(Int, String) - case invalidDataValidation(Int, String) - // + case passwordRequired(Int, String) + case invalidToken(Int, String) + case invalidFormData(Int, String) + case usernameNotFound(Int, String) + case unverifiedAccount(Int, String) + case vatomNotOwned(Int, String) + case maxSharesReached(Int, String) + case redemptionError(Int, String) + case recipientLimit(Int, String) case vatomNotFound(Int, String) - // case unknownUserToken(Int, String) + case insufficientPermission(Int, String) case authenticationFailed(Int, String) - case invalidToken(Int, String) case avatarUploadFailed(Int, String) - case userRefreshTokenInvalid(Int, String) - case authenticationLimit(Int, String) - // - case unknownTokenType(Int, String) case unknownTokenId(Int, String) - case tokenNotFound(Int, String) case cannotDeletePrimaryToken(Int, String) - case unableToRetrieveToken(Int, String) case tokenAlreadyConfirmed(Int, String) case invalidVerificationCode(Int, String) + case unknownTokenType(Int, String) case invalidPhoneNumber(Int, String) case invalidEmailAddress(Int, String) - //TODO: Remove. Temporary until all error responses return a code key-value pair. - case unknownWithMissingCode(Int, String) - case unknown(Int, String) //TODO: Remove. All errors should be mapped. - + + case unknown(Int, String) + /// Init using a BLOCKv platform error code and message. init(code: Int, message: String) { switch code { - case -1: self = .unknownWithMissingCode(code, message) - // App Id is unacceptable. - case 2: self = .unknownAppId(code, message) - // Server encountered an error processing the request. - case 11: self = .internalServerIssue(code, message) - // App Id is unacceptable. - case 17: self = .unknownAppId(code, message) - // Request paylaod is invalid. - case 516: self = .invalidPayload(code, message) - // Request paylaod is invalid. - case 517: self = .invalidPayload(code, message) - // User token (phone, email) is already taken. - case 521: self = .tokenUnavailable(code, message) - // Date format is invalid (e.g. invalid birthday in update user call). - case 527: self = .invalidDateFormat(code, message) - // Invalid request payload on an action. + case -1: self = .unknownWithMissingCode(code, message) + case 2: self = .unknownAppId(code, message) + case 13: self = .unhandledAction(code, message) + case 11: self = .internalServerIssue(code, message) + + case 401: self = .unauthorized(code, message) + case 409: self = .tokenExists(code, message) + case 429: self = .rateLimited(code, message) + + case 513: self = .invalidAppKey(code, message) + case 516: self = .invalidPayload(code, message) + case 517: self = .invalidPayload(code, message) + case 521: self = .tokenUnavailable(code, message) + case 527: self = .invalidDateFormat(code, message) + + case 1001: self = .unauthorized(code, message) case 1004: self = .malformedRequestBody(code, message) - // vAtom is unrecognized by the platform. + case 1006: self = .passwordRequired(code, message) + case 1007: self = .invalidPhoneNumber(code, message) + case 1008: self = .invalidToken(code, message) + case 1010: self = .tokenAlreadyConfirmed(code, message) + case 1012: self = .invalidFormData(code, message) + case 1014: self = .usernameNotFound(code, message) + case 1015: self = .unverifiedAccount(code, message) + + case 1604: self = .vatomNotOwned(code, message) + case 1627: self = .maxSharesReached(code, message) + case 1630: self = .redemptionError(code, message) + case 1632: self = .redemptionError(code, message) + case 1654: self = .recipientLimit(code, message) + case 1701: self = .vatomNotFound(code, message) - // User token (phone, email, id) is unrecognized by the platfrom. + case 1702: self = .unknownUserToken(code, message) + case 1703: self = .unknownUserToken(code, message) + case 1705: self = .unknownUserToken(code, message) + case 1708: self = .insufficientPermission(code, message) + case 2030: self = .unknownUserToken(code, message) - // Login phone/email wrong. password case 2032: self = .authenticationFailed(code, message) - // Uploading the avatar data. failed. case 2037: self = .avatarUploadFailed(code, message) - // Refresh token is not on the whitelist, or the token has expired. - case 2049: self = .userRefreshTokenInvalid(code, message) - // Too many login requests. - case 2051: self = .authenticationLimit(code, message) - //??? - case 2552: self = .unableToRetrieveToken(code, message) - // Token id does not map to a token. case 2553: self = .unknownTokenId(code, message) - // Primary token cannot be deleted. case 2562: self = .cannotDeletePrimaryToken(code, message) - // Attempting to verfiy an already verified token. case 2566: self = .tokenAlreadyConfirmed(code, message) - // Invalid verification code used when attempting to verify an account. case 2567: self = .invalidVerificationCode(code, message) - // Unrecognized token type (only `phone` and `email` are currently accepted). case 2569: self = .unknownTokenType(code, message) - // Invalid email address. case 2571: self = .invalidEmailAddress(code, message) - // Invalid phone number. case 2572: self = .invalidPhoneNumber(code, message) + default: - // useful for debugging - //assertionFailure("Unhandled error: \(code) \(message)") self = .unknown(code, message) } } - + } - + /// public enum WebSocketErrorReason: Equatable { case connectionFailed case connectionDisconnected } - + } extension BVError: Equatable { - + public static func == (lhs: BVError, rhs: BVError) -> Bool { switch (lhs, rhs) { case (let .modelDecoding(lhsReason), let .modelDecoding(rhsReason)): @@ -161,34 +171,34 @@ extension BVError: Equatable { return false } } - + /* # Example Usage - + ## Option 1 - + if case let BVError.platform(reason) = error, case .unknownAppId = reason { - print("App Id Error") + print("App Id Error") } - + ## Option 2 if case let BVError.platform(reason) = error { - if case .unknownAppId(_, _) = reason { - print("App Id Error") - } + if case .unknownAppId(_, _) = reason { + print("App Id Error") } - + } + ## Option 3 - + switch error { case .platform(reason: .unknownAppId(_, _)): - print("App Id Error") + print("App Id Error") default: - return + return } */ - + } extension BVError: LocalizedError { @@ -202,6 +212,8 @@ extension BVError: LocalizedError { return reason.localizedDescription case .modelDecoding(let reason): return "Model decoding failed with error: \(reason)" + case .session(let reason): + return "Session error: \(reason)" case .custom(reason: let reason): return reason } @@ -222,40 +234,48 @@ extension BVError.WebSocketErrorReason { extension BVError.PlatformErrorReason { var localizedDescription: String { switch self { - - //TODO: Is there a better way to do this with pattern matching? + case let .unknownWithMissingCode(_, message): return "Unrecogonized: BLOCKv Platform Error: (Missing Code) - Message: \(message)" case let .unknown(code, message): return "Unrecogonized: BLOCKv Platform Error: (\(code)) - Message: \(message)" - - case let .malformedRequestBody(code, message), - let .invalidDataValidation(code, message), - let .vatomNotFound(code, message), - let .avatarUploadFailed(code, message), - let .unableToRetrieveToken(code, message), - let .tokenUnavailable(code, message), - let .authenticationLimit(code, message), - let .tokenAlreadyConfirmed(code, message), - let .invalidVerificationCode(code, message), - let .invalidPhoneNumber(code, message), - let .invalidEmailAddress(code, message), - let .invalidPayload(code, message), - let .invalidDateFormat(code, message), - let .userRefreshTokenInvalid(code, message), - let .tokenNotFound(code, message), - let .cannotDeletePrimaryToken(code, message), + + case let .unknownWithMissingCode(code, message), let .unknownAppId(code, message), + let .unhandledAction(code, message), let .internalServerIssue(code, message), - let .tokenExpired(code, message), + let .unauthorized(code, message), + let .tokenExists(code, message), + let .rateLimited(code, message), + let .invalidAppKey(code, message), + let .invalidPayload(code, message), + let .tokenUnavailable(code, message), + let .invalidDateFormat(code, message), + let .malformedRequestBody(code, message), + let .passwordRequired(code, message), + let .invalidToken(code, message), + let .invalidFormData(code, message), + let .usernameNotFound(code, message), + let .unverifiedAccount(code, message), + let .vatomNotOwned(code, message), + let .maxSharesReached(code, message), + let .redemptionError(code, message), + let .recipientLimit(code, message), + let .vatomNotFound(code, message), let .unknownUserToken(code, message), + let .insufficientPermission(code, message), let .authenticationFailed(code, message), - let .invalidToken(code, message), + let .avatarUploadFailed(code, message), + let .unknownTokenId(code, message), + let .cannotDeletePrimaryToken(code, message), + let .tokenAlreadyConfirmed(code, message), + let .invalidVerificationCode(code, message), let .unknownTokenType(code, message), - let .unknownTokenId(code, message): + let .invalidPhoneNumber(code, message), + let .invalidEmailAddress(code, message): return "BLOCKv Platform Error: (\(code)) Message: \(message)" - + } } - + } diff --git a/BlockV/Core/Data Pool/Client+PromiseKit.swift b/BlockV/Core/Data Pool/Client+PromiseKit.swift new file mode 100644 index 00000000..f9fbfa04 --- /dev/null +++ b/BlockV/Core/Data Pool/Client+PromiseKit.swift @@ -0,0 +1,73 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit + +// PromiseKit Overlays on Client +internal extension Client { + + /// Performs a request on a given endpoint. + /// + /// - Parameter endpoint: Endpoint on which to perform the request. + /// - Returns: A promise that resolves with the response as `Data`. + func request(_ endpoint: Endpoint) -> Promise { + + return Promise { seal in + // convert result type into promise + self.request(endpoint) { result in + switch result { + case .success(let model): + seal.fulfill(model) + case .failure(let error): + seal.reject(error) + } + } + } + + } + + /// Performs a request on a given endpoint. + /// + /// - Parameter endpoint: Endpoint on which to perform the request. + /// - Returns: A promise that resolves with the reponse as JSON. + func requestJSON(_ endpoint: Endpoint) -> Promise { + + return Promise { seal in + self.requestJSON(endpoint) { result in + switch result { + case .success(let model): + seal.fulfill(model) + case .failure(let error): + seal.reject(error) + } + } + } + + } + + func request(_ endpoint: Endpoint) -> Promise { + + return Promise { seal in + // convert result type into promise + self.request(endpoint) { result in + switch result { + case .success(let model): + seal.fulfill(model) + case .failure(let error): + seal.reject(error) + } + } + } + + } + +} diff --git a/BlockV/Core/Data Pool/DataObject.swift b/BlockV/Core/Data Pool/DataObject.swift new file mode 100644 index 00000000..f928e98e --- /dev/null +++ b/BlockV/Core/Data Pool/DataObject.swift @@ -0,0 +1,32 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// Represents a raw data object, potentially without any data, which is monitored by a region. +class DataObject { + + /// Type unique identifier. + var type: String = "" + + /// Identifier. + var id: String = "" + + /// Freeform object. + var data: [String: Any]? + + /// Cached concrete type. + /// + /// Plugins use the `map` function to transform raw `data` into a concrete type. + /// This property is used to cache the transformed type. This avoids the overhead of performing the transformation. + var cached: Any? + +} diff --git a/BlockV/Core/Data Pool/DataObjectAnimator.swift b/BlockV/Core/Data Pool/DataObjectAnimator.swift new file mode 100644 index 00000000..b3a1d14f --- /dev/null +++ b/BlockV/Core/Data Pool/DataObjectAnimator.swift @@ -0,0 +1,176 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// Singleton. Responsible for receiving, storing, and exeucting changes to objects over time. +internal class DataObjectAnimator { + + /// Singleton + static let shared = DataObjectAnimator() + + /// List of regions that want animation updates + fileprivate var regions: [Weak] = [] + + /// List of changes to execute + fileprivate var changes: [PendingUpdate] = [] + + /// Animation timer + fileprivate var timer: Timer? + + /// Constructor is private + fileprivate init() { + + // subscribe to raw socket messages + BLOCKv.socket.onMessageReceivedRaw.subscribe(with: self) { descriptor in + self.onWebSocketMessage(descriptor) + } + + } + + /// Add a region to receive updates. + func add(region: Region) { + self.regions.append(Weak(value: region)) + } + + /// Stop receiving updates for the specified region. + func remove(region: Region) { + self.regions = self.regions.filter { !($0.value == nil || $0.value === region) } + } + + /// Called when there's a new event message via the WebSocket. + @objc private func onWebSocketMessage(_ descriptor: [String: Any]) { + + // we only handle state update messages here. + guard descriptor["msg_type"] as? String == "state_update" else { + return + } + + // only handle brain updates + guard let payload = descriptor["payload"] as? [String: Any], + payload["action_name"] as? String == "brain-update" else { + return + } + + // get list of next positions from the brain + guard + let vatomID = payload["id"] as? String, + let newObject = payload["new_object"] as? [String: Any], + let nextPositions = newObject["next_positions"] as? [[String: Any]] else { + return + } + + // TODO: Ensure we care about this vatom. Check if any of our regions have this vatomID + + // map coordinates to sparse object updates + let updates = nextPositions.map { PendingUpdate( + time: ($0["time"] as? Double ?? 0) / 1000, + update: DataObjectUpdateRecord( + id: vatomID, + changes: [ + "vAtom::vAtomType": [ + "geo_pos": [ + "coordinates": $0["geo_pos"] + ] + ] + ] + ) + ) } + + // ensure we have any updates + guard updates.count > 0 else { + return + } + + // fetch earliest time + var earliestTime = updates[0].time + for update in updates { + if earliestTime > update.time { + earliestTime = update.time + } + } + + // remove all pending changes for this object that are before our earliest time + self.changes = self.changes.filter { !($0.update.id == vatomID && $0.time <= earliestTime) } + + // add each item to the array + let now = Date.timeIntervalSinceReferenceDate + Date.timeIntervalBetween1970AndReferenceDate + for update in updates { + + // stop if time has passed already + if update.time < now { + continue + } + + // add it + self.changes.append(update) + + } + + // sort changes oldest to newest + self.changes.sort { $0.time - $1.time < 0 } + + // start update timer if needed + if self.timer == nil { + self.timer = Timer.scheduledTimer(timeInterval: 0.5, target: self, + selector: #selector(doNextUpdate), userInfo: nil, repeats: true) + } + + } + + /// Called when the timer is executed, to do the next scheduled update. + @objc fileprivate func doNextUpdate() { + + // if there's no more updates, remove the timer and stop + if self.changes.count == 0 { + self.timer?.invalidate() + self.timer = nil + return + } + + // check if the first entry has passed yet + let now = Date.timeIntervalSinceReferenceDate + Date.timeIntervalBetween1970AndReferenceDate + if self.changes[0].time > now { + return + } + + // make list of all changes + var changes: [DataObjectUpdateRecord] = [] + while self.changes.count > 0 && self.changes[0].time <= now { + + // get the next change to execute + let change = self.changes.removeFirst() + + // add to change list + changes.append(change.update) + + } + + // execute the changes on all regions + for region in self.regions { + region.value?.update(objects: changes, source: .brain) + } + + } + +} + +private struct PendingUpdate { + let time: TimeInterval + let update: DataObjectUpdateRecord +} + +private class Weak { + weak var value: T? + init (value: T) { + self.value = value + } +} diff --git a/BlockV/Core/Data Pool/DataObjectUpdateRecord.swift b/BlockV/Core/Data Pool/DataObjectUpdateRecord.swift new file mode 100644 index 00000000..129ca9a7 --- /dev/null +++ b/BlockV/Core/Data Pool/DataObjectUpdateRecord.swift @@ -0,0 +1,23 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// This contains a *sparse* data object, which represents fields which will be changed on a Data Object. +struct DataObjectUpdateRecord { + + /// The data object ID to modify. + var id: String + + /// The sparse object containing the changed fields. + var changes: [String: Any] + +} diff --git a/BlockV/Core/Data Pool/DataPool.swift b/BlockV/Core/Data Pool/DataPool.swift new file mode 100644 index 00000000..2a76e573 --- /dev/null +++ b/BlockV/Core/Data Pool/DataPool.swift @@ -0,0 +1,144 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit +import MapKit + +/* + # Notes: + + 1. Static vs singleton + Static is problematic for testing. It may be better to have a singleton object. + 2. DataPool should have an array of BLOCKvRegion - no need to have Region + 3. Make sure data pool works for changing user sesssion (non-parallel). + 4. Put some thought into creating isolated instances of the SDK - to allow for unit testing. + 5. onObjectAdded(), onObjectUpdated() is fired for every vatom and seems to be causing an inventory reload. + */ + +/// Data Pool plugin (region classes) must be pre-registered. +/// Region instances are created on-demand. +/// Regions are loaded from disk cache. +public final class DataPool { + + /// List of available plugins, i.e. region classes. + internal static let plugins: [Region.Type] = [ + InventoryRegion.self, + VatomChildrenRegion.self, + VatomIDRegion.self, + GeoPosRegion.self + ] + + /// List of active regions. + internal static var regions: [Region] = [] + + /// Session data. Stores the current user ID, or anything like that that the host uses to identify a session. + internal static var sessionInfo: [String: Any] = [:] { + didSet { + // notify regions + for reg in regions { + reg.onSessionInfoChanged(info: sessionInfo) + } + } + } + + /// Fetches or creates a named data region. + /// + /// - Parameters: + /// - id: The region ID. This is the ID of the region plugin. + /// - descriptor: Any data required by the region plugin. + /// - Returns: A Region. + internal static func region(id: String, descriptor: Any) -> Region { + + // find existing region + if let region = regions.first(where: { $0.matches(id: id, descriptor: descriptor) }) { + return region + } + + // not found, create a new region. find region plugin + guard let regionPlugin = plugins.first(where: { $0.id == id }) else { + fatalError("[DataPool] No region plugin matches ID: \(id)") + } + + // create and store region instance. + guard let region = try? regionPlugin.init(descriptor: descriptor) else { + fatalError("[DataPool] Region can't be created in this context") + // TODO: Better error handling? This shouldn't normally happen though. + } + regions.append(region) + + // load region from disk + region.loadFromCache().recover { err -> Void in + + // unable to load from disk + printBV(error: "[DataPool] Unable to load region state from disk. " + err.localizedDescription) + + }.then { _ -> Guarantee in + + // start sync'ing region data with the server + return region.synchronize() + + }.catch { err in + + // unable to load from network either! + printBV(error: "[DataPool] Unable to load region state from network. " + err.localizedDescription) + + } + + // return new region + return region + + } + + /// Removes the specified region. This is called by Region.close(), it must not be called by anything else. + /// + /// - Parameter region: The region to remove + static func removeRegion(region: Region) { + + // remove region + regions = regions.filter { $0 !== region } + + } + + /// Clear out the session info. + static func clear() { + self.sessionInfo = [:] + } + +} + +extension DataPool { + + /* + Convenience functions for fetching/creating regions. Only the abstract `Region` type is returned. + */ + + /// Returns the global inventory region. + public static func inventory() -> Region { + return DataPool.region(id: InventoryRegion.id, descriptor: "") + } + + /// Returns the vatom region for the specified identifier. + public static func vatom(id: String) -> Region { + return DataPool.region(id: VatomIDRegion.id, descriptor: [id]) + } + + /// Returns the children region for the specifed parent identifier. + public static func children(parentID: String) -> Region { + return DataPool.region(id: VatomChildrenRegion.id, descriptor: parentID) + } + + /// Returns the geo pos region for the specifed coordinate region. + public static func geoPos(region: MKCoordinateRegion) -> Region { + return DataPool.region(id: GeoPosRegion.id, descriptor: region) + } + +} diff --git a/BlockV/Core/Data Pool/Dictionary+DeepMerge.swift b/BlockV/Core/Data Pool/Dictionary+DeepMerge.swift new file mode 100644 index 00000000..12181a52 --- /dev/null +++ b/BlockV/Core/Data Pool/Dictionary+DeepMerge.swift @@ -0,0 +1,31 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +// For reference: https://stackoverflow.com/a/45221496 +extension Dictionary { + + /// Merges one dictionary with another. + public func deepMerged(with other: [Key: Value]) -> [Key: Value] { + var result: [Key: Value] = self + for (key, value) in other { + if let value = value as? [Key: Value], + let existing = result[key] as? [Key: Value], + let merged = existing.deepMerged(with: value) as? Value { + result[key] = merged + } else { + result[key] = value + } + } + return result + } +} diff --git a/BlockV/Core/Data Pool/Helpers/Vatom+Containment.swift b/BlockV/Core/Data Pool/Helpers/Vatom+Containment.swift new file mode 100644 index 00000000..7c7f0092 --- /dev/null +++ b/BlockV/Core/Data Pool/Helpers/Vatom+Containment.swift @@ -0,0 +1,174 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit + +// MARK: - Containment Management + +/// Extension to VatomModel add convenience methods for dealing with the children from the perspective of a +/// container vatom. +extension VatomModel { + + /// Fetches the first-level child vatom of this container vatom. + /// + /// Available on both owned and unowned vatoms. + /// + /// - important: + /// This method will inspect the 'inventory' and 'children' regions, if the regions are synchronized the vatoms + /// are returned, if not, the regions are first synchronized. This means the method is potentially slow. + public func listChildren(completion: @escaping (Swift.Result<[VatomModel], BVError>) -> Void) { + + // check if the vatom is in the owner's inventory region (and stabalized) + DataPool.inventory().getStable(id: self.id).map { inventoryVatom -> Guarantee<[VatomModel]> in + + if inventoryVatom == nil { + // inspect the child region (owner & unowned) + return DataPool.children(parentID: self.id) + .getAllStable() + .map { $0 as! [VatomModel] } // swiftlint:disable:this force_cast + + } else { + // filter current children + let children = (DataPool.inventory() + .getAll() + .compactMap { $0 as? VatomModel } + .filter { $0.props.parentID == self.id }) + return Guarantee.value(children) + } + + //FIXME: Very strange double unwrapping - I think it's to do with .map double wrapping? + }.done { body in + body.done({ children in + completion(.success(children)) + }) + } + + } + + /// Fetches the first-level child vatoms for this container vatom. + /// + /// Only available on *owned* container vatoms. Use this function to get a best-effort snapshot of the number of + /// children contained by this vatom. This call is useful where getting the number of children is critical, e.g. + /// face code in a re-use list. + /// + /// - important: + /// This method will inspect the 'inventory' region irrespective of sync state. This means the method is fast. + public func listCachedChildren() -> [VatomModel] { + + // fetch children from inventory region + let children = DataPool.inventory().getAll() + .compactMap { $0 as? VatomModel } + .filter { $0.props.parentID == self.id } + + return children + + } + +} + +// MARK: - Containment + +extension VatomModel { + + /// Returns `true` if the container vatom will accept a request to contain the child vatom, `false` otherwise. + /// + /// Only applicable to *owned* vatoms. + public func canContainChild(_ childVatom: VatomModel) -> Bool { + + // ensure this vatom is a container + if !self.isContainer { return false } + + // folder containers will always accept requests to contain children + if self.rootType == .container(.folder) { return true } + + // defined containers will only accept requests to contain vatoms matching their child policy rules + if self.rootType == .container(.defined) { + return doesChildPolicyAllowContainmentOf(childVatom) + } + + // standard vatoms cannot contain children + // discover and package containers will not accept requests to contain children + return false + + } + + /// Returns `true` if this container vatom's child policy permits containment of the child vatom. `false` otherwise. + /// + /// Only applicable to *owned* vatoms. + private func doesChildPolicyAllowContainmentOf(_ childVatom: VatomModel) -> Bool { + + // check if any policies match this template variation + for policy in self.props.childPolicy where policy.templateVariationID == childVatom.props.templateVariationID { + + // check if there is a maximum number of children + if policy.creationPolicy.enforcePolicyCountMax { + // check if current child count is less then policy max + let children = self.listCachedChildren().filter { + $0.props.templateVariationID == policy.templateVariationID + } + if policy.creationPolicy.policyCountMax > children.count { + return true + } + } else { + return true + } + } + + return false + + } + + /// Returns `true` if the vatom's root type is a 'Container' type. + /// + /// Container vatoms have the ability to have parent-child relationships. + public var isContainer: Bool { + if case RootType.container = self.rootType { + return true + } + return false + } + + /// Enum modeling the root type of this vatom. + public var rootType: RootType { + + if self.props.rootType == "vAtom::vAtomType" { + return .standard + } else { + if self.props.rootType.hasSuffix("::FolderContainerType") { + return .container(.folder) + } else if self.props.rootType.hasSuffix("::PackageContainerType") { + return .container(.package) + } else if self.props.rootType.hasSuffix("::DiscoverContainerType") { + return .container(.discover) + } else if self.props.rootType.hasSuffix("::DefinedFolderContainerType") { + return .container(.defined) + } else { + return .unknown + } + } + + } + + public enum RootType: Equatable { + case standard + case container(ContainerType) + case unknown + } + + public enum ContainerType: Equatable { + case folder + case package + case discover + case defined + } + +} diff --git a/BlockV/Core/Data Pool/Helpers/Vatom+PreemptiveActions.swift b/BlockV/Core/Data Pool/Helpers/Vatom+PreemptiveActions.swift new file mode 100644 index 00000000..479fc5b3 --- /dev/null +++ b/BlockV/Core/Data Pool/Helpers/Vatom+PreemptiveActions.swift @@ -0,0 +1,205 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// Extends VatomModel with common vatom actions available on owned vatoms. +/// +/// All actions are *preemptive* where possible. That is, data pool is updated locally before the network request is +/// made performing the action on the server. +extension VatomModel { + + // MARK: - Common Actions + + /// Performs the **Transfer** action on the current vatom and preeempts the action result. + /// + /// - Parameters: + /// - token: User token to which the vatom should be transferred. + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + public func transfer(toToken token: UserToken, + completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + let body = [ + "this.id": self.id, + "new.owner.\(token.type.rawValue)": token.value + ] + + // prempt reactor outcome + // remove vatom from inventory region + let undo = DataPool.inventory().preemptiveRemove(id: self.id) + + // perform the action + self.performAction("Transfer", payload: body, undos: [undo], completion: completion) + + } + + /// Perform the **Redeem** action on the current vatom and preeempts the action result. + /// + /// - Parameters: + /// - token: User token to which the vatom should be redeemed. + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + public func redeem(toToken token: UserToken, + completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + let body = [ + "this.id": self.id, + "new.owner.\(token.type.rawValue)": token.value + ] + + // prempt reactor outcome + // remove vatom from inventory region + let undo = DataPool.inventory().preemptiveRemove(id: self.id) + + // perform the action + self.performAction("Redeem", payload: body, undos: [undo], completion: completion) + + } + + /// Performs the **Activate** action on the current vatom and preeempts the action result. + /// + /// - Parameters: + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + public func activate(completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + let body = ["this.id": self.id] + + // prempt reactor outcome + // remove vatom from inventory region + let undo = DataPool.inventory().preemptiveRemove(id: self.id) + + // perform the action + self.performAction("Activate", payload: body, undos: [undo], completion: completion) + + } + + /// Performs the **Clone** action on the current vatom and preeempts the action result. + /// + /// - Parameters: + /// - token: User token to which the vatom should be cloned. + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + public func clone(toToken token: UserToken, + completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + let body = [ + "this.id": self.id, + "new.owner.\(token.type.rawValue)": token.value + ] + + // prempt reactor outcome + + /* + 1. `num_direct_clones` is increased by 1. + 2. `cloning_score` is dependent on the clone gain - which is not know at this point. + */ + let undo = DataPool.inventory().preemptiveChange(id: self.id, + keyPath: "vAtom::vAtomType.num_direct_clones", + value: self.props.numberDirectClones + 1) + + // perform the action + self.performAction("Clone", payload: body, undos: [undo], completion: completion) + + } + + /// Performs the **Drop** action on the current vatom and preeempts the action result. + /// + /// - Parameters: + /// - longitude: The longitude component of the coordinate. + /// - latitude: The latitude component of the coordinate. + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + public func dropAt(longitude: Double, + latitude: Double, + completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + let body: [String: Any] = [ + "this.id": self.id, + "geo.pos": [ + "Lat": latitude, + "Lon": longitude + ] + ] + + // preempt the reactor outcome + let undoCoords = DataPool.inventory().preemptiveChange(id: self.id, + keyPath: "vAtom::vAtomType.geo_pos.coordinates", + value: [longitude, latitude]) + + let undoDropped = DataPool.inventory().preemptiveChange(id: self.id, + keyPath: "vAtom::vAtomType.dropped", + value: true) + + // perform the action + self.performAction("Drop", payload: body, undos: [undoCoords, undoDropped], completion: completion) + + } + + /// Performs the **Pickup** action on the current vatom and preeempts the action result. + /// + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + public func pickUp(completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + let body = ["this.id": self.id] + + // preempt the reactor outcome + let undo = DataPool.inventory().preemptiveChange(id: self.id, + keyPath: "vAtom::vAtomType.dropped", + value: false) + + // perform the action + self.performAction("Pickup", payload: body, undos: [undo], completion: completion) + + } + + private typealias Undo = () -> Void + + /// Performs the action and rolls back unsing the undo functions if an error occurs. + /// + /// - Parameters: + /// - name: Name of the action, e.g. "Transfer". + /// - payload: Action payload + /// - undos: Array of undo closures. If the action fails, each undo closure will be executed. + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + private func performAction(_ name: String, + payload: [String: Any], + undos: [Undo] = [], + completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + /// passed in vatom id must match `this.id` + guard let vatomId = payload["this.id"] as? String, self.id == vatomId else { + let error = BVError.custom(reason: "Invalid payload. Value `this.id` must be match the current vAtom.") + completion(.failure(error)) + return + } + + // perform the action + BLOCKv.performAction(name: name, payload: payload) { result in + + switch result { + case .success(let payload): + completion(.success(payload)) + + case .failure(let error): + // run undo closures + undos.forEach { $0() } + completion(.failure(error)) + } + + } + + } + +} diff --git a/BlockV/Core/Data Pool/Regions/BLOCKvRegion.swift b/BlockV/Core/Data Pool/Regions/BLOCKvRegion.swift new file mode 100644 index 00000000..30f5a674 --- /dev/null +++ b/BlockV/Core/Data Pool/Regions/BLOCKvRegion.swift @@ -0,0 +1,395 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// Abstract subclass of `Region`. This intermediate class handles updates from the BLOCKv Web socket. Regions should +/// subclass to automatically handle Web socket updates. +/// +/// The BLOCKv subclass is hardcoded to treat the 'objects' as vatoms. That is, the freeform object must be a vatom. +/// This is a product of the design, but it could be generalised in the future if say faces were to become a monitored +/// region. +/// +/// Roles: +/// - Handles some Web socket events (including queuing, pausing, and processing). +/// - Only 'state_update' events are intercepted. +/// > State-update messages are transformed into DataObjectUpdateRecord (Sparse Object) and use to update the region. +/// - Data transformations (map) to VatomModel +/// - Notifications +/// > Add, update, remove notification are broadcast for the changed vatom. An update is always emitted for the parent +/// of the changed vatom. This is useful since the parent can then update its state, e.g. on child removal. +/// - Parsing unpackaged vatom payload into data-objects. +class BLOCKvRegion: Region { + + /// Constructor + required init(descriptor: Any) throws { + try super.init(descriptor: descriptor) + + // subscribe to socket connections + BLOCKv.socket.onConnected.subscribe(with: self) { _ in + self.onWebSocketConnect() + } + + // subscribe to raw socket messages + BLOCKv.socket.onMessageReceivedRaw.subscribe(with: self) { descriptor in + self.onWebSocketMessage(descriptor) + } + + // monitor for timed updates + DataObjectAnimator.shared.add(region: self) + + } + + deinit { + + // stop listening for animation updates + DataObjectAnimator.shared.remove(region: self) + + } + + /// Queue of pending messages. + private var queuedMessages: [[String: Any]] = [] + /// Boolean value that is `true` if message processing is paused. + private var socketPaused = false + /// Boolean valie that is `true` if a message is currently being processed. + private var socketProcessing = false + + /// Called when this region is going to be shut down. + override func close() { + super.close() + + // remove listeners + DataObjectAnimator.shared.remove(region: self) + + } + + /// Called to pause processing of socket messages. + func pauseMessages() { + self.socketPaused = true + } + + /// Called to resume processing of socket messages. + func resumeMessages() { + + // unpause + self.socketPaused = false + + // process next message if needed + if !self.socketProcessing { + self.processNextMessage() + } + + } + + /// Called when the Web socket re-connects. + @objc func onWebSocketConnect() { + + // mark as unstable + self.synchronized = false + + // re-sync the entire thing. Don't worry about synchronize() getting called while it's running already, + // it handles that case. + self.synchronize() + + } + + /// Called when there's a new event message via the Web socket. + @objc func onWebSocketMessage(_ descriptor: [String: Any]) { + + // add to queue + self.queuedMessages.append(descriptor) + + // process it if necessary + if !self.socketPaused && !self.socketProcessing { + self.processNextMessage() + } + + } + + /// Called to process the next WebSocket message. + func processNextMessage() { + + // stop if socket is paused + if socketPaused { + return + } + + // stop if already processing + if socketProcessing { return } + socketProcessing = true + + // get next msg to process + if queuedMessages.count == 0 { + + // no more messages + self.socketProcessing = false + return + + } + + // process message + let msg = queuedMessages.removeFirst() + self.processMessage(msg) + + // done, process next message + self.socketProcessing = false + self.processNextMessage() + + } + + /// Processes a raw Web socket message. + /// + /// Only 'state_update' events intercepted and used to perform parital updates on the region's objects. + /// Message processing is not paused for 'state_update' events. + func processMessage(_ msg: [String: Any]) { + + // get info + guard let msgType = msg["msg_type"] as? String else { return } + guard let payload = msg["payload"] as? [String: Any] else { return } + guard let newData = payload["new_object"] as? [String: Any] else { return } + guard let vatomID = payload["id"] as? String else { return } + if msgType != "state_update" { + return + } + + // update existing objects + let changes = DataObjectUpdateRecord(id: vatomID, changes: newData) + self.update(objects: [changes]) + + } + + // MARK: - Transformations + + /// Map data objects to Vatom objects. + /// + /// This is the primary transformation function which converts freeform data pool objects into concrete types. + override func map(_ object: DataObject) -> Any? { + + //FIXME: This method is synchronous which may affect performance. + + /* + How to transfrom data objects into types? + + Data > Decoder > Type (decode from external representation) + Type > Encoder > Data (encode for extrernal representation) + + Facts: + - Data pool store heterogeneous object of type [String: Any] - it is type independent. + - `map(:DataObject)` needs to transformt this into a concrete type. + - The codable machinary is good for data <> native type transformations. + + + Options: + 1. Convert [String: Any] into Data, then Data into Type (very inefficient). + 2. Write an init(descriptor: [String: Any])` - this allows VatomModel to be initialized with a dictionary. + > This sucks because a) it's a lot of work, b) does not leverage the CodingKeys of Codable conformance. + 3. Write a Decoder with transforms [String: Any] into Type AND leverages the CodingKeys + */ + + // only handle vatoms + guard object.type == "vatom" else { + return nil + } + + // stop if no data available + guard var objectData = object.data else { + return nil + } + + // get vatom info + guard let template = object.data![keyPath: "vAtom::vAtomType.template"] as? String else { return nil } + + // fetch all faces linked to this vatom + let faces = objects.values.filter { $0.type == "face" && $0.data?["template"] as? String == template } + objectData["faces"] = faces.map { $0.data } + + // fetch all actions linked to this vatom + let actionNamePrefix = template + "::Action::" + let actions = objects.values.filter { $0.type == "action" && ($0.data?["name"] as? String)? + .starts(with: actionNamePrefix) == true } + objectData["actions"] = actions.map { $0.data } + + // create vatoms, face, anf actions using member-wise initilializer + do { + let faces = faces.compactMap { $0.data }.compactMap { try? FaceModel(from: $0) } + let actions = actions.compactMap { $0.data }.compactMap { try? ActionModel(from: $0) } + var vatom = try VatomModel(from: objectData) + vatom.faceModels = faces + vatom.actionModels = actions + return vatom + } catch { + printBV(error: error.localizedDescription) + return nil + } + + } + + /// Parses the unpackaged vatom payload from the server and returns an array of `DataObject`. + /// + /// Returns `nil` if the payload cannot be parsed. + func parseDataObject(from payload: [String: Any]) -> [DataObject]? { + + // create list of items + var items: [DataObject] = [] + + // ensure + guard + let vatoms = payload["vatoms"] as? [[String: Any]] ?? payload["results"] as? [[String: Any]], + let faces = payload["faces"] as? [[String: Any]], + let actions = payload["actions"] as? [[String: Any]] + else { return nil } + + // add faces to the list + for face in faces { + + // add data object + let obj = DataObject() + obj.type = "face" + obj.id = face["id"] as? String ?? "" + obj.data = face + items.append(obj) + + } + + // add actions to the list + for action in actions { + + // add data object + let obj = DataObject() + obj.type = "action" + obj.id = action["name"] as? String ?? "" + obj.data = action + items.append(obj) + + } + + // add vatoms to the list + for vatom in vatoms { + + // add data object + let obj = DataObject() + obj.type = "vatom" + obj.id = vatom["id"] as? String ?? "" + obj.data = vatom + items.append(obj) + + } + + return items + + } + + // MARK: - Notifications + + // - Add + + /// Called when an object is about to be added. +// override func will(add object: DataObject) { +// +// // Notify parent as well +// guard let parentID = (object.data?["vAtom::vAtomType"] as? [String: Any])?["parent_id"] as? String else { +// return +// } +// DispatchQueue.main.async { +// // broadcast update the vatom's parent +// self.emit(.objectUpdated, userInfo: ["id": parentID]) //FIXME: Does this make sense? If the parent calls list children at this point it will not have updated yet +//// // broadbast the add +// self.emit(.objectAdded, userInfo: ["id": object.id]) +// } +// +// } + + override func did(add object: DataObject) { + // Notify parent as well + guard let parentID = (object.data?["vAtom::vAtomType"] as? [String: Any])?["parent_id"] as? String else { + return + } + DispatchQueue.main.async { + // broadcast update the vatom's parent + self.emit(.objectUpdated, userInfo: ["id": parentID]) + // broadbast the add + self.emit(.objectAdded, userInfo: ["id": object.id]) + } + } + + // - Update + + /// Called when an object is about to be updated. + override func will(update object: DataObject, withFields: [String: Any]) { + + // notify parent as well + guard let oldParentID = (object.data?["vAtom::vAtomType"] as? [String: Any])?["parent_id"] as? String else { + return + } + guard let newParentID = (withFields["vAtom::vAtomType"] as? [String: Any])?["parent_id"] as? String else { + return + } + DispatchQueue.main.async { + self.emit(.objectUpdated, userInfo: ["id": oldParentID]) + self.emit(.objectUpdated, userInfo: ["id": newParentID]) + } + + } + + /// Called when an object is about to be updated. + override func did(update object: DataObject, withFields: [String: Any]) { + + // notify parent as well + guard let oldParentID = (object.data?["vAtom::vAtomType"] as? [String: Any])?["parent_id"] as? String else { + return + } + guard let newParentID = (withFields["vAtom::vAtomType"] as? [String: Any])?["parent_id"] as? String else { + return + } + DispatchQueue.main.async { + self.emit(.objectUpdated, userInfo: ["id": oldParentID]) + self.emit(.objectUpdated, userInfo: ["id": newParentID]) + } + + } + + /// Called when an object is about to be updated. + override func will(update: DataObject, keyPath: String, oldValue: Any?, newValue: Any?) { + + // check if parent ID is changing + if keyPath != "vAtom::vAtomType.parent_id" { + return + } + + // notify parent as well + guard let oldParentID = oldValue as? String else { return } + guard let newParentID = newValue as? String else { return } + DispatchQueue.main.async { + self.emit(.objectUpdated, userInfo: ["id": oldParentID]) + self.emit(.objectUpdated, userInfo: ["id": newParentID]) + } + + } + + // - Remove + + /// Called when an object is about to be removed. + override func will(remove object: DataObject) { + + // notify parent as well + guard let parentID = (object.data?["vAtom::vAtomType"] as? [String: Any])?["parent_id"] as? String else { + return + } + DispatchQueue.main.async { + if parentID != "." { + self.emit(.objectUpdated, userInfo: ["id": parentID]) + } + self.emit(.objectRemoved, userInfo: ["id": object.id]) + } + + } + +} diff --git a/BlockV/Core/Data Pool/Regions/GeoPosRegion.swift b/BlockV/Core/Data Pool/Regions/GeoPosRegion.swift new file mode 100644 index 00000000..0bfb7436 --- /dev/null +++ b/BlockV/Core/Data Pool/Regions/GeoPosRegion.swift @@ -0,0 +1,364 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit +import CoreLocation +import MapKit + +/* + - Map must have it's own array of on-screen vatom array model (which is only those vatoms for the visible region). + - When visialble regions changes, the previous region must close, and a new region created. + - Once the new region is created, the region's vatoms must be diffed with the in-memory model. + This means the in-memory model only holds the on-screen vatoms, but as the region changes, remaining vatoms + arn't removed and re-added. This could be achived with the map's annotation model. + */ + +/// This region plugin provides access to a collection of vatoms that has been dropped within the specified region on +/// the map. +//// +/// To get an instance, call `DataPool.region(id: "geopos", descriptor: MKCoordinateRegion)` +/// +/// Responsibilities +/// - Monitor a region. +/// - Automatically subcribe to premptive brian updates. +class GeoPosRegion: BLOCKvRegion { + + /// Plugin identifier. + override class var id: String { return "geopos" } + + /// The monitored region. + let region: MKCoordinateRegion + + /// Current user ID. + let currentUserID = DataPool.sessionInfo["userID"] as? String ?? "" + + /// Constructor. + required init(descriptor: Any) throws { //TODO: Add filter "all" "avatar" + + // check descriptor type + guard let region = descriptor as? MKCoordinateRegion else { + throw NSError("Region descriptor must be a GeoPosRegionCoordinates object!") + } + + // store region + self.region = region + + // setup base class + try super.init(descriptor: descriptor) + + // send region command + self.sendRegionCommand() + + } + + /// Our state key is the top-right and bottom-left geopos coordinates + override var stateKey: String { + + var hasher = Hasher() + hasher.combine(self.region.topRight.latitude) + hasher.combine(self.region.topRight.longitude) + hasher.combine(self.region.bottomLeft.latitude) + hasher.combine(self.region.bottomLeft.longitude) + let hash = hasher.finalize() // not guaranteed to be equal across different executions of your program + + return "geopos:\(hash)" + } + + /// Check if a region request matches our region. + override func matches(id: String, descriptor: Any) -> Bool { + + // make sure we got passed a proper region object + guard let region = descriptor as? MKCoordinateRegion else { + return false + } + + // check if matches ours + if region.topRight.latitude == self.region.topRight.latitude && + region.topRight.longitude == self.region.topRight.longitude && + region.bottomLeft.latitude == self.region.bottomLeft.latitude && + region.bottomLeft.longitude == self.region.bottomLeft.longitude { + return true + } + + // did not match + return false + + } + + /// Load current state from the server. + override func load() -> Promise<[String]?> { + + // pause websocket events + self.pauseMessages() + + let endpoint: Endpoint = API.Generic.geoDiscover( + bottomLeftLat: self.region.bottomLeft.latitude, + bottomLeftLon: self.region.bottomLeft.longitude, + topRightLat: self.region.topRight.latitude, + topRightLon: self.region.topRight.longitude, + filter: "vatoms") + + // execute request + return BLOCKv.client.requestJSON(endpoint).map { json -> [String]? in + + // parse items + guard + let json = json as? [String: Any], + let payload = json["payload"] as? [String: Any], + let items = self.parseDataObject(from: payload) else { + throw NSError.init("Unable to load") //FIXME: Create a better error + } + // add all objects + self.add(objects: items) + // return IDs + return items.map { $0.id } + + }.ensure { + + // resume websocket events + self.resumeMessages() + + } + + } + + /// Override save with a blank implementation. Regions change too often. + override func save() {} + + /// Override map so we can exclude vatoms which are no longer dropped. + override func map(_ object: DataObject) -> Any? { + + // check if dropped + guard + let vatom = object.data, + let props = vatom["vAtom::vAtomType"] as? [String: Any], + let dropped = props["dropped"] as? Bool, dropped + else { return nil } + + // it is, continue + return super.map(object) + + } + + /// Called when the Web socket reconnects. + override func onWebSocketConnect() { + super.onWebSocketConnect() + + // send region command + self.sendRegionCommand() + + } + + /// Sends the monitor command to the backend. This allows this client to receive preemptive brain updates over the + /// Web socket. + func sendRegionCommand() { + // write region command + BLOCKv.socket.writeRegionCommand(region.toDictionary()) + } + + /// Called on Web socket message. + /// + /// Allows super to handle 'state_update', then goes on to process 'inventory' events. + /// Message process is paused for 'inventory' events which indicate a vatom was added. Since the vatom must + /// be fetched from the server. + override func processMessage(_ msg: [String: Any]) { + + // - Look at state update + + // get info + guard + let msgType = msg["msg_type"] as? String, + let payload = msg["payload"] as? [String: Any] else { return } + + if msgType == "state_update" { + + guard + let newData = payload["new_object"] as? [String: Any], + let vatomID = payload["id"] as? String else { return } + + // check update is related to drop + guard let properties = newData["vAtom::vAtomType"] as? [String: Any], + let dropped = properties["dropped"] as? Bool + else { return } + + // check if vatom was picked up + if !dropped { + // remove vatom from this region + self.remove(ids: [vatomID]) + return + } + + // check if we have the vatom + if self.get(id: vatomID) != nil { + // ask super to process the update to the object (i.e. setting dropped to true) + super.processMessage(msg) + } else { + + // pause this instance's message processing and fetch vatom payload + self.pauseMessages() + + // create endpoint over void + let endpoint: Endpoint = API.Generic.getVatoms(withIDs: [vatomID]) + BLOCKv.client.request(endpoint).done { data in + + // convert + guard + let object = try? JSONSerialization.jsonObject(with: data), + let json = object as? [String: Any], + let payload = json["payload"] as? [String: Any] else { + throw NSError.init("Unable to load") //FIXME: Create a better error + } + + // parse out objects + guard let items = self.parseDataObject(from: payload) else { + throw NSError.init("Unable to parse data") //FIXME: Create a better error + } + + // add new objects + self.add(objects: items) + + }.catch { error in + printBV(error: "[InventoryRegion] Unable to fetch vatom. \(error.localizedDescription)") + }.finally { + // resume WebSocket processing + self.resumeMessages() + } + + } + + } else if msgType == "inventory" { + + // inspect inventory events + guard + let vatomID = payload["id"] as? String, + let oldOwner = payload["old_owner"] as? String, + let newOwner = payload["new_owner"] as? String else { return } + + /* + Iventory events indicate a vatom has entered or exited the user's inventory. It is unlikely that dropped vatoms + will experience inventory events, but it is possible. + Here we check only for outgoing inventory events (e.g. transfer). This gives the listener (e.g. map) the + opportinity to remove the vatom. + Incomming events don't need to be processed, since the user will subsequently need to drop the vatom. This + state-update event will be caught by the superclass `BLOCKvRegion`. + */ + + // check if this is an incoming or outgoing vatom + if oldOwner == self.currentUserID && newOwner != self.currentUserID { + // vatom is no longer owned by us + self.remove(ids: [vatomID]) + } + + } else if msgType == "map" { + + guard let operation = payload["op"] as? String, + let vatomID = payload["vatom_id"] as? String, + let actionName = payload["action_name"] as? String, + let lat = payload["lat"] as? Double, + let lon = payload["lon"] as? Double else { + return + } + + // check operation typw + if operation == "add" { + + // create endpoint over void + let endpoint: Endpoint = API.Generic.getVatoms(withIDs: [vatomID]) + BLOCKv.client.request(endpoint).done { data in + + // convert + guard + let object = try? JSONSerialization.jsonObject(with: data), + let json = object as? [String: Any], + let payload = json["payload"] as? [String: Any] else { + throw RegionError.failedParsingResponse + } + + // parse out objects + guard let items = self.parseDataObject(from: payload) else { + throw RegionError.failedParsingObject + } + + // add new objects + self.add(objects: items) + + }.catch { error in + printBV(error: "[GeoPosRegion] Unable to vatom. \(error.localizedDescription)") + } + + } else if operation == "remove" { + self.remove(ids: [vatomID]) + } + + } + + } + +} + +private extension MKCoordinateRegion { + + /// Returns a dictionary in data pool format. + func toDictionary() -> [String: Any] { + let payload: [String: [String: Any]] = [ + "top_left": [ + "lat": self.topLeft.latitude, + "lon": self.topLeft.longitude + ], + "bottom_right": [ + "lat": self.bottomRight.latitude, + "lon": self.bottomRight.longitude + ] + ] + return payload + } + +} + +public extension MKCoordinateRegion { + + /* + Things to check: + 1. Behaviour around the poles and international date line. + 2. Potentially better way: + > https://stackoverflow.com/questions/8496551/how-to-fit-a-certain-bounds-consisting-of-ne-and-sw-coordinates-into-the-visible + */ + + /// Computes the coordinate of the bottom left point. + var bottomLeft: CLLocationCoordinate2D { + return CLLocationCoordinate2DMake( + self.center.latitude - self.span.latitudeDelta/2, self.center.longitude - self.span.longitudeDelta/2 + ) + } + + /// Computes the coordinate of the bottom right point. + var bottomRight: CLLocationCoordinate2D { + return CLLocationCoordinate2DMake( + self.center.latitude - self.span.latitudeDelta/2, self.center.longitude + self.span.longitudeDelta/2 + ) + } + + /// Computes the coordinate of the top right point. + var topRight: CLLocationCoordinate2D { + return CLLocationCoordinate2DMake( + self.center.latitude + self.span.latitudeDelta/2, self.center.longitude + self.span.longitudeDelta/2 + ) + } + + /// Computes the coordinate of the top left point. + var topLeft: CLLocationCoordinate2D { + return CLLocationCoordinate2DMake( + self.center.latitude + self.span.latitudeDelta/2, self.center.longitude - self.span.longitudeDelta/2 + ) + } + +} diff --git a/BlockV/Core/Data Pool/Regions/InventoryRegion.swift b/BlockV/Core/Data Pool/Regions/InventoryRegion.swift new file mode 100644 index 00000000..2da59b5d --- /dev/null +++ b/BlockV/Core/Data Pool/Regions/InventoryRegion.swift @@ -0,0 +1,292 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit + +/* + Issues + 2. Dependecy inject session info (don't rely on static DataPool.sessionInfo) + 3. Convert discover to inventory call (server dependent). + 4. Convert request to auth-lifecylce participating requests. + */ + +/// This region plugin provides access to the current user's inventory. +/// +/// Two primary functions: +/// 1. Overrides load (to fetch all object from the server). +/// 2. Processing 'inventory' messages. +/// > Importanly, for vatom additions, this pauses the message processing, fetches the vatom, and only resumes the +/// message processing. +class InventoryRegion: BLOCKvRegion { + + /// Plugin identifier. + override class var id: String { return "inventory" } + + /// Constructor. + required init(descriptor: Any) throws { + try super.init(descriptor: descriptor) + + // make sure we have a valid current user + guard let userID = DataPool.sessionInfo["userID"] as? String, !userID.isEmpty else { + throw NSError("You cannot query the inventory region without being logged in.") + } + + } + + var lastHash: String? { + didSet { + print("lastHash \(String(describing:lastHash))") + } + } + + /// Current user ID. + let currentUserID = DataPool.sessionInfo["userID"] as? String ?? "" + + /// Our state key is the current user's Id. + override var stateKey: String { + return "inventory:" + currentUserID + } + + /// Returns `true` if this region matches the `id` and `descriptor`. + /// + /// There should only be one inventory region. + override func matches(id: String, descriptor: Any) -> Bool { + return id == "inventory" + } + + /// Called when session info changes. This should trigger a clean up process since the region may no longer be + /// valid. + override func onSessionInfoChanged(info: Any?) { + // shut down this region if the current user changes. + self.close() + } + + /// Load current state from the server. + override func load() -> Promise<[String]?> { + + // pause websocket events + self.pauseMessages() + + return self.fetchInventoryHash().then { newHash -> Promise<[String]?> in + + // replace current hash + let oldHash = self.lastHash + self.lastHash = newHash + + if oldHash == newHash { + // nothing has changes + self.resumeMessages() + // return nil(no-op) + return Promise.value(nil) + } + + // fetch all pages recursively + return self.fetchBatched().ensure { + // resume websocket events + self.resumeMessages() + } + + } + + } + + /// Called on Web socket message. + /// + /// Allows super to handle 'state_update', then goes on to process 'inventory' events. + /// Message process is paused for 'inventory' events which indicate a vatom was added. Since the vatom must + /// be fetched from the server. + override func processMessage(_ msg: [String: Any]) { + super.processMessage(msg) + + // get info + guard let msgType = msg["msg_type"] as? String else { return } + guard let payload = msg["payload"] as? [String: Any] else { return } + guard let oldOwner = payload["old_owner"] as? String else { return } + guard let newOwner = payload["new_owner"] as? String else { return } + guard let vatomID = payload["id"] as? String else { return } + + // nil out the hash if something has changed + if msgType == "inventory" || msgType == "state_update" { + self.lastHash = nil + } + + if msgType != "inventory" { return } + + // check if this is an incoming or outgoing vatom + if oldOwner == self.currentUserID && newOwner != self.currentUserID { + + // vatom is no longer owned by us + self.remove(ids: [vatomID]) + + } else if oldOwner != self.currentUserID && newOwner == self.currentUserID { + + // vatom is now our inventory + // pause this instance's message processing and fetch vatom payload + self.pauseMessages() + + // create endpoint over void + let endpoint: Endpoint = API.Generic.getVatoms(withIDs: [vatomID]) + BLOCKv.client.request(endpoint).done { data in + + // convert + guard + let object = try? JSONSerialization.jsonObject(with: data), + let json = object as? [String: Any], + let payload = json["payload"] as? [String: Any] else { + throw RegionError.failedParsingResponse + } + + // parse out objects + guard let items = self.parseDataObject(from: payload) else { + throw RegionError.failedParsingObject + } + + // add new objects + self.add(objects: items) + + }.catch { error in + printBV(error: "[InventoryRegion] Unable to fetch inventory. \(error.localizedDescription)") + }.finally { + // resume WebSocket processing + self.resumeMessages() + } + + } else { + + // logic error, old owner and new owner cannot be the same + printBV(error: "[InventoryRegion] Logic error in WebSocket message, old_owner and new_owner shouldn't be the same: \(vatomID)") + + } + + } + + /// Page size parameter sent to the server. + private let pageSize = 100 + /// Upper bound to prevent infinite recursion. + private let maxReasonablePages = 50 + /// Number of batch iterations. + private var iteration = 0 + /// Number of processed pages. + private var proccessedPageCount = 0 + /// Cummulative object ids. + fileprivate var cummulativeIds: [String] = [] + +} + +extension InventoryRegion { + + func fetchBatched(maxConcurrent: Int = 4) -> Promise<[String]?> { + + let intialRange: CountableClosedRange = 1...maxConcurrent + return fetchRange(intialRange) + + } + + private func fetchRange(_ range: CountableClosedRange) -> Promise<[String]?> { + + iteration += 1 + + print("[Pager] fetching range \(range) in iteration \(iteration).") + + var promises: [Promise<[String]?>] = [] + + // tracking flag + var shouldRecurse = true + + for page in range { + + // build raw request + let endpoint: Endpoint = API.Generic.getInventory(parentID: "*", page: page, limit: pageSize) + + // exectute request + let promise = BLOCKv.client.requestJSON(endpoint).then(on: .global(qos: .userInitiated)) { json -> Promise<[String]?> in + + guard let json = json as? [String: Any], + let payload = json["payload"] as? [String: Any] else { + throw RegionError.failedParsingResponse + } + + // parse out data objects + guard let items = self.parseDataObject(from: payload) else { + return Promise.value([]) + } + let newIds = items.map { $0.id } + + return Promise { (resolver: Resolver) in + + DispatchQueue.main.async { + + // append new ids + self.cummulativeIds.append(contentsOf: newIds) + + // add data objects + self.add(objects: items) + + if (items.count == 0) || (self.proccessedPageCount > self.maxReasonablePages) { + shouldRecurse = false + } + + // increment page count + self.proccessedPageCount += 1 + + return resolver.fulfill(newIds) + + } + } + + } + + promises.append(promise) + + } + + return when(resolved: promises).then { _ -> Promise<[String]?> in + + // check stopping condition + if shouldRecurse { + + print("[Pager] recursing.") + + // create the next range (with equal width) + let nextLower = range.upperBound.advanced(by: 1) + let nextUpper = range.upperBound.advanced(by: range.upperBound) + let nextRange: CountableClosedRange = nextLower...nextUpper + + return self.fetchRange(nextRange) + + } else { + + print("[Pager] stopping condition hit.") + + return Promise.value(self.cummulativeIds) + + } + + } + + } + +} + +extension InventoryRegion { + + /// Fetches the remote inventory's hash value. + func fetchInventoryHash() -> Promise { + + let endpoint = API.Vatom.getInventoryHash() + return BLOCKv.client.request(endpoint).map { result -> String in + return result.payload.hash + } + + } + +} diff --git a/BlockV/Core/Data Pool/Regions/Region+Notifications.swift b/BlockV/Core/Data Pool/Regions/Region+Notifications.swift new file mode 100644 index 00000000..870650a8 --- /dev/null +++ b/BlockV/Core/Data Pool/Regions/Region+Notifications.swift @@ -0,0 +1,96 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// Possible events +public enum RegionEvent: String { + + /// Triggered when any data in the region changes. This also indicates that there is no longer an error. + case updated = "region.updated" + + /// Triggered when an object is added. + case objectAdded = "region.object.added" + + /// Triggered when an object is removed. + case objectRemoved = "region.object.removed" + + /// When a data object changes. userInfo["id"] is the ID of the changed object. + case objectUpdated = "region.object.updated" + + /// When an error occurs. userInfo["error"] is the error. You can also access `region.error` to get the error. + case error = "region.error" + + /// Lifecycle events + + /// Triggered when the region stabalizes. + case stabalized = "region.stabalized" + /// Triggered when the region destabalizes. + case destabalized = "region.destablaized" + /// Triggered when the region begins synchronization. + case synchronizing = "region.synchronizing" + +} + +extension RegionEvent { + + /// Convert the notification to a Notification.Name + public var asNotification: Notification.Name { + return Notification.Name(rawValue: self.rawValue) + } + +} + +/// Helpers to deal with events +extension Region { + + /// Add a listener. + public func addObserver(_ observer: Any, selector: Selector, name: RegionEvent) { + NotificationCenter.default.addObserver(observer, + selector: selector, + name: Notification.Name(name.rawValue), + object: self) + } + + /// Remove a listener. + public func removeObserver(_ observer: Any, name: RegionEvent) { + NotificationCenter.default.removeObserver(observer, + name: Notification.Name(name.rawValue), + object: self) + } + + /// Removes a set listener. + /// TODO: Find a way to auto remove block listeners when their container object is dealloc'd? + public typealias RemoveObserverFunction = () -> Void + + /// Add a listener + public func listen(for name: RegionEvent, handler: @escaping (Notification) -> Void) -> RemoveObserverFunction { + + // register observer + let observer = NotificationCenter.default.addObserver(forName: Notification.Name(name.rawValue), + object: self, queue: OperationQueue.main, using: handler) + + // return a function which can be called to remove the observer + return { + NotificationCenter.default.removeObserver(observer) + } + + } + + /// Emits an event. This is used by Region and it's subclasses only. + func emit(_ name: RegionEvent, userInfo: [String: Any] = [:]) { + + // send notification + NotificationCenter.default.post(name: Notification.Name(name.rawValue), object: self, userInfo: userInfo) + + } + +} diff --git a/BlockV/Core/Data Pool/Regions/Region.swift b/BlockV/Core/Data Pool/Regions/Region.swift new file mode 100644 index 00000000..d5bbf17c --- /dev/null +++ b/BlockV/Core/Data Pool/Regions/Region.swift @@ -0,0 +1,652 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit + +/* + # Notes: + + ## Filter & Sort + + Both of these should become classes (which do not inherit from Region). They provide a 'view into' a region. + + Caller: + + DataPool.region(id: "inventory", descriptor: [:]).filter { $0.dateModified > a } + DataPool.region(id: "inventory", descriptor: [:]).sort { $0.title } + + Adding sort directly to Region is hard beacuse multiple callers may use use the same regions - all with different + sorting predicates. So sorting must be cofigurable (isolated) per caller. + + */ + +/// An abstract class that manages a complete collection of objects (a.k.a Regions). +/// +/// Regions are generally "id-complete". That is, the local region should have a complete copy of all remote objects. +/// +/// Roles: +/// - In memory store of objects. +/// - Keep track of synchornization state. +/// - Loads new objects. +/// - CRUD (including update with spare objects). +/// - Change Notifications. +/// - Persistance. +public class Region { + + enum RegionError: Error { + case failedParsingResponse + case failedParsingObject + } + + /// Serial io queue. + /// + /// Each subclass with have it's own io queue (the label does not dictate uniqueness). + let ioQueue = DispatchQueue(label: "io.blockv.sdk.datapool-io", qos: .utility) + + /// Constructor + required init(descriptor: Any) throws { } + + /// This region plugin's ID. + class var id: String { + assertionFailure("subclass-should-override") + return "" + } + + /// `true` if this region contains temporary objects which should not be cached to disk, `false` otherwise. + let noCache = false + + /// All objects currently in our cache. + private(set) var objects: [String: DataObject] = [:] + + /// `true` if data in this region is in sync with the backend. + public internal(set) var synchronized = false { + didSet { + if synchronized { + self.emit(.stabalized, userInfo: [:]) + } else { + self.emit(.destabalized, userInfo: [:]) + } + } + } + + /// Contains the current error. + public internal(set) var error: Error? + + /// An ID which uniquely identifies this region. Used for caching purposes. + var stateKey: String { + assertionFailure("subclass-should-override") + return "" + } + + /// `true` if this region has been closed. + public fileprivate(set) var closed = false + + /// Re-synchronizes the region by manually fetching objects from the server again. + public func forceSynchronize() -> Guarantee { + self.synchronized = false + return self.synchronize() + } + + /// Currently executing synchronization promise. `nil` if there is no synchronization underway. + private var _syncPromise: Guarantee? + + /// Attempts to stablaize the region by querying the backend for all data. + /// + /// - Returns: Promise which resolves when complete. + @discardableResult + public func synchronize() -> Guarantee { + + self.emit(.synchronizing, userInfo: [:]) + + // stop if already running + if let promise = _syncPromise { + return promise + } + + // remove pending error + self.error = nil + self.emit(.updated) //FIXME: Why is this update broadcast? + + // stop if already in sync + if synchronized { + return Guarantee() + } + + // ask the subclass to load it's data + printBV(info: "[DataPool > Region] Starting synchronization for region \(self.stateKey)") + + // load objects + _syncPromise = self.load().map { ids -> Void in + + /* + The subclass is expected to call the add method as it finds object, and then, once + all ids are known, return all the newly added ids. + Super (this) then calls the remove method on all ids that are no longer present. + */ + + // check if subclass returned an array of IDs + if let ids = ids { + self.diffedRemove(ids: ids) + } + + // data is up to date + self.synchronized = true + self._syncPromise = nil + printBV(info: "[DataPool > Region] Region '\(self.stateKey)' is now in sync!") + + }.recover { err in + // error handling, notify listeners of an error + self._syncPromise = nil + self.error = err + printBV(error: "[DataPool > Region] Unable to load: " + err.localizedDescription) + self.emit(.error, userInfo: ["error": err]) + } + + // return promise + return _syncPromise! + + } + + /// Start load of remote objects. The promise should resolve once the region is up to date and provides the + /// set of object ids. + /// + /// This function should fetch the _entire_ region. + /// + /// - Returns: A promise which will fullsil with an array of object IDs, or `nil`. If an array of object IDs is + /// returned, any IDs not in this list should be removed from the region. + func load() -> Promise<[String]?> { + return Promise(error: NSError("Subclasses must override Region.load()")) + } + + /// Stop and destroy this region. Subclasses can override this to do stuff on close. + public func close() { + + // notify data pool we have closed + DataPool.removeRegion(region: self) + // we're closed + self.closed = true + + } + + /// Checks if the specified query matches our region. This is used to identify if a region request + /// can be satisfied by this region, or if a new region should be created. + /// + /// - Parameters: + /// - id: The region plugin ID + /// - descriptor: Region-specific filter data + /// - Returns: True if the described region is this region. + func matches(id: String, descriptor: Any) -> Bool { + fatalError("Subclasses muct override Region.matches()") + } + + /// Add DataObjects to our pool. + /// + /// - Parameter objects: The objects to add + func add(objects: [DataObject]) { + + // go through each object + for obj in objects { + + // skip if no data + guard let data = obj.data else { + continue + } + + // check if exists already + if let existingObject = self.objects[obj.id] { + + // notify + self.will(update: existingObject, withFields: data) + + // it exists already, update the object (replace data) + existingObject.data = data + existingObject.cached = nil + + self.did(update: existingObject, withFields: data) + + } else { + + // it does not exist, add it + self.objects[obj.id] = obj + + // notify + self.did(add: obj) + + } + + // emit event + //FIXME: Why was this being broadcast? +// self.emit(.objectUpdated, userInfo: ["id": obj.id]) + + } + + // Notify updated + if objects.count > 0 { + self.emit(.updated) + self.save() + } + + } + + enum Source: String { + case brain + } + + /// Updates data objects within our pool. + /// + /// - Parameter objects: The list of changes to perform to our data objects. + func update(objects: [DataObjectUpdateRecord], source: Source? = nil) { + + // batch emit events, so if a object is updated multiple times, only one event is sent + var changedIDs = Set() + + for obj in objects { + + // fetch existing object + guard let existingObject = self.objects[obj.id] else { + continue + } + + // stop if existing object doesn't have the full data + guard let existingData = existingObject.data else { + continue + } + + // notify + self.will(update: existingObject, withFields: obj.changes) + + // update fields + existingObject.data = existingData.deepMerged(with: obj.changes) + + // clear cached values + existingObject.cached = nil + + // notify + self.did(update: existingObject, withFields: obj.changes) + + // emit event + changedIDs.insert(obj.id) + + } + + // notify each item that was updated + for id in changedIDs { + self.emit(.objectUpdated, userInfo: ["id": id, "source": source?.rawValue ?? ""]) + } + + // notify overall update + if changedIDs.count > 0 { + self.emit(.updated) + self.save() + } + + } + + /// Computes a diff between the supplied ids and the current object ids. Removes the stale ids. + func diffedRemove(ids: [String]) { + + // create a diff of keys to remove + var keysToRemove: [String] = [] + for id in self.objects.keys { + + // check if it's in our list + if !ids.contains(id) { + keysToRemove.append(id) + } + + } + + // remove objects + self.remove(ids: keysToRemove) + + } + + /// Removes the specified objects from our pool. + /// + /// - Parameter ids: The IDs of objects to remove + func remove(ids: [String]) { + + // remove all data objects with the specified IDs + var didUpdate = false + for id in ids { + + // remove it + guard let object = self.objects.removeValue(forKey: id) else { + continue + } + + // notify + didUpdate = true + self.will(remove: object) //FIXME: Should be didRemove? + + } + + // notify region updated + if didUpdate { + self.emit(.updated) + self.save() + } + + } + + /// If a region plugin depends on the session data, it may override this method and `self.close()` itself if needed. + /// + /// - Parameter info: The new app-specific session info + func onSessionInfoChanged(info: Any?) {} + + /// If the plugin wants, it can map DataObjects to another type. This takes in a DataObject and returns a new type. + /// If the plugin returns `nil`, the specified data object will not be returned and will be skipped. + /// + /// The default implementation simply returns the DataObject. + /// + /// - Parameter object: The DataObject as input + /// - Returns: The new output object. + func map(_ object: DataObject) -> Any? { + return object + } + + /// Returns all the objects within this region. Waits until the region is stable first. + /// + /// - Returns: Array of objects. Check the region-specific map() function to see what types are returned. + public func getAllStable() -> Guarantee<[Any]> { + + // synchronize now + return self.synchronize().map { + return self.getAll() + } + + } + + /// Returns all the objects within this region. Does NOT wait until the region is stable first. + public func getAll() -> [Any] { + + // create array of all items + var items: [Any] = [] + for object in objects.values { + + // check for cached concrete type + if let cached = object.cached { + items.append(cached) + continue + } + + // map to the plugin's intended type + guard let mapped = self.map(object) else { + continue + } + + // cache it + object.cached = mapped + + // add to list + items.append(mapped) + + } + + // done + return items + + } + + /// Returns an object within this region by it's ID. Waits until the region is stable first. + public func getStable(id: String) -> Guarantee { + + // synchronize now + return self.synchronize().map { + // get item + return self.get(id: id) + } + + } + + /// Returns an object within this region by it's ID. + public func get(id: String) -> Any? { + + // get object + guard let object = objects[id] else { + return nil + } + + // check for cached concrete type + if let cached = object.cached { + return cached + } + + // map to the plugin's intended type + guard let mapped = self.map(object) else { + return nil + } + + // cache it + object.cached = mapped + + // done + return mapped + + } + + /// Directory containing all region caches. + static var recommendedCacheDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("regions") + + /// Cache filename. + lazy var cacheFilename = self.stateKey.replacingOccurrences(of: ":", with: "_") + + /// File where cache will be stored. + lazy var cacheFile = Region.recommendedCacheDirectory + .appendingPathComponent(cacheFilename) + .appendingPathExtension("json") + + /// Load objects from local storage. + func loadFromCache() -> Promise { + + return Promise { (resolver: Resolver) in + + ioQueue.async { + + // get filename + let startTime = Date.timeIntervalSinceReferenceDate + + // read data + guard let data = try? Data(contentsOf: self.cacheFile) else { + printBV(error: ("[DataPool > Region] Unable to read cached data")) + resolver.fulfill_() + return + } + + // parse JSON + guard let json = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [[Any]] else { + printBV(error: "[DataPool > Region] Unable to parse cached JSON") + resolver.fulfill_() + return + } + + // create objects + let objects = json.map { fields -> DataObject? in + + // get fields + guard let id = fields[0] as? String, let type = fields[1] as? String, + let data = fields[2] as? [String: Any] else { + return nil + } + + // create DataObject + let obj = DataObject() + obj.id = id + obj.type = type + obj.data = data + return obj + + } + + // Strip out nils + let cleanObjects = objects.compactMap { $0 } + + DispatchQueue.main.async { + // add objects + self.add(objects: cleanObjects) + } + + // done + let delay = (Date.timeIntervalSinceReferenceDate - startTime) * 1000 + printBV(info: ("[DataPool > Region] Loaded \(cleanObjects.count) from cache in \(Int(delay))ms")) + resolver.fulfill_() + + } + + } + + } + + var saveTask: DispatchWorkItem? + + /// Saves the region to local storage. + func save() { + + // cancel the pending save task + if saveTask != nil { + saveTask?.cancel() + } + + // create save task + saveTask = DispatchWorkItem { () -> Void in + + // create data to save + let startTime = Date.timeIntervalSinceReferenceDate + let json = self.objects.values.map { return [ + $0.id, + $0.type, + $0.data ?? [:] + ]} + + // convert to JSON + guard let data = try? JSONSerialization.data(withJSONObject: json, options: []) else { + printBV(error: ("[DataPool > Region] Unable to convert data objects to JSON")) + return + } + + // make sure folder exists + try? FileManager.default.createDirectory(at: self.cacheFile.deletingLastPathComponent(), + withIntermediateDirectories: true, attributes: nil) + + // write file + do { + try data.write(to: self.cacheFile) + } catch let err { + printBV(error: ("[DataPool > Region] Unable to save data to disk: " + err.localizedDescription)) + return + } + + // done + let delay = (Date.timeIntervalSinceReferenceDate - startTime) * 1000 + printBV(info: ("[DataPool > Region] Saved \(self.objects.count) objects to disk in \(Int(delay))ms")) + + } + + // Debounce save task + ioQueue.asyncAfter(deadline: .now() + 5, execute: saveTask!) + + } + + /// Call this to undo an action. + typealias UndoFunction = () -> Void + + /// Change a field, and return a function which can be called to undo the change. + /// + /// - Parameters: + /// - id: The object ID + /// - keyPath: The key to change + /// - value: The new value + /// - Returns: An undo function + func preemptiveChange(id: String, keyPath: String, value: Any) -> UndoFunction { + + // get object. If it doesn't exist, do nothing and return an undo function which does nothing. + guard let object = objects[id], object.data != nil else { + return {} + } + + // get current value + let oldValue = object.data![keyPath: KeyPath(keyPath)] + + // notify + self.will(update: object, keyPath: keyPath, oldValue: oldValue, newValue: value) + + // update to new value + object.data![keyPath: KeyPath(keyPath)] = value + object.cached = nil + self.emit(.objectUpdated, userInfo: ["id": id]) + self.emit(.updated) + self.save() + + // return undo function + return { + + // notify + self.will(update: object, keyPath: keyPath, oldValue: value, newValue: oldValue) + + // update to new value + object.data![keyPath: KeyPath(keyPath)] = oldValue + object.cached = nil + self.emit(.objectUpdated, userInfo: ["id": id]) + self.emit(.updated) + self.save() + + } + + } + + /// Remove an object, and return an undo function. + /// + /// - Parameter id: The object ID to remove + /// - Returns: An undo function + func preemptiveRemove(id: String) -> UndoFunction { + + // remove object + guard let removedObject = objects.removeValue(forKey: id) else { + // no object, do nothing + return {} + } + + // notify + self.will(remove: removedObject) //FIXME: should be didRemove + self.emit(.updated) + self.save() + + // return undo function + return { + + // check that a new object wasn't added in the mean time + guard self.objects[id] == nil else { + return + } + + // notify + self.will(add: removedObject) + self.add(objects: [removedObject]) + self.save() + + } + + } + + // MARK: - Listener functions, can be overridden by subclasses + + func will(add: DataObject) {} + func will(update: DataObject, withFields: [String: Any]) {} + func will(update: DataObject, keyPath: String, oldValue: Any?, newValue: Any?) {} + func will(remove object: DataObject) {} + + func did(add: DataObject) {} + func did(update: DataObject, withFields: [String: Any]) {} +// func did(update: DataObject, keyPath: String, oldValue: Any?, newValue: Any?) {} +// func did(remove object: DataObject) {} + +} diff --git a/BlockV/Core/Data Pool/Regions/VatomChildrenRegion.swift b/BlockV/Core/Data Pool/Regions/VatomChildrenRegion.swift new file mode 100644 index 00000000..298dbe3b --- /dev/null +++ b/BlockV/Core/Data Pool/Regions/VatomChildrenRegion.swift @@ -0,0 +1,147 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit + +/// This region plugin provides access to a collection of vatoms that are children of another vatom. +/// The 'inventory' region is much mor reliable, so if you know that your vatoms are owned by the current user, +/// use the inventory region with a filter rather. +/// +/// To get an instance, call `DataPool.region("children", "parent-id")` +class VatomChildrenRegion: BLOCKvRegion { + + /// Plugin identifier. + override class var id: String { return "children" } + + /// Parent ID. + let parentID: String + + /// Constructor. + required init(descriptor: Any) throws { + + // store ID + parentID = descriptor as? String ?? "" + + // setup base class + try super.init(descriptor: descriptor) + + } + + /// Our state key is the list of IDs. + override var stateKey: String { + return "children:" + self.parentID + } + + /// Check if a region request matches our region. + override func matches(id: String, descriptor: Any) -> Bool { + return id == "children" && (descriptor as? String) == parentID + } + + /// Load current state from the server. + override func load() -> Promise<[String]?> { + + // pause websocket events + self.pauseMessages() + + // fetch all pages recursively + return self.fetch().map { dataObjects -> [String] in + + // add all objects + self.add(objects: dataObjects) + + // return IDs + return dataObjects.map { $0.id } + + }.ensure { + + // resume websocket events + self.resumeMessages() + + } + + } + + /// Recursively fetch all pages of data from the server + fileprivate func fetch(page: Int = 1, previousItems: [DataObject] = []) -> Promise<[DataObject]> { + + // create discover query + let builder = DiscoverQueryBuilder() + builder.setScope(scope: .parentID, value: parentID) + builder.page = page + builder.limit = 1000 + + printBV(info: "[DataPool > VatomChildrenRegion] Loading page \(page), got \(previousItems.count) items so far.") + + // create endpoint over void + let endpoint: Endpoint = API.Generic.discover(builder.toDictionary()) + return BLOCKv.client.requestJSON(endpoint).then { json -> Promise<[DataObject]> in + + // extract payload + guard let json = json as? [String: Any], let payload = json["payload"] as? [String: Any] else { + throw NSError.init("Unable to load") //FIXME: Create a better error + } + + // create list of items + var items = previousItems + + // add vatoms to the list + guard let vatomInfos = payload["results"] as? [[String: Any]] else { return Promise.value(items) } + for vatomInfo in vatomInfos { + + // add data object + let obj = DataObject() + obj.type = "vatom" + obj.id = vatomInfo["id"] as? String ?? "" + obj.data = vatomInfo + items.append(obj) + + } + + // add faces to the list + guard let faces = payload["faces"] as? [[String: Any]] else { return Promise.value(items) } + for face in faces { + + // add data object + let obj = DataObject() + obj.type = "face" + obj.id = face["id"] as? String ?? "" + obj.data = face + items.append(obj) + + } + + // add actions to the list + guard let actions = payload["actions"] as? [[String: Any]] else { return Promise.value(items) } + for action in actions { + + // add data object + let obj = DataObject() + obj.type = "action" + obj.id = action["name"] as? String ?? "" + obj.data = action + items.append(obj) + + } + + // if no more data, stop + if vatomInfos.count == 0 { + return Promise.value(items) + } + + // done, get next page + return self.fetch(page: page+1, previousItems: items) + + } + + } + +} diff --git a/BlockV/Core/Data Pool/Regions/VatomIDRegion.swift b/BlockV/Core/Data Pool/Regions/VatomIDRegion.swift new file mode 100644 index 00000000..0450021d --- /dev/null +++ b/BlockV/Core/Data Pool/Regions/VatomIDRegion.swift @@ -0,0 +1,92 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import PromiseKit + +/// This region plugin provides access to a collection of vatoms identified by their IDs. +/// The 'inventory' region is much more reliable, so if you know that your vatoms are owned by the current user, +/// use the inventory region with a filter rather. +/// +/// TODO: Retry a few times +/// +/// To get an instance, call `DataPool.region("ids", ["id1", "id2"])` +class VatomIDRegion: BLOCKvRegion { + + /// Plugin identifier. + override class var id: String { return "ids" } + + /// IDs being fetched in this region. + let ids: [String] + + /// Constructor. + required init(descriptor: Any) throws { + + // store IDs + ids = descriptor as? [String] ?? [] + + // setup base class + try super.init(descriptor: descriptor) + + } + + /// Our state key is the list of IDs. + override var stateKey: String { + return "ids:" + self.ids.joined(separator: ",") + } + + /// Check if a region request matches our region. + override func matches(id: String, descriptor: Any) -> Bool { + + // check all filters match + if id != "ids" { return false } + guard let otherIds = descriptor as? [String] else { return false } + // check ids match + return self.ids == otherIds + + } + + /// Load current state from the server. + override func load() -> Promise<[String]?> { + + // pause websocket events + self.pauseMessages() + + let endpoint: Endpoint = API.Generic.getVatoms(withIDs: ids) + return BLOCKv.client.requestJSON(endpoint).then { json -> Promise<[String]?> in + + guard let json = json as? [String: Any], let payload = json["payload"] as? [String: Any] else { + throw NSError.init("Unable to load") //FIXME: Create a better error + } + + // parse items + guard let items = self.parseDataObject(from: payload) else { + throw NSError.init("Unable to parse data") //FIXME: Create a better error + } + + // add all objects + self.add(objects: items) + + // return IDs + let ids = items.map { $0.id } + + return Promise.value(ids) + + }.ensure { + + // resume websocket events + self.resumeMessages() + + } + + } + +} diff --git a/BlockV/Core/Debug/DebugHUDViewController.swift b/BlockV/Core/Debug/DebugHUDViewController.swift new file mode 100644 index 00000000..0aa256bc --- /dev/null +++ b/BlockV/Core/Debug/DebugHUDViewController.swift @@ -0,0 +1,143 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import UIKit +import BLOCKv + +/// UIWindow subclass which is designed to be set at the top-level window. +/// +/// All events are passed through to underlying windows expect if the view conforms the `FloatingView` protocol. In this +/// case, the touch events will forwarded to the view itself. +class FloatingHUDWindow: UIWindow { + + init() { + super.init(frame: UIScreen.main.bounds) + backgroundColor = nil + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Determine whether the hit test should be + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + + let hitView = super.hitTest(point, with: event) + // check if this window should handle this event + if hitView!.self is FloatingView { + return hitView + } + // indicates this window does not handle this event + return nil + + } + +} + +/// A view controller which provides a context for a floating HUD. +/// +/// Initialising this view controller will create a floating HUD using an instance of the `FloatingHUDWindow`. +/// +/// HUD content should be added as a subview to the view controller's view. If the view's need to capture touch events +/// they should conform to the `FloatingView` protocol. +class DebugHUDViewController: UIViewController { + + // MARK: - Properties + + lazy var socketContentView: SocketContentView = { + let view = SocketContentView() + view.cornerRadius = 8 + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = UIColor.gray.withAlphaComponent(0.4) + + return view + }() + + private let window = FloatingHUDWindow() + + // MARK: - Initializer + + init() { + super.init(nibName: nil, bundle: nil) + window.windowLevel = UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude) + window.isHidden = false + window.rootViewController = self + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow), + name: UIResponder.keyboardDidShowNotification, + object: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life cycle + + override func viewDidLoad() { + super.viewDidLoad() + + self.setup() + } + + var socketContentViewCenterXContraint: NSLayoutConstraint? + var socketContentViewCenterYContraint: NSLayoutConstraint? + + private func setup() { + + // socket content + self.view.addSubview(socketContentView) + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panDidFire)) + socketContentView.addGestureRecognizer(panGesture) + + // constraints + socketContentViewCenterXContraint = socketContentView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor, + constant: 0) + socketContentViewCenterXContraint?.isActive = true + + socketContentViewCenterYContraint = socketContentView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor, + constant: 0) + socketContentViewCenterYContraint?.isActive = true + + socketContentView.widthAnchor.constraint(equalTo: self.view.widthAnchor, multiplier: 0.25).isActive = true + socketContentView.widthAnchor.constraint(equalTo: socketContentView.heightAnchor, multiplier: 1).isActive = true + + } + + // MARK: - Section + + @objc func panDidFire(pan: UIPanGestureRecognizer) { + + socketContentView.layoutIfNeeded() + + let offset = pan.translation(in: self.view) + pan.setTranslation(CGPoint.zero, in: self.view) + + socketContentViewCenterXContraint?.constant += offset.x + socketContentViewCenterYContraint?.constant += offset.y + + } + + @objc func keyboardDidShow(note: NSNotification) { + window.windowLevel = UIWindow.Level(rawValue: 0) + window.windowLevel = UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude) + } + +} + +// MARK: - Helpers + +/// Protocol that indicates a view is a floating view. This allows for hit testing. +protocol FloatingView where Self: UIView { } + +/// Simple subclass of UIView that conforms to FloatingView. Use this subsclass to add interactable content on a +/// floating window. +class ContentView: UIView, FloatingView { } diff --git a/BlockV/Core/Debug/SocketInstrument.swift b/BlockV/Core/Debug/SocketInstrument.swift new file mode 100644 index 00000000..8c1ee731 --- /dev/null +++ b/BlockV/Core/Debug/SocketInstrument.swift @@ -0,0 +1,196 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +enum HUDStatus { + case green + case orange + case red +} + +/// Controller object that interacts with the web socket. +class SocketInstrument { + + private let pingInterval: TimeInterval = 5 + + private var timer: Timer! + + fileprivate var statusHandler: ((_ status: HUDStatus) -> Void)? + + init() { + + // listen to web socket lifecycle events + BLOCKv.socket.onDisconnected.subscribe(with: self) { [weak self] _ in + self?.statusHandler?(.red) + } + + BLOCKv.socket.onConnected.subscribe(with: self) { [weak self] _ in + self?.statusHandler?(.green) + } + + DispatchQueue.main.async { + self.activate(pingInterval: self.pingInterval) + } + } + + private func activate(pingInterval: TimeInterval) { + + // create timer + self.timer = Timer.scheduledTimer(withTimeInterval: pingInterval, repeats: true) { [weak self] _ in + + /* + Technical: + A pong must + */ + + var didTimeout = false + + BLOCKv.socket.writePing(completion: { + + if didTimeout { + self?.statusHandler?(.red) + } else { + self?.statusHandler?(.green) + } + + }) + + // fire a timeout 0.1 seconds before the next ping + DispatchQueue.main.asyncAfter(deadline: .now() + pingInterval - 0.1, execute: { + didTimeout = true + }) + + } + + } + +} + +// MARK: - Socket Content View + +class SocketContentView: RoundedView { + + // MARK: - Properties + + private var didSetupConstraints = false + + lazy var statusDotView: CircleView = { + let view = CircleView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .red + return view + }() + + // controller object to interact with the model + var socketInstrument: SocketInstrument! + + // MARK: - Initializer + + init() { + super.init(frame: CGRect.zero) + self.commonInit() + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.commonInit() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func commonInit() { + self.addSubview(statusDotView) + self.setNeedsUpdateConstraints() + self.socketInstrument = SocketInstrument() + + socketInstrument.statusHandler = pulse(status:) + } + + // MARK: - Lifecycle + + override func updateConstraints() { + + if !didSetupConstraints { + + statusDotView.widthAnchor.constraint(equalToConstant: 12).isActive = true + statusDotView.heightAnchor.constraint(equalToConstant: 12).isActive = true + statusDotView.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 12).isActive = true + statusDotView.topAnchor.constraint(equalTo: self.topAnchor, constant: 12).isActive = true + + didSetupConstraints = true + } + + super.updateConstraints() + } + + // MARK: - Updates + + private func pulse(status: HUDStatus) { + switch status { + case .green: + statusDotView.backgroundColor = .green + case .orange: + statusDotView.backgroundColor = .orange + case .red: + statusDotView.backgroundColor = .red + } + statusDotView.pulse() + } + +} + +// MARK: - Helper Views + +/// Create a view with rounded edges. +class RoundedView: ContentView { + + var cornerRadius: CGFloat = 0 + + override func layoutSubviews() { + super.layoutSubviews() + self.layer.cornerRadius = cornerRadius + } + +} + +/// Creates a round UIView. +class CircleView: UIView { + + override func layoutSubviews() { + super.layoutSubviews() + self.layer.cornerRadius = self.bounds.size.width/2 + } + +} + +extension CircleView: Pulseable { } + +protocol Pulseable where Self: UIView { + func pulse() +} + +extension Pulseable { + + func pulse() { + let pulseAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.opacity)) + pulseAnimation.duration = 1 + pulseAnimation.fromValue = 1 + pulseAnimation.toValue = 0.2 + pulseAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) + pulseAnimation.autoreverses = true + pulseAnimation.repeatCount = 1 + self.layer.add(pulseAnimation, forKey: "animateOpacity") + } + +} diff --git a/BlockV/Core/Extensions/Decodable+Ext.swift b/BlockV/Core/Extensions/Decodable+Ext.swift index fa2fe6e9..c93881ce 100644 --- a/BlockV/Core/Extensions/Decodable+Ext.swift +++ b/BlockV/Core/Extensions/Decodable+Ext.swift @@ -46,7 +46,7 @@ extension KeyedDecodingContainer { /// Returns `nil` if the key is absent, or if the type cannot be decoded func decodeSafelyIfPresent(_ type: T.Type, forKey key: KeyedDecodingContainer.Key) -> T? { - let decoded = try? decodeIfPresent(Safe.self, forKey: key) + let decoded = ((try? decodeIfPresent(Safe.self, forKey: key)) as Safe??) return decoded??.value } diff --git a/BlockV/Core/Extensions/NSError+Convenience.swift b/BlockV/Core/Extensions/NSError+Convenience.swift new file mode 100644 index 00000000..2baa61f0 --- /dev/null +++ b/BlockV/Core/Extensions/NSError+Convenience.swift @@ -0,0 +1,21 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +extension NSError { + + /// Creates an error with the specified error message. + convenience init(_ text: String, code: Int = 0) { + self.init(domain: "blockv_sdk", code: code, userInfo: [ NSLocalizedDescriptionKey: text ]) + } + +} diff --git a/BlockV/Core/Helpers/DelayOptions.swift b/BlockV/Core/Helpers/DelayOptions.swift new file mode 100644 index 00000000..cd018d4b --- /dev/null +++ b/BlockV/Core/Helpers/DelayOptions.swift @@ -0,0 +1,40 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +// Adapted from: https://github.com/kean + +enum DelayOption { + /// Zero delay. + case immediate + /// Constant delay. + case constant(time: Double) + /// Exponential backoff delay. + case exponential(initial: Double, base: Double, maxDelay: Double) + /// Custom delay where `attempt` is the itteration count. + case custom(closure: (_ attempt: Int) -> Double) +} + +extension DelayOption { + + /// Returns a delay computed as a function of some iteration counter. + func make(_ attempt: Int) -> Double { + switch self { + case .immediate: return 0.0 + case .constant(let time): return time + case .exponential(let initial, let base, let maxDelay): + // for first attempt, simply use initial delay, otherwise calculate delay + let delay = attempt == 1 ? initial : initial * pow(base, Double(attempt - 1)) + return min(maxDelay, delay) + case .custom(let closure): return closure(attempt) + } + } + +} diff --git a/BlockV/Core/Helpers/KeyPath.swift b/BlockV/Core/Helpers/KeyPath.swift index 7277a8b3..6cef1cf6 100644 --- a/BlockV/Core/Helpers/KeyPath.swift +++ b/BlockV/Core/Helpers/KeyPath.swift @@ -12,7 +12,7 @@ import Foundation /// Simple struct that models a key path. -public struct KeyPath: Equatable { +struct KeyPath: Equatable { private(set) var segments: [String] var isEmpty: Bool { return segments.isEmpty } @@ -32,7 +32,7 @@ public struct KeyPath: Equatable { } /// Initializes a KeyPath with a string of the form "this.is.a.keypath" -public extension KeyPath { +extension KeyPath { /// This init is only required for testing - file access is limited to internal to allow for testing export init() { @@ -46,13 +46,67 @@ public extension KeyPath { /// Initializ a KeyPath using a string literal. extension KeyPath: ExpressibleByStringLiteral { - public init(stringLiteral value: String) { + init(stringLiteral value: String) { self.init(value) } - public init(unicodeScalarLiteral value: String) { + init(unicodeScalarLiteral value: String) { self.init(value) } - public init(extendedGraphemeClusterLiteral value: String) { + init(extendedGraphemeClusterLiteral value: String) { self.init(value) } } + +// MARK: - Dictionary Keypath + +extension Dictionary where Key == String { + subscript(keyPath keyPath: KeyPath) -> Any? { + get { + switch keyPath.headAndTail() { + case nil: + // key path is empty. + return nil + case let (head, remainingKeyPath)? where remainingKeyPath.isEmpty: + // Reached the end of the key path. + let key = Key(stringLiteral: head) + return self[key] + case let (head, remainingKeyPath)?: + // Key path has a tail we need to traverse. + let key = Key(stringLiteral: head) + switch self[key] { + case let nestedDict as [Key: Any]: + // Next nest level is a dictionary. + // Start over with remaining key path. + return nestedDict[keyPath: remainingKeyPath] + default: + // Next nest level isn't a dictionary. + // Invalid key path, abort. + return nil + } + } + } + set { + switch keyPath.headAndTail() { + case nil: + // key path is empty. + return + case let (head, remainingKeyPath)? where remainingKeyPath.isEmpty: + // Reached the end of the key path. + let key = Key(stringLiteral: head) + self[key] = newValue as? Value + case let (head, remainingKeyPath)?: + let key = Key(stringLiteral: head) + let value = self[key] + switch value { + case var nestedDict as [Key: Any]: + // Key path has a tail we need to traverse + nestedDict[keyPath: remainingKeyPath] = newValue + self[key] = nestedDict as? Value + default: + // Invalid keyPath + return + } + } + } + } +} diff --git a/BlockV/Core/Helpers/OAuth/AuthorizationServer.swift b/BlockV/Core/Helpers/OAuth/AuthorizationServer.swift new file mode 100644 index 00000000..9de3b014 --- /dev/null +++ b/BlockV/Core/Helpers/OAuth/AuthorizationServer.swift @@ -0,0 +1,154 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import SafariServices + +//TODO: Update for iOS 12 ASWebAuthenticationSession + +public final class AuthorizationServer { + + // MARK: - Properties + + // viewer config + let clientID: String // viewer app-id + let domain: String // login web app domain + let scope: String // requested scope + let redirectURI: String // publisher registered redirect url + + var receivedCode: String? + var receivedState: String? + + private var authSession: SFAuthenticationSession? + private var savedState: String? + + // MARK: - Initialization + + public init(clientID: String, domain: String, scope: String, redirectURI: String) { + self.clientID = clientID + self.domain = domain + self.scope = scope + self.redirectURI = redirectURI + } + + // MARK: - Methods + + /// Begins delegated authorization. + public func authorize(handler: @escaping (Bool) -> Void) { + + savedState = generateState(withLength: 20) + + var urlComp = URLComponents(string: domain)! + + urlComp.queryItems = [ + URLQueryItem(name: "response_type", value: "code"), + URLQueryItem(name: "client_id", value: clientID), + URLQueryItem(name: "redirect_uri", value: redirectURI), + URLQueryItem(name: "state", value: savedState!), + URLQueryItem(name: "scope", value: scope), + URLQueryItem(name: "nopadding", value: "1") + ] + + //TODO: Should the `callbackURLScheme` include more than the redirectURL, e.g. bundle identifier? + // init an auth session + authSession = SFAuthenticationSession(url: urlComp.url!, + callbackURLScheme: redirectURI, + completionHandler: { (url, error) in + guard error == nil else { + return handler(false) + } + + handler(url != nil && self.parseAuthorizeRedirectURL(url!)) + + }) + // start the authentication session + authSession?.start() + + } + + /// Parse authorised redirect URL. + /// + /// `code` and `state` instance properties are updated. + /// + /// - Parameter url: Parses out the information in the redirect URL. + /// - Returns: Boolean value indicating the outcome of parsing the authorize redirect url. + func parseAuthorizeRedirectURL(_ url: URL) -> Bool { + + // decompose into components + guard let urlComp = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + authSession?.cancel() + return false + } + // find query items + guard let items = urlComp.queryItems else { + authSession?.cancel() + return false + } + // extract code and state + receivedCode = items.first(where: { $0.name == "code" })?.value + receivedState = items.first(where: { $0.name == "state" })?.value + + // dismiss + authSession?.cancel() + return receivedCode != nil && receivedState != nil + + } + + /// Exchanges authorization code for tokens. + /// + /// - Parameter completion: Completion handler that is called once the request has been processed. + func getToken(completion: @escaping (Result) -> Void) { + // sanity checks + guard let code = receivedCode else { + let error = BVError.session(reason: .invalidAuthorizationCode) + completion(.failure(error)) + return + } + // security: scheck state match + guard savedState == receivedState else { + let error = BVError.session(reason: .nonMatchingStates) + completion(.failure(error)) + return + } + + // build token exchange endpoint + let endpoint = API.Session.tokenExchange(grantType: "authorization_code", + clientID: self.clientID, + code: code, + redirectURI: redirectURI) + + // perform request + BLOCKv.client.request(endpoint) { result in + switch result { + case .success(let model): completion(.success(model)) + case .failure(let error): completion(.failure(error)) + } + } + + } + + // MARK: Helpers + + private func generateState(withLength len: Int) -> String { + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + let length = UInt32(letters.count) + + var randomString = "" + for _ in 0.. Void) -> TimeInterval { + var info = mach_timebase_info() + guard mach_timebase_info(&info) == KERN_SUCCESS else { return -1 } + + let start = mach_absolute_time() + block() + let end = mach_absolute_time() + + let elapsed = end - start + let nanos = elapsed * UInt64(info.numer) / UInt64(info.denom) + return TimeInterval(nanos) / TimeInterval(NSEC_PER_SEC) +} diff --git a/BlockV/Core/Network/Models/AppUpdateModel.swift b/BlockV/Core/Network/Models/AppUpdateModel.swift new file mode 100644 index 00000000..d8055181 --- /dev/null +++ b/BlockV/Core/Network/Models/AppUpdateModel.swift @@ -0,0 +1,38 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +public struct AppUpdateModel: Decodable { + + /// Latest supported app version. + public var supportedVersion: String = "0" + + /// Update URL. + public var updateURL: String + + /// Update text. + public var updateText: String? + + enum CodingKeys: String, CodingKey { + case supportedVersion = "supported_version" + case updateURL = "update_url" + case updateText = "update_text" + } + + public init(from decoder: Decoder) throws { + let items = try decoder.container(keyedBy: CodingKeys.self) + supportedVersion = try items.decode(String.self, forKey: .supportedVersion) + updateURL = try items.decode(String.self, forKey: .updateURL) + updateText = try items.decodeIfPresent(String.self, forKey: .updateText) + } + +} diff --git a/BlockV/Core/Network/Models/AssetProviderModel.swift b/BlockV/Core/Network/Models/AssetProviderModel.swift index 0e8dcc4c..97fac296 100644 --- a/BlockV/Core/Network/Models/AssetProviderModel.swift +++ b/BlockV/Core/Network/Models/AssetProviderModel.swift @@ -11,6 +11,14 @@ import Foundation +struct AssetProviderRefreshModel: Decodable, Equatable { + let assetProviders: [AssetProviderModel] + + enum CodingKeys: String, CodingKey { + case assetProviders = "asset_provider" + } +} + typealias URLEncoder = (_ url: URL, _ assetProviders: [AssetProviderModel]) -> URL struct AssetProviderModel: Codable, Equatable { diff --git a/BlockV/Core/Network/Models/BVToken.swift b/BlockV/Core/Network/Models/BVToken.swift index 4e99cece..fd79810e 100644 --- a/BlockV/Core/Network/Models/BVToken.swift +++ b/BlockV/Core/Network/Models/BVToken.swift @@ -17,12 +17,10 @@ import Foundation struct BVToken: Codable, Equatable { let token: String let tokenType: String - let expiresIn: Int enum CodingKeys: String, CodingKey { case token = "token" case tokenType = "token_type" - case expiresIn = "expires_in" } } diff --git a/BlockV/Core/Network/Models/Geo Group/GeoGroupModel.swift b/BlockV/Core/Network/Models/Geo Group/GeoGroupModel.swift index 0944a397..4e6a1597 100644 --- a/BlockV/Core/Network/Models/Geo Group/GeoGroupModel.swift +++ b/BlockV/Core/Network/Models/Geo Group/GeoGroupModel.swift @@ -14,7 +14,7 @@ import CoreLocation /// Represents a geo discover groups response. public struct GeoModel: Decodable { - let groups: [GeoGroupModel] + public let groups: [GeoGroupModel] enum CodingKeys: String, CodingKey { case groups diff --git a/BlockV/Core/Network/Models/OAuthTokenExchangeModel.swift b/BlockV/Core/Network/Models/OAuthTokenExchangeModel.swift new file mode 100644 index 00000000..ee4f449f --- /dev/null +++ b/BlockV/Core/Network/Models/OAuthTokenExchangeModel.swift @@ -0,0 +1,28 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +public struct OAuthTokenExchangeModel: Decodable, Equatable { + let accessToken: String + let refreshToken: String + let tokenType: String + let expriesIn: Double + let scope: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + case tokenType = "token_type" + case expriesIn = "expires_in" + case scope = "scope" + } +} diff --git a/BlockV/Core/Network/Models/Package/Action/ActionModel+Descriptor.swift b/BlockV/Core/Network/Models/Package/Action/ActionModel+Descriptor.swift new file mode 100644 index 00000000..7998fabb --- /dev/null +++ b/BlockV/Core/Network/Models/Package/Action/ActionModel+Descriptor.swift @@ -0,0 +1,50 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +//swiftlint:disable identifier_name + +extension ActionModel: Descriptable { + + init(from descriptor: [String: Any]) throws { + + guard + let _compoundName = descriptor["name"] as? String, + let _metaDescriptor = descriptor["meta"] as? [String: Any], + let _propertiesDescriptor = descriptor["properties"] as? [String: Any] + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + let (templateID, actionName) = try ActionModel.splitCompoundName(_compoundName) + let meta = try MetaModel(from: _metaDescriptor) + let properties = try Properties(from: _propertiesDescriptor) + + self.init(compoundName: _compoundName, + name: actionName, + templateID: templateID, + meta: meta, + properties: properties) + + } + +} + +extension ActionModel.Properties: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _reactor = descriptor["reactor"] as? String + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.init(reactor: _reactor) + } + +} diff --git a/BlockV/Core/Network/Models/Package/ActionModel.swift b/BlockV/Core/Network/Models/Package/Action/ActionModel.swift similarity index 95% rename from BlockV/Core/Network/Models/Package/ActionModel.swift rename to BlockV/Core/Network/Models/Package/Action/ActionModel.swift index 52b02c39..eb409d5c 100644 --- a/BlockV/Core/Network/Models/Package/ActionModel.swift +++ b/BlockV/Core/Network/Models/Package/Action/ActionModel.swift @@ -84,7 +84,7 @@ extension ActionModel: Codable { extension ActionModel { /// Extract action and template name from the compound name. - private static func splitCompoundName(_ compoundName: String) throws -> (String, String) { + static func splitCompoundName(_ compoundName: String) throws -> (String, String) { // find the marker guard let markerRange = compoundName.range(of: "::action::", @@ -110,8 +110,8 @@ extension ActionModel { extension ActionModel: Hashable { - public var hashValue: Int { - return compoundName.hashValue + public func hash(into hasher: inout Hasher) { + hasher.combine(compoundName) } } diff --git a/BlockV/Core/Network/Models/Package/Face/FaceModel+Descriptable.swift b/BlockV/Core/Network/Models/Package/Face/FaceModel+Descriptable.swift new file mode 100644 index 00000000..6fbc970e --- /dev/null +++ b/BlockV/Core/Network/Models/Package/Face/FaceModel+Descriptable.swift @@ -0,0 +1,94 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import GenericJSON + +//swiftlint:disable identifier_name + +extension FaceModel: Descriptable { + + init(from descriptor: [String: Any]) throws { + + guard + let _id = descriptor["id"] as? String, + let _templateID = descriptor["template"] as? String, + let _metaDescriptor = descriptor["meta"] as? [String: Any], + let _propertiesDescriptor = descriptor["properties"] as? [String: Any] + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.id = _id + self.templateID = _templateID + self.properties = try Properties(from: _propertiesDescriptor) + self.meta = try MetaModel(from: _metaDescriptor) + // convenience + isNative = properties.displayURL.hasPrefix("native://") + isWeb = properties.displayURL.hasPrefix("https://") + + } + +} + +extension FaceModel.Properties: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _displayURL = descriptor["display_url"] as? String, + let _constraintsDescriptor = descriptor["constraints"] as? [String: Any], + let _resources = descriptor["resources"] as? [String] + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + let _config = descriptor["config"] as? [String: Any] + + self.displayURL = _displayURL + self.constraints = try FaceModel.Properties.Constraints(from: _constraintsDescriptor) + self.resources = _resources + self.config = try? JSON(_config) + + } + +} + +extension FaceModel.Properties.Constraints: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _viewMode = descriptor["view_mode"] as? String, + let _platform = descriptor["platform"] as? String + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.viewMode = _viewMode + self.platform = _platform + + } + +} + +extension MetaModel: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _createdBy = descriptor["created_by"] as? String, + let _dataType = descriptor["data_type"] as? String, + let _whenCreated = descriptor["when_created"] as? String, + let _whenModified = descriptor["when_modified"] as? String, + let _whenCreatedDate = DateFormatter.blockvDateFormatter.date(from: _whenCreated), + let _whenModifiedDate = DateFormatter.blockvDateFormatter.date(from: _whenModified) + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.init(createdBy: _createdBy, + dataType: _dataType, + whenCreated: _whenCreatedDate, + whenModified: _whenModifiedDate) + + } + +} diff --git a/BlockV/Core/Network/Models/Package/FaceModel.swift b/BlockV/Core/Network/Models/Package/Face/FaceModel.swift similarity index 97% rename from BlockV/Core/Network/Models/Package/FaceModel.swift rename to BlockV/Core/Network/Models/Package/Face/FaceModel.swift index c0a04ade..99a9e338 100644 --- a/BlockV/Core/Network/Models/Package/FaceModel.swift +++ b/BlockV/Core/Network/Models/Package/Face/FaceModel.swift @@ -100,8 +100,8 @@ extension FaceModel: Codable { extension FaceModel: Hashable { /// Faces are uniquely identified by their platform identifier. - public var hashValue: Int { - return id.hashValue + public func hash(into hasher: inout Hasher) { + hasher.combine(id) } } diff --git a/BlockV/Core/Network/Models/Package/HashModel.swift b/BlockV/Core/Network/Models/Package/HashModel.swift new file mode 100644 index 00000000..b381dd6f --- /dev/null +++ b/BlockV/Core/Network/Models/Package/HashModel.swift @@ -0,0 +1,36 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +// MARK: - Hash + +public struct InventoryHashModel: Codable { + let hash: String +} + +// MARK: - Sync + +public struct VatomSyncModel: Codable { + let id: String + let sync: UInt +} + +public struct InventorySyncModel: Codable { + + let vatoms: [VatomSyncModel] + let nextToken: String + + enum CodingKeys: String, CodingKey { + case vatoms + case nextToken = "next_token" + } +} diff --git a/BlockV/Core/Network/Models/Package/Vatom/VatomModel+Descriptor.swift b/BlockV/Core/Network/Models/Package/Vatom/VatomModel+Descriptor.swift new file mode 100644 index 00000000..d30f9e54 --- /dev/null +++ b/BlockV/Core/Network/Models/Package/Vatom/VatomModel+Descriptor.swift @@ -0,0 +1,259 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import GenericJSON + +//swiftlint:disable identifier_name + +protocol Descriptable { + init(from descriptor: [String: Any]) throws +} + +extension VatomModel: Descriptable { + + init(from descriptor: [String: Any]) throws { + + guard + let _id = descriptor["id"] as? String, + let _version = descriptor["version"] as? String, + let _whenCreated = descriptor["when_created"] as? String, + let _whenModified = descriptor["when_modified"] as? String, + let _sync = descriptor["sync"] as? UInt, + let _private = descriptor["private"] as? [String: Any], + let _rootDescriptor = descriptor["vAtom::vAtomType"] as? [String: Any] + + else { throw BVError.modelDecoding(reason: "Model decoding failed: \(type(of: self))") } + + self.id = _id + self.version = _version + self.whenCreated = DateFormatter.blockvDateFormatter.date(from: _whenCreated)! //FIXME: force + self.whenModified = DateFormatter.blockvDateFormatter.date(from: _whenModified)! //FIXME: force + self.sync = _sync + self.private = try? JSON.init(_private) + self.props = try RootProperties(from: _rootDescriptor) + + self.faceModels = [] + self.actionModels = [] + + self.eth = nil + self.eos = nil + + self.isUnpublished = (descriptor["unpublished"] as? Bool) ?? false + + } + +} + +extension RootProperties: Descriptable { + + init(from descriptor: [String: Any]) throws { + + guard + let _author = descriptor["author"] as? String, + let _rootType = descriptor["root_type"] as? String, + let _templateID = descriptor["template"] as? String, + let _templateVariationID = descriptor["template_variation"] as? String, + let _publisherFQDN = descriptor["publisher_fqdn"] as? String, + let _title = descriptor["title"] as? String, + let _description = descriptor["description"] as? String, + + let _category = descriptor["category"] as? String, + let _clonedFrom = descriptor["cloned_from"] as? String, + let _cloningScore = descriptor["cloning_score"] as? Double, + let _commerceDescriptor = descriptor["commerce"] as? [String: Any], + let _isInContract = descriptor["in_contract"] as? Bool, + let _inContractWith = descriptor["in_contract_with"] as? String, + let _notifyMessage = descriptor["notify_msg"] as? String, + let _numberDirectClones = descriptor["num_direct_clones"] as? Int, + let _owner = descriptor["owner"] as? String, + let _parentID = descriptor["parent_id"] as? String, + let _transferredBy = descriptor["transferred_by"] as? String, + let _visibilityDescriptor = descriptor["visibility"] as? [String: Any], + + let _isAcquirable = descriptor["acquirable"] as? Bool, + let _isRedeemable = descriptor["redeemable"] as? Bool, + let _isDisabled = descriptor["disabled"] as? Bool, + let _isDropped = descriptor["dropped"] as? Bool, + let _isTradeable = descriptor["tradeable"] as? Bool, + let _isTransferable = descriptor["transferable"] as? Bool, + + let _geoPositionDescriptor = descriptor["geo_pos"] as? [String: Any], + let _resourceDescriptor = descriptor["resources"] as? [[String: Any]] + + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + // decode if present + let _childPolicyDescriptor = descriptor["child_policy"] as? [[String: Any]] ?? [] + let _tags = descriptor["tags"] as? [String] ?? [] + + self.author = _author + self.rootType = _rootType + self.templateID = _templateID + self.templateVariationID = _templateVariationID + self.publisherFQDN = _publisherFQDN + self.title = _title + self.description = _description + + self.category = _category + self.childPolicy = _childPolicyDescriptor.compactMap { try? VatomChildPolicy(from: $0) } + self.clonedFrom = _clonedFrom + self.cloningScore = _cloningScore + self.commerce = try Commerce(from: _commerceDescriptor) + self.isInContract = _isInContract + self.inContractWith = _inContractWith + self.notifyMessage = _notifyMessage + self.numberDirectClones = _numberDirectClones + self.owner = _owner + self.parentID = _parentID + self.tags = _tags + self.transferredBy = _transferredBy + self.visibility = try Visibility(from: _visibilityDescriptor) + + self.isAcquirable = _isAcquirable + self.isRedeemable = _isRedeemable + self.isDisabled = _isDisabled + self.isDropped = _isDropped + self.isTradeable = _isTradeable + self.isTransferable = _isTransferable + + self.geoPosition = try GeoPosition(from: _geoPositionDescriptor) + self.resources = _resourceDescriptor.compactMap { try? VatomResourceModel(from: $0) } + + } + +} + +extension RootProperties.Visibility: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _type = descriptor["type"] as? String, + let _value = descriptor["value"] as? String + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.type = _type + self.value = _value + + } + +} + +extension RootProperties.Commerce: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _pricingDescriptor = descriptor["pricing"] as? [String: Any], + let _vatomPricing = try? VatomPricing(from: _pricingDescriptor) + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.pricing = _vatomPricing + + } + +} + +extension VatomPricing: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _pricingType = descriptor["pricingType"] as? String, + let _valueDescriptor = descriptor["value"] as? [String: Any], + let _currency = _valueDescriptor["currency"] as? String, + let _price = _valueDescriptor["price"] as? String, + let _validFrom = _valueDescriptor["valid_from"] as? String, + let _validThrough = _valueDescriptor["valid_through"] as? String, + let _isVatIncluded = _valueDescriptor["vat_included"] as? Bool + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.pricingType = _pricingType + self.currency = _currency + self.price = _price + self.validFrom = _validFrom + self.validThrough = _validThrough + self.isVatIncluded = _isVatIncluded + } + +} + +extension VatomChildPolicy: Descriptable { + + init(from descriptor: [String: Any]) throws { + + guard + let _count = descriptor["count"] as? Int, + let _creationPolicyDescriptor = descriptor["creation_policy"] as? [String: Any], + let _templateVariationID = descriptor["template_variation"] as? String + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.count = _count + self.creationPolicy = try CreationPolicy(from: _creationPolicyDescriptor) + self.templateVariationID = _templateVariationID + } + +} + +extension VatomChildPolicy.CreationPolicy: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _autoCreate = descriptor["auto_create"] as? String, + let _autoCreateCount = descriptor["auto_create_count"] as? Int, + let _autoCreateCountRandom = descriptor["auto_create_count_random"] as? Bool, + let _enforcePolicyCountMax = descriptor["enforce_policy_count_max"] as? Bool, + let _enforcePolicyCountMin = descriptor["enforce_policy_count_min"] as? Bool, + let _policyCountMax = descriptor["policy_count_max"] as? Int, + let _policyCountMin = descriptor["policy_count_min"] as? Int + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.autoCreate = _autoCreate + self.autoCreateCount = _autoCreateCount + self.autoCreateCountRandom = _autoCreateCountRandom + self.enforcePolicyCountMax = _enforcePolicyCountMax + self.enforcePolicyCountMin = _enforcePolicyCountMin + self.policyCountMax = _policyCountMax + self.policyCountMin = _policyCountMin + + } + +} + +extension RootProperties.GeoPosition: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _type = descriptor["type"] as? String, + let _coordinates = descriptor["coordinates"] as? [Double] + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.type = _type + self.coordinates = _coordinates + } + +} + +extension VatomResourceModel: Descriptable { + + init(from descriptor: [String: Any]) throws { + guard + let _name = descriptor["name"] as? String, + let _type = descriptor["resourceType"] as? String, + let _value = (descriptor["value"] as? [String: Any])?["value"] as? String, + let _url = URL(string: _value) + else { throw BVError.modelDecoding(reason: "Model decoding failed.") } + + self.name = _name + self.type = _type + self.url = _url + + } + +} diff --git a/BlockV/Core/Network/Models/Package/VatomModel+Update.swift b/BlockV/Core/Network/Models/Package/Vatom/VatomModel+Update.swift similarity index 93% rename from BlockV/Core/Network/Models/Package/VatomModel+Update.swift rename to BlockV/Core/Network/Models/Package/Vatom/VatomModel+Update.swift index be6d475e..eb61be2a 100644 --- a/BlockV/Core/Network/Models/Package/VatomModel+Update.swift +++ b/BlockV/Core/Network/Models/Package/Vatom/VatomModel+Update.swift @@ -50,9 +50,9 @@ extension VatomModel { rootProperties["in_contract"]?.boolValue.flatMap { vatom.props.isInContract = $0 } rootProperties["in_contract_with"]?.stringValue.flatMap { vatom.props.inContractWith = $0 } rootProperties["transferred_by"]?.stringValue.flatMap { vatom.props.transferredBy = $0 } - rootProperties["num_direct_clones"]?.floatValue.flatMap { vatom.props.numberDirectClones = Int($0) } + rootProperties["num_direct_clones"]?.doubleValue.flatMap { vatom.props.numberDirectClones = Int($0) } rootProperties["cloned_from"]?.stringValue.flatMap { vatom.props.clonedFrom = $0 } - rootProperties["cloning_score"]?.floatValue.flatMap { vatom.props.cloningScore = Double($0) } + rootProperties["cloning_score"]?.doubleValue.flatMap { vatom.props.cloningScore = Double($0) } rootProperties["acquirable"]?.boolValue.flatMap { vatom.props.isAcquirable = $0 } rootProperties["redeemable"]?.boolValue.flatMap { vatom.props.isRedeemable = $0 } rootProperties["disabled"]?.boolValue.flatMap { vatom.props.isDisabled = $0 } @@ -75,11 +75,8 @@ extension VatomModel { .flatMap { vatom.props.commerce.pricing.validThrough = $0 } rootProperties["commerce"]?["pricing"]?["value"]?["vat_included"]?.boolValue .flatMap { vatom.props.commerce.pricing.isVatIncluded = $0 } - - //FIXME: There is a data type issue here. [18.68768, -33.824017] is converted to - // [18.687679290771484, -33.82401657104492] rootProperties["geo_pos"]?["coordinates"]?.arrayValue.flatMap { - vatom.props.geoPosition.coordinates = $0.compactMap { $0.floatValue }.map { Double($0) } + vatom.props.geoPosition.coordinates = $0.compactMap { $0.doubleValue } } // TODO: Version diff --git a/BlockV/Core/Network/Models/Package/VatomModel.swift b/BlockV/Core/Network/Models/Package/Vatom/VatomModel.swift similarity index 98% rename from BlockV/Core/Network/Models/Package/VatomModel.swift rename to BlockV/Core/Network/Models/Package/Vatom/VatomModel.swift index 499f471e..71329bf7 100644 --- a/BlockV/Core/Network/Models/Package/VatomModel.swift +++ b/BlockV/Core/Network/Models/Package/Vatom/VatomModel.swift @@ -23,6 +23,7 @@ public struct VatomModel: Equatable { // variables public var whenModified: Date public var isUnpublished: Bool + public var sync: UInt /// Template properties. /// @@ -58,6 +59,7 @@ public struct VatomModel: Equatable { case actionModels = "actions" case eos = "eos" case eth = "eth" + case sync = "sync" } } @@ -71,8 +73,9 @@ extension VatomModel: Codable { version = try items.decode(String.self, forKey: .version) whenCreated = try items.decode(Date.self, forKey: .whenCreated) whenModified = try items.decode(Date.self, forKey: .whenModified) + sync = try items.decode(UInt.self, forKey: .sync) props = try items.decode(RootProperties.self, forKey: .props) - + isUnpublished = try items.decodeIfPresent(Bool.self, forKey: .isUnpublished) ?? false `private` = try items.decodeIfPresent(JSON.self, forKey: .private) eos = try items.decodeIfPresent(JSON.self, forKey: .eos) @@ -102,9 +105,10 @@ extension VatomModel: Codable { extension VatomModel: Hashable { /// vAtoms are uniquely identified by their platform identifier. - public var hashValue: Int { - return id.hashValue + public func hash(into hasher: inout Hasher) { + hasher.combine(id) } + } // MARK: - Vatom Root Properties diff --git a/BlockV/Core/Network/Models/Package/VatomResourceModel.swift b/BlockV/Core/Network/Models/Package/Vatom/VatomResourceModel.swift similarity index 93% rename from BlockV/Core/Network/Models/Package/VatomResourceModel.swift rename to BlockV/Core/Network/Models/Package/Vatom/VatomResourceModel.swift index 2935f591..6ca90ccb 100644 --- a/BlockV/Core/Network/Models/Package/VatomResourceModel.swift +++ b/BlockV/Core/Network/Models/Package/Vatom/VatomResourceModel.swift @@ -61,7 +61,10 @@ extension VatomResourceModel: Codable { extension VatomResourceModel: Hashable { - public var hashValue: Int { - return name.hashValue ^ type.hashValue ^ url.hashValue + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(type) + hasher.combine(url) } + } diff --git a/BlockV/Core/Network/Models/Package/Vatom/VatomUpdateModel.swift b/BlockV/Core/Network/Models/Package/Vatom/VatomUpdateModel.swift new file mode 100644 index 00000000..fac6dce4 --- /dev/null +++ b/BlockV/Core/Network/Models/Package/Vatom/VatomUpdateModel.swift @@ -0,0 +1,36 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +public struct VatomUpdateModel: Decodable, Equatable { + + let numberUpdated: Int + let numberErrors: Int + let errorMessage: [String: String] + let ids: [String] + + enum CodingKeys: String, CodingKey { + case numberUpdated = "num_updated" + case numberErrors = "num_errors" + case errorMessage = "error_messages" + case ids + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + numberUpdated = try values.decode(Int.self, forKey: .numberUpdated) + numberErrors = try values.decode(Int.self, forKey: .numberErrors) + errorMessage = try values.decodeIfPresent([String: String].self, forKey: .errorMessage) ?? [:] + ids = try values.decode([String].self, forKey: .ids) + } + +} diff --git a/BlockV/Core/Network/Models/User/AddressAccountModel.swift b/BlockV/Core/Network/Models/User/AddressAccountModel.swift new file mode 100644 index 00000000..e05d260a --- /dev/null +++ b/BlockV/Core/Network/Models/User/AddressAccountModel.swift @@ -0,0 +1,32 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/// Eth address response model. +public struct AddressAccountModel: Codable, Equatable { + + public let id: String + public let userId: String + public let address: String + public let type: String + public let createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId = "user_id" + case address + case type + case createdAt = "created_at" + } + +} + diff --git a/BlockV/Core/Network/Models/User/FullTokenModel.swift b/BlockV/Core/Network/Models/User/FullTokenModel.swift index 525213f3..ced50fa7 100644 --- a/BlockV/Core/Network/Models/User/FullTokenModel.swift +++ b/BlockV/Core/Network/Models/User/FullTokenModel.swift @@ -22,6 +22,7 @@ public struct FullTokenModel: Codable, Equatable { public let appID: String public let isConfirmed: Bool public let isDefault: Bool + public let isDisabled: Bool public let token: String public let tokenType: String public let userID: String @@ -31,6 +32,7 @@ public struct FullTokenModel: Codable, Equatable { case appID = "app_id" case isConfirmed = "confirmed" case isDefault = "is_default" + case isDisabled = "disabled" case token = "token" case tokenType = "token_type" case userID = "user_id" diff --git a/BlockV/Core/Network/Models/User/UserToken.swift b/BlockV/Core/Network/Models/User/UserToken.swift index 68bad290..eccfa3bc 100644 --- a/BlockV/Core/Network/Models/User/UserToken.swift +++ b/BlockV/Core/Network/Models/User/UserToken.swift @@ -24,9 +24,10 @@ import Foundation /// Models types of user tokens supported on the BLOCKv platform. public enum UserTokenType: String, Codable { - case phone = "phone_number" - case email = "email" - case id = "id" + case phone = "phone_number" + case email = "email" + case id = "id" + case username = "username" } /// User token model. diff --git a/BlockV/Core/Network/Params/TokenRegisterParams.swift b/BlockV/Core/Network/Params/TokenRegisterParams.swift index e0b3d4ce..5c5a7bbd 100644 --- a/BlockV/Core/Network/Params/TokenRegisterParams.swift +++ b/BlockV/Core/Network/Params/TokenRegisterParams.swift @@ -23,6 +23,7 @@ public struct UserInfo: Encodable { public var birthday: String? public var isAvatarPublic: Bool? public var language: String? + public var nonPushNotification: Bool? // email/sms public init(firstName: String? = nil, lastName: String? = nil, @@ -30,7 +31,8 @@ public struct UserInfo: Encodable { password: String? = nil, birthday: String? = nil, isAvatarPublic: Bool? = true, - language: String? = nil) { + language: String? = nil, + nonPushNotification: Bool? = false) { self.firstName = firstName self.lastName = lastName @@ -39,6 +41,7 @@ public struct UserInfo: Encodable { self.birthday = birthday self.isAvatarPublic = isAvatarPublic self.language = language + self.nonPushNotification = nonPushNotification } enum CodingKeys: String, CodingKey { @@ -49,6 +52,7 @@ public struct UserInfo: Encodable { case birthday = "birthday" case isAvatarPublic = "avatar_public" case language = "language" + case nonPushNotification = "nonpush_notification" } } @@ -64,7 +68,8 @@ extension UserInfo: DictionaryCodable { "password": password ?? "", "birthday": birthday ?? "", "avatar_public": isAvatarPublic ?? true, - "language": language ?? "" + "language": language ?? "", + "nonpush_notification": nonPushNotification ?? false ] } @@ -95,6 +100,9 @@ extension UserInfo: DictionaryCodable { if let language = language { params["language"] = language } + if let nonPushNotification = nonPushNotification { + params["nonpush_notification"] = nonPushNotification + } return params } diff --git a/BlockV/Core/Network/Stack/API+TypedResponses.swift b/BlockV/Core/Network/Stack/API+TypedResponses.swift new file mode 100644 index 00000000..7251d2f4 --- /dev/null +++ b/BlockV/Core/Network/Stack/API+TypedResponses.swift @@ -0,0 +1,449 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import Alamofire + +/// Extension on API which provides endpoints whcih return typed models. +extension API { + + /// Namespace for session related endpoints with a typed reponse model. + enum Session { + + // MARK: - Push Notifications + + /// Builds the endpoint to update the push notification settings. + static func updatePushNotification(fcmToken: String, + platformID: String = "ios", + enabled: Bool = true) -> Endpoint> { + return Endpoint(method: .post, + path: "v1/user/pushnotification", + parameters: [ + "fcm_token": fcmToken, + "platform_id": platformID, + "on": enabled + ]) + + } + + // MARK: - Version & Support + + /// Builds the endpoint to fetch the support application version and update metadata. + static func getSupportedVersion() -> Endpoint> { + return Endpoint(path: "/v1/general/app/version") + } + + // MARK: - OAuth + + /// Builds the endpoint to exchange an auth code for session tokens. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func tokenExchange(grantType: String, clientID: String, code: String, redirectURI: String) + -> Endpoint { + return API.Generic.token(grantType: grantType, clientID: clientID, code: code, redirectURI: redirectURI) + } + + // MARK: Asset Providers + + /// Builds the endpoint to refresh asset provider credentials. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getAssetProviders() -> Endpoint> { + return API.Generic.getAssetProviders() + } + + // MARK: Register + + private static let registerPath = "/v1/users" + + /// Builds the endpoint for new user registration. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func register(tokens: [RegisterTokenParams], userInfo: UserInfo? = nil) -> + Endpoint> { + + precondition(!tokens.isEmpty, "One or more tokens must be supplied for this endpoint.") + + // dictionary of user information + var params = [String: Any]() + if let userInfo = userInfo { + params = userInfo.toDictionary() + } + + // create an array of tokens in their dictionary representation + let tokens = tokens.map { $0.toDictionary() } + params["user_tokens"] = tokens + + return Endpoint(method: .post, + path: registerPath, + parameters: params) + + } + + // MARK: Login + + private static let loginPath = "/v1/user/login" + + /// Builds the endpoint for user login. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func login(tokenParams: LoginTokenParams) -> Endpoint> { + return Endpoint(method: .post, + path: loginPath, + parameters: tokenParams.toDictionary()) + } + + } + + /// Namespace for current user related endpoints with a typed reponse model. + enum CurrentUser { + + private static let currentUserPath = "/v1/user" + + /// Builds the endpoint to get the current user's properties. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func get() -> Endpoint> { + return Endpoint(path: currentUserPath) + } + + /// Builds the endpoint to get the current user's tokens. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getTokens() -> Endpoint> { + return Endpoint(path: currentUserPath + "/tokens") + } + + /// Builds the endpoint to get the current user's accounts. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getAccounts() -> Endpoint> { + return Endpoint(path: currentUserPath + "/accounts") + } + + /// Builds the endpoint to log out the current user. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func logOut() -> Endpoint> { + return Endpoint(method: .post, path: currentUserPath + "/logout") + } + + /// Builds the endpoint to update current user's information. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func update(userInfo: UserInfo) -> Endpoint> { + return Endpoint(method: .patch, + path: currentUserPath, + parameters: userInfo.toSafeDictionary() + ) + } + + /// Builds the endpoint to verify a token with an OTP code. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func verifyToken(_ token: UserToken, code: String) -> Endpoint> { + return Endpoint(method: .post, + path: currentUserPath + "/verify_token", + parameters: [ + "token": token.value, + "token_type": token.type.rawValue, + "verify_code": code + ] + ) + } + + /// Builds the endpoint to reset a user token. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func resetToken(_ token: UserToken) -> Endpoint> { + return Endpoint(method: .post, + path: currentUserPath + "/reset_token", + parameters: token.toDictionary() + ) + } + + /// Builds the endpoint to send a verification request for a specific token. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func resetTokenVerification(forToken token: UserToken) -> Endpoint> { + return Endpoint(method: .post, + path: currentUserPath + "/reset_token_verification", + parameters: token.toDictionary() + ) + } + + /// Builds the endpoint to add a token to the current user. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func addToken(_ token: UserToken, isPrimary: Bool) -> Endpoint> { + return Endpoint(method: .post, + path: currentUserPath + "/tokens", + parameters: [ + "token": token.value, + "token_type": token.type.rawValue, + "is_primary": isPrimary + ] + ) + } + + /// Builds the endpoint to delete a token. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func deleteToken(id: String) -> Endpoint> { + return Endpoint(method: .delete, + path: currentUserPath + "/tokens/\(id)") + } + + /// Builds the endpoint to set a default token. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func setDefaultToken(id: String) -> Endpoint> { + return Endpoint(method: .put, + path: currentUserPath + "/tokens/\(id)/default") + } + + // MARK: Avatar + + /// Builds the endpoint for the user's avatar. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func uploadAvatar(_ imageData: Data) -> UploadEndpoint> { + + let bodyPart = MultiformBodyPart(data: imageData, + name: "avatar", + fileName: "avatar.png", + mimeType: "image/png") + return UploadEndpoint(path: "/v1/user/avatar", + bodyPart: bodyPart) + + } + + // MARK: Messaging + + /// Builds the endpoint to allow the current user to send a message to a user token. + /// + /// - Parameters: + /// - message: Content of the message. + /// - userID: Unique identifier of the recipient user. + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func sendMessage(_ message: String, toUserId userId: String) -> Endpoint> { + return Endpoint(method: .post, + path: currentUserPath + "/message", + parameters: [ + "message": message, + "id": userId]) + } + + // MARK: DEBUG + + /// DO NOT EXPOSE. ONLY USE FOR TESTING. + /// + /// Builds the endpoint to allow the current user to be deleted. + static func deleteCurrentUser() -> Endpoint> { + return Endpoint(method: .delete, path: "/v1/user") + } + + } + + /// Namespace for public user related endpoints with a typed reponse model. + enum PublicUser { + + /// Builds the endpoint to get a public user's details. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func get(id: String) -> Endpoint> { + return Endpoint(path: "/v1/users/\(id)") + } + + } + + /// Namespace for vatom related endpoints with a typed reponse model. + enum Vatom { + + /// Builds an endpoint to get the current user's inventory sync hash. + /// + /// - Returns: Constructed endpoint specialized to parse out a `InventoryHashModel`. + static func getInventoryHash() -> Endpoint> { + return API.Generic.getInventoryHash() + } + + /// Builds an endpoint to get the current user's inventory vatoms' sync number. + /// + /// - Returns: Constructed endpoint specialized to parse out a `InventorySyncModel`. + static func getInventoryVatomSyncNumbers(limit: Int = 1000, + token: String) -> Endpoint> { + return API.Generic.getInventoryVatomSyncNumbers(limit: limit, token: token) + } + + /// Builds an endpoint to get the current user's inventory. + /// + /// The inventory call is essentially an optimized discover call. The server-pattern is from the child's + /// perspetive. That is, we specify the id of the parent who's children are to be retunred. + /// + /// - Returns: Constructed endpoint specialized to parse out a `UnpackedModel`. + static func getInventory(parentID: String, + page: Int = 0, + limit: Int = 0) -> Endpoint> { + return API.Generic.getInventory(parentID: parentID, page: page, limit: limit) + + } + + /// Builds an endpoint to get a vAtom by its unique identifier. + /// + /// - Parameter ids: Unique identifier of the vatom. + /// - Returns: Constructed endpoint specialized to parse out a `UnpackedModel`. + static func getVatoms(withIDs ids: [String]) -> Endpoint> { + return API.Generic.getVatoms(withIDs: ids) + } + + /// Builds the endpoint to trash a vAtom specified by its id. + /// + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func trashVatom(_ id: String) -> Endpoint> { + return Endpoint(method: .post, + path: "/v1/user/vatom/trash", + parameters: ["this.id": id]) + + } + + /// Builds an endpoint to update a vAtom. + /// + /// - Parameter payload: Raw payload. + /// - Returns: Constructed endpoint specialized to parse out a `VatomUpdateModel`. + static func updateVatom(payload: [String: Any]) -> Endpoint> { + return API.Generic.updateVatom(payload: payload) + } + + /// Builds an endpoint to search for vAtoms. + /// + /// - Parameter payload: Raw request payload. + /// - Returns: Constructed endpoint specialized to parse out a `UnpackedModel`. + static func discover(_ payload: [String: Any]) -> Endpoint> { + return API.Generic.discover(payload) + } + + /// Builds an endpoint to geo search for vAtoms (i.e. search for dropped vAtoms). + /// + /// Use this endpoint to fetch a collection of vAtoms. + /// + /// - Parameters: + /// - bottomLeftLat: Bottom left latitude coordinate. + /// - bottomLeftLon: Bottom left longitude coordinate. + /// - topRightLat: Top right latitude coordinate. + /// - topRightLon: Top right longitude coordinte. + /// - filter: The vAtom filter option to apply. + /// - Returns: Constructed endpoint specialized to parse out a `UnpackedModel`. + static func geoDiscover(bottomLeftLat: Double, + bottomLeftLon: Double, + topRightLat: Double, + topRightLon: Double, + filter: String) -> Endpoint> { + + return API.Generic.geoDiscover(bottomLeftLat: bottomLeftLat, + bottomLeftLon: bottomLeftLon, + topRightLat: topRightLat, + topRightLon: topRightLon, + filter: filter) + } + + /// Builds the endpoint to geo search for vAtom groups (i.e. search for clusters of dropped vAtoms). + /// + /// Use this endpoint to fetch an collection of groups/annotation indicating the count + /// of vAtoms at a particular location. + /// + /// - Parameters: + /// - bottomLeftLat: Bottom left latitude coordinate. + /// - bottomLeftLon: Bottom left longitude coordinate. + /// - topRightLat: Top right latitude coordinate. + /// - topRightLon: Top right longitude coordinte. + /// - precision: The grouping precision applied when computing the groups. + /// - filter: The vAtom filter option to apply. + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func geoDiscoverGroups(bottomLeftLat: Double, + bottomLeftLon: Double, + topRightLat: Double, + topRightLon: Double, + precision: Int, + filter: String) -> Endpoint> { + + return API.Generic.geoDiscoverGroups(bottomLeftLat: bottomLeftLat, + bottomLeftLon: bottomLeftLon, + topRightLat: topRightLat, + topRightLon: topRightLon, + precision: precision, + filter: filter) + + } + + } + + /// Namespace for action related endpoints with a typed reponse model. + enum VatomAction { + + /* + Each action's reactor returns it's own json payload. This does not need to be mapped as yet. + */ + + /// Builds the endpoint to perform and action on a vAtom. + /// + /// - Parameters: + /// - name: Action name. + /// - payload: Raw payload for the action. + /// - Returns: Constructed endpoint generic over `Void` that may be passed to a request. + static func custom(name: String, payload: [String: Any]) -> Endpoint { + return API.Generic.perform(name: name, payload: payload) + } + + } + + /// Namespace for user action related endpoints with a typed reponse model. + enum UserActions { + + /// Builds the endpoint for fetching the actions configured for a template ID. + /// + /// - Parameter id: Uniquie identifier of the template. + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getActions(forTemplateID id: String) -> Endpoint> { + return API.Generic.getActions(forTemplateID: id) + } + + } + + /// Namespace for user activity related endpoints with a typed reponse model. + enum UserActivity { + + /// Builds the endpoint for fetching the threads involving the current user. + /// + /// - Parameters: + /// - cursor: Filters out all threads more recent than the cursor (useful for paging). + /// If omitted or set as zero, the most recent threads are returned. + /// - count: Defines the number of messages to return (after the cursor). + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getThreads(cursor: String, count: Int) -> Endpoint> { + return API.Generic.getThreads(cursor: cursor, count: count) + } + + /// Builds the endpoint for fetching the message for a specified thread involving the current user. + /// + /// - Parameters: + /// - id: Unique identifier of the thread (a.k.a thread `name`). + /// - cursor: Filters out all message more recent than the cursor (useful for paging). + /// If omitted or set as zero, the most recent threads are returned. + /// - count: Defines the number of messages to return (after the cursor). + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getMessages(forThreadId threadId: String, cursor: String, count: Int) -> + Endpoint> { + return API.Generic.getMessages(forThreadId: threadId, cursor: cursor, count: count) + } + + } + +} diff --git a/BlockV/Core/Network/Stack/API.swift b/BlockV/Core/Network/Stack/API.swift index 396c7da2..e6c418b2 100644 --- a/BlockV/Core/Network/Stack/API.swift +++ b/BlockV/Core/Network/Stack/API.swift @@ -12,261 +12,95 @@ import Foundation import Alamofire -// swiftlint:disable file_length - -/// Consolidates all BlockV API endpoints. -/// -/// Endpoints are namespaced to furture proof. -/// +/// Consolidates BLOCKv API endpoints. /// Endpoints closely match their server counterparts where possible. -/// If appropriate, some endpoints may represent a single server endpoint. -/// -/// The goal is to abstract endpoint specific details. The networking client should -/// not need to taylor requests for the specific of an endpoint. +/// The goal is to abstract endpoint specific details. The networking client should not need to taylor requests for the +/// specific of an endpoint. + enum API { } extension API { - /* - All Session, Current User, and Public User endpoints are wrapped in a (unnecessary) - container object. This is modelled here using a `BaseModel`. - */ - - // MARK: - + /// Namespace for endpoints which are generic over their response type. + enum Generic { - /// Consolidates all session related endpoints. - enum Session { - - // MARK: Register + private static let userVatomPath = "/v1/user/vatom" + private static let userActionsPath = "/v1/user/actions" + private static let actionPath = "/v1/user/vatom/action" + private static let userActivityPath = "/v1/activity" - private static let registerPath = "/v1/users" + // MARK: OAuth - /// Builds the endpoint for new user registration. + /// Builds the generic endpoint to exchange a code for tokens. /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func register(tokens: [RegisterTokenParams], userInfo: UserInfo? = nil) -> - Endpoint> { - - precondition(!tokens.isEmpty, "One or more tokens must be supplied for this endpoint.") - - // dictionary of user information - var params = [String: Any]() - if let userInfo = userInfo { - params = userInfo.toDictionary() - } - - // create an array of tokens in their dictionary representation - let tokens = tokens.map { $0.toDictionary() } - params["user_tokens"] = tokens + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func token(grantType: String, clientID: String, code: String, redirectURI: String) -> Endpoint { + + let params: [String: Any] = [ + "grant_type": grantType, + "client_id": clientID, + "code": code, + "redirect_uri": redirectURI + ] return Endpoint(method: .post, - path: registerPath, + path: "/v1/oauth/token", parameters: params) - } - - // MARK: Login - - private static let loginPath = "/v1/user/login" - - /// Builds the endpoint for user login. + + // MARK: Username + + /// Builds the endpoint to edit a username token. /// /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func login(tokenParams: LoginTokenParams) -> Endpoint> { - return Endpoint(method: .post, - path: loginPath, - parameters: tokenParams.toDictionary()) - } - - } - - // MARK: - - - /// Consolidates all current user endpoints. - enum CurrentUser { - - private static let currentUserPath = "/v1/user" - - /// Builds the endpoint to get the current user's properties. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func get() -> Endpoint> { - return Endpoint(path: currentUserPath) - } - - /// Builds the endpoint to get the current user's tokens. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func getTokens() -> Endpoint> { - return Endpoint(path: currentUserPath + "/tokens") - } - - /// Builds the endpoint to log out the current user. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func logOut() -> Endpoint> { - return Endpoint(method: .post, path: currentUserPath + "/logout") - } - - /// Builds the endpoint to update current user's information. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func update(userInfo: UserInfo) -> Endpoint> { + static func updateUsernameToken(id: String, username: String) -> Endpoint> { return Endpoint(method: .patch, - path: currentUserPath, - parameters: userInfo.toSafeDictionary() - ) - } - - /// Builds the endpoint to verify a token with an OTP code. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func verifyToken(_ token: UserToken, code: String) -> Endpoint> { - return Endpoint(method: .post, - path: currentUserPath + "/verify_token", - parameters: [ - "token": token.value, - "token_type": token.type.rawValue, - "verify_code": code - ] - ) - } - - /// Builds the endpoint to reset a user token. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func resetToken(_ token: UserToken) -> Endpoint> { - return Endpoint(method: .post, - path: currentUserPath + "/reset_token", - parameters: token.toDictionary() - ) - } - - /// Builds the endpoint to send a verification request for a specific token. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func resetTokenVerification(forToken token: UserToken) -> Endpoint> { - return Endpoint(method: .post, - path: currentUserPath + "/reset_token_verification", - parameters: token.toDictionary() - ) - } - - /// Builds the endpoint to add a token to the current user. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func addToken(_ token: UserToken, isPrimary: Bool) -> Endpoint> { - return Endpoint(method: .post, - path: currentUserPath + "/tokens", + path: currentUserPath + "/tokens/\(id)", parameters: [ - "token": token.value, - "token_type": token.type.rawValue, - "is_primary": isPrimary - ] - ) - } - - /// Builds the endpoint to delete a token. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func deleteToken(id: String) -> Endpoint> { - return Endpoint(method: .delete, - path: currentUserPath + "/tokens/\(id)") + "token": username, + "token_type": "username" + ]) } - - /// Builds the endpoint to set a default token. + + /// Builds the endpoint to disable a username token. /// /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func setDefaultToken(id: String) -> Endpoint> { + static func disableUsername(tokenId: String) -> Endpoint> { //FIXME: General model is wrong return Endpoint(method: .put, - path: currentUserPath + "/tokens/\(id)/default") + path: currentUserPath + "/tokens/\(tokenId)/disabled") } - // MARK: Avatar - - /// Builds the endpoint for the user's avatar. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func uploadAvatar(_ imageData: Data) -> UploadEndpoint> { - - let bodyPart = MultiformBodyPart(data: imageData, - name: "avatar", - fileName: "avatar.png", - mimeType: "image/png") - return UploadEndpoint(path: "/v1/user/avatar", - bodyPart: bodyPart) + // MARK: Asset Providers + static func getAssetProviders() -> Endpoint { + return Endpoint(method: .get, + path: "/v1/user/asset_providers") } - // MARK: Messaging - - /// Builds the endpoint to allow the current user to send a message to a user token. + // MARK: Vatoms + + /// Builds the generic endpoint to get the current user's inventory vatom's sync number. /// - /// - Parameters: - /// - message: Content of the message. - /// - userID: Unique identifier of the recipient user. - /// - Returns: The endpoint is generic over a response model. This model is parsed on - /// success responses (200...299). - static func sendMessage(_ message: String, toUserId userId: String) -> Endpoint> { - return Endpoint(method: .post, - path: currentUserPath + "/message", - parameters: [ - "message": message, - "id": userId]) - } - - // MARK: Redemption - - /* - /// Endpoint to fetch redeemables. - public static func getRedeemables() -> Endpoint { - return Endpoint(path: currentUserPath + "/redeemables") - } - */ - - // MARK: - DEBUG - - /// DO NOT EXPOSE. ONLY USE FOR TESTING. - /// - /// Builds the endpoint to allow the current user to be deleted. - static func deleteCurrentUser() -> Endpoint> { - return Endpoint(method: .delete, path: "/v1/user") + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getInventoryVatomSyncNumbers(limit: Int = 1000, token: String) -> Endpoint { + return Endpoint(method: .get, + path: userVatomPath + "/inventory/index", + parameters: ["limit": limit, "nextToken": token], + encoding: URLEncoding.queryString) } - - } - - // MARK: - - - /// Consolidates all public user endpoints. - enum PublicUser { - - /// Builds the endpoint to get a public user's details. + + /// Builds the generic endpoint to get the current user's inventory sync hash. /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func get(id: String) -> Endpoint> { - return Endpoint(path: "/v1/users/\(id)") + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getInventoryHash() -> Endpoint { + return Endpoint(method: .get, + path: userVatomPath + "/inventory/hash") } - } - - // MARK: - - - /// Consolidates all user vatom endpoints. - enum UserVatom { - - private static let userVatomPath = "/v1/user/vatom" - - //TODO: Parameterise parameters. - - /// Builds the endpoint to get the current user's inventory. + /// Builds the generic endpoint to get the current user's inventory. /// - /// The inventory call is essentially an optimized discover call. The server-pattern is from the child's - /// perspetive. That is, we specify the id of the parent who's children are to be retunred. - /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func getInventory(parentID: String, - page: Int = 0, - limit: Int = 0) -> Endpoint> { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getInventory(parentID: String, page: Int = 0, limit: Int = 0) -> Endpoint { return Endpoint(method: .post, path: userVatomPath + "/inventory", parameters: [ @@ -277,45 +111,39 @@ extension API { ) } - /// Builds the endpoint to get a vAtom by its unique identifier. + /// Builds a generic endpoint to get a vAtom by its unique identifier. /// - /// The endpoint is generic over a response model. This model is parsed on success responses (200...299). - static func getVatoms(withIDs ids: [String]) -> Endpoint> { + /// - Parameter ids: Unique identifier of the vatom. + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getVatoms(withIDs ids: [String]) -> Endpoint { return Endpoint(method: .post, path: userVatomPath + "/get", parameters: ["ids": ids] ) } - /// Builds the endpoint to trash a vAtom specified by its id. + /// Builds a generic endpoint to update a vAtom. /// - /// Returns an endpoint over a BaseModel over a GeneralModel. - static func trashVatom(_ id: String) -> Endpoint> { - return Endpoint(method: .post, - path: "/v1/user/vatom/trash", - parameters: ["this.id": id]) - + /// - Parameter payload: Raw payload. + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func updateVatom(payload: [String: Any]) -> Endpoint { + return Endpoint(method: .patch, + path: "/v1/vatoms", + parameters: payload) } - } - - // MARK: - - - /// Consolidtaes all discover endpoints. - enum VatomDiscover { - - /// Builds the endpoint to search for vAtoms. + /// Builds a generic endpoint to search for vAtoms. /// /// - Parameter payload: Raw request payload. - /// - Returns: Endpoint generic over `UnpackedModel`. - static func discover(_ payload: [String: Any]) -> Endpoint> { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func discover(_ payload: [String: Any]) -> Endpoint { return Endpoint(method: .post, path: "/v1/vatom/discover", parameters: payload) } - /// Builds the endpoint to geo search for vAtoms (i.e. search for dropped vAtoms). + /// Builds a generic endpoint to geo search for vAtoms (i.e. search for dropped vAtoms). /// /// Use this endpoint to fetch a collection of vAtoms. /// @@ -325,12 +153,12 @@ extension API { /// - topRightLat: Top right latitude coordinate. /// - topRightLon: Top right longitude coordinte. /// - filter: The vAtom filter option to apply. - /// - Returns: Endpoint generic over `UnpackedModel`. - static func geoDiscover(bottomLeftLat: Double, - bottomLeftLon: Double, - topRightLat: Double, - topRightLon: Double, - filter: String) -> Endpoint> { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func geoDiscover(bottomLeftLat: Double, + bottomLeftLon: Double, + topRightLat: Double, + topRightLon: Double, + filter: String) -> Endpoint { // create the payload let payload: [String: Any] = @@ -367,13 +195,13 @@ extension API { /// - topRightLon: Top right longitude coordinte. /// - precision: The grouping precision applied when computing the groups. /// - filter: The vAtom filter option to apply. - /// - Returns: Endpoint generic over `GeoGroupModel`. - static func geoDiscoverGroups(bottomLeftLat: Double, - bottomLeftLon: Double, - topRightLat: Double, - topRightLon: Double, - precision: Int, - filter: String) -> Endpoint> { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func geoDiscoverGroups(bottomLeftLat: Double, + bottomLeftLon: Double, + topRightLat: Double, + topRightLon: Double, + precision: Int, + filter: String) -> Endpoint { assert(1...12 ~= precision, "You must specify a value in the open range [1...12].") @@ -401,57 +229,31 @@ extension API { } - } - - // MARK: - - - /// Consolidates all action endpoints. - enum VatomAction { - - private static let actionPath = "/v1/user/vatom/action" - - /* - Each action's reactor returns it's own json payload. This does not need to be mapped as yet. - */ + // MARK: Perform Actions /// Builds the endpoint to perform and action on a vAtom. /// /// - Parameters: /// - name: Action name. /// - payload: Raw payload for the action. - /// - Returns: Returns endpoint generic over Void, i.e. caller will receive raw data. - static func custom(name: String, payload: [String: Any]) -> Endpoint { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func perform(name: String, payload: [String: Any]) -> Endpoint { return Endpoint(method: .post, - path: actionPath + "/\(name)", - parameters: payload) + path: actionPath + "/\(name)", parameters: payload) } - } - - // MARK: - - - /// Consolidates all the user actions. - enum UserActions { - - private static let userActionsPath = "/v1/user/actions" + // MARK: Fetch Actions /// Builds the endpoint for fetching the actions configured for a template ID. /// /// - Parameter id: Uniquie identifier of the template. - /// - Returns: Endpoint for fectching actions. - static func getActions(forTemplateID id: String) -> Endpoint> { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getActions(forTemplateID id: String) -> Endpoint { return Endpoint(method: .get, path: userActionsPath + "/\(id)") } - } - - // MARK: - - - /// Consolidtes all the user activity endpoints. - enum UserActivity { - - private static let userActivityPath = "/v1/activity" + // MARK: User Activity /// Builds the endpoint for fetching the threads involving the current user. /// @@ -459,8 +261,8 @@ extension API { /// - cursor: Filters out all threads more recent than the cursor (useful for paging). /// If omitted or set as zero, the most recent threads are returned. /// - count: Defines the number of messages to return (after the cursor). - /// - Returns: Endpoint for fetching the thread for the current user. - static func getThreads(cursor: String, count: Int) -> Endpoint> { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getThreads(cursor: String, count: Int) -> Endpoint { let payload: [String: Any] = [ "cursor": cursor, @@ -479,19 +281,18 @@ extension API { /// - cursor: Filters out all message more recent than the cursor (useful for paging). /// If omitted or set as zero, the most recent threads are returned. /// - count: Defines the number of messages to return (after the cursor). - /// - Returns: Endpoint for fetching the messages for a specific thread invoving the current user. - static func getMessages(forThreadId threadId: String, cursor: String, count: Int) -> - Endpoint> { + /// - Returns: Constructed endpoint generic over response model that may be passed to a request. + static func getMessages(forThreadId threadId: String, cursor: String, count: Int) -> Endpoint { - let payload: [String: Any] = [ - "name": threadId, - "cursor": cursor, - "count": count - ] + let payload: [String: Any] = [ + "name": threadId, + "cursor": cursor, + "count": count + ] - return Endpoint(method: .post, - path: userActivityPath + "/mythreadmessages", - parameters: payload) + return Endpoint(method: .post, + path: userActivityPath + "/mythreadmessages", + parameters: payload) } diff --git a/BlockV/Core/Network/Stack/Client.swift b/BlockV/Core/Network/Stack/Client.swift index 326bde63..a69f12c0 100644 --- a/BlockV/Core/Network/Stack/Client.swift +++ b/BlockV/Core/Network/Stack/Client.swift @@ -14,12 +14,13 @@ import Alamofire protocol ClientProtocol { + typealias RawCompletion = (Swift.Result) -> Void + /// Request that returns raw data. - func request(_ endpoint: Endpoint, completion: @escaping (Data?, BVError?) -> Void ) + func request(_ endpoint: Endpoint, completion: @escaping RawCompletion) /// Request that returns native object (must conform to decodable). - func request(_ endpoint: Endpoint, - completion: @escaping (Response?, BVError?) -> Void ) where Response: Decodable + func request(_ endpoint: Endpoint, completion: @escaping (Swift.Result) -> Void ) where T: Decodable } @@ -107,61 +108,95 @@ final class Client: ClientProtocol { /// Endpoints generic over `void` complete by passing in the raw data response. /// - /// This is usefull for actions whose reponse payloads are not know since reactors may change at - /// any time. + /// This is useful for actions whose reponse payloads are not defined. For example, reactors may define their own + /// inner payload structure. /// - /// NOTE: Raw requests do not partake in OAuth and general lifecycle handling. + /// - important: + /// Do not call this endpoint for auth related calls, e.g login. Raw requests do *not* support through the + /// credential refresh mechanism. The access and refresh token will not be extracted and passed to the oauthhandler. /// /// - Parameters: /// - endpoint: Endpoint for the request /// - completion: The completion handler to call when the request is completed. - func request(_ endpoint: Endpoint, completion: @escaping (Data?, BVError?) -> Void) { + func request(_ endpoint: Endpoint, completion: @escaping RawCompletion) { // create request let request = self.sessionManager.request( url(path: endpoint.path), method: endpoint.method, parameters: endpoint.parameters, - encoding: endpoint.encoding + encoding: endpoint.encoding, + headers: endpoint.headers ) // configure validation request.validate() //TODO: May need manual validation - request.responseData { (dataResponse) in + request.responseData(queue: queue) { (dataResponse) in switch dataResponse.result { - case let .success(data): completion(data, nil) + case let .success(data): + completion(.success(data)) case let .failure(err): //TODO: The error should be parsed and a BVError created and passed in. // check for a BVError if let err = err as? BVError { - completion(nil, err) + completion(.failure(err)) } else { - // create a wrapped networking errir + // create a wrapped networking error let error = BVError.networking(error: err) - completion(nil, error) + completion(.failure(error)) } } } } + /// JSON Completion handler. + typealias JSONCompletion = (Swift.Result) -> Void + + func requestJSON(_ endpoint: Endpoint, completion: @escaping JSONCompletion) { + + // create request + let request = self.sessionManager.request( + url(path: endpoint.path), + method: endpoint.method, + parameters: endpoint.parameters, + encoding: endpoint.encoding, + headers: endpoint.headers + ) + + // configure validation + request.validate() + request.responseJSON(queue: queue) { dataResponse in + switch dataResponse.result { + case let .success(json): + completion(.success(json)) + case let .failure(err): + // create a wrapped networking error + let error = BVError.networking(error: err) + completion(.failure(error)) + } + } + + } + /// Performs a request on a given endpoint. /// /// - Parameters: /// - endpoint: Endpoint for the request /// - completion: The completion handler to call when the request is completed. func request(_ endpoint: Endpoint, - completion: @escaping (Response?, BVError?) -> Void ) where Response: Decodable { + completion: @escaping (Swift.Result) -> Void ) where Response: Decodable { // create request (starts immediately) let request = self.sessionManager.request( url(path: endpoint.path), method: endpoint.method, parameters: endpoint.parameters, - encoding: endpoint.encoding + encoding: endpoint.encoding, + headers: endpoint.headers ) // configure validation - will cause an error to be generated for unacceptable status code or MIME type. @@ -171,33 +206,26 @@ final class Client: ClientProtocol { request.validate().responseJSONDecodable(queue: self.queue, decoder: blockvJSONDecoder) { (dataResponse: DataResponse) in - // DEBUG - // let json = try? JSONSerialization.jsonObject(with: dataResponse.data!, options: []) - // dump(json) - switch dataResponse.result { case let .success(val): /* - Not all responses (even in the 200 range) are wrapped in the `BaseModel`. Endpoints must be treated - on a per-endpoint basis. + Certain endpoints return session tokens which need to be persisted (currently further up the chain) + and injected into the oauth session handler. */ // extract auth tokens if available if let model = val as? BaseModel { + // inject token into session's oauth handler self.oauthHandler.set(accessToken: model.payload.accessToken.token, refreshToken: model.payload.refreshToken.token) + } else if let model = val as? OAuthTokenExchangeModel { + // inject token into session's oauth handler + self.oauthHandler.set(accessToken: model.accessToken, + refreshToken: model.refreshToken) } - // ensure the payload was parsed correctly - // on success, the payload should alway have a value - // guard let payload = val.payload else { - // let error = BVError.modelDecoding(reason: "Payload model not parsed correctly.") - // completion(nil, error) - // return - // } - - completion(val, nil) + completion(.success(val)) //TODO: Add some thing like this to pull back to a completion thread? /* @@ -207,18 +235,12 @@ final class Client: ClientProtocol { case let .failure(err): - // DEBUG - // if let data = dataResponse.data { - // let json = String(data: data, encoding: String.Encoding.utf8) - // print("Failure Response: \(json)") - // } - - //FIXME: Can this error casting be done away with? + //TODO: Can this error casting be done away with? if let err = err as? BVError { - completion(nil, err) + completion(.failure(err)) } else { let error = BVError.networking(error: err) - completion(nil, error) + completion(.failure(error)) } } @@ -253,7 +275,6 @@ final class Client: ClientProtocol { switch encodingResult { case .success(let upload, _, _): - //print("Upload response: \(upload.response.debugDescription)") // upload progress upload.uploadProgress { progress in @@ -262,12 +283,10 @@ final class Client: ClientProtocol { upload.validate() // parse out a native model (within the base model) - //TODO: If is fine to capture self here? upload.responseJSONDecodable(queue: self.queue, decoder: self.blockvJSONDecoder) { (dataResponse: DataResponse) in - /// switch dataResponse.result { case let .success(val): completion(val, nil) @@ -286,8 +305,6 @@ final class Client: ClientProtocol { } case .failure(let encodingError): - //print(encodingError) - let error = BVError.networking(error: encodingError) completion(nil, error) } diff --git a/BlockV/Core/Network/Stack/Endpoint.swift b/BlockV/Core/Network/Stack/Endpoint.swift index 0a10cdcc..5083df9d 100644 --- a/BlockV/Core/Network/Stack/Endpoint.swift +++ b/BlockV/Core/Network/Stack/Endpoint.swift @@ -29,17 +29,20 @@ final class Endpoint { let path: Path let parameters: Parameters? let encoding: ParameterEncoding + let headers: HTTPHeaders? //TODO: Does it make sense for json to be the default encoding? init(method: HTTPMethod = .get, path: Path, parameters: Parameters? = nil, - encoding: ParameterEncoding = JSONEncoding.default) { + encoding: ParameterEncoding = JSONEncoding.default, + headers: HTTPHeaders? = nil) { self.method = method self.path = path self.parameters = parameters self.encoding = encoding + self.headers = headers } diff --git a/BlockV/Core/Requests/BLOCKv+ActivityRequests.swift b/BlockV/Core/Requests/BLOCKv+ActivityRequests.swift index daa81353..808dc45a 100644 --- a/BlockV/Core/Requests/BLOCKv+ActivityRequests.swift +++ b/BlockV/Core/Requests/BLOCKv+ActivityRequests.swift @@ -25,23 +25,23 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func getActivityThreads(cursor: String = "", count: Int = 0, - completion: @escaping (ThreadListModel?, Error?) -> Void) { + completion: @escaping (Result) -> Void) { let endpoint = API.UserActivity.getThreads(cursor: cursor, count: count) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let threadListModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(threadListModel, nil) } } @@ -61,23 +61,23 @@ extension BLOCKv { public static func getActivityMessages(forThreadId threadId: String, cursor: String = "", count: Int = 0, - completion: @escaping (MessageListModel?, Error?) -> Void) { + completion: @escaping (Result) -> Void) { let endpoint = API.UserActivity.getMessages(forThreadId: threadId, cursor: cursor, count: count) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let messageListModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(messageListModel, nil) } } @@ -96,18 +96,19 @@ extension BLOCKv { let endpoint = API.CurrentUser.sendMessage(message, toUserId: userId) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - guard baseModel?.payload != nil, error == nil else { + switch result { + case .success: + // model is available + DispatchQueue.main.async { + completion(nil) + } + case .failure(let error): + // handle error DispatchQueue.main.async { completion(error) } - return - } - - // call was successful - DispatchQueue.main.async { - completion(nil) } } diff --git a/BlockV/Core/Requests/BLOCKv+AuthRequests.swift b/BlockV/Core/Requests/BLOCKv+AuthRequests.swift index c81f01b7..5e945e41 100644 --- a/BlockV/Core/Requests/BLOCKv+AuthRequests.swift +++ b/BlockV/Core/Requests/BLOCKv+AuthRequests.swift @@ -16,6 +16,76 @@ extension BLOCKv { // MARK: - Register + //FIXME: callbackURLScheme - where does this get set? + public static func oauth(scope: String, redirectURI: String, completion: @escaping (BVError?) -> Void) { + + // ensure host app has set an app id + let warning = """ + Please call 'BLOCKv.configure(appID:)' with your issued app ID before making network + requests. + """ + precondition(BLOCKv.appID != nil, warning) + + // extract config variables + let appID = BLOCKv.appID! + let webAppDomain = BLOCKv.environment!.oauthWebApp + + let authServer = AuthorizationServer(clientID: appID, + domain: webAppDomain, + scope: scope, + redirectURI: redirectURI) + + // start delegated authorization + authServer.authorize { success in + + guard success else { + printBV(error: ("OAuth Authorize error.")) + return + } + // exchange code for tokens + authServer.getToken { result in + + switch result { + case .success(let tokens): + /* + At this point, accces and refresh tokens have been injected into the oauthhandler by the client + response inspector. + */ + + // build endpoint + let endpoint = API.Session.getAssetProviders() + // perform api call + BLOCKv.client.request(endpoint) { result in + switch result { + case .success(let model): + + // pull back to main queue + DispatchQueue.main.async { + + let refreshToken = BVToken(token: tokens.refreshToken, tokenType: tokens.tokenType) + // persist refresh token and credential + CredentialStore.saveRefreshToken(refreshToken) + CredentialStore.saveAssetProviders(model.payload.assetProviders) + // noifty on login process + self.onLogin() + completion(nil) + } + + case .failure(let error): + completion(error) + } + } + + case .failure(let error): + completion(error) + } + + } + + } + + } + /// Registers a user on the BLOCKv platform. Accepts a user token (phone or email). /// /// - Parameters: @@ -28,7 +98,7 @@ extension BLOCKv { public static func register(withUserToken token: String, type: UserTokenType, userInfo: UserInfo? = nil, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let registerToken = UserToken(value: token, type: type) self.register(tokens: [registerToken], userInfo: userInfo, completion: completion) } @@ -43,7 +113,7 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func register(withOAuthToken oauthToken: OAuthTokenRegisterParams, userInfo: UserInfo? = nil, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { self.register(tokens: [oauthToken], userInfo: userInfo, completion: completion) } @@ -56,27 +126,29 @@ extension BLOCKv { /// authorized to perform requests. public static func register(tokens: [RegisterTokenParams], userInfo: UserInfo? = nil, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let endpoint = API.Session.register(tokens: tokens, userInfo: userInfo) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let authModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + let authModel = baseModel.payload + // persist credentials + CredentialStore.saveRefreshToken(authModel.refreshToken) + CredentialStore.saveAssetProviders(authModel.assetProviders) + // noifty + self.onLogin() + completion(.success(authModel.user)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - // persist credentials - CredentialStore.saveRefreshToken(authModel.refreshToken) - CredentialStore.saveAssetProviders(authModel.assetProviders) - - completion(authModel.user, nil) } } @@ -96,7 +168,7 @@ extension BLOCKv { public static func login(withUserToken token: String, type: UserTokenType, password: String, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let params = UserTokenLoginParams(value: token, type: type, password: password) self.login(tokenParams: params, completion: completion) } @@ -110,7 +182,7 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func login(withOAuthToken oauthToken: String, provider: String, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let params = OAuthTokenLoginParams(provider: provider, oauthToken: oauthToken) self.login(tokenParams: params, completion: completion) } @@ -122,36 +194,37 @@ extension BLOCKv { /// - completion: The completion handler to call when the request is completed. /// This handler is executed on the main queue. public static func login(withGuestID id: String, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let params = GuestIdLoginParams(id: id) self.login(tokenParams: params, completion: completion) } /// Login using token params fileprivate static func login(tokenParams: LoginTokenParams, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let endpoint = API.Session.login(tokenParams: tokenParams) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let authModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + let authModel = baseModel.payload + // persist credentials + CredentialStore.saveRefreshToken(authModel.refreshToken) + CredentialStore.saveAssetProviders(authModel.assetProviders) + // notify + self.onLogin() + // completion + completion(.success(authModel.user)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - - // persist credentials - CredentialStore.saveRefreshToken(authModel.refreshToken) - CredentialStore.saveAssetProviders(authModel.assetProviders) - - // completion - completion(authModel.user, nil) } } @@ -171,24 +244,84 @@ extension BLOCKv { let endpoint = API.CurrentUser.logOut() - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // reset DispatchQueue.main.async { + // reset sdk state reset() + // give viewer opportunity to reset their state + onLogout?() } - // extract model, ensure no error - guard baseModel?.payload != nil, error == nil else { + switch result { + case .success: + // model is available DispatchQueue.main.async { - completion(error!) + completion(nil) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(error) } - return } - // model is available - DispatchQueue.main.async { - completion(nil) + } + + } + + /// Fetches information regarding app versioning and support. + /// + /// - Parameter result: Complettion handler that is called when the request is completed. + public static func getSupportedVersion(result: @escaping (Result) -> Void) { + + let endpoint = API.Session.getSupportedVersion() + // send request + self.client.request(endpoint) { innerResult in + + switch innerResult { + case .success(let model): + // model is available + DispatchQueue.main.async { + result(.success(model.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + result(.failure(error)) + } + } + + } + + } + + /// Updates the push notification settings for this device. + /// + /// - Parameters: + /// - fcmToken: Firebase cloud messaging token. + /// - platformID: Identifier of the current plaform. Defaults to "ios" - recommended. + /// - enabled: Flag indicating whether push notifications should be sent to this device. Defaults to `true`. + /// - completion: Completion handler that is called when the request is completed. + public static func updatePushNotification(fcmToken: String, + platformID: String, + enabled: Bool, + completion: @escaping ((Error?) -> Void)) { + + let endpoint = API.Session.updatePushNotification(fcmToken: fcmToken, platformID: platformID, enabled: enabled) + // send request + self.client.request(endpoint) { result in + + switch result { + case .success: + DispatchQueue.main.async { + completion(nil) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(error) + } } } diff --git a/BlockV/Core/Requests/BLOCKv+UserRequests.swift b/BlockV/Core/Requests/BLOCKv+UserRequests.swift index 4aff3cb3..2638de05 100644 --- a/BlockV/Core/Requests/BLOCKv+UserRequests.swift +++ b/BlockV/Core/Requests/BLOCKv+UserRequests.swift @@ -20,23 +20,23 @@ extension BLOCKv { /// /// - completion: The completion handler to call when the request is completed. /// This handler is executed on the main queue. - public static func getCurrentUser(completion: @escaping (UserModel?, BVError?) -> Void) { + public static func getCurrentUser(completion: @escaping (Result) -> Void) { let endpoint = API.CurrentUser.get() - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let userModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(userModel, nil) } } @@ -51,23 +51,23 @@ extension BLOCKv { /// - completion: The completion handler to call when the request is completed. /// This handler is executed on the main queue. public static func updateCurrentUser(_ userInfo: UserInfo, - completion: @escaping (UserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let endpoint = API.CurrentUser.update(userInfo: userInfo) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let userModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(userModel, nil) } } @@ -130,24 +130,24 @@ extension BLOCKv { public static func verifyUserToken(_ token: String, type: UserTokenType, code: String, - completion: @escaping (UserToken?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let userToken = UserToken(value: token, type: type) let endpoint = API.CurrentUser.verifyToken(userToken, code: code) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let userTokenModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(userTokenModel, nil) } } @@ -166,24 +166,24 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func resetVerification(forUserToken token: String, type: UserTokenType, - completion: @escaping (UserToken?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let userToken = UserToken(value: token, type: type) let endpoint = API.CurrentUser.resetTokenVerification(forToken: userToken) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, handle error - guard let userTokenModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(userTokenModel, nil) } } @@ -202,24 +202,24 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func resetToken(_ token: String, type: UserTokenType, - completion: @escaping (UserToken?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let userToken = UserToken(value: token, type: type) let endpoint = API.CurrentUser.resetToken(userToken) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let userTokenModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(userTokenModel, nil) } } @@ -237,23 +237,23 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func addCurrentUserToken(token: UserToken, isPrimary: Bool = false, - completion: @escaping (FullTokenModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let endpoint = API.CurrentUser.addToken(token, isPrimary: isPrimary) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, handle error - guard let fullToken = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(fullToken, nil) } } @@ -264,33 +264,63 @@ extension BLOCKv { /// /// - completion: The completion handler to call when the request is completed. /// This handler is executed on the main queue. - public static func getCurrentUserTokens(completion: @escaping ([FullTokenModel]?, BVError?) -> Void) { + public static func getCurrentUserTokens(completion: @escaping (Result<[FullTokenModel], BVError>) -> Void) { let endpoint = API.CurrentUser.getTokens() - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, handle error - guard let fullTokens = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - completion(fullTokens, nil) } } } + + /// Fetches the current user's accounts description from the BLOCKv platform. + /// + /// - completion: The completion handler to call when the request is completed. + /// This handler is executed on the main queue. + public static func getCurrentUserBlockchainAccounts(completion: @escaping (Result<[AddressAccountModel], BVError>) -> Void) { + + let endpoint = API.CurrentUser.getAccounts() + + self.client.request(endpoint) { result in + + switch result { + case .success(let baseModel): + // model is available + DispatchQueue.main.async { + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + + } /// Removes the token from the current user's token list on the BLOCKv platform. /// /// Note: Primary tokens may not be deleted. /// + /// - important: Deleting a *username* token will allow the username to be reclaimed immediately by another + /// user. A safer approach is to call `TODO` which will prevent the username from being + /// reclaimed for a defined period of time. + /// /// - Parameters: /// - tokenId: Unique identifier of the token to be deleted. /// - completion: The completion handler to call when the request is completed. @@ -300,18 +330,19 @@ extension BLOCKv { let endpoint = API.CurrentUser.deleteToken(id: tokenId) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - guard baseModel?.payload != nil, error == nil else { + switch result { + case .success: + // model is available + DispatchQueue.main.async { + completion(nil) + } + case .failure(let error): + // handle error DispatchQueue.main.async { completion(error) } - return - } - - // call was successful - DispatchQueue.main.async { - completion(nil) } } @@ -334,19 +365,19 @@ extension BLOCKv { let endpoint = API.CurrentUser.setDefaultToken(id: tokenId) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // - guard baseModel?.payload != nil, error == nil else { + switch result { + case .success: + // model is available + DispatchQueue.main.async { + completion(nil) + } + case .failure(let error): + // handle error DispatchQueue.main.async { completion(error) } - return - } - - // call was succesful - DispatchQueue.main.async { - completion(nil) } } @@ -365,14 +396,47 @@ extension BLOCKv { /// - completion: The completion handler to call when the request is completed. /// This handler is executed on the main queue. public static func getPublicUser(withID userId: String, - completion: @escaping (PublicUserModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { let endpoint = API.PublicUser.get(id: userId) + self.client.request(endpoint) { result in + + switch result { + case .success(let baseModel): + // model is available + DispatchQueue.main.async { + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) + } + } + + } + + } + + /// Updates the current user's username. + /// + /// Updating a username token will automatically disable the current username token. + /// + /// - Parameters: + /// - tokenID: Unique identifier of the current username token. + /// - newUsername: New username. + /// - completion: The completion handler to call when the request is completed. + /// This handler is executed on the main queue. + public static func updateUsernameToken(tokenID: String, newUsername: String, + completion: @escaping (FullTokenModel?, BVError?) -> Void) { + + let endpoint = API.CurrentUser.updateUsernameToken(id: tokenID, username: newUsername) + self.client.request(endpoint) { (baseModel, error) in // extract model, ensure no error - guard let userModel = baseModel?.payload, error == nil else { + guard let tokenModel = baseModel?.payload, error == nil else { DispatchQueue.main.async { completion(nil, error) } @@ -381,22 +445,25 @@ extension BLOCKv { // model is available DispatchQueue.main.async { - completion(userModel, nil) + completion(tokenModel, nil) } } } - /// DO NOT EXPOSE. ONLY USE FOR TESTING. + /// Disables a username token. /// - /// DELETES THE CURRENT USER. - internal static func deleteCurrentUser(completion: @escaping (Error?) -> Void) { + /// Once disabled, the username token is frozen for a period of time. After this period has elapsed, the username + /// is recycled, at which time the username becomes available for other users in the system to claim. + public static func disableUsernameToken(tokenID: String, + completion: @escaping (BVError?) -> Void) { - let endpoint = API.CurrentUser.deleteCurrentUser() + let endpoint = API.CurrentUser.disableUsername(tokenId: tokenID) self.client.request(endpoint) { (baseModel, error) in + // extract model, ensure no error guard baseModel?.payload != nil, error == nil else { DispatchQueue.main.async { completion(error) @@ -404,6 +471,7 @@ extension BLOCKv { return } + // model is available DispatchQueue.main.async { completion(nil) } @@ -412,4 +480,30 @@ extension BLOCKv { } + /// DO NOT EXPOSE. ONLY USE FOR TESTING. + /// + /// DELETES THE CURRENT USER. + internal static func deleteCurrentUser(completion: @escaping (Error?) -> Void) { + + let endpoint = API.CurrentUser.deleteCurrentUser() + + self.client.request(endpoint) { result in + + switch result { + case .success: + // model is available + DispatchQueue.main.async { + completion(nil) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(error) + } + } + + } + + } + } diff --git a/BlockV/Core/Requests/BLOCKv+VatomRequest.swift b/BlockV/Core/Requests/BLOCKv+VatomRequest.swift index 5c7714d3..dd3a6ae3 100644 --- a/BlockV/Core/Requests/BLOCKv+VatomRequest.swift +++ b/BlockV/Core/Requests/BLOCKv+VatomRequest.swift @@ -36,25 +36,25 @@ extension BLOCKv { public static func getInventory(id: String = ".", page: Int = 0, limit: Int = 0, - completion: @escaping (_ vatoms: [VatomModel], _ error: BVError?) -> Void) { + completion: @escaping (Result<[VatomModel], BVError>) -> Void) { - let endpoint = API.UserVatom.getInventory(parentID: id, page: page, limit: limit) + let endpoint = API.Vatom.getInventory(parentID: id, page: page, limit: limit) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let unpackedModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available + let unpackedModel = baseModel.payload + let packedVatoms = unpackedModel.package() DispatchQueue.main.async { - completion([], error!) + completion(.success(packedVatoms)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - let packedVatoms = unpackedModel.package() - - DispatchQueue.main.async { - completion(packedVatoms, nil) } } @@ -73,25 +73,25 @@ extension BLOCKv { /// action models as populated properties. /// - error: BLOCKv error. public static func getVatoms(withIDs ids: [String], - completion: @escaping (_ vatoms: [VatomModel], _ error: BVError?) -> Void) { + completion: @escaping (Result<[VatomModel], BVError>) -> Void) { - let endpoint = API.UserVatom.getVatoms(withIDs: ids) + let endpoint = API.Vatom.getVatoms(withIDs: ids) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard let unpackedModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available + let unpackedModel = baseModel.payload + let packedVatoms = unpackedModel.package() DispatchQueue.main.async { - completion([], error!) + completion(.success(packedVatoms)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - let packedVatoms = unpackedModel.package() - - DispatchQueue.main.async { - completion(packedVatoms, nil) } } @@ -108,21 +108,78 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func trashVatom(_ id: String, completion: @escaping (BVError?) -> Void) { - let endpoint = API.UserVatom.trashVatom(id) + let endpoint = API.Vatom.trashVatom(id) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, ensure no error - guard baseModel?.payload.message != nil, error == nil else { + switch result { + case .success: + // model is available DispatchQueue.main.async { completion(nil) } - return + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(error) + } } - // model is available - DispatchQueue.main.async { - completion(error) + } + + } + + /// Sets the parent ID of the specified vatom. + /// + /// - Parameters: + /// - vatom: Vatom whose parent ID must be set. + /// - parentID: Unique identifier of the parent vatom. + /// - completion: The completion hanlder to call when the request is completed. + /// This handler is executed on the main thread. + public static func setParentID(ofVatoms vatoms: [VatomModel], to parentID: String, + completion: @escaping (Result) -> Void) { + + // perform preemptive action, store undo functions + let undos = vatoms.map { + // tuple: (vatom id, undo function) + (id: $0.id, undo: DataPool.inventory().preemptiveChange(id: $0.id, + keyPath: "vAtom::vAtomType.parent_id", + value: parentID)) + } + + let ids = vatoms.map { $0.id } + let payload: [String: Any] = [ + "ids": ids, + "parent_id": parentID + ] + + let endpoint = API.Vatom.updateVatom(payload: payload) + + BLOCKv.client.request(endpoint) { result in + + switch result { + case .success(let baseModel): + + /* + # Note + The most likely scenario where there will be partial containment errors is when setting the parent id + to a container vatom of type `DefinedFolderContainerType`. However, as of writting, the server does + not enforce child policy rules so this always succeed (using the current API). + */ + let updateVatomModel = baseModel.payload + DispatchQueue.main.async { + // roll back only those failed containments + let undosToRollback = undos.filter { !updateVatomModel.ids.contains($0.id) } + undosToRollback.forEach { $0.undo() } + completion(.success(updateVatomModel)) + } + + case .failure(let error): + DispatchQueue.main.async { + // roll back all containments + undos.forEach { $0.undo() } + completion(.failure(error)) + } } } @@ -140,12 +197,25 @@ extension BLOCKv { /// action models as populated properties. /// - error: BLOCKv error. public static func discover(_ builder: DiscoverQueryBuilder, - completion: @escaping(_ vatoms: [VatomModel], _ error: BVError?) -> Void) { + completion: @escaping(Result<[VatomModel], BVError>) -> Void) { // explicitly set return type to payload builder.setReturn(type: .payload) - self.discover(payload: builder.toDictionary()) { (result, error) in - completion(result?.vatoms ?? [], error) + self.discover(payload: builder.toDictionary()) { result in + + switch result { + case .success(let discoverResult): + // model is available + DispatchQueue.main.async { + completion(.success(discoverResult.vatoms)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } } @@ -165,27 +235,25 @@ extension BLOCKv { /// - count: Number of discovered vAtoms. /// - error: BLOCKv error. public static func discover(payload: [String: Any], - completion: @escaping (_ result: DiscoverResult?, _ error: BVError?) -> Void) { + completion: @escaping (Result) -> Void) { - let endpoint = API.VatomDiscover.discover(payload) + let endpoint = API.Vatom.discover(payload) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, handle error - guard let unpackedModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available + let unpackedModel = baseModel.payload + let packedVatoms = unpackedModel.package() DispatchQueue.main.async { - print(error!.localizedDescription) - completion(nil, error!) + completion(.success((packedVatoms, packedVatoms.count))) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - let packedVatoms = unpackedModel.package() - - DispatchQueue.main.async { - //print(model) - completion((packedVatoms, packedVatoms.count), nil) } } @@ -215,31 +283,29 @@ extension BLOCKv { topRightLat: Double, topRightLon: Double, filter: VatomGeoFilter = .vatoms, - completion: @escaping (_ vatoms: [VatomModel], _ error: BVError?) -> Void) { + completion: @escaping (Result<[VatomModel], BVError>) -> Void) { - let endpoint = API.VatomDiscover.geoDiscover(bottomLeftLat: bottomLeftLat, + let endpoint = API.Vatom.geoDiscover(bottomLeftLat: bottomLeftLat, bottomLeftLon: bottomLeftLon, topRightLat: topRightLat, topRightLon: topRightLon, filter: filter.rawValue) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract model, handle error - guard let unpackedModel = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available + let unpackedModel = baseModel.payload + let packedVatoms = unpackedModel.package() DispatchQueue.main.async { - print(error!.localizedDescription) - completion([], error!) + completion(.success(packedVatoms)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - let packedVatoms = unpackedModel.package() - - DispatchQueue.main.async { - //print(model) - completion(packedVatoms, nil) } } @@ -263,30 +329,28 @@ extension BLOCKv { topRightLon: Double, precision: Int, filter: VatomGeoFilter = .vatoms, - completion: @escaping (GeoModel?, BVError?) -> Void) { + completion: @escaping (Result) -> Void) { - let endpoint = API.VatomDiscover.geoDiscoverGroups(bottomLeftLat: bottomLeftLat, + let endpoint = API.Vatom.geoDiscoverGroups(bottomLeftLat: bottomLeftLat, bottomLeftLon: bottomLeftLon, topRightLat: topRightLat, topRightLon: topRightLon, precision: precision, filter: filter.rawValue) - BLOCKv.client.request(endpoint) { (baseModel, error) in + BLOCKv.client.request(endpoint) { result in - // extract model, handle error - guard let geoGroupModels = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - print(error!.localizedDescription) - completion(nil, error!) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // model is available - DispatchQueue.main.async { - //print(model) - completion(geoGroupModels, nil) } } @@ -302,23 +366,23 @@ extension BLOCKv { /// - completion: The completion handler to call when the call is completed. /// This handler is executed on the main queue. public static func getActions(forTemplateID id: String, - completion: @escaping ([ActionModel]?, BVError?) -> Void) { + completion: @escaping (Result<[ActionModel], BVError>) -> Void) { let endpoint = API.UserActions.getActions(forTemplateID: id) - self.client.request(endpoint) { (baseModel, error) in + self.client.request(endpoint) { result in - // extract array of actions, ensure no error - guard let actions = baseModel?.payload, error == nil else { + switch result { + case .success(let baseModel): + // model is available DispatchQueue.main.async { - completion(nil, error!) + completion(.success(baseModel.payload)) + } + case .failure(let error): + // handle error + DispatchQueue.main.async { + completion(.failure(error)) } - return - } - - // data is available - DispatchQueue.main.async { - completion(actions, nil) } } @@ -336,41 +400,45 @@ extension BLOCKv { /// This handler is executed on the main queue. public static func performAction(name: String, payload: [String: Any], - completion: @escaping ([String: Any]?, BVError?) -> Void) { + completion: @escaping (Result<[String: Any], BVError>) -> Void) { let endpoint = API.VatomAction.custom(name: name, payload: payload) - self.client.request(endpoint) { (data, error) in + self.client.request(endpoint) { result in + + switch result { + case .success(let data): + + do { + guard + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let payload = object["payload"] as? [String: Any] else { + throw BVError.modelDecoding(reason: "Unable to extract payload.") + } + // model is available + DispatchQueue.main.async { + completion(.success(payload)) + } + + } catch { + let error = BVError.modelDecoding(reason: error.localizedDescription) + completion(.failure(error)) + } - // extract data, ensure no error - guard let data = data, error == nil else { + case .failure(let error): + // handle error DispatchQueue.main.async { - completion(nil, error!) + completion(.failure(error)) } - return - } - // - do { - guard - let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let payload = object["payload"] as? [String: Any] else { - throw BVError.modelDecoding(reason: "Unable to extract payload.") - } - // json is available - DispatchQueue.main.async { - completion(payload, nil) - } - - } catch { - let error = BVError.modelDecoding(reason: error.localizedDescription) - completion(nil, error) } } } - /// Performs an acquire action on a vAtom. + // MARK: - Common Actions for Unowned vAtoms + + /// Performs an acquire action on the specified vatom id. /// /// Often, only a vAtom's ID is known, e.g. scanning a QR code with an embeded vAtom /// ID. This call is useful is such circumstances. @@ -380,15 +448,31 @@ extension BLOCKv { /// - completion: The completion handler to call when the action is completed. /// This handler is executed on the main queue. public static func acquireVatom(withID id: String, - completion: @escaping ([String: Any]?, BVError?) -> Void) { + completion: @escaping (Result<[String: Any], BVError>) -> Void) { let body = ["this.id": id] - // perform the action - self.performAction(name: "Acquire", payload: body) { (data, error) in - completion(data, error) + self.performAction(name: "Acquire", payload: body) { result in + completion(result) } } + + /// Performs an acquire pub variation action on the specified vatom id. + /// + /// - Parameters: + /// - id: The id of the vAtom to acquire. + /// - completion: The completion handler to call when the action is completed. + /// This handler is executed on the main queue. + public static func acquirePubVariation(withID id: String, + completion: @escaping (Result<[String: Any], BVError>) -> Void) { + + let body = ["this.id": id] + // perform the action + self.performAction(name: "AcquirePubVariation", payload: body) { result in + completion(result) + } + + } } diff --git a/BlockV/Core/Web Socket/Models/WSMapEvent.swift b/BlockV/Core/Web Socket/Models/WSMapEvent.swift new file mode 100644 index 00000000..a01123ef --- /dev/null +++ b/BlockV/Core/Web Socket/Models/WSMapEvent.swift @@ -0,0 +1,59 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import MapKit + +/// Web socket event model - unowned map vatoms. +public struct WSMapEvent: Decodable { + + // MARK: - Properties + + /// Unique identifier of this inventory event. + public let eventId: String + /// Database operation. + public let operation: String + /// Unique identifier of the vAtom which generated this event. + public let vatomId: String + /// Action name. + public let actionName: String + /// Coordinate of event. + public let coordinate: CLLocationCoordinate2D + + enum CodingKeys: String, CodingKey { + case payload + } + + enum PayloadCodingKeys: String, CodingKey { + case eventId = "event_id" + case operation = "op" + case vatomId = "vatom_id" + case actionName = "action_name" + case latitude = "lat" + case longitude = "lon" + } + + public init(from decoder: Decoder) throws { + + let items = try decoder.container(keyedBy: CodingKeys.self) + // de-nest payload to top level + let payloadContainer = try items.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) + // de-nest payload to top level + eventId = try payloadContainer.decode(String.self, forKey: .eventId) + operation = try payloadContainer.decode(String.self, forKey: .operation) + vatomId = try payloadContainer.decode(String.self, forKey: .vatomId) + actionName = try payloadContainer.decode(String.self, forKey: .actionName) + let latitude = try payloadContainer.decode(Double.self, forKey: .latitude) + let longitude = try payloadContainer.decode(Double.self, forKey: .longitude) + coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + +} diff --git a/BlockV/Core/Web Socket/WebSocketManager.swift b/BlockV/Core/Web Socket/WebSocketManager.swift index 67e87d5a..1a9614a8 100644 --- a/BlockV/Core/Web Socket/WebSocketManager.swift +++ b/BlockV/Core/Web Socket/WebSocketManager.swift @@ -61,6 +61,8 @@ public class WebSocketManager { case stateUpdate = "state_update" /// Activity event case activity = "my_events" + /// Map event (unowned vatom) + case map = "map" } // MARK: - Signals @@ -80,6 +82,9 @@ public class WebSocketManager { /// Fires when the Web socket receives an **activity** update event. public let onActivityUpdate = Signal() + + /// Fires when the Web socket receives a *map* update event for *unowned* vatoms. + public let onMapUpdate = Signal() // - Lifecycle @@ -102,6 +107,15 @@ public class WebSocketManager { return decoder }() + /// Delay option enum used to create a reconnect interval (measured in seconds). + let delayOption = DelayOption.exponential(initial: 1, base: 1.2, maxDelay: 5) + + /// Tally of the numnber of reconnect attempts. + private var reconnectCount: Int = 0 + + /// Timer intendend to trigger reconnects. + private var reconnectTimer: Timer? + /// Web socket instance private var socket: WebSocket? private let baseURLString: String @@ -217,6 +231,50 @@ public class WebSocketManager { self.connect() } + // MARK: - Commands + + /// Writes a raw payload to the socket. + func write(_ payload: [String: Any]) { + + DispatchQueue.global(qos: .userInitiated).async { + // serialize data + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { + return + } + // write + self.socket?.write(data: data) + } + + } + + /// Writes a region command using the specified payload to the socket. + func writeRegionCommand(_ payload: [String: Any]) { + // command package + let commandPackage: [String: Any] = [ + "cmd": "monitor", + "id": "1", + "version": "1", + "type": "command", + "payload": payload + ] + print(#function, payload) + // write + self.write(commandPackage) + + } + + // MARK: Debugging + + /// Writes a ping frame to the socket. + func writePing(data: Data = Data(), completion: @escaping () -> Void) { + + // write a ping control frame + self.socket?.write(ping: data) { + completion() + } + + } + } // MARK: - Extension WebSocket Delegate @@ -225,6 +283,12 @@ extension WebSocketManager: WebSocketDelegate { public func websocketDidConnect(socket: WebSocketClient) { printBV(info: "Web socket - Connected") + + // invalidate auto-reconnect timer + self.reconnectTimer?.invalidate() + self.reconnectTimer = nil + self.reconnectCount = 0 + self.onConnected.fire(()) } @@ -241,9 +305,22 @@ extension WebSocketManager: WebSocketDelegate { // Fire an error informing the observers that the Web socket has disconnected. self.onDisconnected.fire((nil)) - //TODO: The Web socket should reconnect here: - // The app may fire this message when entering the foreground - // (after the Web socket was disconnected after entering the background). + /* + Note + The app may fire this message when entering the foreground (after the Web socket was disconnected after + entering the background). + */ + + if !shouldAutoConnect { return } + + // attempt to reconnect + self.connect() + // attempt to reconnect in `n` seconds + let delay = delayOption.make(reconnectCount) + self.reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: true, block: { _ in + self.reconnectCount += 1 + self.connect() + }) } @@ -327,6 +404,14 @@ extension WebSocketManager: WebSocketDelegate { } catch { printBV(error: error.localizedDescription) } + + case .map: + do { + let mapEvent = try blockvJSONDecoder.decode(WSMapEvent.self, from: data) + self.onMapUpdate.fire(mapEvent) + } catch { + printBV(error: error.localizedDescription) + } } default: diff --git a/BlockV/Core/vAtom/Vatom+Actions.swift b/BlockV/Core/vAtom/Vatom+Actions.swift deleted file mode 100644 index 986bcbc0..00000000 --- a/BlockV/Core/vAtom/Vatom+Actions.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// BlockV AG. Copyright (c) 2018, all rights reserved. -// -// Licensed under the BlockV SDK License (the "License"); you may not use this file or -// the BlockV SDK except in compliance with the License accompanying it. Unless -// required by applicable law or agreed to in writing, the BlockV SDK distributed under -// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -// ANY KIND, either express or implied. See the License for the specific language -// governing permissions and limitations under the License. -// - -import Foundation - -/// This file adds actions to VatomModel (simply for convenience). -extension VatomModel { - - /// Transfers this vAtom to the specified token. - /// - /// Note: Calling this action will trigger the action associated with this vAtom's - /// template. If an action has not been configured, an error will be generated. - /// - /// - Parameters: - /// - token: Standard UserToken (Phone, Email, or User ID) - /// - completion: The completion handler to call when the action is completed. - /// This handler is executed on the main queue. - public func transfer(toToken token: UserToken, - completion: @escaping ([String: Any]?, BVError?) -> Void) { - - let body = [ - "this.id": self.id, - "new.owner.\(token.type.rawValue)": token.value - ] - - // perform the action - BLOCKv.performAction(name: "Transfer", payload: body) { (json, error) in - //TODO: should it be weak self? - completion(json, error) - } - - } - - /// Drops this vAtom as the specified location. - /// - /// Note: Calling this action will trigger the action associated with this vAtom's - /// template. If an action has not been configured, an error will be generated. - /// - /// - Parameters: - /// - latitude: The latitude component of the coordinate. - /// - longitude: The longitude component of the coordinate. - /// - completion: The completion handler to call when the action is completed. - /// This handler is executed on the main queue. - public func drop(latitude: Double, longitude: Double, - completion: @escaping ([String: Any]?, BVError?) -> Void) { - - let body: [String: Any] = [ - "this.id": self.id, - "geo.pos": [ - "lat": latitude, - "lon": longitude - ] - ] - - // perform the action - BLOCKv.performAction(name: "Drop", payload: body) { (json, error) in - //TODO: should it be weak self? - completion(json, error) - } - - } - - /// Picks up this vAtom from it's dropped location. - /// - /// Note: Calling this action will trigger the action associated with this vAtom's - /// template. If an action has not been configured, an error will be generated. - /// - /// - completion: The completion handler to call when the action is completed. - /// This handler is executed on the main queue. - public func pickUp(completion: @escaping ([String: Any]?, BVError?) -> Void) { - - let body = [ - "this.id": self.id - ] - - // perform the action - BLOCKv.performAction(name: "Pickup", payload: body) { (json, error) in - //TODO: should it be weak self? - completion(json, error) - } - - } - -} diff --git a/BlockV/Face/Extensions/Debouncer.swift b/BlockV/Face/Extensions/Debouncer.swift deleted file mode 100644 index ee494f91..00000000 --- a/BlockV/Face/Extensions/Debouncer.swift +++ /dev/null @@ -1,114 +0,0 @@ -// MIT License -// -// Copyright (c) Simon Ljungberg -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. -// - -import Foundation - -/* -- https://stackoverflow.com/questions/25991367/difference-between-throttling-and-debouncing-a-function -- http://demo.nimius.net/debounce_throttle/ -*/ - -extension TimeInterval { - - /** - Checks if the current time has passed the reference date `since` plus some delay `self`. - - ``` - let lastRunDate = Date().timeIntervalSinceReferenceDate() - let delay: TimeInterval = 3 - - if delay.hasPassed(since: lastRunDate) { - // at least 3 seconds has passed - } - - ``` - - - Parameter since: The reference date from which - */ - func hasPassed(since: TimeInterval) -> Bool { - return Date().timeIntervalSinceReferenceDate - self > since - } - -} - -/** - Wraps a function in a new function that will only execute the wrapped function if `delay` has passed without this - function being called. - - - Parameter delay: A `DispatchTimeInterval` to wait before executing the wrapped function after last invocation. - - Parameter queue: The queue to perform the action on. Defaults to the main queue. - - Parameter action: A function to debounce. Can't accept any arguments. - - - Returns: A new function that will only call `action` if `delay` time passes between invocations. - */ -func debounce(delay: DispatchTimeInterval, - queue: DispatchQueue = .main, - action: @escaping (() -> Void)) -> () -> Void { - var currentWorkItem: DispatchWorkItem? - return { - currentWorkItem?.cancel() - currentWorkItem = DispatchWorkItem { action() } - queue.asyncAfter(deadline: .now() + delay, execute: currentWorkItem!) - } -} - -/** - Wraps a function in a new function that will only execute the wrapped function if `delay` has passed without this - function being called. - - Accepsts an `action` with one argument. - - Parameter delay: A `DispatchTimeInterval` to wait before executing the wrapped function after last invocation. - - Parameter queue: The queue to perform the action on. Defaults to the main queue. - - Parameter action: A function to debounce. Can accept one argument. - - Returns: A new function that will only call `action` if `delay` time passes between invocations. - */ -func debounce(delay: DispatchTimeInterval, - queue: DispatchQueue = .main, - action: @escaping ((T) -> Void)) -> (T) -> Void { - var currentWorkItem: DispatchWorkItem? - return { (param1: T) in - currentWorkItem?.cancel() - currentWorkItem = DispatchWorkItem { action(param1) } - queue.asyncAfter(deadline: .now() + delay, execute: currentWorkItem!) - } -} - -/** - Wraps a function in a new function that will only execute the wrapped function if `delay` has passed without this - function being called. - Accepsts an `action` with two arguments. - - Parameter delay: A `DispatchTimeInterval` to wait before executing the wrapped function after last invocation. - - Parameter queue: The queue to perform the action on. Defaults to the main queue. - - Parameter action: A function to debounce. Can accept two arguments. - - Returns: A new function that will only call `action` if `delay` time passes between invocations. - */ -func debounce(delay: DispatchTimeInterval, - queue: DispatchQueue = .main, - action: @escaping ((T, U) -> Void)) -> (T, U) -> Void { - var currentWorkItem: DispatchWorkItem? - return { (param1: T, param2: U) in - currentWorkItem?.cancel() - currentWorkItem = DispatchWorkItem { action(param1, param2) } - queue.asyncAfter(deadline: .now() + delay, execute: currentWorkItem!) - } -} diff --git a/BlockV/Face/Extensions/FileManager+Etx.swift b/BlockV/Face/Extensions/FileManager+Etx.swift new file mode 100644 index 00000000..af4ad170 --- /dev/null +++ b/BlockV/Face/Extensions/FileManager+Etx.swift @@ -0,0 +1,79 @@ + +// +// Copyright (c) 2016, 2018 Nikolai Ruhe. All rights reserved. +// +import Foundation + +public extension FileManager { + + /// Calculate the allocated size of a directory and all its contents on the volume. + /// + /// As there's no simple way to get this information from the file system the method + /// has to crawl the entire hierarchy, accumulating the overall sum on the way. + /// The resulting value is roughly equivalent with the amount of bytes + /// that would become available on the volume if the directory would be deleted. + /// + /// - note: There are a couple of oddities that are not taken into account (like symbolic links, meta data of + /// directories, hard links, ...). + public func allocatedSizeOfDirectory(at directoryURL: URL) throws -> UInt64 { + + // error handler simply stores the error and stops traversal + var enumeratorError: Error? = nil + func errorHandler(_: URL, error: Error) -> Bool { + enumeratorError = error + return false + } + + // enumerate all directory contents, including subdirectories + let enumerator = self.enumerator(at: directoryURL, + includingPropertiesForKeys: Array(allocatedSizeResourceKeys), + options: [], + errorHandler: errorHandler)! + + // sum up content size here: + var accumulatedSize: UInt64 = 0 + + // perform the traversal + for item in enumerator { + + // bail out on errors from the errorHandler + if enumeratorError != nil { break } + + // add up individual file sizes + let contentItemURL = item as! URL + accumulatedSize += try contentItemURL.regularFileAllocatedSize() + } + + // rethrow errors from errorHandler + if let error = enumeratorError { throw error } + + return accumulatedSize + } +} + + +fileprivate let allocatedSizeResourceKeys: Set = [ + .isRegularFileKey, + .fileAllocatedSizeKey, + .totalFileAllocatedSizeKey, +] + + +fileprivate extension URL { + + func regularFileAllocatedSize() throws -> UInt64 { + let resourceValues = try self.resourceValues(forKeys: allocatedSizeResourceKeys) + + // only look at regular files + guard resourceValues.isRegularFile ?? false else { + return 0 + } + + // To get the file's size we first try the most comprehensive value in terms of what + // the file may use on disk. This includes metadata, compression (on file system + // level) and block size. + // In case totalFileAllocatedSize is unavailable we use the fallback value (excluding + // meta data and compression) This value should always be available. + return UInt64(resourceValues.totalFileAllocatedSize ?? resourceValues.fileAllocatedSize ?? 0) + } +} diff --git a/BlockV/Face/Extensions/Nuke+AnimatedImage.swift b/BlockV/Face/Extensions/Nuke+AnimatedImage.swift index bafa3f9c..197a2865 100644 --- a/BlockV/Face/Extensions/Nuke+AnimatedImage.swift +++ b/BlockV/Face/Extensions/Nuke+AnimatedImage.swift @@ -33,3 +33,34 @@ extension FLAnimatedImageView { } } } + + +extension ImageRequest { + + /// Generates a cache key based on the specified arguments. + func generateCacheKey(url: URL, targetSize: CGSize? = nil) -> Int { + // create a hash for the cacheKey + var hasher = Hasher() + hasher.combine(url) + if let targetSize = targetSize { + hasher.combine(targetSize.width) + hasher.combine(targetSize.height) + } + return hasher.finalize() + } + +} + +extension UIImageView { + + /// Size of the bounds of the view in pixels. + /// + /// Be sure to call this property *after* the view has been layed out. + var pixelSize: CGSize { + get { + return CGSize(width: self.bounds.size.width * UIScreen.main.scale, + height: self.bounds.size.height * UIScreen.main.scale) + } + } + +} diff --git a/BlockV/Face/Extensions/String+Etx.swift b/BlockV/Face/Extensions/String+Etx.swift new file mode 100644 index 00000000..f2e02219 --- /dev/null +++ b/BlockV/Face/Extensions/String+Etx.swift @@ -0,0 +1,29 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation +import var CommonCrypto.CC_MD5_DIGEST_LENGTH +import func CommonCrypto.CC_MD5 +import typealias CommonCrypto.CC_LONG + +extension String { + + /// Returns md5 hash. + var md5: String { + let data = Data(self.utf8) + let hash = data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> [UInt8] in + var hash = [UInt8](repeating: 0, count: Int(CC_MD5_DIGEST_LENGTH)) + CC_MD5(bytes.baseAddress, CC_LONG(data.count), &hash) + return hash + } + return hash.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/BlockV/Face/Extensions/UIImageView+Ext.swift b/BlockV/Face/Extensions/UIImageView+Ext.swift index c1c3a243..1531b92a 100644 --- a/BlockV/Face/Extensions/UIImageView+Ext.swift +++ b/BlockV/Face/Extensions/UIImageView+Ext.swift @@ -21,3 +21,15 @@ extension UIImageView { } } + +extension BaseFaceView { + + /// Size of the bounds of the view in pixels. + public var pixelSize: CGSize { + get { + return CGSize(width: self.bounds.size.width * UIScreen.main.scale, + height: self.bounds.size.height * UIScreen.main.scale) + } + } + +} diff --git a/BlockV/Face/Face Views/FaceView.swift b/BlockV/Face/Face Views/FaceView.swift index b2bf3775..c5257e5b 100644 --- a/BlockV/Face/Face Views/FaceView.swift +++ b/BlockV/Face/Face Views/FaceView.swift @@ -11,17 +11,13 @@ import Foundation -/* - Face Views should work for both owned and unowned vAtoms. - */ - -/// FIXME: This operator is useful, but has a drawback in that it always makes an assignment. +/// TODO: This operator is useful, but has a drawback in that it always makes an assignment. infix operator ?= internal func ?= (lhs: inout T, rhs: T?) { lhs = rhs ?? lhs } -/// Composite type that all face views must derive from and conform. +/// Composite type that all face views must derive from and conform to. /// /// A face view is responsile for rendering a single face of a vAtom. public typealias FaceView = BaseFaceView & FaceViewLifecycle & FaceViewIdentifiable @@ -38,25 +34,49 @@ public protocol FaceViewIdentifiable { } /// The protocol that face views must adopt to receive lifecycle events. +/// +/// When implementing a Face View, it is worth considering how it will work for both owned and unowned (public) vAtoms. public protocol FaceViewLifecycle: class { - /// Boolean value indicating whether the face view has loaded. + /// Boolean value indicating whether the face view has loaded. After load has completed that boolean must be + /// updated. var isLoaded: Bool { get } /// Called to initiate the loading of the face view. /// + /// All content and state should be reset on calling load. + /// /// - important: - /// This method is only called once per lifecyle. + /// This method will only be called only once by the Vatom View Life Cycle (VVLC). This is the trigger for the face + /// view to gathering necessary resources and lay out its content. /// - /// Face views should call the completion handler at once the face view has displayable content. Displayable content - /// means a *minimum first view*. The face view may continue loading content after calling the completion handler. + /// Face views *must* call the completion handler once loading has completed or errored out. func load(completion: ((Error?) -> Void)?) + /* + # NOTE + + The face model is set only on init. Vatom by comparison is may be updated over the lifetime of the face view. + This means if a change has been made to the template's actions and faces (after init) those changes will not + be incorporated. + + Perhaps vatomChanged(_ vatom: VatomModel) should be updated to accept the face model for the rare case it has + changed? That said, modifiying the faces after init is bad proactice. + */ + /// Called to inform the face view the specified vAtom should be rendered. /// - /// Face views should respond to this method by refreshing their content. + /// Face views should respond to this method by refreshing their content. Typically, this is achieved by internally + /// calling the load method. + /// + /// Animating changes: + /// If the vatom id has not changed, consider animating the state change. /// /// - important: + /// This method may be called multiple times by the Vatom View Life Cycle (VVLC). It is important to reset the + /// contents of the face view when so that the new state of the Vatom can be shown. + /// + /// - note: /// This method does not guarantee the same vAtom will be passed in. Rather, it guarantees that the vatom passed /// in will, at minimum, share the same template variation. This is typically encountered when VatomView is used /// inside a reuse pool such as those found in `UICollectionView`. @@ -66,19 +86,19 @@ public protocol FaceViewLifecycle: class { /// vAtom's root or private section. VatomView passes these updates on to the face view. func vatomChanged(_ vatom: VatomModel) - /// Called when the face view is no longer being displayed. + /// Called to reset the content of the face view. /// /// - important: /// This event may be called multiple times. /// /// The face view should perform a clean up operation, e.g. cancel all downloads, remove any listers, nil out any - /// references. Typical use cases include: 1. entering a reuse pool or 2. preparing for deallocation. + /// references. Call unload before dealloc. func unload() } /// Abstract class all face views must derive from. -open class BaseFaceView: UIView { +open class BaseFaceView: BoundedView { /// Vatom to render. public internal(set) var vatom: VatomModel @@ -87,9 +107,12 @@ open class BaseFaceView: UIView { public internal(set) var faceModel: FaceModel /// Face view delegate. - weak var delegate: FaceViewDelegate? + weak public internal(set) var delegate: FaceViewDelegate? /// Initializes a BaseFaceView using a vAtom and a face model. + /// + /// Use the initializer purely to configure the face view. Use the `load` lifecycle method to begin heavy operations + /// to update the state of the face view, e.g. downloading resources. public required init(vatom: VatomModel, faceModel: FaceModel) { self.vatom = vatom self.faceModel = faceModel @@ -107,3 +130,43 @@ enum FaceError: Error { case missingVatomResource case invalidURL } + + +/// This view class provides a convenient way to know when the bounds of a view have been set. +open class BoundedView: UIView { + + /// Setting this value to `true` will trigger a subview layout and ensure that `layoutWithKnowBounds()` is called + /// after the layout. + open var requiresBoundsBasedSetup = false { + didSet { + if requiresBoundsBasedSetup { + // trigger a new layout cycle + hasCompletedLayoutSubviews = false + self.setNeedsLayout() + } + } + } + + /// Boolean value indicating whether a layout pass has been completed since `requiresBoundsBasedLayout` + private var hasCompletedLayoutSubviews = false + + override open func layoutSubviews() { + super.layoutSubviews() + + if requiresBoundsBasedSetup && !hasCompletedLayoutSubviews { + setupWithBounds() + hasCompletedLayoutSubviews = true + requiresBoundsBasedSetup = false + } + + } + + /// Called only after `layoutSubviews` has been called (i.e. bounds are set). + /// + /// This function is usefull for cases where the bounds of the view are important, for example, scaling an image + /// to the correct size. + open func setupWithBounds() { + // subclass should override + } + +} diff --git a/BlockV/Face/Face Views/Image Layered/ImageLayeredFaceView.swift b/BlockV/Face/Face Views/Image Layered/ImageLayeredFaceView.swift index 177bf993..6f5155e4 100644 --- a/BlockV/Face/Face Views/Image Layered/ImageLayeredFaceView.swift +++ b/BlockV/Face/Face Views/Image Layered/ImageLayeredFaceView.swift @@ -52,8 +52,8 @@ class ImageLayeredFaceView: FaceView { private var childLayers: [Layer] = [] private var childVatoms: [VatomModel] { - // observer store manages the child vatoms - return Array(vatomObserverStore.childVatoms) + // fetch cached children + return self.vatom.listCachedChildren() } // MARK: - Config @@ -83,25 +83,12 @@ class ImageLayeredFaceView: FaceView { private let config: Config - /* - NOTE - The `vatomChanged()` method called by `VatomView` does not handle child vatom updates. - The `VatomObserver` class is used to receive these events. This is required for the Child Count policy type. - */ - - /// Class responsible for observing changes related backing vAtom. - private var vatomObserverStore: VatomObserverStore - // MARK: - Init required init(vatom: VatomModel, faceModel: FaceModel) { // init face config self.config = Config(faceModel) - // create an observer for the backing vatom - self.vatomObserverStore = VatomObserverStore(vatomID: vatom.id) - super.init(vatom: vatom, faceModel: faceModel) - self.vatomObserverStore.delegate = self self.addSubview(baseLayer) @@ -113,59 +100,51 @@ class ImageLayeredFaceView: FaceView { // MARK: - Face View Lifecycle - /// Holds the completion to call when the face view has completed loading. - private var loadCompletion: ((Error?) -> Void)? - - /// Begin loading the face view's content. + /// Begins loading the face view's content. func load(completion: ((Error?) -> Void)?) { - // assign a single load completion closure - loadCompletion = { (error) in - completion?(error) - } - /* - Business logic: - This face is considered to be 'loaded' once the base image has been downloaded and loaded into the view. - */ - self.loadBaseResource() + // reset content + self.reset() + // load required resources + self.loadResources { [weak self] error in - // continue loading by reloading all required data - self.refreshData() + guard let self = self else { return } + + // update ui + self.updateLayers() + + // update state and inform delegate of load completion + if let error = error { + self.isLoaded = false + completion?(error) + } else { + self.isLoaded = true + completion?(nil) + } + } } + /// Updates the backing Vatom and loads the new state. func vatomChanged(_ vatom: VatomModel) { + // replace vatom self.vatom = vatom - if vatom.id != self.vatomObserverStore.rootVatomID { - // replace vAtom observer - printBV(info: "Image Layered: Vatom Changed. Replacing VatomObserverStore") - self.vatomObserverStore = VatomObserverStore(vatomID: vatom.id) - self.vatomObserverStore.refresh() - } + // update ui + self.updateLayers() - self.refreshUI() } - /// Unload the face view (called when the VatomView must prepare for reuse). - func unload() { - self.baseLayer.image = nil - self.vatomObserverStore.cancel() + /// Resets the contents of the face view. + private func reset() { + self.baseLayer.image = nil + self.removeAllLayers() } - // MARK: - Refresh - - /// Refresh the model layer (triggers a view layer update). - private func refreshData() { - self.vatomObserverStore.refresh(rootCompletion: nil) { _ in - self.refreshUI() - } - } - - /// Refresh the view layer (does not refresh data layer). - private func refreshUI() { - self.loadBaseResource() - self.updateLayers() + /// Unload the face view (called when the VatomView must prepare for reuse). + func unload() { + self.reset() + //TODO: Cancel downloads } // MARK: - Layer Management @@ -221,7 +200,9 @@ class ImageLayeredFaceView: FaceView { return layer } - var request = ImageRequest(url: encodeURL) + var request = ImageRequest(url: encodeURL, + targetSize: pixelSize, + contentMode: .aspectFit) // use unencoded url as cache key request.cacheKey = resourceModel.url // load image (automatically handles reuse) @@ -252,7 +233,7 @@ class ImageLayeredFaceView: FaceView { }, completion: { _ in // remove it - if let index = self.childLayers.index(of: layer) { + if let index = self.childLayers.firstIndex(of: layer) { self.childLayers.remove(at: index) } layer.removeFromSuperview() @@ -263,17 +244,23 @@ class ImageLayeredFaceView: FaceView { } } + /// Remove all child layers without animation. + private func removeAllLayers() { + childLayers.forEach { $0.removeFromSuperview() } + childLayers = [] + } + // MARK: - Resources /// Loads the resource for the backing vAtom's "layerImage" into the base layer. /// /// Calls the `loadCompletion` closure asynchronously. Note: the mechanics of `loadImage(with:into:)` mean only the /// latest completion handler will be executed since all previous tasks are cancelled. - private func loadBaseResource() { + private func loadResources(completion: @escaping (Error?) -> Void) { // extract resource model guard let resourceModel = vatom.props.resources.first(where: { $0.name == config.imageName }) else { - loadCompletion?(FaceError.missingVatomResource) + completion(FaceError.missingVatomResource) return } @@ -281,43 +268,22 @@ class ImageLayeredFaceView: FaceView { // encode url let encodeURL = try BLOCKv.encodeURL(resourceModel.url) - var request = ImageRequest(url: encodeURL) - // use unencoded url as cache key - request.cacheKey = resourceModel.url - // load image (automatically handles reuse) - // GOTCHA: Upon calling load, previous requests are cancelled allong with their completion handlers. + var request = ImageRequest(url: encodeURL, + targetSize: pixelSize, + contentMode: .aspectFit) + + // set cache key + request.cacheKey = request.generateCacheKey(url: resourceModel.url, targetSize: pixelSize) + + // load image (auto cancel previous) Nuke.loadImage(with: request, into: self.baseLayer) { (_, error) in self.isLoaded = true - self.loadCompletion?(error) + completion(error) } } catch { - loadCompletion?(error) + completion(error) } } } - -extension ImageLayeredFaceView: VatomObserverStoreDelegate { - - func vatomObserver(_ observer: VatomObserverStore, rootVatomStateUpdated: VatomModel) { - // nothing to do - } - - func vatomObserver(_ observer: VatomObserverStore, childVatomStateUpdated: VatomModel) { - // nothing to do - } - - func vatomObserver(_ observer: VatomObserverStore, willAddChildVatom vatomID: String) { - // nothing to do - } - - func vatomObserver(_ observer: VatomObserverStore, didAddChildVatom childVatom: VatomModel) { - self.refreshUI() - } - - func vatomObserver(_ observer: VatomObserverStore, didRemoveChildVatom childVatom: VatomModel) { - self.refreshUI() - } - -} diff --git a/BlockV/Face/Face Views/Image Policy/ImagePolicyFaceView.swift b/BlockV/Face/Face Views/Image Policy/ImagePolicyFaceView.swift index 77f0cb58..05704fe3 100644 --- a/BlockV/Face/Face Views/Image Policy/ImagePolicyFaceView.swift +++ b/BlockV/Face/Face Views/Image Policy/ImagePolicyFaceView.swift @@ -45,15 +45,6 @@ class ImagePolicyFaceView: FaceView { /// struct immutalbe and is ONLY populated on init. private let config: Config - /* - NOTE - The `vatomChanged()` method called by `VatomView` does not handle child vatom updates. - The `VatomObserver` class is used to receive these events. This is required for the Child Count policy type. - */ - - /// Class responsible for observing changes related backing vAtom. - private var vatomObserver: VatomObserver - // MARK: - Initialization required init(vatom: VatomModel, faceModel: FaceModel) { @@ -67,11 +58,11 @@ class ImagePolicyFaceView: FaceView { self.config = Config() // default values } - // create an observer for the backing vatom - self.vatomObserver = VatomObserver(vatomID: vatom.id) super.init(vatom: vatom, faceModel: faceModel) - self.vatomObserver.delegate = self + // enable animated images + ImagePipeline.Configuration.isAnimatedImageDataEnabled = true + // add image view self.addSubview(animatedImageView) animatedImageView.frame = self.bounds @@ -84,37 +75,64 @@ class ImagePolicyFaceView: FaceView { // MARK: - Face View Lifecycle - /// Begin loading the face view's content. + /// Begins loading the face view's content. func load(completion: ((Error?) -> Void)?) { - // update resources - updateUI(completion: completion) + // reset content + self.reset() + // update state + self.updateUI { [weak self] error in + + guard let self = self else { return } + // inform delegate of load completion + if let error = error { + self.isLoaded = false + completion?(error) + } else { + self.isLoaded = true + completion?(nil) + } + } + } - /// Respond to updates or replacement of the current vAtom. + /// Updates the backing Vatom and loads the new state. + /// + /// The VVLC ensures the vatom will share the same template variation. This means the vatom will have the same + /// resources but the state of the face (e.g. which recsources it is showing) may be different. func vatomChanged(_ vatom: VatomModel) { - /* - NOTE: - - Changes to to the backing vAtom must be reflected in the face view. - - Specifically, updates to the backing vAtom or it's children may afect the required image policy image. - */ + if self.vatom.id == vatom.id { + // replace vatom, update UI + self.vatom = vatom + + } else { + // replace vatom, reset and update UI + self.vatom = vatom + self.reset() + } + // update ui + self.updateUI(completion: nil) - // replace current vatom - self.vatom = vatom - self.updateUIDebounced() + } + /// Resets the contents of the face view. + private func reset() { + self.animatedImageView.image = nil + self.animatedImageView.animatedImage = nil } - /// Unload the face view (called when the VatomView must prepare for reuse). + /// Unload face view. Reset all content. func unload() { - self.vatomObserver.cancel() + self.reset() + //TODO: Cancel all downloads } // MARK: - Face Code /// Current count of child vAtoms. private var currentChildCount: Int { - return vatomObserver.childVatomIDs.count + // inspect cached children + return self.vatom.listCachedChildren().count } /// Updates the interface using local state. @@ -126,18 +144,6 @@ class ImagePolicyFaceView: FaceView { self.updateImageView(withResource: resourceName, completion: completion) } - /// Updates the interface using local state (debounced). - /// - /// This property debounces the UI update. This avoids the case where numerous parent ID state changes could cause - /// unecessary resource downloads. Essentially, it ensure the the vAtom is "settled" before updating the UI. - private lazy var updateUIDebounced = { - // NOTE: Debounce will cancel work items. - return debounce(delay: DispatchTimeInterval.milliseconds(500)) { - self.updateUI(completion: nil) - //printBV(info: "Debounced: updateUI called") - } - }() - /// Update the face view using *local* data. /// /// Do not call directly. Rather call `debouncedUpdateUI()`. @@ -158,15 +164,25 @@ class ImagePolicyFaceView: FaceView { } else if let policy = policy as? Config.FieldLookup { // create key path and split into head and tail - guard let component = KeyPath(policy.field).headAndTail(), - // only private section lookups are allowed - component.head == "private", + guard let component = KeyPath(policy.field).headAndTail() else { continue } + + var vatomValue: JSON? + // check container + if component.head == "private" { // current value on the vatom - let vatomValue = self.vatom.private?[keyPath: component.tail.path] else { - continue + let vatomValue = self.vatom.private?[keyPath: component.tail.path] + } else if component.head == "vAtom::vAtomType" { + //TODO: Create a keypath-to-keypath look up + if component.tail.path == "cloning_score" { + vatomValue = try? JSON(self.vatom.props.cloningScore) + } else if component.tail.path == "num_direct_clones" { + vatomValue = try? JSON(self.vatom.props.numberDirectClones) + } } - - if vatomValue == policy.value { + + guard let value = vatomValue else { continue } + + if value == policy.value { // update image //print(">>:: vAtom Value: \(vatomValue) | Policy Value: \(policy.value)\n") return policy.resourceName @@ -205,9 +221,13 @@ class ImagePolicyFaceView: FaceView { // encode url let encodeURL = try BLOCKv.encodeURL(resourceModel.url) - var request = ImageRequest(url: encodeURL) - // use unencoded url as cache key - request.cacheKey = resourceModel.url + var request = ImageRequest(url: encodeURL, + targetSize: pixelSize, + contentMode: .aspectFit) + + // set cache key + request.cacheKey = request.generateCacheKey(url: resourceModel.url, targetSize: pixelSize) + // load image (automatically handles reuse) Nuke.loadImage(with: request, into: self.animatedImageView) { (_, error) in self.isLoaded = true @@ -221,20 +241,6 @@ class ImagePolicyFaceView: FaceView { } -// MARK: - Vatom Observer Delegate - -extension ImagePolicyFaceView: VatomObserverDelegate { - - func vatomObserver(_ observer: VatomObserver, didAddChildVatom vatomID: String) { - self.updateUIDebounced() - } - - func vatomObserver(_ observer: VatomObserver, didRemoveChildVatom vatomID: String) { - self.updateUIDebounced() - } - -} - // MARK: - Image Policy /// Protocol image policies should conform to. @@ -326,7 +332,7 @@ private extension ImagePolicyFaceView { } // child count - if let countMax = imagePolicyDescriptor["count_max"]?.floatValue { + if let countMax = imagePolicyDescriptor["count_max"]?.doubleValue { let childCountPolicy = ChildCount(resourceName: resourceName, countMax: Int(countMax)) self.policies.append(childCountPolicy) continue diff --git a/BlockV/Face/Face Views/Image Progress/ImageProgressFaceView.swift b/BlockV/Face/Face Views/Image Progress/ImageProgressFaceView.swift index 828d2eff..15839058 100644 --- a/BlockV/Face/Face Views/Image Progress/ImageProgressFaceView.swift +++ b/BlockV/Face/Face Views/Image Progress/ImageProgressFaceView.swift @@ -86,10 +86,10 @@ class ImageProgressFaceView: FaceView { self.direction ?= faceConfig["direction"]?.stringValue self.showPercentage ?= faceConfig["show_percentage"]?.boolValue - if let paddingEnd = faceConfig["padding_end"]?.floatValue { + if let paddingEnd = faceConfig["padding_end"]?.doubleValue { self.paddingEnd ?= Double(paddingEnd) } - if let paddingStart = faceConfig["padding_start"]?.floatValue { + if let paddingStart = faceConfig["padding_start"]?.doubleValue { self.paddingStart = Double(paddingStart) } } @@ -139,24 +139,52 @@ class ImageProgressFaceView: FaceView { // MARK: - FaceView Lifecycle + /// Begins loading the face view's content. func load(completion: ((Error?) -> Void)?) { - self.updateResources { (error) in - self.setNeedsLayout() - self.updateUI() - completion?(error) + // reset content + self.reset() + /// load required resources + self.loadResources { [weak self] error in + + guard let self = self else { return } + // update state and inform delegate of load completion + if let error = error { + self.setNeedsLayout() + self.updateUI() + self.isLoaded = false + completion?(error) + } else { + self.setNeedsLayout() + self.updateUI() + self.isLoaded = true + completion?(nil) + } + } } + /// Updates the backing Vatom and loads the new state. func vatomChanged(_ vatom: VatomModel) { - // update vatom self.vatom = vatom - updateUI() + // update ui + self.setNeedsLayout() + self.updateUI() + + } + + /// Resets the contents of the face view. + private func reset() { + emptyImageView.image = nil + fullImageView.image = nil } - func unload() { } + func unload() { + reset() + //TODO: Cancel all downloads + } // MARK: - View Lifecycle @@ -187,7 +215,7 @@ class ImageProgressFaceView: FaceView { let paddingEnd = contentClippingRect.width * CGFloat(self.config.paddingEnd) / imagePixelSize.width let innerY = contentClippingRect.height - paddingStart - paddingEnd - let innerX = contentClippingRect.width - paddingStart - paddingEnd +// let innerX = contentClippingRect.width - paddingStart - paddingEnd let innerProgressY = (contentClippingRect.height - paddingStart - paddingEnd) * progress let innerProgressX = (contentClippingRect.width - paddingStart - paddingEnd) * progress @@ -239,48 +267,51 @@ class ImageProgressFaceView: FaceView { /// Fetches required resources and populates the relevant `ImageView`s. The completion handler is called once all /// images are downloaded (or an error is encountered). - private func updateResources(completion: ((Error?) -> Void)?) { + private func loadResources(completion: @escaping (Error?) -> Void) { // ensure required resources are present guard let emptyImageResource = vatom.props.resources.first(where: { $0.name == self.config.emptyImageName }), let fullImageResource = vatom.props.resources.first(where: { $0.name == self.config.fullImageName }) else { - printBV(error: "\(#file) - failed to extract resources.") + completion(FaceError.missingVatomResource) return } // ensure encoding passes - guard - let encodedEmptyURL = try? BLOCKv.encodeURL(emptyImageResource.url), - let encodedFullURL = try? BLOCKv.encodeURL(fullImageResource.url) - else { - printBV(error: "\(#file) - failed to encode resources.") - return - } + do { - dispatchGroup.enter() - dispatchGroup.enter() + let encodedEmptyURL = try BLOCKv.encodeURL(emptyImageResource.url) + let encodedFullURL = try BLOCKv.encodeURL(fullImageResource.url) - var requestEmpty = ImageRequest(url: encodedEmptyURL) - // use unencoded url as cache key - requestEmpty.cacheKey = emptyImageResource.url - // load image (automatically handles reuse) - Nuke.loadImage(with: requestEmpty, into: self.emptyImageView) { (_, _) in - self.dispatchGroup.leave() - } + dispatchGroup.enter() + dispatchGroup.enter() - var requestFull = ImageRequest(url: encodedFullURL) - // use unencoded url as cache key - requestFull.cacheKey = fullImageResource.url - // load image (automatically handles reuse) - Nuke.loadImage(with: requestFull, into: self.fullImageView) { (_, _) in - self.dispatchGroup.leave() - } + var requestEmpty = ImageRequest(url: encodedEmptyURL) + // set cache key + requestEmpty.cacheKey = requestEmpty.generateCacheKey(url: emptyImageResource.url, targetSize: pixelSize) + + // load image (automatically handles reuse) + Nuke.loadImage(with: requestEmpty, into: self.emptyImageView) { (_, _) in + self.dispatchGroup.leave() + } + + var requestFull = ImageRequest(url: encodedFullURL) + // set cache key + requestFull.cacheKey = requestFull.generateCacheKey(url: fullImageResource.url, targetSize: pixelSize) + + // load image (automatically handles reuse) + Nuke.loadImage(with: requestFull, into: self.fullImageView) { (_, _) in + self.dispatchGroup.leave() + } + + dispatchGroup.notify(queue: .main) { + self.isLoaded = true + completion(nil) + } - dispatchGroup.notify(queue: .main) { - self.isLoaded = true - completion?(nil) + } catch { + completion(error) } } diff --git a/BlockV/Face/Face Views/Image/ImageFaceView.swift b/BlockV/Face/Face Views/Image/ImageFaceView.swift index f854f51d..064f5499 100644 --- a/BlockV/Face/Face Views/Image/ImageFaceView.swift +++ b/BlockV/Face/Face Views/Image/ImageFaceView.swift @@ -51,6 +51,9 @@ class ImageFaceView: FaceView { /// The first resource name in the resources array (if present) is used in place of the activate image. init(_ faceModel: FaceModel) { + // enable animated images + ImagePipeline.Configuration.isAnimatedImageDataEnabled = true + // legacy: overwrite fallback if needed self.imageName ?= faceModel.properties.resources.first @@ -112,23 +115,46 @@ class ImageFaceView: FaceView { /// Inspects the face config first and uses the scale if available. If no face config is found, a simple heuristic /// is used to choose the best content mode. private func updateContentMode() { + self.animatedImageView.contentMode = configuredContentMode + } + var configuredContentMode: UIView.ContentMode { // check face config switch config.scale { - case .fill: animatedImageView.contentMode = .scaleAspectFill - case .fit: animatedImageView.contentMode = .scaleAspectFit + case .fill: return .scaleAspectFill + case .fit: return .scaleAspectFit } - } // MARK: - Face View Lifecycle + + private var storedCompletion: ((Error?) -> Void)? - /// Begin loading the face view's content. + /// Begins loading the face view's content. func load(completion: ((Error?) -> Void)?) { - updateResources(completion: completion) + + /* + # Pattern + 1. Call `reset` (which sets `isLoaded` to false) + >>> reset content, cancel downloads + 2. Update face state + >>> set `isLoaded` to true + >>> call the delegate + */ + + // reset content + self.reset() + // store the completion + self.storedCompletion = completion + // + self.requiresBoundsBasedSetup = true + } - /// Respond to updates or replacement of the current vAtom. + /// Updates the backing Vatom and loads the new state. + /// + /// The VVLC ensures the vatom will share the same template variation. This means the vatom will have the same + /// resources but the state of the face (e.g. which recsources it is showing) may be different. func vatomChanged(_ vatom: VatomModel) { /* @@ -138,45 +164,85 @@ class ImageFaceView: FaceView { - Thus, no meaningful UI update can be made. */ - // replace current vatom self.vatom = vatom - updateResources(completion: nil) } + /// Resets the contents of the face view. + private func reset() { + self.animatedImageView.image = nil + self.animatedImageView.animatedImage = nil + } + /// Unload the face view. /// - /// Also called before reuse (when used inside a reuse pool). + /// Unload should reset the face view contents *and* stop any expensive operations e.g. downloading resources. func unload() { - self.animatedImageView.image = nil - self.animatedImageView.animatedImage = nil + reset() + //TODO: Cancel resource downloading } // MARK: - Resources - private func updateResources(completion: ((Error?) -> Void)?) { + var nukeContentMode: ImageDecompressor.ContentMode { + // check face config, convert to nuke content mode + switch config.scale { + case .fill: return .aspectFill + case .fit: return .aspectFit + } + } + + override func setupWithBounds() { + super.setupWithBounds() + + // load required resources + self.loadResources { [weak self] error in + + guard let self = self else { return } + // update state and inform delegate of load completion + if let error = error { + self.isLoaded = false + self.storedCompletion?(error) + } else { + self.isLoaded = true + self.storedCompletion?(nil) + } + + } + + } + + private func loadResources(completion: ((Error?) -> Void)?) { // extract resource model guard let resourceModel = vatom.props.resources.first(where: { $0.name == config.imageName }) else { + completion?(FaceError.missingVatomResource) return } - // encode url - guard let encodeURL = try? BLOCKv.encodeURL(resourceModel.url) else { - return - } - - //FIXME: Where should this go? - ImagePipeline.Configuration.isAnimatedImageDataEnabled = true - - //TODO: Should the size of the VatomView be factoring in and the image be resized? + do { + // encode url + let encodeURL = try BLOCKv.encodeURL(resourceModel.url) + // create request + var request = ImageRequest(url: encodeURL, + targetSize: pixelSize, + contentMode: nukeContentMode) + + // set cache key + request.cacheKey = request.generateCacheKey(url: resourceModel.url, targetSize: pixelSize) + + /* + Nuke's `loadImage` cancels any exisitng requests and nils out the old image. This takes care of the reuse-pool + use case where the same face view is used to display a vatom of the same template variation. + */ + + // load image (auto cancel previous) + Nuke.loadImage(with: request, into: self.animatedImageView) { (_, error) in + self.isLoaded = true + completion?(error) + } - var request = ImageRequest(url: encodeURL) - // use unencoded url as cache key - request.cacheKey = resourceModel.url - // load image (automatically handles reuse) - Nuke.loadImage(with: request, into: self.animatedImageView) { (_, error) in - self.isLoaded = true + } catch { completion?(error) } diff --git a/BlockV/Face/Face Views/Utilities/VatomObserver.swift b/BlockV/Face/Face Views/Utilities/VatomObserver.swift deleted file mode 100644 index c5513c85..00000000 --- a/BlockV/Face/Face Views/Utilities/VatomObserver.swift +++ /dev/null @@ -1,177 +0,0 @@ -// -// BlockV AG. Copyright (c) 2018, all rights reserved. -// -// Licensed under the BlockV SDK License (the "License"); you may not use this file or -// the BlockV SDK except in compliance with the License accompanying it. Unless -// required by applicable law or agreed to in writing, the BlockV SDK distributed under -// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -// ANY KIND, either express or implied. See the License for the specific language -// governing permissions and limitations under the License. -// - -import Foundation -import Signals - -protocol VatomObserverDelegate: class { - - /// Called after the observer has added a child vAtom (and it's state is available). - func vatomObserver(_ observer: VatomObserver, didAddChildVatom vatomID: String) - /// Called after the observer has removed a child vAtom. - func vatomObserver(_ observer: VatomObserver, didRemoveChildVatom vatomID: String) - -} - -//TODO: Add start and stop methods - -/// This class allows any user *owned* vAtom to be observed for state changes. This includes the adding and removing of -/// child vAtoms. -/// -/// On `init(vatomID:)`, this class will fetch the vAtoms remote state and then begin observing changes to the vAtom. -/// As such, soon after `init(vatomID:)` is called, the event closures may be exectured. -/// -/// This class provides a simple means of observing an *owned* vAtom and its immediate children. -/// -/// Handles fetching the root and child vatoms' state directly from the platform. Additionally, performs partial updates -/// using Web socket. -/// -/// Restrictions: -/// - Access level: Internal -/// - vAtom ownership: Owner only -/// -/// - Essentialy a wrapper around the Web socket and provides closure interfaces. This could also be a delegate... -/// - Only watches vAtom ids (does not store full vatoms - though that could be usefull). -/// - This class interfaces directly with the update stream. Note, you may receive state events before the internal -/// vatom management system (i.e. VatomInventory) has had a chance to update. -class VatomObserver { - - // MARK: - Properties - - /// Unique identifier of the root vAtom. - private(set) var rootVatomID: String - /// Set of unique identifiers of root's child vAtoms. - private(set) var childVatomIDs: Set = [] { - didSet { - let added = childVatomIDs.subtracting(oldValue) - let removed = oldValue.subtracting(childVatomIDs) - // notify delegate of changes - added.forEach { self.delegate?.vatomObserver(self, didAddChildVatom: $0) } - removed.forEach { self.delegate?.vatomObserver(self, didRemoveChildVatom: $0) } - } - } - - /// Delegate - weak var delegate: VatomObserverDelegate? - - // MARK: - Initializers - - /// Initialize using a vAtom ID. - init(vatomID: String) { - - self.rootVatomID = vatomID - - // move block out of init - DispatchQueue.main.async { - self.refresh() - } - - self.subscribeToUpdates() - - } - - // MARK: - Push State Update (Real-Time) - - /// Cancel observing the vAtom and it's children. - func cancel() { - // cancel observation updates - self.onConnected?.cancel() - self.onConnected = nil - self.onVatomStateUpdate?.cancel() - self.onVatomStateUpdate = nil - } - - private var onConnected: SignalSubscription? - private var onVatomStateUpdate: SignalSubscription? - - /// Subscribe to state updates - private func subscribeToUpdates() { - - // subscribe to web socket connect - self.onConnected = BLOCKv.socket.onConnected.subscribe(with: self) { [weak self] in - self?.refresh() - } - - // subscribe to state update - self.onVatomStateUpdate = BLOCKv.socket.onVatomStateUpdate.subscribe(with: self) { [weak self] stateUpdate in - - guard let `self` = self else { return } - - // - Parent ID Change - - // check for parent id changes - if let newParentID = stateUpdate.vatomProperties["vAtom::vAtomType"]?["parent_id"]?.stringValue { - - /* - Use the parent ID and the known list of children to determine if a child was added or removed. - */ - - if newParentID == self.rootVatomID { - // filter out duplicates - if !self.childVatomIDs.contains(stateUpdate.vatomId) { - // add - self.childVatomIDs.insert(stateUpdate.vatomId) - // notify delegate of imminent addition - self.delegate?.vatomObserver(self, didAddChildVatom: stateUpdate.vatomId) - } - } else { - /* - GOTCHA: - If for some reason, local children become out of sync with the remote there is no sensible way - for the client to know if a child vAtom was removed. For example, if the remote children are - [A, B, C] but locally the state is [A, B] and a "parent_id" change comes down for C. - - In this case, the removal of the child will not be noticied (since the child was "not" a local - child). To reduce the likelyhood of this, a remote state pull should be performed in cases - where the observer suspects remote-local sync issues, e.g. connetion drops. - */ - - // remove child id - if self.childVatomIDs.remove(stateUpdate.vatomId) != nil { - // notify delegate of removal - self.delegate?.vatomObserver(self, didRemoveChildVatom: stateUpdate.vatomId) - } - - } - - } - - } - - } - - // MARK: - Pull State Update - - /// Refresh root and child vatoms using remote state. - func refresh() { - self.updateChildVatoms() - } - - /// Replace all the root vAtom's direct children using remote state. - private func updateChildVatoms() { - - BLOCKv.getInventory(id: self.rootVatomID) { [weak self] (vatoms, error) in - - // ensure no error - guard error == nil else { - printBV(error: "Unable to fetch children. Error: \(String(describing: error?.localizedDescription))") - return - } - // ensure correct parent ID - let validChildren = vatoms.filter { $0.props.parentID == self?.rootVatomID } - // replace the list of children - self?.childVatomIDs = Set(validChildren.map { $0.id }) - - } - - } - -} diff --git a/BlockV/Face/Face Views/Utilities/VatomObserverStore.swift b/BlockV/Face/Face Views/Utilities/VatomObserverStore.swift deleted file mode 100644 index 5e507061..00000000 --- a/BlockV/Face/Face Views/Utilities/VatomObserverStore.swift +++ /dev/null @@ -1,244 +0,0 @@ -// -// BlockV AG. Copyright (c) 2018, all rights reserved. -// -// Licensed under the BlockV SDK License (the "License"); you may not use this file or -// the BlockV SDK except in compliance with the License accompanying it. Unless -// required by applicable law or agreed to in writing, the BlockV SDK distributed under -// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF -// ANY KIND, either express or implied. See the License for the specific language -// governing permissions and limitations under the License. -// - -import Foundation -import Signals - -/// Aggregates updates from remote data pull and update stream push. -protocol VatomObserverStoreDelegate: class { - - /// Called when the root vAtom has experienced a property change. - func vatomObserver(_ observer: VatomObserverStore, rootVatomStateUpdated: VatomModel) - /// Called when a child vAtom has experienced a property change. - func vatomObserver(_ observer: VatomObserverStore, childVatomStateUpdated: VatomModel) - - /// Called when the observer is about to add a child vAtom. - /// - /// Use this event to be notified of an imminent child vAtom beign added to the root vAtom, e.g. beginning an - /// incomming animation. - /// - /// To know when the child's state is available, conform to: - /// `vatomObserver(:didAddChildVatom:)`. - func vatomObserver(_ observer: VatomObserverStore, willAddChildVatom vatomID: String) - /// Called after the observer has added a child vAtom (and it's state is available). - func vatomObserver(_ observer: VatomObserverStore, didAddChildVatom childVatom: VatomModel) - /// Called after the observer has removed a child vAtom. - func vatomObserver(_ observer: VatomObserverStore, didRemoveChildVatom childVatom: VatomModel) - -} - -/// This class provides a simple means of observing an *owned* vAtom and its immediate children. -/// -/// Handles fetching the root and child vatoms' state directly from the platform. Additionally, performs partial updates -/// using Web socket. -/// -/// Restrictions: -/// - Access level: Internal -/// - vAtom ownership: Owner only -class VatomObserverStore { - - // MARK: - Properties - - /// Unique identifier of the root vAtom. - public private(set) var rootVatomID: String - - public private(set) var rootVatom: VatomModel? - - /// Set of **direct** child vAtoms. - /// - /// Direct children are those vAtoms whose parent ID matches the root vAtom's ID. - public private(set) var childVatoms: Set = [] { - didSet { - let added = childVatoms.subtracting(oldValue) - let removed = oldValue.subtracting(childVatoms) - // notify delegate of changes - added.forEach { self.delegate?.vatomObserver(self, didAddChildVatom: $0) } - removed.forEach { self.delegate?.vatomObserver(self, didRemoveChildVatom: $0) } - } - } - - /// Delegate - weak var delegate: VatomObserverStoreDelegate? - - // MARK: - Initialization - - /// Initialize using a vAtom ID. - init(vatomID: String) { - self.rootVatomID = vatomID - self.subscribeToUpdates() - } - - // MARK: - Life Cycle - - /// Cancel observing the vAtom and it's children. - func cancel() { - // cancel observation updates - self.onConnected?.cancel() - self.onConnected = nil - self.onVatomStateUpdate?.cancel() - self.onVatomStateUpdate = nil - } - - private var onConnected: SignalSubscription? - private var onVatomStateUpdate: SignalSubscription? - - // MARK: - Push State Update (Real-Time) - - /// Subscribes to Web socket signals. - /// - /// - important: This method should only be called once. Signal subscription is additive. - private func subscribeToUpdates() { - - // subscribe to web socket connect - self.onConnected = BLOCKv.socket.onConnected.subscribe(with: self) { [weak self] in - self?.refresh() - } - - // subscribe to state update - self.onVatomStateUpdate = BLOCKv.socket.onVatomStateUpdate.subscribe(with: self) { [weak self] stateUpdate in - - guard let `self` = self else { return } - - // - Raw Update - - // check if root vatom - if stateUpdate.vatomId == self.rootVatomID, - let updatedVatom = self.rootVatom?.updated(applying: stateUpdate) { - self.delegate?.vatomObserver(self, rootVatomStateUpdated: updatedVatom) - } else { - // check if child vatom - if let childVatom = self.childVatoms.first(where: { $0.id == stateUpdate.vatomId }), - let updatedVatom = childVatom.updated(applying: stateUpdate) { - self.delegate?.vatomObserver(self, childVatomStateUpdated: updatedVatom) - } - } - - // - Parent ID Change - - // check for parent id changes - if let newParentID = stateUpdate.vatomProperties["vAtom::vAtomType"]?["parent_id"]?.stringValue { - - /* - Use the parent ID and the known list of children to determine if a child was added or removed. - */ - - if newParentID == self.rootVatomID { - // filter out duplicates - if !self.childVatoms.contains(where: { $0.id == stateUpdate.vatomId }) { - // notify delegate of imminent addition - self.delegate?.vatomObserver(self, willAddChildVatom: stateUpdate.vatomId) - // add child (async) - self.addChildVatom(withID: stateUpdate.vatomId) - } - } else { - // if the vatom's parentID is not the rootID and is in the list of children - if let index = self.childVatoms.firstIndex(where: { $0.id == stateUpdate.vatomId }) { - /* - GOTCHA: - If for some reason, local children become out of sync with the remote there is no sensible way - for the client to know if a child vAtom was removed. For example, if the remote children are - [A, B, C] but locally the state is [A, B] and a "parent_id" change comes down for C. - - In this case, the removal of the child will not be noticied (since the child was "not" a local - child). To reduce the likelyhood of this, a remote state pull should be performed in cases - where the observer suspects remote-local sync issues, e.g. connetion drops. - */ - - // remove child vatom - let removedVatom = self.childVatoms.remove(at: index) - // notify delegate of removal - self.delegate?.vatomObserver(self, didRemoveChildVatom: removedVatom) - } - } - - } - - } - - } - - // MARK: - Pull State Update - - typealias Completion = (Error?) -> Void - - /// Refresh root and child vatoms using remote state. - /// - /// - Parameters: - /// - rootCompletion: Completion handler that is called once the root vAtom has completed loading. - /// - childrenCompletion: Completion handler that is called once the children vAtoms has completed loading. - public func refresh(rootCompletion: Completion? = nil, childCompletion: Completion? = nil) { - // refresh - self.updateRootVatom(completion: rootCompletion) - self.updateChildVatoms(completion: childCompletion) - } - - /// Fetch root vAtom's remote state. - private func updateRootVatom(completion: Completion?) { - - BLOCKv.getVatoms(withIDs: [self.rootVatomID]) { [weak self] (vatoms, error) in - - // ensure no error - guard let rootVatom = vatoms.first, error == nil else { - //printBV(error: "Unable to fetch root vAtom: \(String(describing: error?.localizedDescription))") - completion?(error) - return - } - // update root vAtom - self?.rootVatom = rootVatom - completion?(nil) - } - - } - - /// Fetch remote state for the specified child vAtom and adds it to the list of children. - private func addChildVatom(withID childID: String) { - - BLOCKv.getVatoms(withIDs: [childID]) { [weak self] (vatoms, error) in - - // ensure no error - guard let childVatom = vatoms.first, error == nil else { - //printBV(error: "Unable to vAtom. Error: \(String(describing: error?.localizedDescription))") - return - } - - // ensure the vatom is still a child - // there is a case, due to the async arch, where the retrieved vAtom may no longer be a child - if childVatom.props.parentID == self?.rootVatomID { - // insert child (async) - self?.childVatoms.insert(childVatom) - } - - } - - } - - /// Replace all the root vAtom's direct children using remote state. - private func updateChildVatoms(completion: Completion?) { - - BLOCKv.getInventory(id: self.rootVatomID) { [weak self] (vatoms, error) in - - // ensure no error - guard error == nil else { - //printBV(error: "Unable to fetch children. Error: \(String(describing: error?.localizedDescription))") - completion?(error) - return - } - // ensure correct parent ID - let validChildren = vatoms.filter { $0.props.parentID == self?.rootVatomID } - // replace the list of children - self?.childVatoms = Set(validChildren) - completion?(nil) - - } - - } - -} diff --git a/BlockV/Face/Face Views/Web/Face Bridge/CoreBridge.swift b/BlockV/Face/Face Views/Web/Face Bridge/CoreBridge.swift index 148d90f8..062038b8 100644 --- a/BlockV/Face/Face Views/Web/Face Bridge/CoreBridge.swift +++ b/BlockV/Face/Face Views/Web/Face Bridge/CoreBridge.swift @@ -23,7 +23,7 @@ protocol CoreBridge { /// - parameters: /// - payload: Payload portion of the response. /// - error: Any error encountered during message processing. - typealias Completion = (_ payload: JSON?, _ error: BridgeError?) -> Void + typealias Completion = (Result) -> Void /// Processes the message and calls the completion handler once the output is known. /// @@ -36,11 +36,20 @@ protocol CoreBridge { /// Returns `true` if the bridge is capable of processing the message and `false` otherwise. func canProcessMessage(_ message: String) -> Bool - /// Send a formatted vAtom to the Bridge SDK. + /// Sends a Vatom over the native bridge. /// /// This is to specialized for a protocol. Surely something more generic exists. func sendVatom(_ vatom: VatomModel) + /// Sends the vatoms over the native bridge informing the web face of changes to the backing vatom's first-level + /// children. + func sendVatomChildren(_ vatoms: [VatomModel]) + + /// Reference to the face view which this bridge is interacting with. + /// + /// Declare as `weak` to avoid retain cycle. + var faceView: WebFaceView? { get } + } // MARK: - Errors diff --git a/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV1.swift b/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV1.swift index fab3198f..29f2c013 100644 --- a/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV1.swift +++ b/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV1.swift @@ -17,6 +17,11 @@ import GenericJSON /// Bridges into the Core module. class CoreBridgeV1: CoreBridge { + // MARK: - Native Bridge Requests + + /// Sends the current vAtom to the Bridge SDK. + /// + /// Called on state update. func sendVatom(_ vatom: VatomModel) { guard @@ -39,6 +44,10 @@ class CoreBridgeV1: CoreBridge { } + func sendVatomChildren(_ vatoms: [VatomModel]) { + // V1.0.0 - Unsupported message. + } + // MARK: - Enums /// Represents the contract for the Web bridge (version 1). @@ -73,7 +82,7 @@ class CoreBridgeV1: CoreBridge { } // swiftlint:disable cyclomatic_complexity - + // swiftlint:disable function_body_length /// Processes the face script message and calls the completion handler with the result for encoding. func processMessage(_ scriptMessage: RequestScriptMessage, completion: @escaping Completion) { @@ -93,7 +102,7 @@ class CoreBridgeV1: CoreBridge { // ensure caller supplied params guard let vatomID = scriptMessage.payload["id"]?.stringValue else { let error = BridgeError.caller("Missing vAtom ID.") - completion(nil, error) + completion(.failure(error)) return } @@ -107,7 +116,7 @@ class CoreBridgeV1: CoreBridge { permittedIDs.contains(vatomID) else { let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") - completion(nil, bridgeError) + completion(.failure(bridgeError)) return } @@ -119,13 +128,13 @@ class CoreBridgeV1: CoreBridge { // ensure caller supplied params guard let vatomID = scriptMessage.payload["id"]?.stringValue else { let error = BridgeError.caller("Missing vAtom ID.") - completion(nil, error) + completion(.failure(error)) return } // security check guard vatomID == self.faceView?.vatom.id else { let error = BridgeError.caller("This method is only permitted on the backing vatom: \(vatomID).") - completion(nil, error) + completion(.failure(error)) return } self.discoverChildren(forVatomID: vatomID, completion: completion) @@ -134,7 +143,7 @@ class CoreBridgeV1: CoreBridge { // ensure caller supplied params guard let userID = scriptMessage.payload["userID"]?.stringValue else { let error = BridgeError.caller("Missing user ID.") - completion(nil, error) + completion(.failure(error)) return } self.getPublicUser(forUserID: userID, completion: completion) @@ -143,7 +152,7 @@ class CoreBridgeV1: CoreBridge { // ensure caller supplied params guard let userID = scriptMessage.payload["userID"]?.stringValue else { let error = BridgeError.caller("Missing user ID.") - completion(nil, error) + completion(.failure(error)) return } self.getPublicAvatarURL(forUserID: userID, completion: completion) @@ -156,13 +165,13 @@ class CoreBridgeV1: CoreBridge { let thisID = actionData["this.id"]?.stringValue else { let error = BridgeError.caller("Invalid payload.") - completion(nil, error) + completion(.failure(error)) return } // security check - backing vatom guard thisID == self.faceView?.vatom.id else { let error = BridgeError.caller("This method is only permitted for the backing vatom.") - completion(nil, error) + completion(.failure(error)) return } @@ -205,7 +214,7 @@ class CoreBridgeV1: CoreBridge { // santiy check guard let faceView = self.faceView else { let error = BridgeError.viewer("Invalid state.") - completion(nil, error) + completion(.failure(error)) return } @@ -213,58 +222,62 @@ class CoreBridgeV1: CoreBridge { let viewMode = faceView.faceModel.properties.constraints.viewMode // async fetch current user - BLOCKv.getCurrentUser { [weak self] (user, error) in - - // ensure no error - guard let user = user, error == nil else { - let bridgeError = BridgeError.viewer("Unable to fetch current user.") - completion(nil, bridgeError) - return - } - // encode url - var encodedURL: URL? - if let url = user.avatarURL { - encodedURL = try? BLOCKv.encodeURL(url) - } - // build user - let userInfo = BRUser(id: user.id, - firstName: user.firstName, - lastName: user.lastName, - avatarURL: encodedURL?.absoluteString ?? "") - - // fetch backing vAtom - self?.getVatomsFormatted(withIDs: [faceView.vatom.id], completion: { (vatoms, error) in - - // ensure no error - guard error == nil else { - let bridgeError = BridgeError.viewer("Unable to fetch backing vAtom.") - completion(nil, bridgeError) - return - } - // ensure a single vatom - guard let firstVatom = vatoms.first else { - let bridgeError = BridgeError.viewer("Unable to fetch backing vAtom.") - completion(nil, bridgeError) - return - } - // create bridge response - let vatomInfo = BRVatom(id: firstVatom.id, - properties: firstVatom.properties, - resources: firstVatom.resources) - let response = BRSetup(viewMode: viewMode, - user: userInfo, - vatomInfo: vatomInfo) - - do { - // json encode the model - let json = try JSON.init(encodable: response) - completion(json, nil) - } catch { - let bridgeError = BridgeError.viewer("Unable to encode response.") - completion(nil, bridgeError) + BLOCKv.getCurrentUser { [weak self] result in + + switch result { + case .success(let user): + // model is available + // encode url + var encodedURL: URL? + if let url = user.avatarURL { + encodedURL = try? BLOCKv.encodeURL(url) } + // build user + let userInfo = BRUser(id: user.id, + firstName: user.firstName, + lastName: user.lastName, + avatarURL: encodedURL?.absoluteString ?? "") + + // fetch backing vAtom + self?.getVatomsFormatted(withIDs: [faceView.vatom.id], completion: { (vatoms, error) in + + // ensure no error + guard error == nil else { + let bridgeError = BridgeError.viewer("Unable to fetch backing vAtom.") + completion(.failure(bridgeError)) + return + } + // ensure a single vatom + guard let firstVatom = vatoms.first else { + let bridgeError = BridgeError.viewer("Unable to fetch backing vAtom.") + completion(.failure(bridgeError)) + return + } + // create bridge response + let vatomInfo = BRVatom(id: firstVatom.id, + properties: firstVatom.properties, + resources: firstVatom.resources) + let response = BRSetup(viewMode: viewMode, + user: userInfo, + vatomInfo: vatomInfo) + + do { + // json encode the model + let json = try JSON.init(encodable: response) + completion(.success(json)) + } catch { + let bridgeError = BridgeError.viewer("Unable to encode response.") + completion(.failure(bridgeError)) + } + + }) + + case .failure: + // handle error + let bridgeError = BridgeError.viewer("Unable to fetch current user.") + completion(.failure(bridgeError)) - }) + } } @@ -277,12 +290,12 @@ class CoreBridgeV1: CoreBridge { // ensure no error guard error == nil else { - completion(nil, error!) + completion(.failure(error!)) return } // ensure there is at least one vatom guard let formattedVatom = formattedVatoms.first else { - completion(nil, BridgeError.viewer("vAtom not found.")) + completion(.failure(BridgeError.viewer("vAtom not found."))) return } let response = ["vatomInfo": formattedVatom] @@ -290,10 +303,10 @@ class CoreBridgeV1: CoreBridge { do { // json encode the model let json = try JSON.init(encodable: response) - completion(json, nil) + completion(.success(json)) } catch { let bridgeError = BridgeError.viewer("Unable to encode response.") - completion(nil, bridgeError) + completion(.failure(bridgeError)) } } @@ -309,7 +322,7 @@ class CoreBridgeV1: CoreBridge { // ensure no error guard error == nil else { - completion(nil, error!) + completion(.failure(error!)) return } let vatomItems = formattedVatoms.map { ["vatomInfo": $0] } @@ -317,10 +330,10 @@ class CoreBridgeV1: CoreBridge { do { // json encode the model let json = try JSON.init(encodable: response) - completion(json, nil) + completion(.success(json)) } catch { let bridgeError = BridgeError.viewer("Unable to encode response.") - completion(nil, bridgeError) + completion(.failure(bridgeError)) } } @@ -329,32 +342,34 @@ class CoreBridgeV1: CoreBridge { /// Fetches the publically available properties of the user specified by the id. private func getPublicUser(forUserID id: String, completion: @escaping Completion) { - BLOCKv.getPublicUser(withID: id) { (user, error) in + BLOCKv.getPublicUser(withID: id) { result in - // ensure no error - guard let user = user, error == nil else { - let bridgeError = BridgeError.viewer("Unable to fetch public user: \(id).") - completion(nil, bridgeError) - return - } - // encode url - var encodedURL: URL? - if let url = user.properties.avatarURL { - encodedURL = try? BLOCKv.encodeURL(url) - } - // build response - let response = BRUser(id: user.id, - firstName: user.properties.firstName, - lastName: user.properties.lastName, - avatarURL: encodedURL?.absoluteString ?? "") + switch result { + case .success(let user): + // encode url + var encodedURL: URL? + if let url = user.properties.avatarURL { + encodedURL = try? BLOCKv.encodeURL(url) + } + // build response + let response = BRUser(id: user.id, + firstName: user.properties.firstName, + lastName: user.properties.lastName, + avatarURL: encodedURL?.absoluteString ?? "") - do { - // json encode the model - let json = try JSON.init(encodable: response) - completion(json, nil) - } catch { - let bridgeError = BridgeError.viewer("Unable to encode response.") - completion(nil, bridgeError) + do { + // json encode the model + let json = try JSON.init(encodable: response) + completion(.success(json)) + } catch { + let bridgeError = BridgeError.viewer("Unable to encode response.") + completion(.failure(bridgeError)) + } + + case .failure: + // handle error + let bridgeError = BridgeError.viewer("Unable to fetch public user: \(id).") + completion(.failure(bridgeError)) } } @@ -369,29 +384,31 @@ class CoreBridgeV1: CoreBridge { /// Fetches the avatar URL of the user specified by the id. private func getPublicAvatarURL(forUserID id: String, completion: @escaping Completion) { - BLOCKv.getPublicUser(withID: id) { (user, error) in + BLOCKv.getPublicUser(withID: id) { result in - // ensure no error - guard let user = user, error == nil else { - let bridgeError = BridgeError.viewer("Unable to fetch public user: \(id).") - completion(nil, bridgeError) - return - } - // encode url - var encodedURL: URL? - if let url = user.properties.avatarURL { - encodedURL = try? BLOCKv.encodeURL(url) - } - // create avatar response - let response = PublicAvatarFormat(id: user.id, avatarURL: encodedURL?.absoluteString ?? "") + switch result { + case .success(let user): + // encode url + var encodedURL: URL? + if let url = user.properties.avatarURL { + encodedURL = try? BLOCKv.encodeURL(url) + } + // create avatar response + let response = PublicAvatarFormat(id: user.id, avatarURL: encodedURL?.absoluteString ?? "") - do { - // json encode the model - let json = try JSON.init(encodable: response) - completion(json, nil) - } catch { - let bridgeError = BridgeError.viewer("Unable to encode response.") - completion(nil, bridgeError) + do { + // json encode the model + let json = try JSON.init(encodable: response) + completion(.success(json)) + } catch { + let bridgeError = BridgeError.viewer("Unable to encode response.") + completion(.failure(bridgeError)) + } + + case .failure: + // handle error + let bridgeError = BridgeError.viewer("Unable to fetch public user: \(id).") + completion(.failure(bridgeError)) } } @@ -412,21 +429,29 @@ class CoreBridgeV1: CoreBridge { throw BridgeError.viewer("Unable to encode data.") } - BLOCKv.performAction(name: name, payload: dict) { (payload, error) in - // ensure no error - guard let payload = payload, error == nil else { + BLOCKv.performAction(name: name, payload: dict) { result in + + switch result { + case .success(let payload): + // convert to json + guard let json = try? JSON(payload) else { + let bridgeError = BridgeError.viewer("Unable to perform action: \(name).") + completion(.failure(bridgeError)) + return + } + completion(.success(json)) + + case .failure: + // handle error let bridgeError = BridgeError.viewer("Unable to perform action: \(name).") - completion(nil, bridgeError) - return + completion(.failure(bridgeError)) } - // convert to json - let json = try? JSON(payload) - completion(json, nil) + } } catch { let error = BridgeError.viewer("Unable to encode data.") - completion(nil, error) + completion(.failure(error)) } } @@ -456,19 +481,21 @@ private extension CoreBridgeV1 { let builder = DiscoverQueryBuilder() builder.setScope(scope: .parentID, value: backingID) - BLOCKv.discover(builder) { (vatoms, error) in + BLOCKv.discover(builder) { result in - // ensure no error - guard error == nil else { + switch result { + case .success(let vatoms): + // create a list of the child vatoms and add the backing (parent vatom) + var permittedIDs = vatoms.map { $0.id } + permittedIDs.append(backingID) + + completion(permittedIDs, nil) + case .failure: + // handle error let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") completion(nil, bridgeError) - return } - // create a list of the child vatoms and add the backing (parent vatom) - var permittedIDs = vatoms.map { $0.id } - permittedIDs.append(backingID) - completion(permittedIDs, nil) } } @@ -481,18 +508,18 @@ private extension CoreBridgeV1 { /// The method uses the vatom endpoint. Therefore, only public vAtoms are returned (irrespecitve of ownership). private func getVatomsFormatted(withIDs ids: [String], completion: @escaping BFVatomCompletion) { - BLOCKv.getVatoms(withIDs: ids) { (vatoms, error) in + BLOCKv.getVatoms(withIDs: ids) { result in - // ensure no error - guard error == nil else { + switch result { + case .success(let vatoms): + // convert vAtom into bridge format + completion(self.formatVatoms(vatoms), nil) + + case .failure: let bridgeError = BridgeError.viewer("Unable to fetch backing vAtom.") completion([], bridgeError) - return } - // convert vAtom into bridge format - completion(self.formatVatoms(vatoms), nil) - } } @@ -505,16 +532,18 @@ private extension CoreBridgeV1 { let builder = DiscoverQueryBuilder() builder.setScope(scope: .parentID, value: id) - BLOCKv.discover(builder) { (vatoms, error) in + BLOCKv.discover(builder) { result in - // ensure no error - guard error == nil else { + switch result { + case .success(let vatoms): + // format vatoms + completion(self.formatVatoms(vatoms), nil) + + case .failure: + // handle error let bridgeError = BridgeError.viewer("Unable to fetch children for vAtom \(id).") completion([], bridgeError) - return } - // format vatoms - completion(self.formatVatoms(vatoms), nil) } diff --git a/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV2.swift b/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV2.swift index 63a5c4bb..d0323464 100644 --- a/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV2.swift +++ b/BlockV/Face/Face Views/Web/Face Bridge/CoreBridgeV2.swift @@ -12,11 +12,50 @@ import Foundation import GenericJSON +//FIXME: Remove temporary disable type_body_length + /// Core Bridge (Version 2.0.0) /// /// Bridges into the Core module. -class CoreBridgeV2: CoreBridge { +class CoreBridgeV2: CoreBridge { //swiftlint:disable:this type_body_length + + // MARK: - Enums + + /// Represents Web Face Request the contract. + enum MessageName: String { + // 2.0 + case initialize = "core.init" + case getUser = "core.user.get" + case getVatomChildren = "core.vatom.children.get" + case getVatom = "core.vatom.get" + case performAction = "core.action.perform" + case encodeResource = "core.resource.encode" + // 2.1 + case setVatomParent = "core.vatom.parent.set" + case observeVatomChildren = "core.vatom.children.observe" + case getCurrentUser = "core.user.current.get" + } + + /// Represents the Native Bridge Request contract. + enum NativeBridgeMessageName: String { + // 2.0 + case vatomUpdate = "core.vatom.update" + // 2.1 + case vatomChildrenUpdate = "core.vatom.children.update" + } + + /// Reference to the face view which this bridge is interacting with. + weak var faceView: WebFaceView? + + // MARK: - Initializer + required init(faceView: WebFaceView) { + self.faceView = faceView + } + + /// Sends the specified vAtom to the Web Face SDK. + /// + /// Called on state update. func sendVatom(_ vatom: VatomModel) { guard let jsonVatom = try? JSON(encodable: vatom) else { @@ -26,8 +65,8 @@ class CoreBridgeV2: CoreBridge { let payload: [String: JSON] = ["vatom": jsonVatom] let message = RequestScriptMessage(source: "ios-vatoms", - name: "core.vatom.update", - requestID: "req_1", + name: NativeBridgeMessageName.vatomUpdate.rawValue, + requestID: "req_x", version: "2.0.0", payload: payload) @@ -36,54 +75,75 @@ class CoreBridgeV2: CoreBridge { } - // MARK: - Enums + /// List of vatom ids which have requested child observation. + var childObservationVatomIds: Set = [] - /// Represents the contract for the Web bridge (version 2). - enum MessageName: String { - case initialize = "core.init" - case getUser = "core.user.get" - case getVatomChildren = "core.vatom.children.get" - case getVatom = "core.vatom.get" - case performAction = "core.action.perform" - case encodeResource = "core.resource.encode" - } + /// Sends the specified vAtoms to the Web Face SDK. + func sendVatomChildren(_ vatoms: [VatomModel]) { + + // ensure observation has been requested + guard let backingId = self.faceView?.vatom.id, + childObservationVatomIds.contains(backingId) else { + return + } + // encode vatoms + guard let jsonVatoms = try? JSON(encodable: vatoms) else { + printBV(error: "Unable to pass vatom update over bridge.") + return + } - var faceView: WebFaceView? + // create payload + let payload: [String: JSON] = [ + "id": JSON.string(backingId), + "vatoms": jsonVatoms + ] + // create message + let message = RequestScriptMessage(source: "ios-vatoms", + name: NativeBridgeMessageName.vatomChildrenUpdate.rawValue, + requestID: "req_x", + version: "2.1.0", + payload: payload) - // MARK: - Initializer + // fire and forget + self.faceView?.sendRequestMessage(message, completion: nil) - required init(faceView: WebFaceView) { - self.faceView = faceView } - // MARK: - Face Brige + // MARK: - Web Face Requests /// Returns `true` if the bridge is capable of processing the message and `false` otherwise. func canProcessMessage(_ message: String) -> Bool { return !(MessageName(rawValue: message) == nil) } - // swiftlint:disable cyclomatic_complexity - /// Processes the face script message and calls the completion handler with the result for encoding. - func processMessage(_ scriptMessage: RequestScriptMessage, completion: @escaping (JSON?, BridgeError?) -> Void) { + // swiftlint:disable cyclomatic_complexity + // swiftlint:disable function_body_length + func processMessage(_ scriptMessage: RequestScriptMessage, + completion: @escaping (Result) -> Void) { let message = MessageName(rawValue: scriptMessage.name)! - printBV(info: "CoreBride_2: \(message)") + //printBV(info: "CoreBride_2: \(message)") // switch and route message switch message { case .initialize: - self.setupBridge { (payload, error) in + self.setupBridge { result in - // json dance - if let payload = payload { - let payload = try? JSON.init(encodable: payload) - completion(payload, error) - return + switch result { + case .success(let payload): + // json dance + guard let payload = try? JSON.init(encodable: payload) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(payload)) + + case .failure(let error): + completion(.failure(error)) } - completion(nil, error) } @@ -91,32 +151,44 @@ class CoreBridgeV2: CoreBridge { // ensure caller supplied params guard let vatomID = scriptMessage.payload["id"]?.stringValue else { let error = BridgeError.caller("Missing 'id' key.") - completion(nil, error) + completion(.failure(error)) return } // security check - backing vatom or first-level children - self.permittedVatomIDs { (permittedIDs, error) in + self.permittedVatomIDs { result in + + switch result { + case .success(let permittedIDs): - // ensure no error - guard error == nil, - let permittedIDs = permittedIDs, // check if the id is permitted to be queried - permittedIDs.contains(vatomID) - else { + guard permittedIDs.contains(vatomID) else { let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") - completion(nil, bridgeError) - return - } - - self.getVatoms(withIDs: [vatomID], completion: { (payload, error) in - // json dance - if let payload = payload?["vatoms"]?.first { - let payload = try? JSON.init(encodable: ["vatom": payload]) - completion(payload, error) + completion(.failure(bridgeError)) return } - completion(nil, error) - }) + + self.getVatoms(withIDs: [vatomID], completion: { result in + + switch result { + case .success(let payload): + // json dance + guard let vatom = payload["vatoms"]?.first, + let payload = try? JSON.init(encodable: ["vatom": vatom]) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(payload)) + case .failure(let error): + completion(.failure(error)) + } + + }) + + case .failure: + let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") + completion(.failure(bridgeError)) + } } @@ -124,40 +196,75 @@ class CoreBridgeV2: CoreBridge { // ensure caller supplied params guard let vatomID = scriptMessage.payload["id"]?.stringValue else { let error = BridgeError.caller("Missing 'id' key.") - completion(nil, error) + completion(.failure(error)) return } // security check - backing vatom guard vatomID == self.faceView?.vatom.id else { let error = BridgeError.caller("This method is only permitted on the backing vatom.") - completion(nil, error) + completion(.failure(error)) return } - self.discoverChildren(forVatomID: vatomID, completion: { (payload, error) in - // json dance - if let payload = payload?["vatoms"] { - let payload = try? JSON.init(encodable: ["vatoms": payload]) - completion(payload, error) - return + self.discoverChildren(forVatomID: vatomID, completion: { result in + + switch result { + case .success(let payload): + // json dance + guard + let vatoms = payload["vatoms"], + let payload = try? JSON.init(encodable: ["vatoms": vatoms]) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(payload)) + + case .failure(let error): + completion(.failure(error)) } - completion(nil, error) + }) case .getUser: // ensure caller supplied params guard let userID = scriptMessage.payload["id"]?.stringValue else { let error = BridgeError.caller("Missing 'id' key.") - completion(nil, error) + completion(.failure(error)) return } - self.getPublicUser(userID: userID) { (payload, error) in - // json dance - if let payload = payload { - let payload = try? JSON.init(encodable: ["user": payload]) - completion(payload, error) - return + self.getPublicUser(userID: userID) { result in + + switch result { + case .success(let payload): + // json dance + guard let payload = try? JSON.init(encodable: ["user": payload]) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(payload)) + + case .failure(let error): + completion(.failure(error)) + } + + } + + case .getCurrentUser: + self.getCurrentUser { result in + switch result { + case .success(let payload): + // json dance + guard let payload = try? JSON.init(encodable: ["user": payload]) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(payload)) + + case .failure(let error): + completion(.failure(error)) } - completion(nil, error) } case .performAction: @@ -168,24 +275,32 @@ class CoreBridgeV2: CoreBridge { let thisID = actionPayload["this.id"]?.stringValue else { let error = BridgeError.caller("Missing 'action_name' or 'payload' keys.") - completion(nil, error) + completion(.failure(error)) return } // security check - backing vatom guard thisID == self.faceView?.vatom.id else { let error = BridgeError.caller("This method is only permitted on the backing vatom.") - completion(nil, error) + completion(.failure(error)) return } // perform action - self.performAction(name: actionName, payload: actionPayload) { (payload, error) in - // json dance - if let payload = payload { - let payload = try? JSON.init(encodable: ["user": payload]) - completion(payload, error) - return + self.performAction(name: actionName, payload: actionPayload) { result in + + switch result { + case .success(let payload): + // json dance + guard let payload = try? JSON.init(encodable: ["user": payload]) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(payload)) + + case .failure(let error): + completion(.failure(error)) } - completion(nil, error) + } case .encodeResource: @@ -196,30 +311,105 @@ class CoreBridgeV2: CoreBridge { // extract urls guard let urlStrings = scriptMessage.payload["urls"]?.arrayValue?.map({ $0.stringValue }) else { - let error = BridgeError.caller("Missing 'urls' key.") - completion(nil, error) - return + let error = BridgeError.caller("Missing 'urls' key.") + completion(.failure(error)) + return } // ensure all urls are strings let flatURLStrings = urlStrings.compactMap { $0 } guard urlStrings.count == flatURLStrings.count else { let error = BridgeError.caller("Invalid url data type.") - completion(nil, error) + completion(.failure(error)) return } + // encode resources + self.encodeResources(flatURLStrings) { result in - self.encodeResources(flatURLStrings) { (payload, error) in - // json dance - if let payload = payload { - let payload = try? JSON.init(encodable: ["urls": payload]) - completion(payload, error) + switch result { + case .success(let payload): + // json dance + guard let payload = try? JSON.init(encodable: ["urls": payload]) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(payload)) + + case .failure(let error): + completion(.failure(error)) + } + + } + + case .setVatomParent: + // ensure caller supplied params + guard + let childVatomId = scriptMessage.payload["id"]?.stringValue, + let parentId = scriptMessage.payload["parent_id"]?.stringValue else { + let error = BridgeError.caller("Missing 'id' or 'parent_id' key.") + completion(.failure(error)) return + } + + // security check - backing vatom or first-level children + self.permittedVatomIDs { result in + + switch result { + case .success(let permittedIDs): + // security check + if permittedIDs.contains(childVatomId) { + // set parent + self.setParentId(on: childVatomId, parentId: parentId, completion: completion) + } else { + let message = "This method is only permitted on the backing vatom or one of its children." + let bridgeError = BridgeError.viewer(message) + completion(.failure(bridgeError)) + } + case .failure: + let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") + completion(.failure(bridgeError)) } - completion(nil, error) + } - } + case .observeVatomChildren: + // ensure caller supplied params + guard let vatomId = scriptMessage.payload["id"]?.stringValue else { + let error = BridgeError.caller("Missing 'id' key.") + completion(.failure(error)) + return + } + // security check - backing vatom + guard vatomId == self.faceView?.vatom.id else { + let error = BridgeError.caller("This method is only permitted on the backing vatom.") + completion(.failure(error)) + return + } + // update observer list (this informs the native bridge to forward child updates to the WFSDK) + childObservationVatomIds.insert(vatomId) + + // find current children + self.discoverChildren(forVatomID: vatomId, completion: { result in + + switch result { + case .success(let payload): + // json dance + guard + let vatoms = payload["vatoms"], + let response = try? JSON.init(encodable: ["vatoms": vatoms]) else { + let error = BridgeError.viewer("Unable to encode data.") + completion(.failure(error)) + return + } + completion(.success(response)) + case .failure(let error): + completion(.failure(error)) + } + + }) + + } } // MARK: - Bridge Responses @@ -248,26 +438,47 @@ class CoreBridgeV2: CoreBridge { } + private struct BRCurrentUser: Encodable { + + struct Properties: Encodable { + let firstName: String + let lastName: String + let avatarURI: String + let isGuest: Bool + + enum CodingKeys: String, CodingKey { //swiftlint:disable:this nesting + case firstName = "first_name" + case lastName = "last_name" + case avatarURI = "avatar_uri" + case isGuest = "is_guest" + } + } + + let id: String + let properties: Properties + + } + // MARK: - Message Handling /// Invoked when a face would like to create the web bridge. /// /// Creates the bridge initializtion JSON data. /// - /// - Parameter completion: Completion handler to call with JSON data to be passed to the webpage. - private func setupBridge(_ completion: @escaping (BRSetup?, BridgeError?) -> Void) { + /// - Parameter completion: Completion handler to call with JSON data to be passed to the Web Face SDK. + private func setupBridge(_ completion: @escaping (Result) -> Void) { // santiy check guard let faceView = self.faceView else { let error = BridgeError.viewer("Invalid state.") - completion(nil, error) + completion(.failure(error)) return } let vatom = faceView.vatom let face = faceView.faceModel let response = BRSetup(vatom: vatom, face: face) - completion(response, nil) + completion(.success(response)) } @@ -277,22 +488,22 @@ class CoreBridgeV2: CoreBridge { /// /// - Parameters: /// - ids: Unique identifier of the vAtom. - /// - completion: Completion handler to call with JSON data to be passed to the webpage. + /// - completion: Completion handler to call with JSON data to be passed to the Web Face SDK. private func getVatoms(withIDs ids: [String], - completion: @escaping ([String: [VatomModel]]?, BridgeError?) -> Void) { + completion: @escaping (Result<[String: [VatomModel]], BridgeError>) -> Void) { + + BLOCKv.getVatoms(withIDs: ids) { result in - BLOCKv.getVatoms(withIDs: ids) { (vatoms, error) in + switch result { + case .success(let vatoms): + let response = ["vatoms": vatoms] + completion(.success(response)) - // ensure no error - guard error == nil else { + case .failure: let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") - completion(nil, bridgeError) - return + completion(.failure(bridgeError)) } - let response = ["vatoms": vatoms] - completion(response, nil) - } } @@ -300,31 +511,32 @@ class CoreBridgeV2: CoreBridge { /// Returns an array of vAtom IDs which are permitted to be queried. /// /// Business Rule: Only the backing vAtom or one of it's children may be queried. - private func permittedVatomIDs(completion: @escaping ([String]?, Error?) -> Void) { + private func permittedVatomIDs(completion: @escaping (Result<[String], Error>) -> Void) { guard let backingID = self.faceView?.vatom.id else { assertionFailure("The backing vatom must be non-nil.") let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") - completion(nil, bridgeError) + completion(.failure(bridgeError)) return } let builder = DiscoverQueryBuilder() builder.setScope(scope: .parentID, value: backingID) - BLOCKv.discover(builder) { (vatoms, error) in + BLOCKv.discover(builder) { result in + + switch result { + case .success(let vatoms): + // create a list of the child vatoms and add the backing (parent vatom) + var permittedIDs = vatoms.map { $0.id } + permittedIDs.append(backingID) + completion(.success(permittedIDs)) - // ensure no error - guard error == nil else { + case .failure: let bridgeError = BridgeError.viewer("Unable to fetch vAtoms.") - completion(nil, bridgeError) - return + completion(.failure(bridgeError)) } - // create a list of the child vatoms and add the backing (parent vatom) - var permittedIDs = vatoms.map { $0.id } - permittedIDs.append(backingID) - completion(permittedIDs, nil) } } @@ -335,25 +547,24 @@ class CoreBridgeV2: CoreBridge { /// /// - Parameters: /// - id: Unique identifier of the vAtom. - /// - completion: Completion handler to call with JSON data to be passed to the webpage. + /// - completion: Completion handler to call with JSON data to be passed to the Web Face SDK. private func discoverChildren(forVatomID id: String, - completion: @escaping ([String: [VatomModel]]?, BridgeError?) -> Void) { + completion: @escaping (Result<[String: [VatomModel]], BridgeError>) -> Void) { let builder = DiscoverQueryBuilder() builder.setScope(scope: .parentID, value: id) - BLOCKv.discover(builder) { (vatoms, error) in + BLOCKv.discover(builder) { result in - // ensure no error - guard error == nil else { + switch result { + case .success(let vatoms): + let response = ["vatoms": vatoms] + completion(.success(response)) + case .failure: let bridgeError = BridgeError.viewer("Unable to fetch children for vAtom \(id).") - completion(nil, bridgeError) - return + completion(.failure(bridgeError)) } - let response = ["vatoms": vatoms] - completion(response, nil) - } } @@ -362,25 +573,48 @@ class CoreBridgeV2: CoreBridge { /// /// - Parameters: /// - id: Unique identifier of the user. - /// - completion: Completion handler to call with JSON data to be passed to the webpage. - private func getPublicUser(userID id: String, completion: @escaping (BRUser?, BridgeError?) -> Void) { + /// - completion: Completion handler to call with JSON data to be passed to the Web Face SDK. + private func getPublicUser(userID id: String, completion: @escaping (Result) -> Void) { - BLOCKv.getPublicUser(withID: id) { (user, error) in + BLOCKv.getPublicUser(withID: id) { result in - // ensure no error - guard let user = user, error == nil else { + switch result { + case .success(let user): + // build response + let properties = BRUser.Properties(firstName: user.properties.firstName, + lastName: user.properties.lastName, + avatarURI: user.properties.avatarURL?.absoluteString ?? "") + let response = BRUser(id: user.id, properties: properties) + completion(.success(response)) + + case .failure: let bridgeError = BridgeError.viewer("Unable to fetch public user: \(id).") - completion(nil, bridgeError) - return + completion(.failure(bridgeError)) } - // build response - let properties = BRUser.Properties(firstName: user.properties.firstName, - lastName: user.properties.lastName, - avatarURI: user.properties.avatarURL?.absoluteString ?? "") - let response = BRUser(id: user.id, properties: properties) - completion(response, nil) + } + + } + /// Fetches the bridge-available properties of the current user. + private func getCurrentUser(completion: @escaping (Result) -> Void) { + + BLOCKv.getCurrentUser { result in + + switch result { + case .success(let user): + // build response + let properties = BRCurrentUser.Properties(firstName: user.firstName, + lastName: user.lastName, + avatarURI: user.avatarURL?.absoluteString ?? "", + isGuest: user.guestID.isEmpty ? false : true) + let response = BRCurrentUser(id: user.id, properties: properties) + completion(.success(response)) + + case .failure: + let bridgeError = BridgeError.viewer("Unable to fetch current user.") + completion(.failure(bridgeError)) + } } } @@ -390,9 +624,9 @@ class CoreBridgeV2: CoreBridge { /// - Parameters: /// - name: Name of the action. /// - payload: Payload to send to the server. - /// - completion: Completion handler to call with JSON data to be passed to the webpage. + /// - completion: Completion handler to call with JSON data to be passed to the Web Face SDK. private func performAction(name: String, payload: [String: JSON], - completion: @escaping (JSON?, BridgeError?) -> Void) { + completion: @escaping (Result) -> Void) { do { /* @@ -405,21 +639,28 @@ class CoreBridgeV2: CoreBridge { throw BridgeError.viewer("Unable to encode data.") } - BLOCKv.performAction(name: name, payload: dict) { (payload, error) in - // ensure no error - guard let payload = payload, error == nil else { + BLOCKv.performAction(name: name, payload: dict) { result in + + switch result { + case .success(let payload): + // convert to json + guard let json = try? JSON(payload) else { + let bridgeError = BridgeError.viewer("Unable to perform action: \(name).") + completion(.failure(bridgeError)) + return + } + completion(.success(json)) + + case .failure: let bridgeError = BridgeError.viewer("Unable to perform action: \(name).") - completion(nil, bridgeError) - return + completion(.failure(bridgeError)) } - // convert to json - let json = try? JSON(payload) - completion(json, nil) + } } catch { let error = BridgeError.viewer("Unable to encode data.") - completion(nil, error) + completion(.failure(error)) } } @@ -434,8 +675,9 @@ class CoreBridgeV2: CoreBridge { /// /// - Parameters: /// - urlStrings: Array of URL strings to be encoded (if possible). - /// - completion: Completion handler to call with JSON data to be passed to the webpage. - private func encodeResources(_ urlStrings: [String], completion: @escaping ([String]?, BridgeError?) -> Void) { + /// - completion: Completion handler to call with JSON data to be passed to the Web Face SDK. + private func encodeResources(_ urlStrings: [String], + completion: @escaping (Result<[String], BridgeError>) -> Void) { // convert to URL type let urls = urlStrings.map { URL(string: $0) } @@ -450,7 +692,41 @@ class CoreBridgeV2: CoreBridge { } } - completion(responseURLs, nil) + completion(.success(responseURLs)) + } + + // MARK: - 2.1 + + /// Sets the parent id on the specified vatom. + /// + /// - Parameters: + /// - vatomId: Identifier of the vatom whose parent id is to be set. + /// - parentId: Identifier of the parent vatom. + /// - completion: Completion handler to call with JSON data to be passed to the Web Face SDK. + private func setParentId(on vatomId: String, parentId: String, completion: @escaping Completion) { + + // fetch from data pool + guard let vatom = DataPool.inventory().get(id: vatomId) as? VatomModel else { + let message = "Unable to set parent Id: \(parentId). Data Pool inventory lookup failed." + let bridgeError = BridgeError.viewer(message) + completion(.failure(bridgeError)) + return + } + + // update parent id + BLOCKv.setParentID(ofVatoms: [vatom], to: parentId) { result in + switch result { + case .success: + let response: JSON = ["new_parent_id": JSON.string(parentId)] + completion(.success(response)) + + case .failure(let error): + let message = "Unable to set parent Id: \(parentId). \(error.localizedDescription)" + let bridgeError = BridgeError.viewer(message) + completion(.failure(bridgeError)) + } + } + } } diff --git a/BlockV/Face/Face Views/Web/WebFaceView.swift b/BlockV/Face/Face Views/Web/WebFaceView.swift index 49c5b9b2..cd386a82 100644 --- a/BlockV/Face/Face Views/Web/WebFaceView.swift +++ b/BlockV/Face/Face Views/Web/WebFaceView.swift @@ -53,9 +53,11 @@ class WebFaceView: FaceView { webConfiguration.userContentController.add(LeakAvoider(delegate: self), name: "vatomicBridge") webConfiguration.userContentController.add(LeakAvoider(delegate: self), name: "blockvBridge") - // content controller + // web view let webView = WKWebView(frame: self.bounds, configuration: webConfiguration) webView.navigationDelegate = self + webView.scrollView.isScrollEnabled = false + webView.scrollView.contentInsetAdjustmentBehavior = .never webView.autoresizingMask = [ .flexibleWidth, .flexibleHeight ] return webView @@ -70,7 +72,6 @@ class WebFaceView: FaceView { super.init(vatom: vatom, faceModel: faceModel) self.addSubview(webView) - self.webView.backgroundColor = UIColor.red.withAlphaComponent(0.3) } required public init?(coder aDecoder: NSCoder) { @@ -81,17 +82,17 @@ class WebFaceView: FaceView { var isLoaded: Bool = false - var timer: Timer? - /// Holds the completion handler. private var completion: ((Error?) -> Void)? + /// Begins loading the face view's content. func load(completion: ((Error?) -> Void)?) { // store the completion self.completion = completion self.loadFace() } + /// Updates the backing Vatom and loads the new state. func vatomChanged(_ vatom: VatomModel) { // if the vatom has changed, load the face url again if vatom.id != self.vatom.id { @@ -99,6 +100,14 @@ class WebFaceView: FaceView { return } self.coreBridge?.sendVatom(vatom) + // fetch first-level children + let children = self.vatom.listCachedChildren() + self.coreBridge?.sendVatomChildren(children) + } + + /// Resets the contents of the face view. + private func reset() { + } func unload() { @@ -113,7 +122,7 @@ class WebFaceView: FaceView { printBV(error: "Cannot initialise URL from: \(faceURL)") return } - let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 20) + let request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: 20) self.webView.load(request) } @@ -124,7 +133,6 @@ class WebFaceView: FaceView { extension WebFaceView: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - print(#function) self.completion?(nil) } @@ -136,7 +144,9 @@ extension WebFaceView: WKNavigationDelegate { if navigationAction.navigationType == .linkActivated { if let url = navigationAction.request.url { // open in Safari.app - UIApplication.shared.open(url, options: convertToUIApplicationOpenExternalURLOptionsKeyDictionary([:]), completionHandler: nil) + UIApplication.shared.open(url, + options: convertToUIApplicationOpenExternalURLOptionsKeyDictionary([:]), + completionHandler: nil) return decisionHandler(.cancel) } } @@ -148,6 +158,9 @@ extension WebFaceView: WKNavigationDelegate { } // Helper function inserted by Swift 4.2 migrator. -fileprivate func convertToUIApplicationOpenExternalURLOptionsKeyDictionary(_ input: [String: Any]) -> [UIApplication.OpenExternalURLOptionsKey: Any] { - return Dictionary(uniqueKeysWithValues: input.map { key, value in (UIApplication.OpenExternalURLOptionsKey(rawValue: key), value)}) +private func convertToUIApplicationOpenExternalURLOptionsKeyDictionary(_ input: [String: Any]) + -> [UIApplication.OpenExternalURLOptionsKey: Any] { + return Dictionary(uniqueKeysWithValues: input.map { key, value in + (UIApplication.OpenExternalURLOptionsKey(rawValue: key), value) + }) } diff --git a/BlockV/Face/Face Views/Web/WebScriptMessageHandler.swift b/BlockV/Face/Face Views/Web/WebScriptMessageHandler.swift index 8aff9877..437840fc 100644 --- a/BlockV/Face/Face Views/Web/WebScriptMessageHandler.swift +++ b/BlockV/Face/Face Views/Web/WebScriptMessageHandler.swift @@ -57,7 +57,7 @@ extension WebFaceView: WKScriptMessageHandler { script += jsonString } script += ");" - printBV(info: "Posting script for evaluation:\n\(script)") +// printBV(info: "Posting script for evaluation:\n\(script)") // return to main queue DispatchQueue.main.async { @@ -76,7 +76,7 @@ extension WebFaceView: WKScriptMessageHandler { /// /// Ideally, this function should only allow [String: Any] payloads. However, allows primative types, e.g. Bool, /// to be passed across as Javascript strings. - func sendResponse(forRequestMessage message: RequestScriptMessage, payload: JSON?, error: BridgeError?) { + func sendResponse(forRequestMessage message: RequestScriptMessage, result: Result) { /* Note: Accepted Viewer Payloads @@ -87,32 +87,36 @@ extension WebFaceView: WKScriptMessageHandler { - V2: Only objects. */ - if let payload = payload { - + switch result { + case .success(let payload): // create response var response: JSON? if message.version == "1.0.0" { response = payload } else { response = [ - "name": try! JSON(message.name), - "response_id": try! JSON(message.requestID), - "payload": payload + "name": try! JSON(message.name), + "response_id": try! JSON(message.requestID), + "payload": payload ] } // encode response - guard let data = try? JSONEncoder.blockv.encode(response), - let jsonString = String.init(data: data, encoding: .utf8) else { - // handle error - let error = BridgeError.viewer("Unable to encode response.") - self.sendResponse(forRequestMessage: message, payload: nil, error: error) - return + if let jsonString = response?.stringValue?.json { + // corner case: ui.qr.scan return a top-level string which JSONEncoder cannot encode + self.postMessage(message.requestID, withJSONString: jsonString) + } else { + guard let data = try? JSONEncoder.blockv.encode(response), + let jsonString = String.init(data: data, encoding: .utf8) else { + // handle error + let error = BridgeError.viewer("Unable to encode response.") + self.sendResponse(forRequestMessage: message, result: .failure(error)) + return + } + self.postMessage(message.requestID, withJSONString: jsonString) } - self.postMessage(message.requestID, withJSONString: jsonString) - - } else if let error = error { + case .failure(let error): // create response var response: JSON? if message.version == "1.0.0" { @@ -130,13 +134,11 @@ extension WebFaceView: WKScriptMessageHandler { let jsonString = String.init(data: data, encoding: .utf8) else { // handle error let error = BridgeError.viewer("Unable to encode response.") - self.sendResponse(forRequestMessage: message, payload: nil, error: error) + self.sendResponse(forRequestMessage: message, result: .failure(error)) return } self.postMessage(message.requestID, withJSONString: jsonString) - } else { - assertionFailure("Either 'payload' or 'error' must be non nil.") } } @@ -185,13 +187,20 @@ extension WebFaceView { // create bridge switch message.version { - case "1.0.0": // original Face SDK - self.coreBridge = CoreBridgeV1(faceView: self) + case "1.0.0": + // lazily create bridge on first web face request (core.init) + if self.coreBridge == nil { + self.coreBridge = CoreBridgeV1(faceView: self) + } // transform V1 to V2 message = self.transformScriptMessage(message) case "2.0.0": - self.coreBridge = CoreBridgeV2(faceView: self) + // lazily create bridge on first web face request (core.init) + if self.coreBridge == nil { + self.coreBridge = CoreBridgeV2(faceView: self) + } + default: throw BridgeError.caller("Unsupported Bridge version: \(message.version)") } @@ -217,10 +226,16 @@ extension WebFaceView { // determine appropriate responder (core or viewer) if coreBridge.canProcessMessage(message.name) { // forward to core bridge - coreBridge.processMessage(message) { (payload, error) in + coreBridge.processMessage(message) { result in - // post response - self.sendResponse(forRequestMessage: message, payload: payload, error: error) + switch result { + case .success(let payload): + // post response + self.sendResponse(forRequestMessage: message, result: .success(payload)) + case .failure(let error): + // post response + self.sendResponse(forRequestMessage: message, result: .failure(error)) + } } } else { @@ -236,28 +251,28 @@ extension WebFaceView { private func routeMessageToViewer(_ message: RequestScriptMessage) { // notify the host's message delegate of the custom message from the web page - self.delegate?.faceView(self, - didSendMessage: message.name, - withObject: message.payload, - completion: { (payload, error) in - - // transform the bridge error - let bridgeError = BridgeError.viewer(error?.message ?? "Unknown error occured.") - - // V1 Support. Transform the `viewer.qr.scan` reponse paylaod from [String: String] - // to String. - if message.version == "1.0.0" && message.name == "viewer.qr.scan" { - if let newPayload = payload?["data"] { - self.sendResponse(forRequestMessage: message, payload: newPayload, - error: bridgeError) - return - } - } - - self.sendResponse(forRequestMessage: message, - payload: payload, - error: bridgeError) - + self.delegate?.faceView(self, didSendMessage: message.name, withObject: message.payload, completion: + { result in + switch result { + case .success(let payload): + + // v1.0.0 backward compatibility + if message.version == "1.0.0" && message.name == "viewer.qr.scan" { + // transform reponse paylaod from ["data": ] to . + if let newPayload = payload["data"] { + self.sendResponse(forRequestMessage: message, result: .success(newPayload)) + return + } + } + + self.sendResponse(forRequestMessage: message, result: .success(payload)) + return + case .failure(let error): + // transform the bridge error + let bridgeError = BridgeError.viewer(error.message) + self.sendResponse(forRequestMessage: message, result: .failure(bridgeError)) + return + } }) } @@ -307,3 +322,16 @@ extension WebFaceView { } } + +fileprivate extension String { + + /// Returns a JSON compatible version of the string. + var json: String { + return "\"" + self + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\"", with: "\\\"") + "\"" + } + +} diff --git a/BlockV/Face/Resources/DataDownloader.swift b/BlockV/Face/Resources/DataDownloader.swift new file mode 100644 index 00000000..a8e108fd --- /dev/null +++ b/BlockV/Face/Resources/DataDownloader.swift @@ -0,0 +1,240 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +public protocol Cancellable: class { + func cancel() +} + +public protocol DataDownloading { + + /// - Parameters: + /// - url: Request URL. + /// - destination: Destination directory. + /// - progress: Progress value. + /// - completion: Must be called once after all (or none in case + /// of an error) `didFinishDownloadingTo` has been called. + /// - Returns: Cancellable item. + func downloadData(url: URL, + destination: @escaping DataDownloader.Destination, + progress: @escaping (NSNumber) -> Void, + completion: @escaping (Result) -> Void) -> Cancellable +} + +extension URLSessionTask: Cancellable {} + +public class DataDownloader: DataDownloading { + + // MARK: - Session + + public let session: URLSession + private let _impl: _DataDownloader + + public static var recommendedCacheDirectory: URL { + + let directory = FileManager.SearchPathDirectory.cachesDirectory + let domain = FileManager.SearchPathDomainMask.userDomainMask + + let directoryURLs = FileManager.default.urls(for: directory, in: domain) + + let destinationURL = directoryURLs.first! + .appendingPathComponent("face_data") + .appendingPathComponent("resources") + return destinationURL + } + + /// A closure executed once a download request has successfully completed in order to determine where to move the + /// temporary file written to during the download process. The closure takes two arguments: the temporary file URL + /// and the + public typealias Destination = (_ temporaryURL: URL) -> URL //, options: Options) + + /// Create a download file destination closure which uses the default file manager to move the temporary file to a + /// file URL in the recommended face directory `face_data/resources/`. Placing downloads in this file gives all + /// faces the opportunity to share the on disk cache. + /// + /// - Returns: The `Destination` closure. + public static let recommenedDestination: Destination = { (url: URL) in + + let hash = url.path.md5 + + return recommendedCacheDirectory + .appendingPathComponent(hash) + .appendingPathComponent(url.lastTwoPathComponents) + + } + + /// Returns a default configuration which has a `nil` set as a `urlCache`. + public static var defaultConfiguration: URLSessionConfiguration { + let conf = URLSessionConfiguration.default + conf.urlCache = nil // cache is on disk + return conf + } + + /// Validates `HTTP` responses by checking that the status code is 2xx. If + /// it's not returns `DataLoader.Error.statusCodeUnacceptable`. + public static func validate(response: URLResponse) -> Swift.Error? { + guard let response = response as? HTTPURLResponse else { return nil } + return (200..<300).contains(response.statusCode) ? nil : Error.statusCodeUnacceptable(response.statusCode) + } + + /// Initializes `DataDownloader` with the given configuration. + init(configuration: URLSessionConfiguration = DataDownloader.defaultConfiguration, + validate: @escaping (URLResponse) -> Swift.Error? = DataDownloader.validate ) { + _impl = _DataDownloader() + self.session = URLSession(configuration: configuration, delegate: _impl, delegateQueue: _impl.queue) //FIXME: Nuke uses a separate queue + self._impl.session = self.session + self._impl.validate = validate + } + + public func downloadData(url: URL, destination: @escaping DataDownloader.Destination, progress: @escaping (NSNumber) -> Void, completion: @escaping (Result) -> Void) -> Cancellable { + return _impl.downloadData(url: url, destination: destination, progress: progress, completion: completion) + } + + /// Errors produced by `DataLoader`. + public enum Error: Swift.Error, CustomDebugStringConvertible { + /// Validation failed. + case statusCodeUnacceptable(Int) + + public var debugDescription: String { + switch self { + case let .statusCodeUnacceptable(code): return "Response status code was unacceptable: " + code.description + } + } + } + +} + +// MARK: - Implementation + +/// DataDownloader implementation. +private final class _DataDownloader: NSObject, URLSessionDelegate, URLSessionDownloadDelegate { + + weak var session: URLSession! // this is safe + var validate: (URLResponse) -> Swift.Error? = DataDownloader.validate + let queue = OperationQueue() + + private var handlers = [URLSessionTask: _Handler]() + + override init() { + self.queue.maxConcurrentOperationCount = 1 + } + + // MARK: - Methods + + public func downloadData(url: URL, destination: @escaping DataDownloader.Destination, progress: @escaping (NSNumber) -> Void, completion: @escaping (Result) -> Void) -> Cancellable { + + let downloadTask = session.downloadTask(with: url) + let handler = _Handler(url: url, destination: destination, progress: progress, completion: completion) + queue.addOperation { + self.handlers[downloadTask] = handler + } + downloadTask.resume() + return downloadTask + + } + + // MARK: URLSession Delegate + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let handler = handlers[task] else { return } + handlers[task] = nil + guard let error = error else { return } + handler.completion(.failure(error)) + } + + // MARK: - URLSessionDownload Delegate + + func urlSession(_ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64) { + + guard let handler = handlers[downloadTask] else { return } + + // compute progress + let calculatedProgress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) + DispatchQueue.main.async { + NSNumber(value: calculatedProgress) + + //TODO: Implement + + } + + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + + guard let handler = handlers[downloadTask], let response = downloadTask.response else { return } + + if let error = validate(response) { + handler.completion(.failure(error)) + return + } + + do { + + let destinationURL = handler.destinationURL + + if FileManager.default.fileExists(atPath: destinationURL.path) { + // in this case it should not have been re-downloaded, but return anyway + handler.completion(.success(destinationURL)) + } else { + // create directory + try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil) + // move from temp to final url + try FileManager.default.moveItem(at: location, to: destinationURL) + handler.completion(.success(destinationURL)) + } + + } catch { + handler.completion(.failure(error)) + } + + } + + // MARK: - Helper + + private final class _Handler { + + let destinationURL: URL + let completion: (Result) -> Void + + init(url: URL, + destination: @escaping DataDownloader.Destination = DataDownloader.recommenedDestination, + progress: @escaping (NSNumber) -> Void, + completion: @escaping (Result) -> Void) { + + self.destinationURL = destination(url) + self.completion = completion + } + } +} + +fileprivate extension URL { + + /// The last two components of the path, or an empty string if there are less than two compoenents. + /// + /// If the URL has less than two path components + var lastTwoPathComponents: String { + + if self.pathComponents.count > 2 { + let last = self.lastPathComponent + let secondLast = self.deletingLastPathComponent().lastPathComponent + return secondLast + "/" + last + } else { + return "" + } + + } + +} diff --git a/BlockV/Face/Resources/DataPipeline.swift b/BlockV/Face/Resources/DataPipeline.swift new file mode 100644 index 00000000..975c2a97 --- /dev/null +++ b/BlockV/Face/Resources/DataPipeline.swift @@ -0,0 +1,49 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +/* + TODO: + - Rate limiting +*/ +public class DataPipeline { + + public static let shared = DataPipeline() + + /// Data loader used by the pipeline. + public var dataDownloader: DataDownloader + + init(dataDownloader: DataDownloader = DataDownloader()) { + self.dataDownloader = dataDownloader + } + + public func downloadData(url: URL, + destination: @escaping DataDownloader.Destination = DataDownloader.recommenedDestination, + progress: @escaping (NSNumber) -> Void, + completion: @escaping (Result) -> Void) -> Cancellable? { + + let finalURL = destination(url) + if self.checkDiskCache(for: finalURL) { + progress(1) + completion(.success(finalURL)) + return nil + } else { + return self.dataDownloader.downloadData(url: url, destination: destination, progress: progress, completion: completion) + } + + } + + private func checkDiskCache(for url: URL) -> Bool { + return FileManager.default.fileExists(atPath: url.path) + } + +} diff --git a/BlockV/Face/Vatom View/DefaultErrorView.swift b/BlockV/Face/Vatom View/DefaultErrorView.swift index 5803fa10..740fde87 100644 --- a/BlockV/Face/Vatom View/DefaultErrorView.swift +++ b/BlockV/Face/Vatom View/DefaultErrorView.swift @@ -10,6 +10,7 @@ // import UIKit +import FLAnimatedImage import Nuke /// Default error view. @@ -17,7 +18,7 @@ import Nuke /// Shows: /// 1. vAtoms activated image. /// 2. Warning trigangle (that is tappable). -internal final class DefaultErrorView: UIView & VatomViewError { +internal final class DefaultErrorView: BoundedView & VatomViewError { // MARK: - Debug @@ -48,16 +49,18 @@ internal final class DefaultErrorView: UIView & VatomViewError { return button }() - private let activatedImageView: UIImageView = { - let imageView = UIImageView() + private let activatedImageView: FLAnimatedImageView = { + let imageView = FLAnimatedImageView() imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.clipsToBounds = true imageView.contentMode = .scaleAspectFit return imageView }() var vatom: VatomModel? { didSet { - self.loadResources() + // raise flag that layout is needed on the bounds are known + self.requiresBoundsBasedSetup = true } } @@ -73,19 +76,27 @@ internal final class DefaultErrorView: UIView & VatomViewError { activityIndicator.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true activityIndicator.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true - infoButton.leftAnchor.constraint(equalTo: self.leftAnchor, constant: 12).isActive = true - infoButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 12).isActive = true + infoButton.rightAnchor.constraint(equalTo: self.rightAnchor, constant: -12).isActive = true + infoButton.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -12).isActive = true activatedImageView.leftAnchor.constraint(equalTo: self.leftAnchor).isActive = true activatedImageView.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true activatedImageView.topAnchor.constraint(equalTo: self.topAnchor).isActive = true activatedImageView.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true + ImagePipeline.Configuration.isAnimatedImageDataEnabled = true + } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func setupWithBounds() { + super.setupWithBounds() + // only at this point is the bounds known which can be used to compute the target size + loadResources() + } // MARK: - Logic @@ -95,11 +106,10 @@ internal final class DefaultErrorView: UIView & VatomViewError { private func loadResources() { activityIndicator.startAnimating() - defer { self.activityIndicator.stopAnimating() } // extract error guard let vatom = vatom else { - assertionFailure("vatom must not be nil.") + assertionFailure("Vatom must not be nil.") return } @@ -112,11 +122,17 @@ internal final class DefaultErrorView: UIView & VatomViewError { guard let encodeURL = try? BLOCKv.encodeURL(resourceModel.url) else { return } - - var request = ImageRequest(url: encodeURL) - // use unencoded url as cache key - request.cacheKey = resourceModel.url - // load the image (reuse pool is automatically handled) + + let targetSize = self.activatedImageView.pixelSize + // create request + var request = ImageRequest(url: encodeURL, + targetSize: targetSize, + contentMode: .aspectFill) + + // set cache key + request.cacheKey = request.generateCacheKey(url: resourceModel.url, targetSize: targetSize) + + // load image Nuke.loadImage(with: request, into: activatedImageView) { [weak self] (_, _) in self?.activityIndicator.stopAnimating() } diff --git a/BlockV/Face/Vatom View/FaceMessageDelegate.swift b/BlockV/Face/Vatom View/FaceMessageDelegate.swift index cbca0014..50626103 100644 --- a/BlockV/Face/Vatom View/FaceMessageDelegate.swift +++ b/BlockV/Face/Vatom View/FaceMessageDelegate.swift @@ -12,84 +12,116 @@ import Foundation import GenericJSON -/// Face message error (view bridge). -public struct FaceMessageError: Error { - public let message: String - /// Initialise with a message. This message is forwarded to the Web Face SDK. - public init(message: String) { - self.message = message - } -} - -/// The protocol types must conform to in order to comuninicate with vatom view. +/// The protocol types must conform to in order to communinicate with vatom view. /// -/// Faces may send messages to their underlying viewer to request additional functionality. As a viewer it is -/// important that you state which messages you support (by implementing `determinedSupport(forFaceMessages:[Srting])`). -/// This gives the face an opportunity to adapt it's behaviour. +/// Faces may send custom messages to their underlying viewer to request additional functionality. public protocol VatomViewDelegate: class { - /// Completion handler for face messages. + /// Tells the delegate that a face message has been received. /// /// - Parameters: - /// - payload: The JSON payload to be sent back to the Web Face SDK. - /// - error: Error with description if one was encountered. - typealias Completion = (_ payload: JSON?, _ error: FaceMessageError?) -> Void + /// - vatomView: A vatom-view object informing the delegate about the face message. + /// - message: The unique identifier of the message. + /// - object: Companion object which addtional information relevant to the request. + /// - completion: The completion handler to call once the request has been processed. Passed a `Result` type + /// with either the success payload of a face message error. + func vatomView(_ vatomView: VatomView, + didRecevieFaceMessage message: String, + withObject object: [String: JSON], + completion: ((Result) -> Void)?) - /// Called when the vatom view receives a message from the face. + /// Tells the delegate that a face view was selected, or an error was encountered. + /// + /// A face view is selected as part of the Vatom View Life Cycle (VVLC). There are two scenarios where this will + /// happen: + /// 1. A `VatomView` instance was created (triggering the VVLC). + /// 2. After calling `update(usingVatom:procedure:)` (triggering the VVLC), which resulted in a new Face View being + /// selected. /// /// - Parameters: - /// - vatomView: The `VatomView` instance which the face message was received from. - /// - message: The unique identifier of the message. - /// - object: Companion object which addtional information relevant to the request. - /// - completion: The completion handler to call once the request has been processed. + /// - vatomView: A vatom-view object informing the delegate about the selected face. + /// - result: A `Result` type type containing either the selected face view or an error. + func vatomView(_ vatomView: VatomView, didSelectFaceView result: Result) + + /// Tells the delgate that the selected face view has completed loading, or an error was encountered. + /// + /// - Parameters: + /// - vatomView: A vatom-view object informing the delegate about the selected face completing its load. + /// - result: A `Result` instance with the result of the selected face view's load outcome. + func vatomView(_ vatomView: VatomView, didLoadFaceView result: Result) + +} + +/// This extension contians default and empty implementations of `VatomViewDelegate` methods. +/// This is the 'Swifty' way to make the methods optional. +public extension VatomViewDelegate { + func vatomView(_ vatomView: VatomView, didRecevieFaceMessage message: String, withObject object: [String: JSON], - completion: VatomViewDelegate.Completion?) + completion: ((Result) -> Void)?) { + // optional method + } + + func vatomView(_ vatomView: VatomView, didSelectFaceView result: Result) { + // optional method + } + + func vatomView(_ vatomView: VatomView, didLoadFaceView result: Result) { + // optional method + } + +} + +/// Vatom View Life Cycle error. +public enum VVLCError: Error, CustomStringConvertible { + + /// Face selection failed. + case faceViewSelectionFailed(reason: String) + /// A face model was selected but no corresponding face view was registered. + case unregisteredFaceViewSelected(_ displayURL: String) + /// The face view failed to load. + case faceViewLoadFailed + + public var description: String { + switch self { + case .faceViewSelectionFailed: + return "Face Selection Procedure (FSP) did not select a face view." + case .unregisteredFaceViewSelected(let url): + return """ + Face Selection Procedure (FSP) selected a face view '\(url)' without the face view being registered. + """ + case .faceViewLoadFailed: + return "Face View load failed." + } + } } // MARK: - Face View Delegate -/// The protocol face views must conform to in order to communicate -protocol FaceViewDelegate: class { +/// Face message error (view bridge). +public struct FaceMessageError: Error { + public let message: String + /// Initialise with a message. This message is forwarded to the Web Face SDK. + public init(message: String) { + self.message = message + } +} - /// Completion handler for face messages. - /// - /// - Parameters: - /// - payload: The JSON payload to be sent back to the Web Face View. - /// - error: Error with description if one was encountered. - typealias Completion = (_ payload: JSON?, _ error: FaceMessageError?) -> Void +/// The protocol face views must conform to in order to communicate +public protocol FaceViewDelegate: class { /// Called when the vatom view receives a message from the face. /// /// - Parameters: - /// - vatomView: The `VatomView` instance which the face message was received from. + /// - faceView: The face view from which this message was received. /// - message: The unique identifier of the message. /// - object: Companion object which addtional information relevant to the request. /// - completion: The completion handler to call once the request has been processed. func faceView(_ faceView: FaceView, didSendMessage message: String, withObject object: [String: JSON], - completion: FaceViewDelegate.Completion?) - -} - -/// Extend VatomView to conform to `FaceViewDelegate`. -/// -/// This is the conduit of communication between the VatomView and it's Face View. -extension VatomView: FaceViewDelegate { - - func faceView(_ faceView: FaceView, - didSendMessage message: String, - withObject object: [String: JSON], - completion: ((JSON?, FaceMessageError?) -> Void)?) { - - // forward the message to the vatom view delegate - self.vatomViewDelegate?.vatomView(self, - didRecevieFaceMessage: message, - withObject: object, - completion: completion) - } + completion: ((Result) -> Void)?) } diff --git a/BlockV/Face/Vatom View/FaceSelectionProcedure.swift b/BlockV/Face/Vatom View/FaceSelectionProcedure.swift index 963b1b27..0c0ed476 100644 --- a/BlockV/Face/Vatom View/FaceSelectionProcedure.swift +++ b/BlockV/Face/Vatom View/FaceSelectionProcedure.swift @@ -18,7 +18,7 @@ import Foundation /// face for a specific visual context. /// /// Closure inputs: -/// - Packed vAtom to be displayed. +/// - vAtom to be displayed. /// - Installed display URLs. This is the set of native face display URLs (i.e. unique identifiers of the installed /// native faces). /// @@ -214,7 +214,7 @@ private struct EmbeddedProcedureBuilder { if face.isNative { - // enusrue the native face is supported (i.e. the face code is installed) + // ensure the native face is supported (i.e. the face code is installed) if installedURLs.contains(where: { ($0.caseInsensitiveCompare(face.properties.displayURL) == .orderedSame) }) { // prefer 'native' over 'web' @@ -228,7 +228,7 @@ private struct EmbeddedProcedureBuilder { if face.isWeb { - // enusrue the native face is supported (i.e. the face code is installed) + // ensure the native face is supported (i.e. the face code is installed) if installedURLs.contains(where: { ($0.caseInsensitiveCompare("https://*") == .orderedSame) }) { } else { diff --git a/BlockV/Face/Vatom View/VatomView.swift b/BlockV/Face/Vatom View/VatomView.swift index a7c06f3a..539ed4ca 100644 --- a/BlockV/Face/Vatom View/VatomView.swift +++ b/BlockV/Face/Vatom View/VatomView.swift @@ -10,6 +10,7 @@ // import Foundation +import GenericJSON // MARK: - Protocols @@ -27,12 +28,6 @@ public protocol VatomViewError where Self: UIView { var vatom: VatomModel? { get set } } -/// Types that manage a `VatomView` should conform to this delegate to know when the face has completed loading. -protocol VatomViewLifecycleDelegate: class { - /// Called when the vatom view's selected face view has loaded successful or with an error. - func faceViewDidCompleteLoad(error: Error?) -} - /* Design Goals: 1. Vatom View will ask for the best face (default routine for each view context). @@ -42,6 +37,8 @@ protocol VatomViewLifecycleDelegate: class { prepareForReuse by calling `unLoad`. 4. Viewers must be able to use embedded FSPs. 5. Viewers must be able supply a custom FSP (defaults to the icon). + 6. Viewers must be informed of the selected face view (or any errors in selected the face view). + 7. Viewers must be informed of the completion of loading the face view (or any error encountered). */ /// Visualizes a vAtom by attempting to display one of the vAtom's face views. @@ -77,6 +74,8 @@ open class VatomView: UIView { self.loaderView.startAnimating() self.errorView.isHidden = true case .error: + self.selectedFaceView?.removeFromSuperview() + self.selectedFaceView = nil self.loaderView.isHidden = true self.loaderView.stopAnimating() self.errorView.isHidden = false @@ -107,7 +106,7 @@ open class VatomView: UIView { /// /// The roster is a consolidated list of the face views registered by both the SDK and Viewer. /// This list represents the face views that are *capable* of being rendered. - public private(set) var roster: Roster + public internal(set) var roster: Roster /// Face model selected by the specifed face selection procedure (FSP). public private(set) var selectedFaceModel: FaceModel? @@ -175,6 +174,8 @@ open class VatomView: UIView { self.loaderView = VatomView.defaultLoaderView.init() self.errorView = VatomView.defaultErrorView.init() super.init(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + + commonSetup() } /// Intializes using a vatom and optional procedure. @@ -186,13 +187,15 @@ open class VatomView: UIView { /// - procedure: The Face Selection Procedure (FSP) that determines which face view to display. /// Defaults to `.icon`. public init(vatom: VatomModel, - procedure: @escaping FaceSelectionProcedure = EmbeddedProcedure.icon.procedure) { + procedure: @escaping FaceSelectionProcedure = EmbeddedProcedure.icon.procedure, + delegate: VatomViewDelegate? = nil) { self.procedure = procedure self.roster = FaceViewRoster.shared.roster self.loaderView = VatomView.defaultLoaderView.init() self.errorView = VatomView.defaultErrorView.init() self.vatom = vatom + self.vatomViewDelegate = delegate super.init(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) commonSetup() @@ -221,13 +224,15 @@ open class VatomView: UIView { procedure: @escaping FaceSelectionProcedure = EmbeddedProcedure.icon.procedure, loadingView: VVLoaderView = VatomView.defaultLoaderView.init(), errorView: VVErrorView = VatomView.defaultErrorView.init(), - roster: Roster = FaceViewRoster.shared.roster) { + roster: Roster = FaceViewRoster.shared.roster, + delegate: VatomViewDelegate? = nil) { self.procedure = procedure self.loaderView = loadingView self.errorView = errorView self.roster = roster self.vatom = vatom + self.vatomViewDelegate = delegate super.init(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) commonSetup() @@ -270,6 +275,7 @@ open class VatomView: UIView { errorView.autoresizingMask = [.flexibleHeight, .flexibleWidth] self.state = .loading + } // MARK: - State Management @@ -315,73 +321,72 @@ open class VatomView: UIView { self.selectedFaceView?.unload() } - /// Vatom View Life Cycle error - public enum VVLCError: Error, CustomStringConvertible { - - case selectionFailed - case unregisteredFaceViewSelected(_ displayURL: String) - - public var description: String { - switch self { - case .selectionFailed: - return "Face Selection Procedure (FSP) did not to select a face view." - case .unregisteredFaceViewSelected(let url): - return """ - Face selection procedure (FSP) selected a face view '\(url)' without the face view being registered. - """ - } - } - - } - // MARK: - Vatom View Life Cycle /// Exectues the Vatom View Lifecycle (VVLC) on the current vAtom. /// + /// Two important cases must be handled: + /// A) New instance: selected-face-model, selected-face-view are nil. + /// B) Re-use: selected-face-view not nil. + /// /// 1. Run face selection procedure - /// > Compare the selected face to the current face - /// 2. Create face view - /// 3. Inform the face view to load it's content - /// 4. Display the face view + /// > Compare the selected face view to the current face view + /// + /// New instance (or re-use criteria don't match): + /// A.1. Create face view + /// A.2. Inform the face view to load it's content + /// A.3. Display the face view + /// Re-use: + /// B.1 Update current face view /// /// - Parameter oldVatom: The previous vAtom being visualized by this VatomView. internal func runVVLC(oldVatom: VatomModel? = nil) { + /* + Note: + VVLC traps with assertion failure in two cases: + 1. Backing vatom is nil. + 2. The supplied FSP selected a Face Model and no eligilbe Face View was installed. + Both of these cases indicated developer error. + */ + + // ensure a vatom has been set guard let vatom = vatom else { - assertionFailure("Developer error: vatom must not be nil.") + self.state = .error + let reason = "Developer error: vatom must not be nil." + self.vatomViewDelegate?.vatomView(self, didSelectFaceView: + .failure(VVLCError.faceViewSelectionFailed(reason: reason))) + assertionFailure(reason) return } - //TODO: Offload FSP to a background queue. - // 1. select the best face model - guard let selectedFaceModel = procedure(vatom, Set(roster.keys)) else { - - /* - Error - Case 1 - Show the error view if the FSP fails to select a face view. - */ + guard let newFaceModel = procedure(vatom, Set(roster.keys)) else { - //printBV(error: "Face Selection Procedure (FSP) returned without selecting a face model.") + // error - case 1 - show the error view if the FSP fails to select a face view self.state = .error + let reason = "Face Selection Procedure (FSP) returned without selecting a face model." + self.vatomViewDelegate?.vatomView(self, didSelectFaceView: + .failure(VVLCError.faceViewSelectionFailed(reason: reason))) return } - //printBV(info: "Face Selection Procedure (FSP) selected face model: \(selectedFaceModel)") - /* - Here we check: + Here we check the re-use criteria. + + A. A face view has previously been selected (i.e. we are in a re-use flow). - A. If selected face model is still equal to the current. This is necessary since the face may + B. The newly selected face model is still equal to the previous. This is necessary since the face may change as a result of the publisher modifying the face (typically via a delete/add operation). - B. If the new vatom and the previous vatom share a template variation. This is needed since resources are + C. The new vatom and the previous vatom share a common template variation. This is needed since resources are defined at the template variation level. */ - // 2. check if the face model has not changed - if (vatom.props.templateVariationID == oldVatom?.props.templateVariationID) && - (selectedFaceModel == self.selectedFaceModel) { - + if (self.selectedFaceView != nil) && + (newFaceModel == self.selectedFaceModel) && + (vatom.props.templateVariationID == oldVatom?.props.templateVariationID) { + //printBV(info: "Face model unchanged - Updating face view.") /* @@ -390,53 +395,79 @@ open class VatomView: UIView { (since the selected face view does not need replacing). */ - self.state = .completed // update currently selected face view (without replacement) self.selectedFaceView?.vatomChanged(vatom) + // complete + self.state = .completed + // inform delegate the face view is unchanged + self.vatomViewDelegate?.vatomView(self, didSelectFaceView: .success(self.selectedFaceView!)) } else { - - //printBV(info: "Face model changed - Replacing face view.") - - // replace currently selected face model - self.selectedFaceModel = selectedFaceModel - - // 3. find face view type - var faceViewType: FaceView.Type? - - if selectedFaceModel.isWeb { - faceViewType = roster["https://*"] - } else { - faceViewType = roster[selectedFaceModel.properties.displayURL] - } - - guard let viewType = faceViewType else { - // viewer developer MUST have registered the face view with the face registry - assertionFailure( - """ - Developer error: Face selection procedure (FSP) selected a face without the face view being - installed. Your FSP MUST check if the face view has been registered with the FaceRegistry. - """) - return + + //printBV(info: "Face model changed - Creating new face view.") + + do { + let faceView = try createFaceView(forModel: newFaceModel, onVatom: vatom) + faceView.delegate = self + + // replace the selected face model + self.selectedFaceModel = newFaceModel + // replace currently selected face view with newly selected + self.replaceFaceView(with: faceView) + // inform delegate + self.vatomViewDelegate?.vatomView(self, didSelectFaceView: .success(faceView)) + + } catch { + self.state = .error + let reason = "Unable to create face view." + self.vatomViewDelegate?.vatomView(self, didSelectFaceView: + .failure(.faceViewSelectionFailed(reason: reason))) } - //printBV(info: "Face view for face model: \(faceViewType)") - - //let selectedFaceView: FaceView = ImageFaceView(vatom: vatom, faceModel: selectedFace, host: self) - let selectedFaceView: FaceView = viewType.init(vatom: vatom, - faceModel: selectedFaceModel) - selectedFaceView.delegate = self - - // replace currently selected face view with newly selected - self.replaceFaceView(with: selectedFaceView) - } } + + /// Finds the face view for the specidfied face model. + private func createFaceView(forModel faceModel: FaceModel, onVatom vatom: VatomModel) throws -> FaceView { + + //TODO: Check the face model is present on the vatom. + + var faceViewType: FaceView.Type? + + if faceModel.isWeb { + // find web face type + faceViewType = roster["https://*"] + } else { + // find native face type + faceViewType = roster[faceModel.properties.displayURL] + } + + guard let viewType = faceViewType else { + + // viewer developer MUST have registered the face view with the face registry + let reason = """ + Developer error: Face Selection Procedure (FSP) selected a face model without an eligible face view + being registered. Your FSP MUST check if the face view has been registered with the FaceRegistry. + """ + + throw VVLCError.faceViewSelectionFailed(reason: reason) + + } + + let newSelectedFaceView: FaceView = viewType.init(vatom: vatom, faceModel: faceModel) + return newSelectedFaceView + + } /// Replaces the current face view (if any) with the specified face view and starts the FVLC. private func replaceFaceView(with newFaceView: (FaceView)) { + DispatchQueue.mainThreadPrecondition() + + // vatom id currectly associated with the vatom view (important for reuse pool) + guard let contextID = self.vatom?.id else { return } + // update current state self.state = .loading @@ -444,8 +475,9 @@ open class VatomView: UIView { self.selectedFaceView?.unload() self.selectedFaceView?.removeFromSuperview() self.selectedFaceView = nil - self.selectedFaceView = newFaceView + // replace with new + self.selectedFaceView = newFaceView // insert face view into the view hierarcy newFaceView.frame = self.bounds newFaceView.autoresizingMask = [.flexibleWidth, .flexibleHeight] @@ -453,24 +485,65 @@ open class VatomView: UIView { // 1. instruct face view to load its content (must be able to handle being called multiple times). newFaceView.load { [weak self] (error) in - - ///printBV(info: "Face view load completion called.") - - // Error - Case 2 - Display error view if the face view encounters an error during its load operation. - - // ensure no error - guard error == nil else { - // face view encountered an error - self?.selectedFaceView?.removeFromSuperview() - self?.state = .error - return + + guard let self = self else { return } + + DispatchQueue.main.async { + + /* + Important: + - Since vatom view may be in a reuse pool, and load is async, we must check the underlying vatom has not + changed. + - As the vatom-view comes out of the reuse pool, `update(usingVatom:procedure:)` is called. Since `load` is + async, by the time load's closure executes the underlying vatom may have changed. + */ + guard self.vatom!.id == contextID else { + // vatom-view is no longer displaying the original vatom + // printBV(info: "Load completed, but original vatom has changed.") + return + } + + // Error - Case 2 - Display error view if the face view encounters an error during its load operation. + + // ensure no error + guard error == nil else { + + // face view encountered an error + self.selectedFaceView?.unload() + self.selectedFaceView?.removeFromSuperview() + self.selectedFaceView = nil + self.state = .error + self.vatomViewDelegate?.vatomView(self, didLoadFaceView: .failure(VVLCError.faceViewLoadFailed) ) + return + } + + // show face + self.state = .completed + // inform delegate + self.vatomViewDelegate?.vatomView(self, didLoadFaceView: .success(self.selectedFaceView!)) + } + } - // show face - self?.state = .completed + } - } +} +/// Extend VatomView to conform to `FaceViewDelegate`. +/// +/// This is the conduit of communication between the VatomView and it's Face View. +extension VatomView: FaceViewDelegate { + + public func faceView(_ faceView: FaceView, + didSendMessage message: String, + withObject object: [String: JSON], + completion: ((Result) -> Void)?) { + + // forward the message to the vatom view delegate + self.vatomViewDelegate?.vatomView(self, + didRecevieFaceMessage: message, + withObject: object, + completion: completion) } } diff --git a/Example/.swiftlint.yml b/Example/.swiftlint.yml index 88b7bbdb..afbf02dc 100644 --- a/Example/.swiftlint.yml +++ b/Example/.swiftlint.yml @@ -12,6 +12,7 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. - Pods identifier_name: + allowed_symbols: "_" excluded: # excluded via string array - id nesting: diff --git a/Example/BLOCKv Unit Tests/Miscellaneous/BVError_Tests.swift b/Example/BLOCKv Unit Tests/Miscellaneous/BVError_Tests.swift index d01beac7..a2255913 100644 --- a/Example/BLOCKv Unit Tests/Miscellaneous/BVError_Tests.swift +++ b/Example/BLOCKv Unit Tests/Miscellaneous/BVError_Tests.swift @@ -30,7 +30,7 @@ class BVError_Tests: XCTestCase { // Top level - let errorPlatform = BVError.platform(reason: .authenticationLimit(0, "test")) + let errorPlatform = BVError.platform(reason: .authenticationFailed(0, "test")) let errorWebSocket = BVError.webSocket(error: .connectionFailed) let errorModelDecoding = BVError.modelDecoding(reason: "test") diff --git a/Example/BLOCKv Unit Tests/Mocks/MockAddressModel.swift b/Example/BLOCKv Unit Tests/Mocks/MockAddressModel.swift new file mode 100644 index 00000000..fd7c3780 --- /dev/null +++ b/Example/BLOCKv Unit Tests/Mocks/MockAddressModel.swift @@ -0,0 +1,20 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +public struct MockAddressModel { + + public static let ethAddress = """ + {"id": "aaaa","user_id": "bbbb","address": "0x4bbbb","type": "system","created_at": "2018-04-09T12:31:36Z"} + """.data(using: .utf8)! + +} diff --git a/Example/BLOCKv Unit Tests/Models/Codable/AddressModelCodable_Tests.swift b/Example/BLOCKv Unit Tests/Models/Codable/AddressModelCodable_Tests.swift new file mode 100644 index 00000000..784a07c8 --- /dev/null +++ b/Example/BLOCKv Unit Tests/Models/Codable/AddressModelCodable_Tests.swift @@ -0,0 +1,35 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import XCTest +@testable import BLOCKv + +class AddressModelCodable_Tests: XCTestCase { + + // MARK: - Test Methods + + func testActionDecoding() { + + do { + _ = try TestUtility.jsonDecoder.decode(AddressAccountModel.self, from: MockAddressModel.ethAddress) + } catch { + XCTFail("Decoding failed: \(error.localizedDescription)") + } + + } + + func testModelCodable() { + + self.decodeEncodeCompare(type: AddressAccountModel.self, from:MockAddressModel.ethAddress) + + } + +} diff --git a/Example/BLOCKv Unit Tests/Models/Web Socket/MockWebSocket.swift b/Example/BLOCKv Unit Tests/Models/Web Socket/MockWebSocket.swift new file mode 100644 index 00000000..57f44e11 --- /dev/null +++ b/Example/BLOCKv Unit Tests/Models/Web Socket/MockWebSocket.swift @@ -0,0 +1,30 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import Foundation + +struct MockWebSocket { + + static let mapEvent = """ + { + "msg_type": "map", + "payload": { + "event_id": "map_83ce7912-82ea-45c4-9b91-36daa3e61234", + "op": "add", + "vatom_id": "83ce7912-82ea-1234-9b91-36daa3e61234", + "action_name": "Drop", + "lat": -33.123456789, + "lon": 18.123456789 + } + } + """.data(using: .utf8)! + +} diff --git a/Example/BLOCKv Unit Tests/Models/Web Socket/WebSocketEvent_Tests.swift b/Example/BLOCKv Unit Tests/Models/Web Socket/WebSocketEvent_Tests.swift new file mode 100644 index 00000000..4f069c45 --- /dev/null +++ b/Example/BLOCKv Unit Tests/Models/Web Socket/WebSocketEvent_Tests.swift @@ -0,0 +1,27 @@ +// +// BlockV AG. Copyright (c) 2018, all rights reserved. +// +// Licensed under the BlockV SDK License (the "License"); you may not use this file or +// the BlockV SDK except in compliance with the License accompanying it. Unless +// required by applicable law or agreed to in writing, the BlockV SDK distributed under +// the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +// ANY KIND, either express or implied. See the License for the specific language +// governing permissions and limitations under the License. +// + +import XCTest +@testable import BLOCKv + +class WebSocketEvent_Tests: XCTestCase { + + func testMapEventDecoding() { + + do { + _ = try TestUtility.jsonDecoder.decode(WSMapEvent.self, from: MockWebSocket.mapEvent) + } catch { + XCTFail("Decoding failed: \(error.localizedDescription)") + } + + } + +} diff --git a/Example/BlockV.xcodeproj/project.pbxproj b/Example/BlockV.xcodeproj/project.pbxproj index 4f33dcc9..989fbb54 100644 --- a/Example/BlockV.xcodeproj/project.pbxproj +++ b/Example/BlockV.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ AD0BCC98217A0106001836DE /* LiveVatomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0BCC97217A0106001836DE /* LiveVatomView.swift */; }; AD36A59F215D1CE4009EFD55 /* CustomLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD36A59E215D1CE4009EFD55 /* CustomLoaderView.swift */; }; AD36A5B0215E3578009EFD55 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = AD36A5AF215E3578009EFD55 /* CHANGELOG.md */; }; + AD3B591D224CAF0600257EF3 /* OutboundActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3B591C224CAF0600257EF3 /* OutboundActionViewController.swift */; }; AD3DB5302157FD7200E00B67 /* TappedVatomViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3DB52F2157FD7200E00B67 /* TappedVatomViewController.swift */; }; AD61462E213DC88700204E5B /* FaceSelectionProcedure_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD61462A213DC83D00204E5B /* FaceSelectionProcedure_Tests.swift */; }; AD61462F213DC88B00204E5B /* MockModelFaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD61462C213DC87100204E5B /* MockModelFaces.swift */; }; @@ -29,6 +30,8 @@ ADA07DF921A94068007A6322 /* VatomModelCodable_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA07DF121A94025007A6322 /* VatomModelCodable_Tests.swift */; }; ADA07DFA21A94068007A6322 /* ActionModelCodable_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA07DF221A94025007A6322 /* ActionModelCodable_Tests.swift */; }; ADA07DFE21A94737007A6322 /* MockModel2.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA07DFC21A9464C007A6322 /* MockModel2.swift */; }; + ADA36A4322E717140064BB58 /* MockWebSocket.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA36A4222E717140064BB58 /* MockWebSocket.swift */; }; + ADA36A4622E717B70064BB58 /* WebSocketEvent_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA36A4522E717B70064BB58 /* WebSocketEvent_Tests.swift */; }; CDC268C7232CDB5AF1386116 /* Pods_BlockV_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4DE93FD598C0B61200B996A4 /* Pods_BlockV_Example.framework */; settings = {ATTRIBUTES = (Required, ); }; }; D519A84B205E83AB006B0D19 /* VatomDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D519A84A205E83AB006B0D19 /* VatomDetailTableViewController.swift */; }; D530759C21119E2A00DE7FD0 /* MockModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5307588211199C200DE7FD0 /* MockModel.swift */; }; @@ -44,11 +47,12 @@ D55B21F52056745700B6D5C2 /* UIViewController+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55B21F42056745700B6D5C2 /* UIViewController+Ext.swift */; }; D55B21F92056DE6D00B6D5C2 /* UIColor+Ext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55B21F82056DE6D00B6D5C2 /* UIColor+Ext.swift */; }; D55B21FB2056E04B00B6D5C2 /* InventoryCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55B21FA2056E04B00B6D5C2 /* InventoryCollectionViewController.swift */; }; - D5728D5C206131980041F4F7 /* TransferActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5728D5B206131980041F4F7 /* TransferActionViewController.swift */; }; D5728D5E206155600041F4F7 /* ActionListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5728D5D206155600041F4F7 /* ActionListTableViewController.swift */; }; D597FF272051305A009B2910 /* UIAlertController+Etx.swift in Sources */ = {isa = PBXBuildFile; fileRef = D597FF262051305A009B2910 /* UIAlertController+Etx.swift */; }; D59C7DDD206CE1D000FCE1DD /* Actions.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D59C7DDC206CE1D000FCE1DD /* Actions.storyboard */; }; D5E3AC9F2049883900CFFEEA /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E3AC9E2049883800CFFEEA /* LoginViewController.swift */; }; + D9BEC41D22E0C48C0068F280 /* AddressModelCodable_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BEC41922E0BCDB0068F280 /* AddressModelCodable_Tests.swift */; }; + D9BEC41E22E0C4A40068F280 /* MockAddressModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9BEC41B22E0BEBD0068F280 /* MockAddressModel.swift */; }; FE05FFA026A3E5A0C79B6E20 /* Pods_BLOCKv_Unit_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7956FE101E9335CDDC8248F3 /* Pods_BLOCKv_Unit_Tests.framework */; }; /* End PBXBuildFile section */ @@ -90,6 +94,7 @@ AD0BCC97217A0106001836DE /* LiveVatomView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVatomView.swift; sourceTree = ""; }; AD36A59E215D1CE4009EFD55 /* CustomLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomLoaderView.swift; sourceTree = ""; }; AD36A5AF215E3578009EFD55 /* CHANGELOG.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; name = CHANGELOG.md; path = ../CHANGELOG.md; sourceTree = ""; }; + AD3B591C224CAF0600257EF3 /* OutboundActionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OutboundActionViewController.swift; sourceTree = ""; }; AD3DB52F2157FD7200E00B67 /* TappedVatomViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TappedVatomViewController.swift; sourceTree = ""; }; AD61462A213DC83D00204E5B /* FaceSelectionProcedure_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaceSelectionProcedure_Tests.swift; sourceTree = ""; }; AD61462C213DC87100204E5B /* MockModelFaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockModelFaces.swift; sourceTree = ""; }; @@ -103,6 +108,8 @@ ADA07DF121A94025007A6322 /* VatomModelCodable_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VatomModelCodable_Tests.swift; sourceTree = ""; }; ADA07DF221A94025007A6322 /* ActionModelCodable_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionModelCodable_Tests.swift; sourceTree = ""; }; ADA07DFC21A9464C007A6322 /* MockModel2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockModel2.swift; sourceTree = ""; }; + ADA36A4222E717140064BB58 /* MockWebSocket.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWebSocket.swift; sourceTree = ""; }; + ADA36A4522E717B70064BB58 /* WebSocketEvent_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketEvent_Tests.swift; sourceTree = ""; }; B5A79A9FDD3524D3628C080E /* Pods-BlockV_Example.dev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlockV_Example.dev.xcconfig"; path = "Pods/Target Support Files/Pods-BlockV_Example/Pods-BlockV_Example.dev.xcconfig"; sourceTree = ""; }; BC27A08D03F2D2401C48F394 /* Pods-BlockV_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlockV_Example.release.xcconfig"; path = "Pods/Target Support Files/Pods-BlockV_Example/Pods-BlockV_Example.release.xcconfig"; sourceTree = ""; }; C55D0BD7FD8ECD417C235202 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; @@ -123,11 +130,12 @@ D55B21F42056745700B6D5C2 /* UIViewController+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Ext.swift"; sourceTree = ""; }; D55B21F82056DE6D00B6D5C2 /* UIColor+Ext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Ext.swift"; sourceTree = ""; }; D55B21FA2056E04B00B6D5C2 /* InventoryCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InventoryCollectionViewController.swift; sourceTree = ""; }; - D5728D5B206131980041F4F7 /* TransferActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransferActionViewController.swift; sourceTree = ""; }; D5728D5D206155600041F4F7 /* ActionListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionListTableViewController.swift; sourceTree = ""; }; D597FF262051305A009B2910 /* UIAlertController+Etx.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Etx.swift"; sourceTree = ""; }; D59C7DDC206CE1D000FCE1DD /* Actions.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Actions.storyboard; sourceTree = ""; }; D5E3AC9E2049883800CFFEEA /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; + D9BEC41922E0BCDB0068F280 /* AddressModelCodable_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressModelCodable_Tests.swift; sourceTree = ""; }; + D9BEC41B22E0BEBD0068F280 /* MockAddressModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAddressModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -283,6 +291,7 @@ ADA07DF121A94025007A6322 /* VatomModelCodable_Tests.swift */, ADA07DF221A94025007A6322 /* ActionModelCodable_Tests.swift */, AD09ECAE222D5667003F46C0 /* ActivityModelCodable_Tests.swift */, + D9BEC41922E0BCDB0068F280 /* AddressModelCodable_Tests.swift */, ); path = Codable; sourceTree = ""; @@ -293,10 +302,20 @@ AD61462C213DC87100204E5B /* MockModelFaces.swift */, D5307588211199C200DE7FD0 /* MockModel.swift */, ADA07DFC21A9464C007A6322 /* MockModel2.swift */, + D9BEC41B22E0BEBD0068F280 /* MockAddressModel.swift */, ); path = Mocks; sourceTree = ""; }; + ADA36A4422E7178F0064BB58 /* Web Socket */ = { + isa = PBXGroup; + children = ( + ADA36A4222E717140064BB58 /* MockWebSocket.swift */, + ADA36A4522E717B70064BB58 /* WebSocketEvent_Tests.swift */, + ); + path = "Web Socket"; + sourceTree = ""; + }; D519A849205E8360006B0D19 /* Inventory */ = { isa = PBXGroup; children = ( @@ -331,6 +350,7 @@ D53075A02111DBD600DE7FD0 /* Models */ = { isa = PBXGroup; children = ( + ADA36A4422E7178F0064BB58 /* Web Socket */, ADA07DEF21A94025007A6322 /* Codable */, D53075A32112E9E000DE7FD0 /* FaceModel_Tests.swift */, AD0BCC912178828A001836DE /* VatomModelUpdate_Tests.swift */, @@ -341,8 +361,8 @@ D5728D5A206130E40041F4F7 /* Actions */ = { isa = PBXGroup; children = ( + AD3B591C224CAF0600257EF3 /* OutboundActionViewController.swift */, D5728D5D206155600041F4F7 /* ActionListTableViewController.swift */, - D5728D5B206131980041F4F7 /* TransferActionViewController.swift */, ); path = Actions; sourceTree = ""; @@ -453,12 +473,12 @@ 607FACCF1AFB9204008FA782 = { CreatedOnToolsVersion = 6.3.1; DevelopmentTeam = WA2R22UJPX; - LastSwiftMigration = 1010; + LastSwiftMigration = 1020; ProvisioningStyle = Automatic; }; D530759021119E0E00DE7FD0 = { CreatedOnToolsVersion = 9.4.1; - LastSwiftMigration = 1010; + LastSwiftMigration = 1020; ProvisioningStyle = Automatic; TestTargetID = 607FACCF1AFB9204008FA782; }; @@ -469,6 +489,7 @@ developmentRegion = English; hasScannedForEncodings = 0; knownRegions = ( + English, en, Base, ); @@ -556,6 +577,7 @@ "${BUILT_PRODUCTS_DIR}/JWTDecode/JWTDecode.framework", "${BUILT_PRODUCTS_DIR}/NVActivityIndicatorView/NVActivityIndicatorView.framework", "${BUILT_PRODUCTS_DIR}/Nuke/Nuke.framework", + "${BUILT_PRODUCTS_DIR}/PromiseKit/PromiseKit.framework", "${BUILT_PRODUCTS_DIR}/Signals/Signals.framework", "${BUILT_PRODUCTS_DIR}/Starscream/Starscream.framework", "${BUILT_PRODUCTS_DIR}/VatomFace3D/VatomFace3D.framework", @@ -569,6 +591,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/JWTDecode.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/NVActivityIndicatorView.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Nuke.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/PromiseKit.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Signals.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Starscream.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/VatomFace3D.framework", @@ -600,6 +623,7 @@ files = ( AD0BCC98217A0106001836DE /* LiveVatomView.swift in Sources */, D5475764205D124100E6FE90 /* UIView+Ext.swift in Sources */, + AD3B591D224CAF0600257EF3 /* OutboundActionViewController.swift in Sources */, AD920A5C215FB47200CEFC1D /* PasswordTableViewController.swift in Sources */, D547576B205E67AD00E6FE90 /* VatomCell.swift in Sources */, D519A84B205E83AB006B0D19 /* VatomDetailTableViewController.swift in Sources */, @@ -620,7 +644,6 @@ D55B21EA2052E38000B6D5C2 /* RoundedImageView.swift in Sources */, D5728D5E206155600041F4F7 /* ActionListTableViewController.swift in Sources */, AD36A59F215D1CE4009EFD55 /* CustomLoaderView.swift in Sources */, - D5728D5C206131980041F4F7 /* TransferActionViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -633,14 +656,18 @@ AD61462F213DC88B00204E5B /* MockModelFaces.swift in Sources */, ADA07DF621A94061007A6322 /* BVError_Tests.swift in Sources */, ADA07DF821A94068007A6322 /* FaceModelCodable_Tests.swift in Sources */, + ADA36A4622E717B70064BB58 /* WebSocketEvent_Tests.swift in Sources */, + D9BEC41D22E0C48C0068F280 /* AddressModelCodable_Tests.swift in Sources */, AD61462E213DC88700204E5B /* FaceSelectionProcedure_Tests.swift in Sources */, D53075A42112E9E000DE7FD0 /* FaceModel_Tests.swift in Sources */, + D9BEC41E22E0C4A40068F280 /* MockAddressModel.swift in Sources */, ADA07DF721A94061007A6322 /* KeyPath_Tests.swift in Sources */, AD0BCC922178828A001836DE /* VatomModelUpdate_Tests.swift in Sources */, ADA07DFA21A94068007A6322 /* ActionModelCodable_Tests.swift in Sources */, AD09ECAF222D5667003F46C0 /* ActivityModelCodable_Tests.swift in Sources */, D530759C21119E2A00DE7FD0 /* MockModel.swift in Sources */, ADA07DF921A94068007A6322 /* VatomModelCodable_Tests.swift in Sources */, + ADA36A4322E717140064BB58 /* MockWebSocket.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -726,7 +753,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; }; name = Debug; }; @@ -774,7 +801,7 @@ MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; name = Dev; @@ -791,14 +818,14 @@ DEVELOPMENT_TEAM = WA2R22UJPX; ENVIRONMENT_MAPPING = dev_env; INFOPLIST_FILE = BlockV/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MODULE_NAME = ExampleApp; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" -D DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = "io.blockv.sdk.sample.${CONFIGURATION}"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; @@ -815,13 +842,13 @@ DEVELOPMENT_TEAM = WA2R22UJPX; ENVIRONMENT_MAPPING = dev_env; INFOPLIST_FILE = BlockV/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "io.blockv.sdk.sample.${CONFIGURATION}"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Dev; @@ -847,7 +874,7 @@ PRODUCT_BUNDLE_IDENTIFIER = "io.blockv.BLOCKv-Unit-Tests"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BlockV_Example.app/BlockV_Example"; }; @@ -909,7 +936,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BlockV_Example.app/BlockV_Example"; VALIDATE_PRODUCT = YES; @@ -972,7 +999,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BlockV_Example.app/BlockV_Example"; VALIDATE_PRODUCT = YES; @@ -1023,7 +1050,7 @@ MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; name = Prod; @@ -1040,13 +1067,13 @@ DEVELOPMENT_TEAM = WA2R22UJPX; ENVIRONMENT_MAPPING = prod_env; INFOPLIST_FILE = BlockV/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "io.blockv.sdk.sample.${CONFIGURATION}"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Prod; diff --git a/Example/BlockV.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/BlockV.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Example/BlockV.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/BlockV.xcodeproj/xcshareddata/xcschemes/BlockV-Example.xcscheme b/Example/BlockV.xcodeproj/xcshareddata/xcschemes/BlockV-Example.xcscheme index 5feb9978..0fd3de7a 100644 --- a/Example/BlockV.xcodeproj/xcshareddata/xcschemes/BlockV-Example.xcscheme +++ b/Example/BlockV.xcodeproj/xcshareddata/xcschemes/BlockV-Example.xcscheme @@ -1,6 +1,6 @@ - + - + @@ -14,13 +14,13 @@ - + - + - + @@ -36,9 +36,7 @@ - - - + @@ -63,7 +61,7 @@ - + diff --git a/Example/BlockV/Base.lproj/Main.storyboard b/Example/BlockV/Base.lproj/Main.storyboard index 50f869bf..22123670 100644 --- a/Example/BlockV/Base.lproj/Main.storyboard +++ b/Example/BlockV/Base.lproj/Main.storyboard @@ -1,11 +1,11 @@ - + - + @@ -745,6 +745,14 @@ + + + + + + + + @@ -835,6 +843,11 @@ + + + + + diff --git a/Example/BlockV/Controllers/Actions/ActionListTableViewController.swift b/Example/BlockV/Controllers/Actions/ActionListTableViewController.swift index 99010b34..3af7eefd 100644 --- a/Example/BlockV/Controllers/Actions/ActionListTableViewController.swift +++ b/Example/BlockV/Controllers/Actions/ActionListTableViewController.swift @@ -39,11 +39,21 @@ class ActionListTableViewController: UITableViewController { var vatom: VatomModel! - // For now, we hardcode to display a single action. - // - // In a future release, the actions configure for the vAtom's template - // will be returned. - fileprivate var actions: [String] = ["Transfer"] + /// Currently selected action. + var selectedAction: AvailableAction? + + // table view data model + struct AvailableAction { + let action: ActionModel + /// Flag indicating whether this action is supported by this viewer. + let isSupported: Bool + } + + /// List of available actions. + fileprivate var availableActions: [AvailableAction] = [] + + /// List of actions this viewer supports (i.e. knows how to handle). + private var supportedActions = ["Transfer", "Clone", "Redeem"] // MARK: - Actions @@ -66,30 +76,67 @@ class ActionListTableViewController: UITableViewController { /// Fetches all the actions configured / associated with our vAtom's template. fileprivate func fetchActions() { + self.showNavBarActivityRight() + let templateID = self.vatom.props.templateID - BLOCKv.getActions(forTemplateID: templateID) { (actions, error) in + BLOCKv.getActions(forTemplateID: templateID) { result in - // unwrap actions, handle error - guard let actions = actions, error == nil else { - print(error!.localizedDescription) - return - } + self.hideNavBarActivityRight() - // success - print("Actions: \(actions.debugDescription)") + switch result { + case .success(let actions): + // success + print("Actions: \(actions.debugDescription)") + // update data source + self.availableActions = actions.map { action -> AvailableAction in + // record + let supported = self.supportedActions.contains(action.name) + return AvailableAction(action: action, isSupported: supported) + } + self.tableView.reloadData() + + case .failure(let error): + print(error.localizedDescription) + } } } + + /// Prompts the user to optionally delete the vatom. + fileprivate func deleteVatom() { + + let message = "This vAtom will be deleted from all your devices." + let alert = UIAlertController.confirmAlert(title: "Delete vAtom", message: message) { confirmed in + if confirmed { + BLOCKv.trashVatom(self.vatom.id) { [weak self] _ in + self?.hide() + } + } + } + + self.present(alert, animated: true, completion: nil) + + } + + fileprivate func hide() { + self.presentingViewController?.presentingViewController?.dismiss(animated: true, completion: nil) + } // MARK: - Navigation + + override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool { + return false + } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - let destination = segue.destination as! TransferActionViewController + // dependecy injection + let destination = segue.destination as! OutboundActionViewController destination.vatom = self.vatom + destination.actionName = self.selectedAction!.action.name } - + } // MARK: - Table view data source @@ -97,18 +144,44 @@ class ActionListTableViewController: UITableViewController { extension ActionListTableViewController { override func numberOfSections(in tableView: UITableView) -> Int { - return 1 + return 2 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if section == 0 { + return availableActions.count + } return 1 } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell.action.id", for: indexPath) - cell.textLabel?.text = actions[indexPath.row] + if indexPath.section == 0 { + let cell = tableView.dequeueReusableCell(withIdentifier: "cell.action.id", for: indexPath) + let availableAction = availableActions[indexPath.row] + cell.textLabel?.text = availableAction.action.name + cell.textLabel?.alpha = availableAction.isSupported ? 1 : 0.5 + cell.accessoryView = nil + return cell + } + + let cell = UITableViewCell.init(style: .default, reuseIdentifier: "id.cell.delete") + cell.textLabel?.textColor = UIColor.destruciveOrange + cell.textLabel?.text = "Delete" return cell + + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + if indexPath.section == 0 { + selectedAction = availableActions[indexPath.row] + if selectedAction!.isSupported { + self.performSegue(withIdentifier: "seg.action.selection", sender: self) + } + } else { + self.deleteVatom() + } } } diff --git a/Example/BlockV/Controllers/Actions/TransferActionViewController.swift b/Example/BlockV/Controllers/Actions/OutboundActionViewController.swift similarity index 66% rename from Example/BlockV/Controllers/Actions/TransferActionViewController.swift rename to Example/BlockV/Controllers/Actions/OutboundActionViewController.swift index 7228a1d9..feddcccc 100644 --- a/Example/BlockV/Controllers/Actions/TransferActionViewController.swift +++ b/Example/BlockV/Controllers/Actions/OutboundActionViewController.swift @@ -22,18 +22,19 @@ // // -// TransferActionViewController.swift +// OutboundActionViewController.swift // BlockV_Example // import UIKit import BLOCKv -class TransferActionViewController: UIViewController { +class OutboundActionViewController: UIViewController { // MARK: - Properties var vatom: VatomModel! + var actionName: String! /// Type of token selected. var tokenType: UserTokenType { @@ -56,9 +57,10 @@ class TransferActionViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - precondition(vatom != nil, "A vAtom must be passed into this view controller.") + precondition(actionName != nil, "An action name must be passed into this view controller.") + self.title = actionName } // MARK: - Actions @@ -78,61 +80,37 @@ class TransferActionViewController: UIViewController { // create the token guard let value = userTokenTextField.text else { return } let token = UserToken(value: value, type: tokenType) - - performTransferManual(token: token) - // OR - //performTransferConvenience(token: token) + // execute the action + performAction(token: token) } - /// Option 1 - This show the convenience `transfer` method on VatomModel to transfer the vAtom to - /// a another user via a phone, email, or user id token. - func performTransferConvenience(token: UserToken) { + /// Performs the appropriate action using the + func performAction(token: UserToken) { - self.vatom.transfer(toToken: token) { [weak self] (json, error) in - - // unwrap data, handle error - guard let json = json, error == nil else { - print(error!.localizedDescription) + let resultHandler: ((Result<[String: Any], BVError>) -> Void) = { [weak self] result in + switch result { + case .success(let json): + // success + print("Action response: \(json.debugDescription)") + self?.hide() + case .failure(let error): + print(error.localizedDescription) + return } - - // success - print("Action response: \(json.debugDescription)") - self?.hide() } - } - - /// Option 2 - This show the manual, method of performing an action by constructing the - /// action body payload. - func performTransferManual(token: UserToken) { - - /* - Each action has a defined payload structure. - */ - let body = [ - "this.id": self.vatom.id, - "new.owner.\(token.type.rawValue)": token.value - ] - - BLOCKv.performAction(name: "Transfer", payload: body) { [weak self] (json, error) in - - // unwrap data, handle error - guard let json = json, error == nil else { - print(error!.localizedDescription) - return - } - - // success - print("Action response: \(json.debugDescription)") - self?.hide() - + switch actionName { + case "Transfer": self.vatom.transfer(toToken: token, completion: resultHandler) + case "Clone": self.vatom.clone(toToken: token, completion: resultHandler) + case "Redeem": self.vatom.redeem(toToken: token, completion: resultHandler) + default: + return } } - // MARK: - Helpers fileprivate func configureTokenTextField(for type: UserTokenType) { @@ -141,17 +119,20 @@ class TransferActionViewController: UIViewController { case .phone: userTokenTextField.keyboardType = .phonePad userTokenTextField.placeholder = "Phone number" + userTokenTextField.autocorrectionType = .no case .email: userTokenTextField.keyboardType = .emailAddress userTokenTextField.placeholder = "Email address" + userTokenTextField.autocorrectionType = .no case .id: userTokenTextField.keyboardType = .asciiCapable userTokenTextField.placeholder = "User ID" + userTokenTextField.autocorrectionType = .no } } fileprivate func hide() { - self.navigationController?.dismiss(animated: true, completion: nil) + self.presentingViewController?.presentingViewController?.dismiss(animated: true, completion: nil) } } diff --git a/Example/BlockV/Controllers/Inventory/InventoryCollectionViewController.swift b/Example/BlockV/Controllers/Inventory/InventoryCollectionViewController.swift index f6db213c..ab84ba08 100644 --- a/Example/BlockV/Controllers/Inventory/InventoryCollectionViewController.swift +++ b/Example/BlockV/Controllers/Inventory/InventoryCollectionViewController.swift @@ -152,30 +152,30 @@ class InventoryCollectionViewController: UICollectionViewController { /// Note: Input parameters are left to their defautls. fileprivate func fetchInventory() { - BLOCKv.getInventory { [weak self] (vatomModels, error) in + BLOCKv.getInventory { [weak self] result in - // handle error - guard error == nil else { - print("\n>>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let vatomModels): + print("\nViewer > Fetched inventory") + + /* + NOTE + + It is sometimes useful to order vAtoms by their `whenModified` date. This will + ensure new vAtoms appear at the top of the user's inventory. + + Additionally, if a vAtom's state changes on the BLOCKv platform so to will its + `whenModifed` date. For example, if a vAtom is picked up off the map, its + `droppped` flag is set as `false` and the `whenModified` date updated. + */ + self?.vatoms = vatomModels.sorted { $0.whenModified > $1.whenModified } + + case .failure(let error): + print("\n>>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // handle success - print("\nViewer > Fetched inventory") - - /* - NOTE - - It is sometimes useful to order vAtoms by their `whenModified` date. This will - ensure new vAtoms appear at the top of the user's inventory. - - Additionally, if a vAtom's state changes on the BLOCKv platform so to will its - `whenModifed` date. For example, if a vAtom is picked up off the map, its - `droppped` flag is set as `false` and the `whenModified` date updated. - */ - self?.vatoms = vatomModels.sorted { $0.whenModified > $1.whenModified } - } } @@ -191,19 +191,19 @@ class InventoryCollectionViewController: UICollectionViewController { builder.addDefinedFilter(forField: .templateID, filterOperator: .equal, value: "vatomic.prototyping::DrinkCoupon::v1", combineOperator: .and) // execute the discover call - BLOCKv.discover(builder) { [weak self] (vatomModels, error) in + BLOCKv.discover(builder) { [weak self] result in - // handle error - guard error == nil else { - print("\n>>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let vatomModels): + print("\nViewer > Fetched discover vatom models") + print("\n\(vatomModels)") + + case .failure(let error): + print("\n>>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - - // handle success - print("\nViewer > Fetched discover vatom models") - print("\n\(vatomModels)") - + } } diff --git a/Example/BlockV/Controllers/Inventory/TappedVatomViewController.swift b/Example/BlockV/Controllers/Inventory/TappedVatomViewController.swift index c5b484f0..37e6e72c 100644 --- a/Example/BlockV/Controllers/Inventory/TappedVatomViewController.swift +++ b/Example/BlockV/Controllers/Inventory/TappedVatomViewController.swift @@ -105,27 +105,28 @@ class TappedVatomViewController: UIViewController { print(#function) // refresh the vatom - BLOCKv.getVatoms(withIDs: [vatom!.id]) { [weak self] (responseVatom, error) in - - // handle error - guard error == nil else { - print("\n>>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) - return - } - - // ensure a vatom was returned - guard let responseVatom = responseVatom.first else { - print("\n>>> Error > Viewer: No vAtom found") + BLOCKv.getVatoms(withIDs: [vatom!.id]) { [weak self] result in + + switch result { + case .success(let responseVatoms): + // ensure a vatom was returned + guard let responseVatom = responseVatoms.first else { + print("\n>>> Error > Viewer: No vAtom found") + return + } + + self?.vatom = responseVatom + + // update the vatom view + self?.vatomViewA.update(usingVatom: responseVatom) + self?.vatomViewB.update(usingVatom: responseVatom) + self?.vatomViewC.update(usingVatom: responseVatom) + + case .failure(let error): + print("\n>>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - - self?.vatom = responseVatom - - // update the vatom view - self?.vatomViewA.update(usingVatom: responseVatom) - self?.vatomViewB.update(usingVatom: responseVatom) - self?.vatomViewC.update(usingVatom: responseVatom) } @@ -142,6 +143,11 @@ class TappedVatomViewController: UIViewController { if segue.identifier == "seg.vatom.detail" { let destination = segue.destination as! VatomDetailTableViewController destination.vatom = vatom + } else if segue.identifier == "seg.action.storyboard" { + let destination = segue.destination as! UINavigationController + let vc = destination.viewControllers[0] as! ActionListTableViewController + // pass vatom along + vc.vatom = self.vatom } } @@ -150,12 +156,14 @@ class TappedVatomViewController: UIViewController { // MARK: - Vatom View Delegate extension TappedVatomViewController: VatomViewDelegate { - + func vatomView(_ vatomView: VatomView, didRecevieFaceMessage message: String, - withObject object: [String : JSON], - completion: ((JSON?, FaceMessageError?) -> Void)?) { - print("Message: \(message)") + withObject object: [String: JSON], + completion: ((Result) -> Void)?) { + + print("Handle face message: \(message)") + } - + } diff --git a/Example/BlockV/Controllers/Inventory/VatomDetailTableViewController.swift b/Example/BlockV/Controllers/Inventory/VatomDetailTableViewController.swift index 311c7dab..2248e4b6 100644 --- a/Example/BlockV/Controllers/Inventory/VatomDetailTableViewController.swift +++ b/Example/BlockV/Controllers/Inventory/VatomDetailTableViewController.swift @@ -102,31 +102,32 @@ class VatomDetailTableViewController: UITableViewController { /// Fetches the input vatom's properties from the BLOCKv platform. fileprivate func fetchVatom() { - BLOCKv.getVatoms(withIDs: [vatom.id]) { [weak self] (vatomModels, error) in + BLOCKv.getVatoms(withIDs: [vatom.id]) { [weak self] result in // end refreshing self?.refreshControl?.endRefreshing() - // handle error - guard error == nil else { - print("\n>>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let vatomModels): + // check for our vatom + guard let newVatom = vatomModels.first else { + let message = "Unable to fetch vAtom with id: \(String(describing: self?.vatom.id))." + print("\n>>> Warning > \(message)") + self?.present(UIAlertController.infoAlert(message: message), animated: true) + return + } + + // handle success + print("\nViewer > Fetched vAtom:\n\(newVatom)") + self?.vatom = newVatom + self?.updateUI() + + case .failure(let error): + print("\n>>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // check for our vatom - guard let newVatom = vatomModels.first else { - let message = "Unable to fetch vAtom with id: \(String(describing: self?.vatom.id))." - print("\n>>> Warning > \(message)") - self?.present(UIAlertController.infoAlert(message: message), animated: true) - return - } - - // handle success - print("\nViewer > Fetched vAtom:\n\(newVatom)") - self?.vatom = newVatom - self?.updateUI() - } } diff --git a/Example/BlockV/Controllers/Onboarding/LoginViewController.swift b/Example/BlockV/Controllers/Onboarding/LoginViewController.swift index a21873e4..b412763c 100644 --- a/Example/BlockV/Controllers/Onboarding/LoginViewController.swift +++ b/Example/BlockV/Controllers/Onboarding/LoginViewController.swift @@ -73,22 +73,22 @@ class LoginViewController: UIViewController { // ask the BV platform to login BLOCKv.login(withUserToken: token, type: tokenType, password: password) { - [weak self] (userModel, error) in + [weak self] result in // reset nav bar self?.hideNavBarActivityRight() - // handle error - guard let model = userModel, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let userModel): + self?.performSegue(withIdentifier: "seg.login.success", sender: self) + print("Viewer > \(userModel)\n") + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // handle success - self?.performSegue(withIdentifier: "seg.login.success", sender: self) - print("Viewer > \(model)\n") - } } @@ -103,25 +103,24 @@ class LoginViewController: UIViewController { let token = userTokenTextField.text ?? "" // ask the BV platform to reset the token - BLOCKv.resetToken(token, type: tokenType) { - [weak self] (userToken, error) in + BLOCKv.resetToken(token, type: tokenType) { [weak self] result in // hide loader self?.hideNavBarActivityRight() - // handle error - guard let model = userToken, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let userToken): + let message = "An OTP has been sent to your token. Please use the OTP as a password to login." + self?.present(UIAlertController.okAlert(title: "Info", message: message), animated: true) + + print("Viewer > \(userToken)\n") + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // handle success - self?.present(UIAlertController.okAlert(title: "Info", - message: "An OTP has been sent to your token. Please use the OTP as a password to login."), animated: true) - - print("Viewer > \(model)\n") - } } diff --git a/Example/BlockV/Controllers/Onboarding/RegisterTableViewController.swift b/Example/BlockV/Controllers/Onboarding/RegisterTableViewController.swift index 4bb3a2f0..3ac594d3 100644 --- a/Example/BlockV/Controllers/Onboarding/RegisterTableViewController.swift +++ b/Example/BlockV/Controllers/Onboarding/RegisterTableViewController.swift @@ -165,24 +165,23 @@ class RegisterTableViewController: UITableViewController { } /// Register a user with multiple tokens - BLOCKv.register(tokens: tokens, userInfo: userInfo) { - [weak self] (userModel, error) in + BLOCKv.register(tokens: tokens, userInfo: userInfo) { [weak self] result in // hide loader self?.hideNavBarActivityRight() self?.navigationItem.setRightBarButton(self!.doneButton, animated: true) - // handle error - guard let model = userModel, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let userModel): + print("Viewer > \(userModel)\n") + self?.performSegue(withIdentifier: "seg.register.done", sender: self) + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // handle success - print("Viewer > \(model)\n") - self?.performSegue(withIdentifier: "seg.register.done", sender: self) - } } diff --git a/Example/BlockV/Controllers/Onboarding/VerifyTableViewController.swift b/Example/BlockV/Controllers/Onboarding/VerifyTableViewController.swift index a1ce1a0b..48c2b816 100644 --- a/Example/BlockV/Controllers/Onboarding/VerifyTableViewController.swift +++ b/Example/BlockV/Controllers/Onboarding/VerifyTableViewController.swift @@ -102,25 +102,25 @@ class VerifyTableViewController: UITableViewController { // show loader self.showNavBarActivityRight() - BLOCKv.getCurrentUserTokens { [weak self] (fullTokens, error) in + BLOCKv.getCurrentUserTokens { [weak self] result in // hide loader self?.hideNavBarActivityRight() - // handle error - guard let model = fullTokens, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let fullTokens): + print("Viewer > \(fullTokens)\n") + + // update the tokens + self?.allTokens = fullTokens + self?.tableView.reloadData() + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - - // handle success - print("Viewer > \(model)\n") - - // update the tokens - self?.allTokens = model - self?.tableView.reloadData() - + } } @@ -224,27 +224,27 @@ extension VerifyTableViewController: VerfiyTokenDelegate { // show loader self.showNavBarActivityRight() - BLOCKv.verifyUserToken(token.value, type: token.type, code: code) { - [weak self] (userToken, error) in + BLOCKv.verifyUserToken(token.value, type: token.type, code: code) { [weak self] result in // hide loader self?.hideNavBarActivityRight() self?.navigationItem.rightBarButtonItem = self?.nextBarButton - // handle error - guard let model = userToken, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let userToken): + self?.tableView.reloadData() + self?.present(UIAlertController.okAlert(title: "Info", message: "Token: \(userToken.value) has been verified."), animated: true) + + completion(true) + print("Viewer > \(userToken)\n") + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) completion(false) return } - // handle success - self?.tableView.reloadData() - self?.present(UIAlertController.okAlert(title: "Info", message: "Token: \(model.value) has been verified."), animated: true) - - completion(true) - print("Viewer > \(model)\n") } } @@ -253,29 +253,29 @@ extension VerifyTableViewController: VerfiyTokenDelegate { // show loader self.showNavBarActivityRight() - BLOCKv.resetVerification(forUserToken: token.value, type: token.type) { - [weak self] (userToken, error) in + BLOCKv.resetVerification(forUserToken: token.value, type: token.type) { [weak self] result in // hide loader self?.hideNavBarActivityRight() self?.navigationItem.rightBarButtonItem = self?.nextBarButton - // handle error - guard let model = userToken, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let userToken): + self?.tableView.reloadData() + self?.present(UIAlertController.okAlert(title: "Info", + message: "An verification link/OTP has been sent to your token."), + animated: true) + + completion(true) + print("Viewer > \(userToken)\n") + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) completion(false) return } - // handle success - self?.tableView.reloadData() - self?.present(UIAlertController.okAlert(title: "Info", - message: "An verification link/OTP has been sent to your token."), - animated: true) - - completion(true) - print("Viewer > \(model)\n") } } diff --git a/Example/BlockV/Controllers/User Profile/PasswordTableViewController.swift b/Example/BlockV/Controllers/User Profile/PasswordTableViewController.swift index 7d0a9336..888d8210 100644 --- a/Example/BlockV/Controllers/User Profile/PasswordTableViewController.swift +++ b/Example/BlockV/Controllers/User Profile/PasswordTableViewController.swift @@ -102,29 +102,27 @@ class PasswordTableViewController: UITableViewController { let userInfo = buildForm() - BLOCKv.updateCurrentUser(userInfo) { - [weak self] (userModel, error) in + BLOCKv.updateCurrentUser(userInfo) { [weak self] result in // reset nav bar self?.hideNavBarActivityRight() self?.navigationItem.setRightBarButton(self!.doneButton, animated: true) - // handle error - guard let model = userModel, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let userModel): + print("Viewer > \(userModel)\n") + + // update the model + self?.tableView.reloadData() + + // pop back + self?.navigationController?.popViewController(animated: true) + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // handle success - print("Viewer > \(model)\n") - - // update the model - self?.tableView.reloadData() - - // pop back - self?.navigationController?.popViewController(animated: true) - } } diff --git a/Example/BlockV/Controllers/User Profile/ProfileViewController.swift b/Example/BlockV/Controllers/User Profile/ProfileViewController.swift index e0bb181d..335ffbe7 100644 --- a/Example/BlockV/Controllers/User Profile/ProfileViewController.swift +++ b/Example/BlockV/Controllers/User Profile/ProfileViewController.swift @@ -149,27 +149,28 @@ class ProfileViewController: UITableViewController { /// Fetches the user's profile information. fileprivate func fetchUserProfile() { - BLOCKv.getCurrentUser { [weak self] (userModel, error) in + BLOCKv.getCurrentUser { [weak self] result in // end refreshing self?.refreshControl?.endRefreshing() - - // handle error - guard let model = userModel, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)\n") - self?.present(UIAlertController.errorAlert(error!), animated: true) + + switch result { + case .success(let userModel): + print("\nViewer > Fetched user model:\n\(userModel)") + self?.userModel = userModel + + // update ui + self?.displayNameLabel.text = self?.displayName + self?.userIdLabel.text = self?.userModel?.id + + self?.fetchAvatarImage() + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)\n") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // handle success - print("\nViewer > Fetched user model:\n\(model)") - self?.userModel = model - - // update ui - self?.displayNameLabel.text = self?.displayName - self?.userIdLabel.text = self?.userModel?.id - - self?.fetchAvatarImage() } } diff --git a/Example/BlockV/Controllers/User Profile/UserInfoTableViewController.swift b/Example/BlockV/Controllers/User Profile/UserInfoTableViewController.swift index 14805ccc..500493a0 100644 --- a/Example/BlockV/Controllers/User Profile/UserInfoTableViewController.swift +++ b/Example/BlockV/Controllers/User Profile/UserInfoTableViewController.swift @@ -124,30 +124,29 @@ class UserInfoTableViewController: UITableViewController { let userInfo = buildForm() - BLOCKv.updateCurrentUser(userInfo) { - [weak self] (userModel, error) in + BLOCKv.updateCurrentUser(userInfo) { [weak self] result in // reset nav bar self?.hideNavBarActivityRight() self?.navigationItem.setRightBarButton(self!.doneButton, animated: true) - // handle error - guard let model = userModel, error == nil else { - print(">>> Error > Viewer: \(error!.localizedDescription)") - self?.present(UIAlertController.errorAlert(error!), animated: true) + switch result { + case .success(let userModel): + print("Viewer > \(userModel)\n") + + // update the model + self?.userModel = userModel + self?.tableView.reloadData() + + // pop back + self?.navigationController?.popViewController(animated: true) + + case .failure(let error): + print(">>> Error > Viewer: \(error.localizedDescription)") + self?.present(UIAlertController.errorAlert(error), animated: true) return } - // handle success - print("Viewer > \(model)\n") - - // update the model - self?.userModel = model - self?.tableView.reloadData() - - // pop back - self?.navigationController?.popViewController(animated: true) - } } diff --git a/Example/BlockV/Extensions/UIAlertController+Etx.swift b/Example/BlockV/Extensions/UIAlertController+Etx.swift index 634d7fc9..89f679e5 100644 --- a/Example/BlockV/Extensions/UIAlertController+Etx.swift +++ b/Example/BlockV/Extensions/UIAlertController+Etx.swift @@ -73,4 +73,22 @@ extension UIAlertController { return alertController } + static func confirmAlert(title: String, + message: String, + confirmed: @escaping (Bool) -> Void) -> UIAlertController { + let alertController = UIAlertController.init(title: title, + message: message, + preferredStyle: .alert) + + let okAction = UIAlertAction.init(title: "OK", style: .default) { action in + confirmed(true) + } + let cancelAction = UIAlertAction.init(title: "Cancel", style: .destructive) { action in + confirmed(false) + } + alertController.addAction(okAction) + alertController.addAction(cancelAction) + return alertController + } + } diff --git a/Example/BlockV/Extensions/UIColor+Ext.swift b/Example/BlockV/Extensions/UIColor+Ext.swift index f1f7499c..0d9d580c 100644 --- a/Example/BlockV/Extensions/UIColor+Ext.swift +++ b/Example/BlockV/Extensions/UIColor+Ext.swift @@ -29,4 +29,8 @@ extension UIColor { return UIColor(red: 86.0 / 255.0, green: 194.0 / 255.0, blue: 201.0 / 255.0, alpha: 1.0) } + @nonobjc class var destruciveOrange: UIColor { + return UIColor(red: 219.0 / 255.0, green: 76.0 / 255.0, blue: 43.0 / 255.0, alpha: 1.0) + } + } diff --git a/Example/BlockV/Views/LiveVatomView.swift b/Example/BlockV/Views/LiveVatomView.swift index 934831f6..0432191c 100644 --- a/Example/BlockV/Views/LiveVatomView.swift +++ b/Example/BlockV/Views/LiveVatomView.swift @@ -36,8 +36,8 @@ class LiveVatomView: VatomView { commonInit() } - override init(vatom: VatomModel, procedure: @escaping FaceSelectionProcedure) { - super.init(vatom: vatom, procedure: procedure) + override init(vatom: VatomModel, procedure: @escaping FaceSelectionProcedure, delegate: VatomViewDelegate? = nil) { + super.init(vatom: vatom, procedure: procedure, delegate: delegate) commonInit() } @@ -77,17 +77,19 @@ class LiveVatomView: VatomView { guard let vatomID = self.vatom?.id else { return } // fetch vatom model from remote - BLOCKv.getVatoms(withIDs: [vatomID], completion: { (vatomModels, error) in + BLOCKv.getVatoms(withIDs: [vatomID], completion: { result in - // ensure no error - guard error == nil, let vatom = vatomModels.first else { + switch result { + case .success(let vatomModels): + guard let vatom = vatomModels.first else { return } + // update vatom view using the new state of the vatom + self.update(usingVatom: vatom) + + case .failure: print(">>> Viewer: Unable to fetch vAtom.") return } - // update vatom view using the new state of the vatom - self.update(usingVatom: vatom) - }) } diff --git a/Example/Podfile b/Example/Podfile index 911864bb..f53b1f05 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -1,5 +1,5 @@ use_frameworks! -platform :ios, '10.0' +platform :ios, '11.0' target 'BlockV_Example' do diff --git a/Example/Podfile.lock b/Example/Podfile.lock index 85020b48..6ecf0ce8 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -1,29 +1,39 @@ PODS: - - Alamofire (4.8.1) - - BLOCKv (3.0.0): - - BLOCKv/Face (= 3.0.0) - - BLOCKv/Core (3.0.0): + - Alamofire (4.8.2) + - BLOCKv (3.1.0): + - BLOCKv/Face (= 3.1.0) + - BLOCKv/Core (3.1.0): - Alamofire (~> 4.7) - - GenericJSON (~> 1.2) + - GenericJSON (~> 2.0) - JWTDecode (~> 2.1) + - PromiseKit (~> 6.8) - Signals (~> 6.0) - - Starscream (~> 3.0) + - Starscream (~> 3.0.6) - SwiftLint (~> 0.26) - - BLOCKv/Face (3.0.0): + - BLOCKv/Face (3.1.0): - BLOCKv/Core - FLAnimatedImage (~> 1.0) - Nuke (~> 7.0) - FLAnimatedImage (1.0.12) - - GenericJSON (1.2.0) - - JWTDecode (2.2.0) - - Nuke (7.5.2) - - NVActivityIndicatorView (4.6.1): - - NVActivityIndicatorView/Presenter (= 4.6.1) - - NVActivityIndicatorView/Presenter (4.6.1) - - Signals (6.0.0) + - GenericJSON (2.0.0) + - JWTDecode (2.3.0) + - Nuke (7.6.3) + - NVActivityIndicatorView (4.7.0): + - NVActivityIndicatorView/Presenter (= 4.7.0) + - NVActivityIndicatorView/Presenter (4.7.0) + - PromiseKit (6.10.0): + - PromiseKit/CorePromise (= 6.10.0) + - PromiseKit/Foundation (= 6.10.0) + - PromiseKit/UIKit (= 6.10.0) + - PromiseKit/CorePromise (6.10.0) + - PromiseKit/Foundation (6.10.0): + - PromiseKit/CorePromise + - PromiseKit/UIKit (6.10.0): + - PromiseKit/CorePromise + - Signals (6.1.0) - Starscream (3.0.6) - - SwiftLint (0.30.1) - - VatomFace3D (2.0.0): + - SwiftLint (0.33.1) + - VatomFace3D (3.0.2): - BLOCKv/Face - FLAnimatedImage - GenericJSON @@ -42,6 +52,7 @@ SPEC REPOS: - JWTDecode - Nuke - NVActivityIndicatorView + - PromiseKit - Signals - Starscream - SwiftLint @@ -52,18 +63,19 @@ EXTERNAL SOURCES: :path: "../" SPEC CHECKSUMS: - Alamofire: 16ce2c353fb72865124ddae8a57c5942388f4f11 - BLOCKv: 60d3a98639188c430c00d126669e452c4aac1d73 + Alamofire: ae5c501addb7afdbb13687d7f2f722c78734c2d3 + BLOCKv: 6d0675eb6f8802e3864af8ec77e735358e535f90 FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31 - GenericJSON: 05eb212cd12bf0562b816075090bea3eda2ab2ed - JWTDecode: 85a405ab16d5473e99bd89ded1d535090ebd6a0e - Nuke: 0350d346a688426e8f2331253ef28dc2fc4f6178 - NVActivityIndicatorView: 4ca19fccc84595a78957336a086d00a49be6ce61 - Signals: eca6a9098b40ed5f8d8b60498d0e91168fa7c0cc + GenericJSON: a967fb5c199c9aae24063e47a771e04e305dc836 + JWTDecode: fb77675c8049c1a7e9433c7cf448c3ce33ee4ac7 + Nuke: 44130e95e09463f8773ae4b96b90de1eba6b3350 + NVActivityIndicatorView: b19ddab2576f805cbe0fb2306cba3476e09a1dea + PromiseKit: 1fdaeb6c0a94a5114fcb814ff3d772b86886ad4e + Signals: f8e5c979e93c35273f8dfe55ee7f47213d3e8fe8 Starscream: ef3ece99d765eeccb67de105bfa143f929026cf5 - SwiftLint: a54bf1fe12b55c68560eb2a7689dfc81458508f7 - VatomFace3D: 78d08c503819d5e2d8e1978d953885d596e379cf + SwiftLint: 9fc1143bbacec37f94994a7d0d2a85b2154b6294 + VatomFace3D: aabeb4a3c9d72835fbfd8a4bcac3917151b30e0c -PODFILE CHECKSUM: d63113581b30008d083f82a8a354c17db0a9e801 +PODFILE CHECKSUM: d2ad0cdcd830de687ee1e7475c49221546a727b8 -COCOAPODS: 1.6.0 +COCOAPODS: 1.7.4