I am using Apple's Instruments tool to check out the current progress of my application and manage any leaks early. I seem to have a lot of leaks, but I cannot figure out where they are coming from.
In my application, I have a SignInOperation which is a subclass of Operation. It also conforms to URLSessionDataDelegate so that it can handle my requests without needing to use completion handlers. For example, when adding an instance of SignInOperation to an OperationQueue instance, the operation that performs updates to the UI can just check the error and user properties on the SignInOperation and handle UI updates accordingly since it will have the SignInOperation instance as a dependency.
The class follows:
import UIKit
/// Manages a sign-in operation.
internal final class SignInOperation: Operation, URLSessionDataDelegate {
// MARK: - Properties
/// An internal flag that indicates whether the operation is currently executing.
private var _executing = false
/// An internal flag that indicates wheterh the operation is finished.
private var _finished = false
/// The received data from the operation.
private var receivedData = Data()
/// The data task used for sign-in.
private var sessionTask: URLSessionDataTask?
/// The URL session that is used for coordinating tasks used for sign-in.
private var localURLSession: URLSession { return URLSession(configuration: localConfiguration, delegate: self, delegateQueue: nil) }
/// The configuration used for configuring the URL session used for sign-in.
private var localConfiguration: URLSessionConfiguration { return .ephemeral }
/// The credentials used for user-sign-in.
private var credentials: UserCredentials
/// The current user.
internal var currentUser: User?
/// The error encountered while attempting sign-in.
internal var error: NetworkRequestError?
/// The cookie storage used for persisting an authentication cookie.
internal var cookieStorage: HTTPCookieStorage?
/// A Boolean value indicating whether the operation is currently executing.
override internal(set) var isExecuting: Bool {
get { return _executing }
set {
willChangeValue(forKey: "isExecuting")
_executing = newValue
didChangeValue(forKey: "isExecuting")
}
}
/// A Boolean value indicating whether the operation has finished executing its task.
override internal(set) var isFinished: Bool {
get { return _finished }
set {
willChangeValue(forKey: "isFinished")
_finished = newValue
didChangeValue(forKey: "isFinished")
}
}
/// A Boolean value indicating whether the operation executes its task asynchronously.
override var isAsynchronous: Bool { return true }
// MARK: - Initialization
/// Returns an instane of `SignInOperation`.
/// - parameter credentials: The credentials for user-sign-in.
init(credentials: UserCredentials, cookieStorage: HTTPCookieStorage = CookieStorage.defaultStorage) {
self.credentials = credentials
self.cookieStorage = cookieStorage
super.init()
localURLSession.configuration.httpCookieAcceptPolicy = .never
}
// MARK: - Operation Lifecycle
override func start() {
if isCancelled {
isFinished = true
return
}
isExecuting = true
let request = NetworkingRouter.signIn(credentials: credentials).urlRequest
sessionTask = localURLSession.dataTask(with: request)
guard let task = sessionTask else { fatalError("Failed to get task") }
task.resume()
}
// MARK: - URL Session Delegate
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: #escaping (URLSession.ResponseDisposition) -> Void) {
if isCancelled {
isFinished = true
sessionTask?.cancel()
return
}
guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { fatalError("Could not determine status code") }
setError(from: statusCode)
completionHandler(disposition(from: statusCode))
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
if isCancelled {
guard let task = sessionTask else { fatalError("Failed to get task") }
task.cancel()
return
}
receivedData.append(data)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
defer { isFinished = true }
if isCancelled {
guard let task = sessionTask else { fatalError("Failed to get task") }
task.cancel()
}
if let statusCode = (task.response as? HTTPURLResponse)?.statusCode { setError(from: statusCode) } else if let taskError = error as? NSError { setError(from: taskError) }
if self.error == nil {
guard let taskResponse = task.response else { fatalError("Invalid response") }
setAuthenticationCookie(from: taskResponse)
processData()
}
}
// MARK: - Helpers
/// Handles the processing of the data received from the data task.
private func processData() {
currentUser = UserModelCreator.user(from: receivedData)
}
/// Handles the persistence of the returned cookie from the request's response.
private func setAuthenticationCookie(from response: URLResponse) {
guard let storage = cookieStorage else { fatalError() }
let cookiePersistenceManager = ResponseCookiePersistenceManger(storage: storage)
cookiePersistenceManager.removePreviousCookies()
guard let httpURLResponse = response as? HTTPURLResponse else { fatalError("Invalid response type") }
if let cookie = ResponseCookieParser.cookie(from: httpURLResponse) {cookiePersistenceManager.persistCookie(cookie: cookie) }
}
/// Handles the return of a specified HTTP status code.
/// - parameter statusCode: The status code.
private func setError(from statusCode: Int) {
switch statusCode {
case 200: error = nil
case 401: error = .invalidCredentials
default: error = .generic
}
}
/// Returns a `URLResponse.ResponseDisposition` for the specified HTTP status code.
/// - parameter code: The status code.
/// - Returns: A disposition.
private func disposition(from code: Int) -> URLSession.ResponseDisposition {
switch code {
case 200: return .allow
default: return .cancel
}
}
/// Handles the return of an error from a network request.
/// - parameter error: The error.
private func setError(from error: NSError) {
switch error.code {
case Int(CFNetworkErrors.cfurlErrorTimedOut.rawValue): self.error = .requestTimedOut
case Int(CFNetworkErrors.cfurlErrorNotConnectedToInternet.rawValue): self.error = .noInternetConnection
default: self.error = .generic
}
}
}
Then, to see if everything works, I call the operation in viewDidAppear:, which results in all of the expected data being printed:
import UIKit
class ViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let credentials = UserCredentials(emailAddress: "xxxxxxx#xxxx.xx", password: "xxxxxxxxxxxxxxxxxx")
let signInOp = SignInOperation(credentials: credentials)
let printOperation = Operation()
printOperation.addDependency(signInOp)
printOperation.completionBlock = {
if let error = signInOp.error { return print("\n====> Sign-in Error: \(error.message)\n") }
if let user = signInOp.currentUser { print("\n====> User: \(user)\n") }
}
let queue = OperationQueue()
queue.addOperations([signInOp, printOperation], waitUntilFinished: false)
}
}
However, when using the Leaks profiler in Instruments, I get some alarming data.
I don't really know where to start here. When I click on any of the detected leaks, I am not taken to my code that the leak originates from. I have watched a few tutorials and read Apple's documentation, but I am stuck trying to figure out where the leaks are coming from. It seems like a ridiculous amount/
I don't see anywhere in my code where I have strong reference cycles, so I am asking for some help with trying to figure out how to resolve the 421 detected leaks.
It turns out that I do have two strong reference cycles, which are the two following properties in my SignInOperation subclass: sessionTask & localURLSession.
After making these properties weak, I no longer have leaks detected:
/// The URL session that is used for coordinating tasks used for sign-in.
private weak var localURLSession: URLSession { return URLSession(configuration: localConfiguration, delegate: self, delegateQueue: nil) }
/// The configuration used for configuring the URL session used for sign-in.
private weak var localConfiguration: URLSessionConfiguration { return .ephemeral }
Related
I have asynchronous operation implementation like this:
class AsyncOperation: Operation {
private enum State: String {
case ready, executing, finished
fileprivate var keyPath: String {
return "is\(rawValue.capitalized)"
}
}
private let stateAccessQueue = DispatchQueue(label: "com.beamo.asyncoperation.state")
private var unsafeState = State.ready
private var state: State {
get {
return stateAccessQueue.sync {
return unsafeState
}
}
set {
willChangeValue(forKey: newValue.keyPath)
stateAccessQueue.sync(flags: [.barrier]) {
self.unsafeState = newValue
}
didChangeValue(forKey: newValue.keyPath)
}
}
override var isReady: Bool {
return super.isReady && state == .ready
}
override var isExecuting: Bool {
return state == .executing
}
override var isFinished: Bool {
return state == .finished
}
override var isAsynchronous: Bool {
return true
}
override func start() {
if isCancelled {
if !isFinished {
finish()
}
return
}
state = .executing
main()
}
public final func finish() {
state = .finished
}
}
App will download multiple resources with API. Implementation of downloader is managed by operation queue and custom asynchronous operation. Here is implementation of asynchronous downloader operation:
final class DownloaderOperation: AsyncOperation {
private let downloadTaskAccessQueue = DispatchQueue(label: "com.download.task.access.queue")
private var downloadTask: URLSessionDownloadTask?
private var threadSafeDownloadTask: URLSessionDownloadTask? {
get {
return downloadTaskAccessQueue.sync {
return downloadTask
}
}
set {
downloadTaskAccessQueue.sync(flags: [.barrier]) {
self.downloadTask = newValue
}
}
}
private let session: URLSession
let download: Download
let progressHandler: DownloadProgressHandler
init(session: URLSession,
download: Download,
progressHandler: #escaping DownloadProgressHandler) {
self.session = session
self.download = download
self.progressHandler = progressHandler
}
override func main() {
let bodyModel = DownloaderRequestBody(fileUrl: download.fileUrl.absoluteString)
let bodyData = (try? JSONEncoder().encode(bodyModel)) ?? Data()
var request = URLRequest(url: URL(string: "api url here")!)
request.httpMethod = "POST"
request.httpBody = bodyData
let task = session.downloadTask(with: request)
task.countOfBytesClientExpectsToSend = 512
task.countOfBytesClientExpectsToSend = 1 * 1024 * 1024 * 1024 // 1GB
task.resume()
threadSafeDownloadTask = task
DispatchQueue.main.async {
self.download.progress = 0
self.download.status = .inprogress
self.progressHandler(self.model, 0)
}
}
override func cancel() {
super.cancel()
threadSafeDownloadTask?.cancel()
}
}
And downloader implementation is here:
final class MultipleURLSessionDownloader: NSObject {
private(set) var unsafeOperations: [Download: DownloaderOperation] = [:]
private(set) var progressHandlers: [Download: DownloadProgressHandler] = [:]
private(set) var completionHandlers: [Download: DownloadCompletionHandler] = [:]
private let operationsAccessQueue = DispatchQueue(label: "com.multiple.downloader.operations")
private(set) var operations: [Download: DownloaderOperation] {
get {
return operationsAccessQueue.sync {
return unsafeOperations
}
}
set {
operationsAccessQueue.sync(flags: [.barrier]) {
self.unsafeOperations = newValue
}
}
}
private lazy var downloadOperationQueue: OperationQueue = {
var queue = OperationQueue()
queue.name = "com.multiple.downloader.queue"
queue.maxConcurrentOperationCount = 2
return queue
}()
private(set) var urlSession = URLSession.shared
init(configuration: URLSessionConfiguration = URLSessionConfiguration.background(withIdentifier: "com.multiple.downloader.session")) {
super.init()
configuration.isDiscretionary = false
configuration.sessionSendsLaunchEvents = true
configuration.timeoutIntervalForResource = 120
configuration.timeoutIntervalForRequest = 10
self.urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
}
deinit {
debugPrint("[MultipleURLSessionDownloader] deinit")
}
func startDownload(downloads: [Download],
progressHandler: #escaping DownloadProgressHandler,
completionHandler: #escaping DownloadCompletionHandler) {
for download in downloads {
startDownload(
download: download,
progressHandler: progressHandler,
completionHandler: completionHandler
)
}
}
func cancelDownload(download: Download) {
cancelOperation(download: download)
}
func cancelAllDownloads() {
for download in operations.keys {
cancelOperation(download: download)
}
urlSession.getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in
dataTasks.forEach {
$0.cancel()
}
uploadTasks.forEach {
$0.cancel()
}
downloadTasks.forEach {
$0.cancel()
}
}
}
private func startDownload(download: Download,
progressHandler: #escaping DownloadProgressHandler,
completionHandler: #escaping DownloadCompletionHandler) {
download.status = .default
progressHandlers[download] = progressHandler
completionHandlers[download] = completionHandler
if let operation = operations[download] {
if operation.isExecuting,
let inProgressDownload = operations.keys.first(where: { $0.id == download.id }) {
progressHandlers[download]?(inProgressDownload, inProgressDownload.progress ?? 0)
}
} else {
let operation = DownloaderOperation(
session: urlSession,
download: download) {[weak self] (progressDownload, progress) in
self?.progressHandlers[progressDownload]?(progressDownload, progress)
}
operations[download] = operation
downloadOperationQueue.addOperation(operation)
}
}
private func cancelOperation(download: Download) {
download.status = .cancelled
operations[download]?.cancel()
callCompletion(download: download, error: nil)
}
private func callCompletion(download: Download,
error: DownloadError?) {
operations[download]?.finish()
operations[download] = nil
progressHandlers[download] = nil
download.progress = nil
let handler = completionHandlers[download]
completionHandlers[download] = nil
handler?(download, error)
}
}
There is 2 cancel method:
cancelDownload(download: Download)
cancelAllDownloads()
If download is canceled and tried to download multiple times app is crashed and crash log is here:
If download is cancelledAll and tried to download multiple times app is crashed and crash log is here:
And the strange thing here is if I open app by running from Xcode the crash is not happened.
This is happening only when if I open app from device without running by Xcode.
For now I fixed replecaing operation queue with dispatch queue like this:
downloadOperationQueue.addOperation(operation)
downloadDispatchQueue.async {
operation.start()
}
And this is working fine without any crash.
I think crash is happening on addOperation method of OperationQueue, but I don't know the reason.
It does not make much sense to use operations in conjunction with a background URLSession. The whole idea of background URLSession is network requests proceed if the user leaves the app, and even if the app has been terminated in the course of its normal lifecycle (e.g., jettisoned due to memory pressure). But an operation queue cannot survive the termination of an app. If you really want to use operations with background session, you will need to figure out how you will handle network requests that are still underway, but for which there is no longer any operation to represent that request.
Let us set that aside and look at the operation and diagnose the crash.
A critical issue is the isFinished KVO when the operation is canceled. The documentation is very clear that if the operation has started and is subsequently canceled, you must perform the isFinished KVO notifications. If the task not yet started. If you do that, you can receive a warning:
went isFinished=YES without being started by the queue it is in
That would seem to suggest that one should not issue isFinished KVO notifications if an unstarted operation is canceled. But, note you absolutely must set the underlying state to finished, or else the unstarted canceled operations will be retained by the operation queue.
But what is worse, that performing the isFinished KVO notifications for unstarted operations it can lead to NSKeyValueDidChangeWithPerThreadPendingNotifications in the stack trace, just like you showed in your question. Here is my stack trace from my crash:
So, all of that said, I discovered two work-arounds:
Use locks for synchronization rather than GCD. They are more performant, anyway, and in my experiments, avoid the crash. (This is an unsatisfying solution because it is unclear as to whether it really solved the root problem, or just moved the goal-posts sufficiently, such that the crash no longer manifests itself.)
Alternatively, when you set the state to .finished, only issue the isFinished KVO if the operation was currently running. (This is also a deeply unsatisfying solution, as it is contemplated nowhere in Apple’s documentation. But it silences the above warning and eliminates the crash.)
For example:
func finish() {
if isExecuting {
state = .finished // change state with KVO
} else {
synchronized { _state = .finished } // change state without KVO
}
}
Basically, that sets the state to finished either way, with KVO notifications if the operation is executing, and without if not yet started when it is canceled.
So, you might end up with:
open class AsynchronousOperation: Operation {
override open var isReady: Bool { super.isReady && state == .ready }
override open var isExecuting: Bool { state == .executing }
override open var isFinished: Bool { state == .finished }
override open var isAsynchronous: Bool { true }
private let lock = NSLock()
private var _state: State = .ready
private var state: State {
get { synchronized { _state } }
set {
let oldValue = state
guard oldValue != .finished else { return }
willChangeValue(forKey: oldValue.keyPath)
willChangeValue(forKey: newValue.keyPath)
synchronized { _state = newValue }
didChangeValue(forKey: newValue.keyPath)
didChangeValue(forKey: oldValue.keyPath)
}
}
override open func start() {
if isCancelled {
finish()
return
}
state = .executing
main()
}
open func finish() {
state = .finished
}
}
// MARK: - State
private extension AsynchronousOperation {
enum State: String {
case ready, executing, finished
fileprivate var keyPath: String {
return "is\(rawValue.capitalized)"
}
}
}
// MARK: - Private utility methods
private extension AsynchronousOperation {
func synchronized<T>(block: () throws -> T) rethrows -> T {
lock.lock()
defer { lock.unlock() }
return try block()
}
}
A few notes on the above:
If we refer to Listing 2-7 the old Concurrency Programming Guide: Operations, they illustrate (in Objective-C) the correct KVO notifications that must take place when we, for example, transition from “executing” to “finished”:
- (void)completeOperation {
[self willChangeValueForKey:#"isFinished"];
[self willChangeValueForKey:#"isExecuting"];
executing = NO;
finished = YES;
[self didChangeValueForKey:#"isExecuting"];
[self didChangeValueForKey:#"isFinished"];
}
So, as we transition from executing to finished, we have to perform the notification for both isExecuting and isFinished. But your state setter is only performing the KVO for the newValue (thus, only isFinished, neglecting to perform the isExecuting-related KVO notifications). This is likely unrelated to your problem at hand, but is important, nonetheless.
If you were to synchronize with GCD serial queue, the barrier becomes redundant. This lingering barrier was probably a legacy of a previous rendition using a reader-writer pattern. IMHO, the transition to a serial queue was prudent (as the reader-writer just introduces more problems than its negligible performance difference warrants). But if we eliminate the concurrent queue, we should also remove the redundant barrier. Or just eliminate GCD altogether as shown above.
I guard against state changes after the operation is already finished. This is a bit of defensive-programming adapted from Apple’s “Advanced NSOperations” implementation (which, admittedly, is no longer available on Apple’s site).
I would not recommend making finish a final function. While unlikely, it is possible that a subclass might want to add functionality. So I removed final.
I moved the GCD synchronization code into its own method, synchronized, so that I could easily switch between different synchronization mechanisms.
Given that Operation is an open class, I did the same here.
I append failed requests to queue manager (contains array) in case of no connection
I'm presenting a custom pop-up with a retry button. When the retry button is pressed, I want to retry the requests that cannot be sent in the no connection state. There may be more than one request.
When I try the retryRequest method from Alamofire Session class, the task state of the request remains in the initilazed or finished state, but it must be resumed in order to send the request successfully, how can I solve this situation?
InterceptorInterface.swift
public func didGetNoInternetConnection() {
let viewModel = AppPopupViewModel(title: L10n.AppPopUp.areYouOffline, description: L10n.AppPopUp.checkInternetConnection, image: Images.noInternetConnection.image, firstButtonTitle: L10n.General.tryAgain, secondButtonTitle: nil, firstButtonAction: { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
self.retry()
}
}, secondButtonAction: nil, dismissCompletion: nil, dimColor: Colors.appGray.color.withAlphaComponent(0.8), showCloseButton: true, customView: nil)
DispatchQueue.main.async {
AppPopupManager.show(with: viewModel, completion: nil)
}
}
private func retry() {
guard let head = NetworkRequestStorage.shared.head else {
return
}
let request = head.request
let session = head.session
session.retryRequest(request, withDelay: nil)
}
APIInterceptor.swift
final class APIInterceptor: Alamofire.RequestInterceptor {
private let configure: NetworkConfigurable
private var lock = NSLock()
// MARK: - Initilize
internal init(configure: NetworkConfigurable) {
self.configure = configure
}
// MARK: - Request Interceptor Method
internal func adapt(_ urlRequest: URLRequest, for session: Session, completion: #escaping (Result<URLRequest, Error>) -> Void) {
lock.lock()
defer {
lock.unlock()
}
var urlRequest = urlRequest
if let token = self.configure.accessToken {
/// Set the Authorization header value using the access token.
urlRequest.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
}
// Set Accept-Language header value using language code
urlRequest.setValue(configure.languageCode, forHTTPHeaderField: "Accept-Language")
// Arrange Request logs for develope and staging environment
if let reachability = NetworkReachabilityManager(), !reachability.isReachable {
configure.didGetNoInternetConnection()
completion(.failure(APIClientError.networkError))
}
completion(.success(urlRequest))
}
// MARK: - Error Retry Method
internal func retry(_ request: Request, for session: Session, dueTo error: Error, completion: #escaping (RetryResult) -> Void) {
// Arrange Error logs for develope and staging environment
if let aError = error as? APIClientError, aError.statusCode == 400 { // no connection state
NetworkRequestStorage.shared.enqueue(request: request, session: session)
completion(.doNotRetryWithError(error))
} else {
request.retryCount <= configure.retryCount ? completion(.retry) : completion(.doNotRetryWithError(error))
}
}
}
If the request is successful or there is no connection error, I remove it from the NetworkRequestStoroge class.
I'm using a companion app to authorize a user with a 3rd party service. Once authorized, I update a UserDefaults variable to true. On the companion app side, the view updates correctly and shows that the user has been authenticated. However, on the watch OS side the view does not update. Would I need to use the Watch Connectivity API and send a message to the watch to update the state? Or is there a simple way?
Phone App
struct AuthenticationView: View {
#State private var startingWebAuthenticationSession = false
#AppStorage("authorized") private var authorized = false
var body: some View {
Group {
if !authorized {
VStack {
Button("Connect", action: { self.startingWebAuthenticationSession = true })
.webAuthenticationSession(isPresented: $startingWebAuthenticationSession) {
WebAuthenticationSession(
url: URL(string: "https://service.com/oauth/authorize?scope=email%2Cread_stats&response_type=code&redirect_uri=watch%3A%2F%2Foauth-callback&client_id=\(clientId)")!,
callbackURLScheme: callbackURLScheme
) { callbackURL, error in
guard error == nil, let successURL = callbackURL else {
return
}
let oAuthCode = NSURLComponents(string: (successURL.absoluteString))?.queryItems?.filter({$0.name == "code"}).first
guard let authorizationCode = oAuthCode?.value else { return }
let url = URL(string: "https://service.com/oauth/token")
var request = URLRequest(url: url!)
request.httpMethod = "POST"
let params = "client_id=\(clientId)&client_secret=\(clientSecret)&grant_type=authorization_code&code=\(authorizationCode)&redirect_uri=\(callbackURLScheme)://oauth-callback";
request.httpBody = params.data(using: String.Encoding.utf8);
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
print("Error took place \(error)")
return
}
if let data = data, let response = String(data: data, encoding: .utf8) {
let accessTokenResponse: AccessTokenResponse = try! JSONDecoder().decode(AccessTokenResponse.self, from: response.data(using: .utf8)!)
let defaults = UserDefaults.standard
authorized = true
startingWebAuthenticationSession = false
defaults.set(accessTokenResponse.access_token, forKey: DefaultsKeys.accessToken) //TODO: Store securely
ConnectivityService.shared.send(authorized: true)
}
}
task.resume()
}
.prefersEphemeralWebBrowserSession(false)
}
}
}
else {
VStack {
Text("Authenticated!")
}
}
}
}
}
WatchOS
import SwiftUI
struct ConnectView: View {
#ObservedObject var connectivityService: ConnectivityService
var body: some View {
if !$connectivityService.authorized.wrappedValue {
VStack {
Text("Open the app on your primary device to connect.")
}
}
else {
//Some other view
}
}
}
EDIT:
Trying with Watch Connectivity API but the issue I'm experiencing is that when I authenticate from the phone, it'll take some time for the ConnectView to update the authorized variable. I know Watch Connectivity API doesn't update right away but at minimum I'd need some way for the watch to pick up that a secret access token has been retrieved and it can transition to the next view; whether that's through a shared state variable, UserDefaults, or whatever other mechanism.
Here is the ConnectivityService class I'm using:
import Foundation
import Combine
import WatchConnectivity
final class ConnectivityService: NSObject, ObservableObject {
static let shared = ConnectivityService()
#Published var authorized: Bool = false
override private init() {
super.init()
#if !os(watchOS)
guard WCSession.isSupported() else {
return
}
#endif
WCSession.default.delegate = self
WCSession.default.activate()
}
public func send(authorized: Bool, errorHandler: ((Error) -> Void)? = nil) {
guard WCSession.default.activationState == .activated else {
return
}
#if os(watchOS)
guard WCSession.default.isCompanionAppInstalled else {
return
}
#else
guard WCSession.default.isWatchAppInstalled else {
return
}
#endif
let authorizationInfo: [String: Bool] = [
DefaultsKeys.authorized: authorized
]
WCSession.default.sendMessage(authorizationInfo, replyHandler: nil)
WCSession.default.transferUserInfo(authorizationInfo)
}
}
extension ConnectivityService: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { }
func session(
_ session: WCSession,
didReceiveUserInfo userInfo: [String: Any] = [:]
) {
let key = DefaultsKeys.authorized
guard let authorized = userInfo[key] as? Bool else {
return
}
self.authorized = authorized
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
self.authorized = true
}
#if os(iOS)
func sessionDidBecomeInactive(_ session: WCSession) {
}
func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}
#endif
}
I tried doing these two lines but they have varying results:
WCSession.default.sendMessage(authorizationInfo, replyHandler: nil)
WCSession.default.transferUserInfo(authorizationInfo)
In the first line, XCode will say that no watch app could be found, even though I'm connected to both physical devices through XCode; launch phone first then watch. I believe the first one is immediate and the second is more of when the queue feels like it. Sometimes if I hard close the watch app, it'll pick up the state change in the authorized variable, sometimes it won't. Very frustrating inter-device communication.
UserDefaults doesn't pick up the access token value on the watch side. Maybe I have to use App Groups?
I do see this error on the Watch side:
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
So I thought to try and encapsulate the self.authorized = authorized call into something like:
DispatchQueue.main.async {
self.authorized = authorized
}
But it didn't do anything as far as solving the immediate state change issue.
I've centralized API calls for my App in a class called APIService.
Calls look like the one below:
// GET: Attempts getconversations API call. Returns Array of Conversation objects or Error
func getConversations(searchString: String = "", completion: #escaping(Result<[Conversation], APIError>) -> Void) {
{...} //setting up URLRequest
let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let _ = data
else {
print("ERROR: ", error ?? "unknown error")
completion(.failure(.responseError))
return
}
do {
{...} //define custom decoding strategy
}
let result = try decoder.decode(ResponseMultipleElements<[Conversation]>.self, from: data!)
completion(.success(result.detailresponse.element))
}catch {
completion(.failure(.decodingError))
}
}
dataTask.resume()
}
I'm executing API calls from anywhere in the Application like so:
func searchConversations(searchString: String) {
self.apiService.getConversations(searchString: searchString, completion: {result in
switch result {
case .success(let conversations):
DispatchQueue.main.async {
{...} // do stuff
}
case .failure(let error):
print("An error occured \(error.localizedDescription)")
}
})
}
What I would like to achieve now is to execute func searchConversations for each character tapped by the user when entering searchString.
This would be easy enough by just calling func searchConversations based on a UIPressesEvent being fired. Like so:
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else { return }
switch key.keyCode {
{...} // handle special cases
default:
super.pressesEnded(presses, with: event)
searchConversations(searchString: SearchText.text)
}
}
My problem is this now:
Whenever a new character is entered, I'd like to cancel the previous URLSession and kick-off a new one. How can I do that from inside the UIPressesEvent handler?
The basic idea is to make sure the API returns an object that can later be canceled, if needed, and then modifying the search routine to make sure to cancel any pending request, if any:
First, make your API call return the URLSessionTask object:
#discardableResult
func getConversations(searchString: String = "", completion: #escaping(Result<[Conversation], APIError>) -> Void) -> URLSessionTask {
...
let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
...
}
dataTask.resume()
return dataTask
}
Have your search routine keep track of the last task, canceling it if needed:
private weak var previousTask: URLSessionTask?
func searchConversations(searchString: String) {
previousTask?.cancel()
previousTask = apiService.getConversations(searchString: searchString) { result in
...
}
}
We frequently add a tiny delay so that if the user is typing quickly we avoid lots of unnecessary network requests:
private weak var previousTask: URLSessionTask?
private weak var delayTimer: Timer?
func searchConversations(searchString: String) {
previousTask?.cancel()
delayTimer?.invalidate()
delayTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { [weak self] _ in
guard let self = self else { return }
self.previousTask = self.apiService.getConversations(searchString: searchString) {result in
...
}
}
}
The only other thing is that you probably want to change your network request error handler so that the “cancel” of a request isn’t handled like an error. From the URLSession perspective, cancelation is an error, but from our app’s perspective, cancelation is not an error condition, but rather an expected flow.
You can achieve this by using a timer,
1) Define a timer variable
var requestTimer: Timer?
2) Update searchConversations function
#objc func searchConversations() {
self.apiService.getConversations(searchString: SearchText.text, completion: {result in
switch result {
case .success(let conversations):
DispatchQueue.main.async {
{...} // do stuff
}
case .failure(let error):
print("An error occured \(error.localizedDescription)")
}
})
}
3) Update pressesEnded
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else { return }
switch key.keyCode {
{...} // handle special cases
default:
super.pressesEnded(presses, with: event)
self.requestTimer?.invalidate()
self.requestTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(searchConversations), userInfo: nil, repeats: false)
}
}
I'm using AWSAppSyncClient to upload files but I'm struggling to connect the upload progress hook with the view.
AWSAppSyncClient is a property of the the application delegate initialized with an S3ObjectManager. The object manager method upload has access to the upload progress via the AWSTransferUtilityUplaodExpression:
expression.progressBlock = {(task, progress) in
DispatchQueue.main.async(execute: {
// Can we update the controller's progress bar here?
print("Progress: \(Float(progress.fractionCompleted))")
})
}
My controller invokes the upload by calling perform:
var appSyncClient: AWSAppSyncClient? // retrieved from the app delegate singleton
appSyncClient?.perform(mutation: CreatePostMutation(input: input)) { (result, error) in ...
What I am struggling with: how do I provide the S3ObjectManager a reference to the controller? I thought of instantiating the AWSAppSyncClient in each controller, and maybe using some sort of delegate pattern?
It's probably overkill to instantiate a new client on each view controller. Setup & teardown take a bit of time & system resources to perform, and you'd probably prefer to keep those activities separate from the view controller in any case, just for separation of responsibilities.
There isn't really a good way of registering a per-object listener, since mutations are queued for eventual, asynchronous delivery. Your delegate idea seems like the best approach at this point.
NOTE: Code below is untested, and not thread-safe.
For example, you could declare a singleton delegate that manages watchers for individual views that need to report progress:
class AppSyncS3ObjectManagerProgressWatcher {
typealias ProgressSubscription = UUID
static let shared = AppSyncS3ObjectManagerProgressWatcher()
private var watchers = [UUID: AppSyncS3ObjectManagerProgressDelegate?]()
func add(_ watcher: AppSyncS3ObjectManagerProgressDelegate) -> ProgressSubscription {
let subscription = UUID()
weak var weakWatcher = watcher
watchers[subscription] = weakWatcher
return subscription
}
func remove(_ subscription: ProgressSubscription?) {
guard let subscription = subscription else {
return
}
watchers[subscription] = nil
}
}
extension AppSyncS3ObjectManagerProgressWatcher: AppSyncS3ObjectManagerProgressDelegate {
func progressReportingExpression(forDownloadingObject object: AWSS3ObjectProtocol) -> AWSS3TransferUtilityDownloadExpression {
let expression = AWSS3TransferUtilityDownloadExpression()
expression.progressBlock = { _, progress in
self.didReportProgress(forDownloadingObject: object, progress: progress)
}
return expression
}
func progressReportingExpression(forUploadingObject object: AWSS3ObjectProtocol & AWSS3InputObjectProtocol) -> AWSS3TransferUtilityUploadExpression {
let expression = AWSS3TransferUtilityUploadExpression()
expression.progressBlock = { _, progress in
self.didReportProgress(forUploadingObject: object, progress: progress)
}
return expression
}
func didReportProgress(forDownloadingObject object: AWSS3ObjectProtocol, progress: Progress) {
for watcher in watchers.values {
watcher?.didReportProgress(forDownloadingObject: object, progress: progress)
}
}
func didReportProgress(forUploadingObject object: AWSS3ObjectProtocol & AWSS3InputObjectProtocol, progress: Progress) {
for watcher in watchers.values {
watcher?.didReportProgress(forUploadingObject: object, progress: progress)
}
}
}
Wherever you conform S3TransferUtility to S3ObjectManager, you would do something like:
extension AWSS3TransferUtility: AWSS3ObjectManager {
public func download(s3Object: AWSS3ObjectProtocol, toURL: URL, completion: #escaping ((Bool, Error?) -> Void)) {
let completionBlock: AWSS3TransferUtilityDownloadCompletionHandlerBlock = { task, url, data, error -> Void in
if let _ = error {
completion(false, error)
} else {
completion(true, nil)
}
}
let progressReportingExpression = AppSyncS3ObjectManagerProgressWatcher
.shared
.progressReportingExpression(forDownloadingObject: s3Object)
let _ = self.download(
to: toURL,
bucket: s3Object.getBucketName(),
key: s3Object.getKeyName(),
expression: progressReportingExpression,
completionHandler: completionBlock)
}
public func upload(s3Object: AWSS3ObjectProtocol & AWSS3InputObjectProtocol, completion: #escaping ((_ success: Bool, _ error: Error?) -> Void)) {
let completionBlock : AWSS3TransferUtilityUploadCompletionHandlerBlock = { task, error -> Void in
if let _ = error {
completion(false, error)
} else {
completion(true, nil)
}
}
let progressReportingExpression = AppSyncS3ObjectManagerProgressWatcher
.shared
.progressReportingExpression(forUploadingObject: s3Object)
let _ = self.uploadFile(
s3Object.getLocalSourceFileURL()!,
bucket: s3Object.getBucketName(),
key: s3Object.getKeyName(),
contentType: s3Object.getMimeType(),
expression: progressReportingExpression,
completionHandler: completionBlock
).continueWith { (task) -> Any? in
if let err = task.error {
completion(false, err)
}
return nil
}
}
}
And then in the progress reporting view:
override func awakeFromNib() {
super.awakeFromNib()
progressSubscription = AppSyncS3ObjectManagerProgressWatcher.shared.add(self)
}
func didReportProgress(forUploadingObject object: AWSS3InputObjectProtocol & AWSS3ObjectProtocol, progress: Progress) {
// TODO: Filter by object local URI/key/etc to ensure we're updating the correct progress
print("Progress received for \(object.getKeyName()): \(progress.fractionCompleted)")
self.progress = progress
}
As I noted, this code is untested, but it should outline a general approach for you to start from. I'd welcome your feedback and would like to hear what approach you eventually settle on.
Finally, please feel free to open a feature request on our issues page: https://github.com/awslabs/aws-mobile-appsync-sdk-ios/issues