I've built an app for iOS 9 and WatchOS 2. The iOS app will periodically transfer image files from the iPhone to the Watch. Sometimes, these are pushed from the app, sometimes the Watch requests (pulls) them. If pulled, I make the requests asynchronous, and use the exact same iOS code to transfer images in both cases.
About half the time (maybe 2/3), the file transfer works. The other times, it appears that nothing happens. This is the same whether I'm pushing or pulling images.
On the iOS side, I use code similar to this (session activated already):
if ([WCSession isSupported]) {
WCSession *session = [WCSession defaultSession];
if (session.reachable) {
NSData *imgData = UIImagePNGRepresentation(img);
NSURL *tempFile = [[session watchDirectoryURL] URLByAppendingPathComponent: #"camera.png"];
BOOL success = [imgData writeToFile: [tempFile path] atomically: NO];
if (success) {
NSLog(#"transferFile:metadata:");
[session transferFile: tempFile metadata: nil];
} else {
NSLog(#"will not call transferFile:metadata:");
}
} else {
NSLog(#"Camera watch client not reachable.");
}
}
On the watch extension side, I have a singleton that activates the watch session and receives the file:
- (void)session:(WCSession *)session didReceiveFile:(WCSessionFile *)file {
// pass the data file to the data listener (if any)
[self.dataListener session: session didReceiveFile: file];
}
My "data listener" converts the file to a UIImage and displays it on the UI thread. However, that's probably irrelevant, as the unsuccessful operations never get that far.
During unsuccessful transfers, session:didReceiveFile: is never called. If I inspect the iOS app's log, however, I see these messages only during the operations that fail:
Dec 26 15:10:47 hostname companionappd[74893]: (Note ) WatchKit:
application (com.mycompany.MyApp.watchkitapp), install status: 2,
message: application install success
Dec 26 15:10:47 hostname
companionappd[74893]: (Note ) WatchKit: Purging
com.mycompany.MyApp.watchkitapp from installation queue, 0 apps
remaining
What is happening here? It looks like the app is trying to reinstall the Watch app (?). When this is happening, I do not see the watch app crash/close and restart. It simply does nothing. No file received.
On the iOS side, I scale down the image to about 136x170 px, so the PNG files shouldn't be too big.
Any ideas what's going wrong?
Update:
I have posted a complete, minimal project that demonstrates the problem on Github here
I am now under the impression that this is a bug in the simulators. It seems to work more reliably on the Apple Watch hardware. Not sure if it's 100% reliable, though.
Apple bug report filed (#24023088). Will update status if there is any, and leave unsolved for any potential answers that may provide workarounds.
For me, not a single transfer was working anymore. Polling transfer.progress showed isTransferring == true, but I never got beyond 0 completed units.
I ended up:
Deleting apps on watch and iPhone
Rebooting both
Reinstalling
And it works.
This is how I managed to transfer files from phone to watch:
In order for this to work, the file must be locate in appGroupFolder, and "App Groups" must be enabled from Capabilities tab, for phone and watch.
In order to get appGroup folder use following line of code:
NSURL * myFileLocationFolder = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier: #"myGroupID"]; //something like group.bundle.projName
Once you got that use this to send message and handle response from watch:
[session sendMessage:#{#"file":myFileURL.absoluteString} replyHandler:^(NSDictionary<NSString *,id> * _Nonnull replyMessage) {
//got reply
} errorHandler:^(NSError * _Nonnull error) {
//got Error
}];
Even though WCSession *session = [WCSession defaultSession]; I have noticed that sometimes session is deallocated, so you might consider using [WCSession defaultSession]; instead.
To catch this on the phone use:
- (void)session:(WCSession *)session didReceiveMessage:(NSDictionary<NSString *, id> *)message replyHandler:(void(^)(NSDictionary<NSString *, id> *replyMessage))replyHandler{
//message[#"file"] - addres to my file
//do stuff with it here
replyHandler(#{#"myResponse":#"responseData"}); //this call triggers replyHandler block on the watch
}
Now a if you didn't forget to implement WCSessionDelegate and use
if ([WCSession isSupported]) {
_session = [WCSession defaultSession];
_session.delegate = self;
[_session activateSession];
}
//here session is #property (strong, nonatomic) WCSession * session;
It all should work.
Made a broader answer, hopefully will reach out to more people.
Related
So i want to build a iOS, i am pretty new to the world of objective-c and one feature i want to implement is the ability to send a API request and do a bit of background processing while the app is not "in focus/in background". I have researched for a couple days about this BGTask API for iOS 13 and have created a projected to see if i can get "background fetch" working. I have not be able to. Im pretty sure i have everything setup correctly but i can not get background fetch functionality to trigger on my iPhone, not even once over the past couple days.
I am using a actual iOS device to test this with iOS 13.4.1
"Permitted background task scheduler identifiers" is setup properly in Info.plist
App is signed
Background processing and Background fetch is checked in Background Modes
I waited the 15 minute interval as per Apples documentation
Here is my code. All this is just a blank iOS project using objective-c. I only edited AppDelegate.m and Info.plist
AppDelegate.m
#import "AppDelegate.h"
#import <BackgroundTasks/BackgroundTasks.h>
static NSString* TaskID = #"com.myapp.task";
#interface AppDelegate ()
#end
#implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
[[BGTaskScheduler sharedScheduler] registerForTaskWithIdentifier:TaskID
usingQueue:nil
launchHandler:^(BGProcessingTask *task) {
[self handleAppRefreshTask:task];
}];
return YES;
}
#pragma mark - UISceneSession lifecycle
- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return [[UISceneConfiguration alloc] initWithName:#"Default Configuration" sessionRole:connectingSceneSession.role];
}
- (void)application:(UIApplication *)application didDiscardSceneSessions:(NSSet<UISceneSession *> *)sceneSessions {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
-(void)handleAppRefreshTask:(BGProcessingTask *)task {
//do things with task
NSLog(#"Process started!");
task.expirationHandler = ^{
NSLog(#"WARNING: expired before finish was executed.");
};
NSString *targetUrl = #"https://webhook.site/1b274a6f-016f-4edf-8e31-4ed7058eaeac";
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
[request setHTTPMethod:#"GET"];
[request setURL:[NSURL URLWithString:targetUrl]];
[[[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:
^(NSData * _Nullable data,
NSURLResponse * _Nullable response,
NSError * _Nullable error) {
NSString *myString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
NSLog(#"Data received: %#", myString);
}] resume];
task.expirationHandler = ^{
NSLog(#"WARNING: expired before finish was executed.");
};
[task setTaskCompletedWithSuccess:YES];
}
- (void)applicationDidEnterBackground:(UIApplication *)application
{
NSLog(#"Entering background");
BGProcessingTaskRequest *request = [[BGProcessingTaskRequest alloc] initWithIdentifier:TaskID];
request.requiresNetworkConnectivity = true;
request.requiresExternalPower = false;
request.earliestBeginDate = [NSDate dateWithTimeIntervalSinceNow:60];
#try {
[[BGTaskScheduler sharedScheduler] submitTaskRequest:request error:nil];
}
#catch(NSException *e){
NSLog(#" Unable to submit request");
}
}
#end
Is background fetch broken in iOS 13? Even clicking on the “Simulate background fetch" in Xcode debug menu does not work. It just closes the app and nothing happens. Can anybody help/give any advice?
A few observations:
The setTaskCompletedWithSuccess should be inside the network request’s completion handler. You don’t want to mark the task as complete until the request has had a chance to run and you’ve processed the result.
You are calling submitTaskRequest, but passing nil for the NSError reference. You have also wrapped that in an exception handler. But this API call doesn’t throw exceptions, but rather just passes back errors. But you have to supply it an error reference. E.g.
NSLog(#"Entering background");
BGProcessingTaskRequest *request = [[BGProcessingTaskRequest alloc] initWithIdentifier:TaskID];
request.requiresNetworkConnectivity = true;
request.requiresExternalPower = false;
request.earliestBeginDate = [NSDate dateWithTimeIntervalSinceNow:60];
NSError *error;
if (![[BGTaskScheduler sharedScheduler] submitTaskRequest:request error:&error]) {
NSLog(#"BGTaskScheduler failed: %#", error);
}
In your code, if it failed, you would never know.
You have placed this code in applicationDidEnterBackground. I.e., are you seeing this “Entering background” message at all? The reason I ask is that if you’ve supplied a scene delegate (common if you just created a new iOS 13 app), this method won’t be called, whereas sceneDidEnterBackground will.
You said that you tried “Simulate background fetch”. But you haven’t created a background fetch request (a BGAppRefreshTask). You created a background task (a BGProcessingTask), which is a different thing. To test background processing requests, refer to Starting and Terminating Tasks During Development.
There’s an interesting question as to how you know that the fetch request was processed. You’re just using NSLog (which presumes that you’re keeping your app attached to the Xcode debugger). I would suggest testing this without the app being attached to Xcode. There are a few options:
If you can watch your server logs for requests, that works.
I personally will often put in UserNotifications (and make sure to go into settings and turn on persistent notifications so I don’t miss them).
Another approach that I’ve done is to log these events in some table in my app’s persistent storage and then have some UI within the app to fetch this data so I can confirm what happened.
I’ll often use Unified Logging so that I can watch os_log statements issued by my device from the macOS Console even when Xcode is not running. This is very useful in logging app/scene methods. See WWDC 2016 Unified Logging and Activity Tracing
Whatever you do, for things like background processing, background app refresh, etc., I will program some mechanism so that I can check to see if the requests/tasks took place, even when not attached to Xcode. Being attached to the debugger can, in some cases, affect the app lifecycle, and I want to make sure I’ve got some way to confirm what was going on without the benefit of the console.
Likely obvious, but make sure you never “force quit” the app, as that will stop background processes from taking place.
For more information, See WWDC 2019 video Advances in App Background Execution.
So I am trying send a large video file (over 100 mb), and whenever I access the video file with dataWithContentsOfURL, the extension terminates. This works fine with smaller files.
How am I supposed to work around it?
if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie]){
[itemProvider loadItemForTypeIdentifier:(NSString *)kUTTypeMovie options:nil completionHandler:urlHandler];
}
NSItemProviderCompletionHandler urlHandler = ^(NSURL *item, NSError *error) {
if ([itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeVideo] | [itemProvider hasItemConformingToTypeIdentifier:(NSString *)kUTTypeMovie])
{
NSData *fileData = [NSData dataWithContentsOfURL:item]
// ----> fileData WORKS for small files.
// ----> for large files, extension QUITS - without any trace - and control does not proceed after this. This may be due to memory pressure?
[_shareExtensionActionsManager sendTextMessage:contentText attachmentData:fileData attachmentName:#"video-1" toChatEntity:_selectedItem completion:^(BOOL success)
{
[self.extensionContext completeRequestReturningItems:nil completionHandler:^(BOOL expired) {
exit(0);
}];
}];
}
};
From the app extension docs:
Users tend to return to the host app immediately after they finish their task in your app extension. If the task involves a potentially lengthy upload or download, you need to ensure that it can finish after your extension gets terminated.
and
After your app extension calls completeRequestReturningItems:completionHandler: to tell the host app that its request is complete, the system can terminate your extension at any time.
You will need to use NSURLSession to create a URL session that initiates a background task.
If your extension isn’t running when the background task completes, the system will launch your containing app in the background and call application:handleEventsForBackgroundURLSession:completionHandler: in your AppDelegate.
You'll also need to setup a shared container that both your extension and the containing app can access. For this, you'll want to use NSURLSessionConfiguration's sharedContainerIdentifier property to specify an identifier for the container so you can access it later.
Here's a sample from the docs that shows how you can achieve this:
NSURLSession *mySession = [self configureMySession];
NSURL *url = [NSURL URLWithString:#"http://www.example.com/LargeFile.zip"];
NSURLSessionTask *myTask = [mySession downloadTaskWithURL:url];
[myTask resume];
- (NSURLSession *) configureMySession {
if (!mySession) {
NSURLSessionConfiguration* config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:#“com.mycompany.myapp.backgroundsession”];
// To access the shared container you set up, use the sharedContainerIdentifier property on your configuration object.
config.sharedContainerIdentifier = #“com.mycompany.myappgroupidentifier”;
mySession = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];
}
return mySession;
}
Here's a related resource that might help further.
App extensions may not have the memory capacity for this task.
Memory limits for running app extensions are significantly lower than the memory limits imposed on a foreground app. On both platforms, the system may aggressively terminate extensions because users want to return to their main goal in the host app. Some extensions may have lower memory limits than others: For example, widgets must be especially efficient because users are likely to have several widgets open at the same time.
https://developer.apple.com/library/content/documentation/General/Conceptual/ExtensibilityPG/ExtensionCreation.html#//apple_ref/doc/uid/TP40014214-CH5-SW1
I have just set up WCConnectivity in my app using tutorials/sample code and I feel I have it implemented correctly. I am not using the simulator to test. For some reason, the didReceiveApplicationContext is not being called in the watch app, even though everything is set up correctly.
I've tried calling in the the Interface Controller of the WatchKit app, in the ExtensionDelegate and using NSUserDefaults to set the Interface Controller data instead.
iOS App
ViewController.m
- (void) viewDidLoad{
if ([WCSession isSupported]) {
WCSession *session = [WCSession defaultSession];
session.delegate = self;
[session activateSession];
}
}
-(void) saveTrip{
NSMutableArray *currentTrips = [NSMutableArray arrayWithArray:[self.sharedDefaults objectForKey:#"UserLocations"]];
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:newLocation];
[currentTrips addObject:data];
[self.sharedDefaults setObject:currentTrips forKey:#"UserLocations"];
[self.sharedDefaults synchronize];
WCSession *session = [WCSession defaultSession];
NSDictionary *applicationDict = [[NSDictionary alloc] initWithObjects:#[currentTrips] forKeys:#[#"UserLocations"]];;
[session updateApplicationContext:applicationDict error:nil];
}
Watch Extension Code
ExtensionDelegate.m
- (void)applicationDidFinishLaunching {
if ([WCSession isSupported]) {
WCSession *session = [WCSession defaultSession];
session.delegate = self;
[session activateSession];
}
}
- (void)session:(nonnull WCSession *)session didReceiveApplicationContext:(nonnull NSDictionary<NSString *,id> *)applicationContext {
self.places= [applicationContext objectForKey:#"UserLocations"];
[[NSUserDefaults standardUserDefaults] setObject:self.places forKey:#"UserLocations"];
[[NSUserDefaults standardUserDefaults] synchronize];
}
InterfaceController.m
- (void)willActivate {
[super willActivate];
self.placesData = [NSMutableArray arrayWithArray:[[NSUserDefaults standardUserDefaults] objectForKey:#"UserLocations"]];
[self loadData];
}
For me it was two things:
Passing invalid values in the dictionary. Can't even pass NSNull() to represent nil values. If you have data in there that can't be represented in a plist, it fails.
If the dictionary doesn't change, subsequent calls to updateApplicationContext won't trigger a corresponding call to didReceiveApplicationContext. To force an update—perhaps in debug builds—you could add a UUID to the payload, e.g.
context["force_send"] = UUID().uuidString
It might prove useful to handle any errors, so change:
[session updateApplicationContext:applicationDict error:nil];
to
NSError *error;
if (![session updateApplicationContext:applicationDict error:&error]) {
NSLog(#"updateApplicationContext failed with error %#", error);
}
If it's not working, it's probably not implemented correctly.
A few issues at first glance:
In your iOS app, you get a reference to the shared session and activate it, but don't assign that local variable to any property on your view controller. This means your local variable will go out of scope, once viewDidLoad exits. You also need to correct that in your watch extension.
In saveTrip, you again create another local session, which isn't activated and doesn't have any delegate. You need to use the first session that you set up and activated earlier.
On your watch, you save data that is received but your interface controller won't know that there is new data that it should load and display.
A few tips:
If you setup and activate the session in application:didFinishLaunchingWithOptions, it will be available app-wide (and activated much earlier in your app's lifecycle).
You should check that your session is valid, as a watch may not be paired, or the watch app may not be installed. Here's a good (Swift) tutorial that covers those cases, and also uses a session manager to make it easier to support using Watch Connectivity throughout your app.
You may want to pass less/lighter data across to the watch, than trying to deal with archiving an array of custom objects.
As an aside, what you're trying to do with NSUserDefaults is very convoluted. It's really meant to persist preferences across launches. It's not appropriate to misuse it as a way to pass your model back and forth between your extension and controller.
I had the same issue and fixed.
I forgot to add WatchConnectivity framework in watch extension.
I have big trouble with NSURLSession when i'll terminate the App.
I have downloaded the apple sample:
https://developer.apple.com/library/ios/samplecode/SimpleBackgroundTransfer/Introduction/Intro.html
on Apple reference.
When i start download the file download correctly.
When i enter in background the download continues to.
When i terminate the application and i restart the app the application enter in:
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
And i catch this error:
The operation couldn't be completed. (NSURLErrorDomain error -999.)
It seems that i cannot restore download when app has been terminated. It's correct?For proceed with download i must leave application active in background?
Thank you
Andrea
A couple of observations:
Error -999 is kCFURLErrorCancelled.
If you are using NSURLSessionDownloadTask, you can download those in the background using background session configuration, e.g.
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:kBackgroundIdentifier];
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
If not using background session (e.g. you have to use data task, for example), you can use beginBackgroundTaskWithExpirationHandler to request a little time for the app the finish requests in the background before the app terminates.
Note, when using background sessions, your app delegate must respond to handleEventsForBackgroundURLSession, capturing the completion handler that it will call when appropriate (e.g., generally in URLSessionDidFinishEventsForBackgroundURLSession).
How did you "terminate the app"? If you manually kill it (by double tapping on home button, holding down on icon for running app, and then hitting the little red "x"), that will not only terminate the app, but it will stop background sessions, too. Alternatively, if the app crashes or if it is simply jettisoned because foreground apps needed more memory, the background session will continue.
Personally, whenever I want to test background operation after app terminates, I have code in my app to crash (deference nil pointer, like Apple did in their WWDC video introduction to NSURLSession). Clearly you'd never do that in a production app, but it's hard to simulate the app being jettisoned due to memory constraints, so deliberately crashing is a fine proxy for that scenario.
i insert this new lines of code:
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
BLog();
NSInteger errorReasonNum = [[error.userInfo objectForKey:#"NSURLErrorBackgroundTaskCancelledReasonKey"] integerValue];
if([error.userInfo objectForKey:#"NSURLErrorBackgroundTaskCancelledReasonKey"] &&
(errorReasonNum == NSURLErrorCancelledReasonUserForceQuitApplication ||
errorReasonNum == NSURLErrorCancelledReasonBackgroundUpdatesDisabled))
{
NSData *resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData];
if (resumeData) {
// resume
NSURL *downloadURL = [NSURL URLWithString:DownloadURLString];
NSURLRequest *request = [NSURLRequest requestWithURL:downloadURL];
if (!self.downloadTask) {
self.downloadTask = [self.session downloadTaskWithRequest:request];
}
[self.downloadTask resume];
if (!_session){
[[_session downloadTaskWithResumeData:resumeData]resume];
}
}
}
}
It catch NSURLErrorCancelledReasonUserForceQuitApplication but when the application try to [[_session downloadTaskWithResumeData:resumeData]resume]
reenter again in:
(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error
{
and give me again -999 error.
I use this configuration
- (NSURLSession *)backgroundSession
{
/*
Using disptach_once here ensures that multiple background sessions with the same identifier are not created in this instance of the application. If you want to support multiple background sessions within a single process, you should create each session with its own identifier.
*/
static NSURLSession *session = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfiguration:#"com.example.apple-samplecode.SimpleBackgroundTransfer.BackgroundSession"];
session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
});
return session;
}
let me explain what i mean with "terminate the app" (in ios8):
double tap on home button
swipe on my open app.
app disappear from open app list
relaunch app.
When i reopen the app i enter into callback with error
The operation couldn't be completed. (NSURLErrorDomain error -999.)
There is something that i can't understand. This behaviour make me crazy! :-(
Guys I'm working on the Newsstand stuff now. I'm trying to handle network errors.
What you see on the image below is my simple log ("Percentage: %i" is inside connection:didWriteData:totalBytesWritten:expectedTotalBytes:).
My problem is depicted in the last 3 lines of code.
What I've done in this lines:
After that line I've switched on the airplane mode (simulated network error)
I've received connection:didWriteData:totalBytesWritten:expectedTotalBytes: with totalBytesWritten equal to expectedTotalBytes
I've received connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *)destinationURL.
After that:
Hooray, I've just finished downloading my .zip, I can unpack it, announce the status to my view and so on... :(
My question is what's going on?
I have implemented connection:didFailWithError: but it's not invoked.
I was trying to grab the totalBytesWritten in last invoked didWriteData: and compare it to real file size in DidFinishDownloading:
I have stripped all my project away just to make sure that its not related to my whole design.
I'm thinking about combination of NSTimer and NKIssueContentStatusAvailable to check the real download status.
It's all hacky. Isn't it?
Update:
Reproduced on iOS 6 and 7 with XCode 5
All NewsstandKit methods invoked on the main thread
Same thing when simulating offline mode with Charles proxy (app in foreground)
It's not an issue anymore when switching to Airplane, but still can reproduce the issue when throttling on Charles proxy.
I ended up with this solution (checking if connection:didWriteData:... is telling the truth in connectionDidFinishDownloading:destinationURL:):
- (void)connection:(NSURLConnection *)connection didWriteData:(long long)bytesWritten totalBytesWritten:(long long)totalBytesWritten expectedTotalBytes:(long long)expectedTotalBytes
{
...
self.declaredSizeOfDownloadedFile = expectedTotalBytes;
}
And:
- (void)connectionDidFinishDownloading:(NSURLConnection *)connection destinationURL:(NSURL *) destinationURL
{
NSDictionary* fileAttributes = [[NSFileManager defaultManager] attributesOfItemAtPath:destinationURL.absoluteString error:nil];
NSNumber* destinationFileSize = [fileAttributes objectForKey:NSFileSize];
if (destinationFileSize.intValue != self.declaredSizeOfDownloadedFile)
{
NSError* error = ...;
[self connection:connection didFailWithError:error];
self.declaredSizeOfDownloadedFile = 0;
return;
}
...
}