I'm using a Series 6 emulator on watchOS7 and I'm trying to upload some JSON data to a remote server using an URLSession background task. However the delegate functions are not being called so I cannot clean up any local data from the upload and handle any errors. I got the original idea from my implementation from this WWDC video WWDC Video. I've looked at many posts on the Internet but nothing I've tried seems to work. Here is my code:
UploadSession class
class UploadSession: NSObject, Identifiable, URLSessionDelegate {
var backgroundTasks = [WKURLSessionRefreshBackgroundTask]()
private lazy var urlSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "my.app.watchextension")
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func enqueueBackgroundTask(idToken: String, uploadData: Data, url: URL) throws {
//build the JSON object we want to send in this post request
let tempDir = FileManager.default.temporaryDirectory
let localURL = tempDir.appendingPathComponent("throwaway")
try? uploadData.write(to: localURL)
//set up the URLRequest
var request = URLRequest(url: url)
request.httpMethod = K.Upload.httpPost
request.setValue(K.Upload.jsonContent, forHTTPHeaderField: K.Upload.contentType)
request.setValue("\(K.Upload.bearer)\(idToken)", forHTTPHeaderField: K.Upload.authorization)
request.timeoutInterval = K.Upload.httpUploadTimeout
//keep a reference to this class
BackgroundURLSessions.shared.sessions["my.app.watchextension"] = self
//create the upload task and kick it off
let task = urlSession.uploadTask(with: request, fromFile: localURL)
task.earliestBeginDate = Date().advanced(by: 120)//when setting this to zero the upload runs straight away.
task.resume()
}
func addBackgroundRefreshTask(_ task: WKURLSessionRefreshBackgroundTask) {
backgroundTasks.append(task)
}
//gets called when the task background task completes
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
//PROBLEM. This delegate method is never called
if let sessionId = session.configuration.identifier {
//TODO delete any local data copies
//set the session to nil so the system doesn't try to execute it again
BackgroundURLSessions.shared.sessions[sessionId] = nil
}
for task in backgroundTasks {
task.setTaskCompletedWithSnapshot(false)
}
}
//gets called if the background task throws an error
func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
//PROBLEM. This delegate method is never called
}
}
BackgroundURLSessions class
class BackgroundURLSessions: NSObject {
static let shared: BackgroundURLSessions = BackgroundURLSessions()
var sessions = [String: UploadSession]()
override private init() {
super.init()
}
}
Extension Delegate class
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
// Use a switch statement to check the task type
switch task {
case let urlSessionTask as WKURLSessionRefreshBackgroundTask:
// Be sure to complete the URL session task once you’re done.
if let session = BackgroundURLSessions.shared.sessions[urlSessionTask.sessionIdentifier] {
session.addBackgroundRefreshTask(urlSessionTask)
} else {
urlSessionTask.setTaskCompletedWithSnapshot(false)
}
default:
// make sure to complete unhandled task types
task.setTaskCompletedWithSnapshot(false)
}
}
}
Any help really appreciated as I'm really struggling to get this work and it's a vital part of the application. Thanks.
Related
Wed 5/18 Additional Info added at Step 5
I am able to create a URLSesion, build a request with a file to upload and successfully call it from my app. On my server side, the proper script is called, uploaded file is saved, etc,. However, I am not receiving the HTTP responses, data, etc.
Actually had this working without the delegate, when the HTTP response functions were within the task itself. But am now trying to expand functionality and am missing something while trying implement the delegate.
The trimmed code is below, and it all works, with the exception of setting up UIViewController as the URLSession delegate. Just trying to figure out why my UIViewController is not receiving the HTTP responses.
Below is the code for:
UIViewController
Class which creates the upload session (UploadService)
Extension for
UIViewController which I want to use to process the responses
How the previous task looked, when it worked. Before I tried to implement the delegate.
Used print to confirm that my UIViewConroller is the delegate, yet it still receives no HTTP response, data, or error messages
UIViewController
class UploadInv : UIViewController {
var xFile : XFile?
...create UI....
let uploadService = UploadService()
lazy var uploadSession: URLSession = {
let configuration = URLSessionConfiguration.default
return URLSession(configuration: configuration, delegate: self, delegateQueue: .main)
}()
override func viewWillAppear(_ animated: Bool) {
...
}
override func viewDidLoad() {
super.viewDidLoad()
uploadService.uploadSession = uploadSession
... code the lays out all buttons, labels, etc...
}
#objc func buttonAction(sender: UIButton!) {
guard let theButton = sender else { return}
let myTag = theButton.tag
switch myTag {
//button to start upload
case ButtType.up.rawValue:
uploadService.start(upFile: xFile!, script: "uploadOrig.pl", upLoadInvClass: self)
uploadService.task?.resume()
//button to select file to upload
case ButtType.file.rawValue:
... file xFile with file info
}
}
UploadService
class UploadService {
var uploadSession : URLSession!
var task: URLSessionUploadTask?
func start(upFile: XFile, script: String, upLoadInvClass: UploadInv) {
var request = upFile.makeUrlReq(upFile: upFile, script: script)
task = uploadSession.uploadTask(with: request, from: request.httpBody! )
print("\(uploadSession.delegate)")
task?.resume()
}
}
extension
extension UploadInv: UIDocumentPickerDelegate, URLSessionDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
... file xFile info for upload ....
... http request created ....
}
// Below are the three simple functions which I would handle
// responses the server, but these never seem to get called.
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let err = error {
print("Error: \(err.localizedDescription)")
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: (URLSession.ResponseDisposition) -> Void) {
print("didReceive response")
completionHandler(URLSession.ResponseDisposition.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
print("didReceive data")
if let responseText = String(data: data, encoding: .utf8) {
print(responseText)
}
}
}
Pre-Delegate model which worked
class UploadService {
var uploadSession = URLSession.shared
func start(upFile: XFile, script: String, upLoadInvClass: UploadInv) {
var request = upFile.makeUrlReq(upFile: upFile, script: script)
uploadSession.uploadTask(with: request, from: request.httpBody )
{ (data, response, error) in
if let response = response {
upLoadInvClass.upResp(resp: response)
}
if let error = error {
upLoadInvClass.upErr(error: error)
}
if let data = data {
upLoadInvClass.upData(data: data)
}
}.resume()
}
}
Step 5:
task = uploadSession.uploadTask(with: request, from: request.httpBody! )
print("\(uploadSession.delegate)")
task?.resume()
For other newbies also stuck on this, it turns out there's more than one delegate to look at. There are:
URLSessionTaskDelegate, URLSessionDataDelegate, URLSessionDownloadDelegate, and more. So obviously I was using the wrong one, might have been fell trap to "autocomplete." Nevertheless, I have to make sure I read more documentation on the subject.
Thanks to Scott who "passively/aggressively" gave me the answer, here, while still allowing me to "think." I mean that as a compliment. He told me to add the line:
assert(uploadSession.delegate! is URLSessionDataDelegate)
I've consulted many variations of background app refresh for Apple Watch so that I can update the complications for my app. However, the process seems very much hit or miss and most of the time it doesn't run at all after some time.
Here is the code I currently have:
BackgroundService.swift
Responsibility: Schedule background refresh and handle download processing and update complications.
import Foundation
import WatchKit
final class BackgroundService: NSObject, URLSessionDownloadDelegate {
var isStarted = false
private let requestFactory: RequestFactory
private let logManager: LogManager
private let complicationService: ComplicationService
private let notificationService: NotificationService
private var pendingBackgroundTask: WKURLSessionRefreshBackgroundTask?
private var backgroundSession: URLSession?
init(requestFactory: RequestFactory,
logManager: LogManager,
complicationService: ComplicationService,
notificationService: NotificationService
) {
self.requestFactory = requestFactory
self.logManager = logManager
self.complicationService = complicationService
self.notificationService = notificationService
super.init()
NotificationCenter.default.addObserver(self,
selector: #selector(handleInitialSchedule(_:)),
name: Notification.Name("ScheduleBackgroundTasks"),
object: nil
)
}
func updateContent() {
self.logManager.debugMessage("In BackgroundService updateContent")
let complicationsUpdateRequest = self.requestFactory.makeComplicationsUpdateRequest()
let config = URLSessionConfiguration.background(withIdentifier: "app.wakawatch.background-refresh")
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
self.backgroundSession = URLSession(configuration: config,
delegate: self,
delegateQueue: nil)
let backgroundTask = self.backgroundSession?.downloadTask(with: complicationsUpdateRequest)
backgroundTask?.resume()
self.isStarted = true
self.logManager.debugMessage("backgroundTask started")
}
func handleDownload(_ backgroundTask: WKURLSessionRefreshBackgroundTask) {
self.logManager.debugMessage("Handling finished download")
self.pendingBackgroundTask = backgroundTask
}
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
processFile(file: location)
self.logManager.debugMessage("Marking pending background tasks as completed.")
if self.pendingBackgroundTask != nil {
self.pendingBackgroundTask?.setTaskCompletedWithSnapshot(false)
self.backgroundSession?.invalidateAndCancel()
self.pendingBackgroundTask = nil
self.backgroundSession = nil
self.logManager.debugMessage("Pending background task cleared")
}
self.schedule()
}
func processFile(file: URL) {
guard let data = try? Data(contentsOf: file) else {
self.logManager.errorMessage("file could not be read as data")
return
}
guard let backgroundUpdateResponse = try? JSONDecoder().decode(BackgroundUpdateResponse.self, from: data) else {
self.logManager.errorMessage("Unable to decode response to Swift object")
return
}
let defaults = UserDefaults.standard
defaults.set(backgroundUpdateResponse.totalTimeCodedInSeconds,
forKey: DefaultsKeys.complicationCurrentTimeCoded)
self.complicationService.updateTimelines()
self.notificationService.isPermissionGranted(onGrantedHandler: {
self.notificationService.notifyGoalsAchieved(newGoals: backgroundUpdateResponse.goals)
})
self.logManager.debugMessage("Complication updated")
}
func schedule() {
let time = self.isStarted ? 15 * 60 : 60
let nextInterval = TimeInterval(time)
let preferredDate = Date.now.addingTimeInterval(nextInterval)
WKExtension.shared().scheduleBackgroundRefresh(withPreferredDate: preferredDate,
userInfo: nil) { error in
if error != nil {
self.logManager.reportError(error!)
return
}
self.logManager.debugMessage("Scheduled for \(preferredDate)")
}
}
#objc func handleInitialSchedule(_ notification: NSNotification) {
if !self.isStarted {
self.schedule()
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
The flow for the above file's usage is that it will be used by the ExtensionDelegate to schedule background refresh. The first time, it'll schedule a refresh for 1 minute out and then every 15 minutes after that.
Here is the ExtensionDelegate:
import Foundation
import WatchKit
class ExtensionDelegate: NSObject, WKExtensionDelegate {
private var backgroundService: BackgroundService?
private var logManager: LogManager?
override init() {
super.init()
self.backgroundService = DependencyInjection.shared.container.resolve(BackgroundService.self)!
self.logManager = DependencyInjection.shared.container.resolve(LogManager.self)!
}
func isAuthorized() -> Bool {
let defaults = UserDefaults.standard
return defaults.bool(forKey: DefaultsKeys.authorized)
}
func applicationDidFinishLaunching() {
self.logManager?.debugMessage("In applicationDidFinishLaunching")
if isAuthorized() && !(self.backgroundService?.isStarted ?? false) {
self.backgroundService?.schedule()
}
}
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
self.logManager?.debugMessage("In handle backgroundTasks")
if !isAuthorized() {
return
}
for task in backgroundTasks {
self.logManager?.debugMessage("Processing task: \(task.debugDescription)")
switch task {
case let backgroundTask as WKApplicationRefreshBackgroundTask:
self.backgroundService?.updateContent()
backgroundTask.setTaskCompletedWithSnapshot(false)
case let urlSessionTask as WKURLSessionRefreshBackgroundTask:
self.backgroundService?.handleDownload(urlSessionTask)
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}
}
When the app is launched from background, it'll try to schedule for the first time in applicationDidFinishLaunching.
From my understanding, WKExtension.shared().scheduleBackgroundRefresh will get called in schedule, then after the preferred time WatchOS will call handle with WKApplicationRefreshBackgroundTask task. Then I will use that to schedule a background URL session task and immediately start it as seen in the updateContent method of BackgroundService. After some time, WatchOS will then call ExtensionDelegate's handle method with WKURLSessionRefreshBackgroundTask and I'll handle that using the handleDownload task. In there, I process the response, update the complications, clear the task, and finally schedule a new background refresh.
I've found it works great if I'm actively working on the app or interacting with it in general. But let's say I go to sleep then the next day the complication will not have updated at all.
Ideally, I'd like for it to function as well as the Weather app complication WatchOS has. I don't interact with the complication, but it reliably updates.
Is the above process correct or are there any samples of correct implementations?
Some of the posts I've consulted:
https://wjwickham.com/posts/refreshing-data-in-the-background-on-watchOS/
https://developer.apple.com/documentation/watchkit/background_execution/using_background_tasks
https://spin.atomicobject.com/2021/01/26/complications-basic-functionality/
I have the problem, that my delegate-class is never reinitialised if I pass it as delegate to NSURLSession:
// Playground-compatible
import Foundation
class Downloader: NSObject, URLSessionDataDelegate {
private var session: URLSession! = nil
private var dataTask: URLSessionDataTask! = nil
init(url: URL) {
super.init()
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData, timeoutInterval: 60)
self.session = URLSession(configuration: URLSessionConfiguration.default, delegate: self, delegateQueue: OperationQueue.main)
self.dataTask = session.dataTask(with: request)
}
deinit {
print("Downloader released")
}
func dummy() -> String {
self.dataTask = nil // I've also tried it without this
self.session = nil // I've also tried it without this
return "Dummy 👨🏻🎤"
}
}
func test() {
let downloader = Downloader(url: URL(fileURLWithPath: "/"))
print(downloader.dummy())
}
test()
print("After test")
If I pass nil instead of self as delegate, Downloader is deinitialized; but obviously this is not a solution^^
Please read the documentation for URLSession init(configuration:delegate:delegateQueue:) and the description for the delegate parameter:
Important
The session object keeps a strong reference to the delegate until your app exits or explicitly invalidates the session. If you do not invalidate the session by calling the invalidateAndCancel() or finishTasksAndInvalidate() method, your app leaks memory until it exits.
You need to call one of those two methods on self.session when your Downloader is finished with the session.
I have an app that needs to download a file which may be rather large (perhaps as large as 20 MB). I've been reading up on URLSession downloadTasks and how they work when the app goes to the background or is terminated by iOS. I'd like for the download to continue and from what I've read, that's possible. I found a blog post here that discusses this topic in some detail.
Based on what I've read, I first created a download manager class that looks like this:
class DownloadManager : NSObject, URLSessionDownloadDelegate, URLSessionTaskDelegate {
static var shared = DownloadManager()
var backgroundSessionCompletionHandler: (() -> Void)?
var session : URLSession {
get {
let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background")
return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
}
}
private override init() {
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
DispatchQueue.main.async {
if let completionHandler = self.backgroundSessionCompletionHandler {
self.backgroundSessionCompletionHandler = nil
completionHandler()
}
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
if let sessionId = session.configuration.identifier {
log.info("Download task finished for session ID: \(sessionId), task ID: \(downloadTask.taskIdentifier); file was downloaded to \(location)")
do {
// just for testing purposes
try FileManager.default.removeItem(at: location)
print("Deleted downloaded file from \(location)")
} catch {
print(error)
}
}
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
if totalBytesExpectedToWrite > 0 {
let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite)
let progressPercentage = progress * 100
print("Download with task identifier: \(downloadTask.taskIdentifier) is \(progressPercentage)% complete...")
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error = error {
print("Task failed with error: \(error)")
} else {
print("Task completed successfully.")
}
}
}
I also add this method in my AppDelegate:
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: #escaping () -> Void) {
DownloadManager.shared.backgroundSessionCompletionHandler = completionHandler
// if the app gets terminated, I need to reconstruct the URLSessionConfiguration and the URLSession in order to "re-connect" to the previous URLSession instance and process the completed download tasks
// for now, I'm just putting the app in the background (not terminating it) so I've commented out the lines below
//let config = URLSessionConfiguration.background(withIdentifier: identifier)
//let session = URLSession(configuration: config, delegate: DownloadManager.shared, delegateQueue: OperationQueue.main)
// since my app hasn't been terminated, my existing URLSession should still be around and doesn't need to be re-created
let session = DownloadManager.shared.session
session.getTasksWithCompletionHandler { (dataTasks, uploadTasks, downloadTasks) -> Void in
// downloadTasks = [URLSessionDownloadTask]
print("There are \(downloadTasks.count) download tasks associated with this session.")
for downloadTask in downloadTasks {
print("downloadTask.taskIdentifier = \(downloadTask.taskIdentifier)")
}
}
}
Finally, I start my test download like this:
let session = DownloadManager.shared.session
// this is a 100MB PDF file that I'm using for testing
let testUrl = URL(string: "https://scholar.princeton.edu/sites/default/files/oversize_pdf_test_0.pdf")!
let task = session.downloadTask(with: testUrl)
// I think I'll ultimately need to persist the session ID, task ID and a file path for use in the delegate methods once the download has completed
task.resume()
When I run this code and start my download, I see the delegate methods being called but I also see a message that says:
A background URLSession with identifier com.example.testapp.background already exists!
I think this is happening because of the following call in application:handleEventsForBackgroundURLSession:completionHandler:
let session = DownloadManager.shared.session
The getter for the session property in my DownloadManager class (which I took directly from the blog post cited previously) is always trying to create a new URLSession using the background configuration. As I understand it, if my app had been terminated, then this would be the appropriate behavior to "reconnect" to the original URLSession. But since may app is not being terminated but rather just going to the background, when the call to application:handleEventsForBackgroundURLSession:completionHandler: happens, I should be referencing the existing instance of URLSession. At least I think that's what the problem is. Can anyone clarify this behavior for me? Thanks!
Your problem is that you are creating a new session every time you reference the session variable:
var session : URLSession {
get {
let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background")
return URLSession(configuration: config, delegate: self, delegateQueue: OperationQueue())
}
}
Instead, keep the session as an instance variable, and just get it:
class DownloadManager:NSObject {
static var shared = DownloadManager()
var delegate = DownloadManagerSessionDelegate()
var session:URLSession
let config = URLSessionConfiguration.background(withIdentifier: "\(Bundle.main.bundleIdentifier!).background")
override init() {
session = URLSession(configuration: config, delegate: delegate, delegateQueue: OperationQueue())
super.init()
}
}
class DownloadManagerSessionDelegate: NSObject, URLSessionDelegate {
// implement here
}
When I do this in a playground, it shows that repeated calls give the same session, and no error:
The session doesn't live in-process, it's part of the OS. You're incrementing reference count every time you access your session variable as written, which causes the error.
I want to build an analytics class for my application, and i am using singleton.
If I run this, the tagEvent function immediately runs rather than first running the openSession(), so sessionId returns nil.
How can I create a class like this with proper initialisation and use it application wide like singleton instances.
Analytics.swift
final class Analytics {
public static let instance = Analytics()
private var baseUrl = "http://localhost"
public var deviceId: String?
private init(){
self.deviceId = SomeFunctionGetsDeviceID()
}
func openSession(){
// make an API call to create a session and save sessionId to UserDefaults
if let url = URL(string: self.baseUrl + "/session"){
let params:[String:Any] = ["deviceId": "\(self.deviceId!)"]
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField:"Content-Type")
request.httpMethod = "POST"
request.httpBody = try! JSONSerialization.data(withJSONObject: params, options: [])
AnalyticsSessionManager.sharedManager.request(request as URLRequestConvertible).validate().responseObject(completionHandler: { (response: DataResponse<SessionOpenResponse>) in
if response.result.value != nil {
UserDefaults.standard.set(response.result.value?.sessionId, forKey: "sessionId")
}
})
}
}
func closeSession(){
// make an API call to close a session and delete sessionId from UserDefaults
...
}
func tagEvent(eventName: String, attributes: [String : String]? = nil) {
if let url = URL(string: self.baseUrl + "/event"),
let sessionId = UserDefaults.standard.string(forKey: "sessionId"){
...
// make an API call to create an event with that sessionId
}
}
}
AppDelegate.swift
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
Analytics.instance.openSession()
Analytics.instance.tagEvent(eventName: "App Launch", attributes:
["userID":"1234"])
}
My best guess is the openSession function is doing work asynchronously and the tagEvent call comes in BEFORE the asynchronous code has completed. There are a couple of ways around this:
1) Add synchronization so the tagEvent code will wait for the openSession call to complete (if in progress). If not in progress, perhaps it should automatically call openSession, wait for completion, then execute the code in that function
2) Add a completion handler from openSession and inside that enclosure you can call tagEvent such as:
func openSession(completionHandler: #escapaing (Bool) -> ()){
// make an API call to create a session and save sessionId to UserDefaults
...
UserDefaults.standard.set(someSessionID, forKey: "sessionId")
// when done with async work
completionHandler(true)
}
Then in your app delegate:
Analytics.instance.openSession() { (success)
Analytics.instance.tagEvent(eventName: "App Launch", attributes:["userID":"1234"])
}
3) * This is the way I would fix it * I would not make a call to openSession to be required outside of the class. I would add a flag to the Analytics class:
private var initialized = false
In the openSession function, set this after everything is done
initialized = true
In the tagEvent function:
func tagEvent(eventName: String, attributes: [String : String]? = nil) {
// Check for initialization
if (!initialized) {
openSession() { (success) in
// perform your tagEvent code
})
} else {
if let url = URL(string: self.baseUrl + "/event"),
let sessionId = UserDefaults.standard.string(forKey: "sessionId"){
...
// make an API call to create an event with that sessionId
}
}
}
You can implement an "internal" class to provide a static reference to your Analytics class, as follows (note: remove final keyword):
class Analytics {
// properties ...
class var sharedInstance: Analytics {
struct Static {
static let instance: Analytics = Analytics()
}
return Static.instance
}
// other methods (init(), openSession(), closeSession(), tagEvent())
}
Next call methods from your Analytics class in any other class, as follows:
Analytics.sharedInstance.openSession()
Analytics.sharedInstance.closeSession()
Analytics.sharedInstance.tagEvent( ... )