Files
baRSS/baRSS/FeedDownload.m

340 lines
13 KiB
Objective-C

//
// The MIT License (MIT)
// Copyright (c) 2018 Oleg Geier
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
// of the Software, and to permit persons to whom the Software is furnished to do
// so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
#import "FeedDownload.h"
#import "Constants.h"
#import "StoreCoordinator.h"
#import "Feed+Ext.h"
#import "FeedMeta+Ext.h"
#import "FeedGroup+Ext.h"
#import <SystemConfiguration/SystemConfiguration.h>
static SCNetworkReachabilityRef _reachability = NULL;
static BOOL _isReachable = NO;
static BOOL _updatePaused = NO;
static BOOL _nextUpdateIsForced = NO;
@implementation FeedDownload
#pragma mark - User Interaction -
/// @return @c YES if current network state is reachable and updates are not paused by user.
+ (BOOL)allowNetworkConnection {
return (_isReachable && !_updatePaused);
}
/// @return @c YES if update is paused by user.
+ (BOOL)isPaused {
return _updatePaused;
}
/// Set paused flag and cancel timer regardless of network connectivity.
+ (void)setPaused:(BOOL)flag {
_updatePaused = flag;
if (_updatePaused)
[self pauseUpdates];
else
[self resumeUpdates];
}
/// Cancel current timer and stop any updates until enabled again.
+ (void)pauseUpdates {
[self scheduleTimer:nil];
}
/// Start normal (non forced) schedule if network is reachable.
+ (void)resumeUpdates {
if (_isReachable)
[self scheduleUpdateForUpcomingFeeds];
}
#pragma mark - Update Feed Timer -
/**
Get date of next up feed and start the timer.
*/
+ (void)scheduleUpdateForUpcomingFeeds {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
NSDate *nextTime = [StoreCoordinator nextScheduledUpdate];
if (!nextTime) return; // no timer means no feeds to update
if ([nextTime timeIntervalSinceNow] < 1) { // mostly, if app was closed for a long time
nextTime = [NSDate dateWithTimeIntervalSinceNow:1];
}
[self scheduleTimer:nextTime];
}
/**
Start download of all feeds (immediatelly) regardless of @c .scheduled property.
*/
+ (void)forceUpdateAllFeeds {
if (![self allowNetworkConnection]) // timer will restart once connection exists
return;
_nextUpdateIsForced = YES;
[self scheduleTimer:[NSDate dateWithTimeIntervalSinceNow:0.2]];
}
/**
Set new @c .fireDate and @c .tolerance for update timer.
@param nextTime If @c nil timer will be disabled with a @c .fireDate very far in the future.
*/
+ (void)scheduleTimer:(NSDate*)nextTime {
static NSTimer *timer;
if (!timer) {
timer = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:[self class] selector:@selector(updateTimerCallback) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
}
if (!nextTime)
nextTime = [NSDate dateWithTimeIntervalSinceNow:NSTimeIntervalSince1970];
NSTimeInterval tolerance = [nextTime timeIntervalSinceNow] * 0.15;
timer.tolerance = (tolerance < 1 ? 1 : tolerance); // at least 1 sec
timer.fireDate = nextTime;
}
/**
Called when schedule timer runs out (earliest @c .schedule date). Or if forced by user request.
*/
+ (void)updateTimerCallback {
if (![self allowNetworkConnection])
return;
NSLog(@"fired");
__block NSManagedObjectContext *childContext = [StoreCoordinator createChildContext];
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:_nextUpdateIsForced inContext:childContext];
_nextUpdateIsForced = NO;
if (list.count == 0) {
NSLog(@"ERROR: Something went wrong, timer fired too early.");
[childContext reset];
childContext = nil;
// thechnically should never happen, anyway we need to reset the timer
[self resumeUpdates];
return; // nothing to do here
}
dispatch_group_t group = dispatch_group_create();
for (Feed *feed in list) {
[self downloadFeed:feed group:group];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
[StoreCoordinator saveContext:childContext andParent:YES];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:[list valueForKeyPath:@"objectID"]];
[childContext reset];
childContext = nil;
[self resumeUpdates];
});
}
#pragma mark - Download RSS Feed -
/// @return New request with no caching policy and timeout interval of 30 seconds.
+ (NSMutableURLRequest*)newRequestURL:(NSString*)urlStr {
NSURL *url = [NSURL URLWithString:urlStr];
if (!url.scheme) {
url = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", urlStr]]; // usually will redirect to https if necessary
}
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url];
req.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
req.HTTPShouldHandleCookies = NO;
// req.timeoutInterval = 30;
// [req setValue:@"Mon, 10 Sep 2018 10:32:19 GMT" forHTTPHeaderField:@"If-Modified-Since"];
// [req setValue:@"wII2pETT9EGmlqyCHBFJpm25/7w" forHTTPHeaderField:@"If-None-Match"]; // ETag
return req;
}
/// @return New request with etag and modified headers set.
+ (NSURLRequest*)newRequest:(FeedMeta*)meta {
NSMutableURLRequest *req = [self newRequestURL:meta.url];
NSString* etag = [meta.etag stringByReplacingOccurrencesOfString:@"-gzip" withString:@""];
if (meta.modified.length > 0)
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
if (etag.length > 0)
[req setValue:etag forHTTPHeaderField:@"If-None-Match"]; // ETag
if (!_nextUpdateIsForced) // any FeedMeta-request that is not forced, is a background update
req.networkServiceType = NSURLNetworkServiceTypeBackground;
return req;
}
/**
Perform feed download request from URL alone. Not updating any @c Feed item.
*/
+ (void)newFeed:(NSString *)urlStr block:(void(^)(RSParsedFeed *feed, NSError *error, NSHTTPURLResponse *response))block {
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequestURL:urlStr] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
if (error || [httpResponse statusCode] == 304) {
block(nil, error, httpResponse);
return;
}
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:urlStr];
RSParseFeed(xml, ^(RSParsedFeed * _Nullable parsedFeed, NSError * _Nullable err) {
NSAssert(err || parsedFeed, @"Only parse error XOR parsed result can be set. Not both. Neither none.");
// TODO: Need for error?: "URL does not contain a RSS feed. Can't parse feed items."
block(parsedFeed, err, httpResponse);
});
}] resume];
}
/**
Start download request with existing @c Feed object. Reuses etag and modified headers.
@param feed @c Feed on which the update is executed.
@param group Mutex to count completion of all downloads.
*/
+ (void)downloadFeed:(Feed*)feed group:(dispatch_group_t)group {
if (![self allowNetworkConnection])
return;
dispatch_group_enter(group);
[[[NSURLSession sharedSession] dataTaskWithRequest:[self newRequest:feed.meta] completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse *header = (NSHTTPURLResponse*)response;
RSParsedFeed *parsed = nil; // can stay nil if !error and statusCode = 304
BOOL hasError = (error != nil);
if (!error && [header statusCode] != 304) { // only parse if modified
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:header.URL.absoluteString];
// should be fine to call synchronous since dataTask is already in the background (always? proof?)
parsed = RSParseFeedSync(xml, &error); // reuse error
if (error || !parsed || parsed.articles.count == 0) {
hasError = YES;
}
}
[feed.managedObjectContext performBlock:^{ // otherwise access on feed will EXC_BAD_INSTRUCTION
if (hasError) {
[feed.meta setErrorAndPostponeSchedule];
} else {
feed.meta.errorCount = 0; // reset counter
[feed.meta setEtagAndModified:header];
[feed.meta calculateAndSetScheduled];
if (parsed) [feed updateWithRSS:parsed postUnreadCountChange:YES];
// TODO: save changes for this feed only? / Partial Update
//[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:feed.objectID];
}
dispatch_group_leave(group);
}];
}] resume];
}
/**
Download feed at url and append to persistent store in root folder.
On error present user modal alert.
*/
+ (void)autoDownloadAndParseURL:(NSString*)url {
[FeedDownload newFeed:url block:^(RSParsedFeed *feed, NSError *error, NSHTTPURLResponse *response) {
if (error) {
dispatch_async(dispatch_get_main_queue(), ^{
[NSApp presentError:error];
});
} else {
[FeedDownload autoParseFeedAndAppendToRoot:feed response:response];
}
}];
}
/**
Create new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and save them to the persistent store.
Appends feed to the end of the root folder, so that the user will immediatelly see it.
Update duration is set to the default of 30 minutes.
@param rss Parsed RSS feed. If @c @c nil no feed object will be added.
@param response May be @c nil but then feed download URL will not be set.
*/
+ (void)autoParseFeedAndAppendToRoot:(nonnull RSParsedFeed*)rss response:(NSHTTPURLResponse*)response {
if (!rss || rss.articles.count == 0) return;
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
NSUInteger idx = [StoreCoordinator sortedObjectIDsForParent:nil isFeed:NO inContext:moc].count;
FeedGroup *newFeed = [FeedGroup newGroup:FEED inContext:moc];
FeedMeta *meta = newFeed.feed.meta;
[meta setURL:response.URL.absoluteString refresh:30 unit:RefreshUnitMinutes];
[meta calculateAndSetScheduled];
[newFeed setName:rss.title andRefreshString:[meta readableRefreshString]];
[meta setEtagAndModified:response];
[newFeed.feed updateWithRSS:rss postUnreadCountChange:YES];
newFeed.sortIndex = (int32_t)idx;
[newFeed.feed calculateAndSetIndexPathString];
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset];
}
#pragma mark - Network Connection & Reachability -
/// Set callback on @c self to listen for network reachability changes.
+ (void)registerNetworkChangeNotification {
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
if (_reachability != NULL) return;
_reachability = SCNetworkReachabilityCreateWithName(NULL, "1.1.1.1");
if (_reachability == NULL) return;
// If reachability information is available now, we don't get a callback later
SCNetworkConnectionFlags flags;
if (SCNetworkReachabilityGetFlags(_reachability, &flags))
networkReachabilityCallback(_reachability, flags, NULL);
if (!SCNetworkReachabilitySetCallback(_reachability, networkReachabilityCallback, NULL) ||
!SCNetworkReachabilityScheduleWithRunLoop(_reachability, [[NSRunLoop currentRunLoop] getCFRunLoop], kCFRunLoopCommonModes))
{
CFRelease(_reachability);
_reachability = NULL;
}
}
/// Remove @c self callback (network reachability changes).
+ (void)unregisterNetworkChangeNotification {
if (_reachability != NULL) {
SCNetworkReachabilitySetCallback(_reachability, nil, nil);
SCNetworkReachabilitySetDispatchQueue(_reachability, nil);
CFRelease(_reachability);
_reachability = NULL;
}
}
/// Called when network interface or reachability changes.
static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) {
if (_reachability == NULL) return;
_isReachable = [FeedDownload hasConnectivity:flags];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationNetworkStatusChanged object:@(_isReachable)];
if (_isReachable) {
NSLog(@"reachable");
[FeedDownload resumeUpdates];
} else {
NSLog(@"not reachable");
[FeedDownload pauseUpdates];
}
}
/// @return @c YES if network connection established.
+ (BOOL)hasConnectivity:(SCNetworkReachabilityFlags)flags {
if ((flags & kSCNetworkReachabilityFlagsReachable) == 0)
return NO;
if ((flags & kSCNetworkReachabilityFlagsConnectionRequired) == 0)
return YES;
if ((flags & kSCNetworkReachabilityFlagsInterventionRequired) == 0 &&
((flags & kSCNetworkReachabilityFlagsConnectionOnDemand) != 0 ||
(flags & kSCNetworkReachabilityFlagsConnectionOnTraffic) != 0))
return YES; // no-intervention AND ( on-demand OR on-traffic )
return NO;
}
@end