Refactoring Part 4: Update Timer and Pausing

This commit is contained in:
relikd
2018-12-09 19:37:45 +01:00
parent 4c1ec7c474
commit 746018be62
7 changed files with 180 additions and 128 deletions

View File

@@ -27,6 +27,7 @@
- (void)calculateAndSetScheduled;
- (void)setEtag:(NSString*)etag modified:(NSString*)modified;
- (void)setEtagAndModified:(NSHTTPURLResponse*)http;
- (BOOL)setURL:(NSString*)url refresh:(int32_t)refresh unit:(int16_t)unit;
- (NSString*)readableRefreshString;

View File

@@ -24,12 +24,16 @@
@implementation FeedMeta (Ext)
/// Increment @c errorCount (max. 19) and set new @c scheduled (2^N seconds, max. 6 days).
/// Increment @c errorCount and set new @c scheduled date (2^N minutes, max. 5.7 days).
- (void)setErrorAndPostponeSchedule {
int16_t n = self.errorCount + 1;
self.errorCount = (n < 1 ? 1 : (n > 19 ? 19 : n)); // between: 2 sec and 6 days
NSTimeInterval retryWaitTime = pow(2, self.errorCount); // 2^n seconds
if (self.errorCount < 0)
self.errorCount = 0;
int16_t n = self.errorCount + 1; // always increment errorCount (can be used to indicate bad feeds)
NSTimeInterval retryWaitTime = pow(2, (n > 13 ? 13 : n)) * 60; // 2^N (between: 2 minutes and 5.7 days)
self.errorCount = n;
self.scheduled = [NSDate dateWithTimeIntervalSinceNow:retryWaitTime];
// TODO: remove logging
NSLog(@"ERROR: Feed download failed: %@ (errorCount: %d)", self.url, n);
}
/// Calculate date from @c refreshNum and @c refreshUnit and set as next scheduled feed update.
@@ -44,6 +48,12 @@
if (![self.modified isEqualToString:modified]) self.modified = modified;
}
/// Read header field "Etag" and "Date" and set @c .etag and @c .modified.
- (void)setEtagAndModified:(NSHTTPURLResponse*)http {
NSDictionary *header = [http allHeaderFields];
[self setEtag:header[@"Etag"] modified:header[@"Date"]]; // @"Expires", @"Last-Modified"
}
/**
Set download url and refresh interval (popup button selection). @note Only values that differ will be updated.

View File

@@ -23,6 +23,10 @@
#ifndef Constants_h
#define Constants_h
// TODO: Add support for media player?
// <enclosure url="https://url.mp3" length="63274022" type="audio/mpeg" />
// TODO: Disable 'update all' menu item during update?
static NSString *kNotificationFeedUpdated = @"baRSS-notification-feed-updated";
static NSString *kNotificationNetworkStatusChanged = @"baRSS-notification-network-status-changed";
static NSString *kNotificationTotalUnreadCountChanged = @"baRSS-notification-total-unread-count-changed";

View File

@@ -24,9 +24,15 @@
#import <RSXML/RSXML.h>
@interface FeedDownload : NSObject
+ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block;
// Register for network change notifications
+ (void)registerNetworkChangeNotification;
+ (void)unregisterNetworkChangeNotification;
+ (BOOL)isNetworkReachable;
+ (void)scheduleNextUpdateForced:(BOOL)flag;
// Scheduled feed update
+ (void)newFeed:(NSString *)url block:(void(^)(RSParsedFeed *feed, NSError* error, NSHTTPURLResponse* response))block;
+ (void)scheduleUpdateForUpcomingFeeds;
+ (void)forceUpdateAllFeeds;
// User interaction
+ (BOOL)allowNetworkConnection;
+ (BOOL)isPaused;
+ (void)setPaused:(BOOL)flag;
@end

View File

@@ -30,10 +30,126 @@
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*)url {
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
@@ -76,72 +192,6 @@ static BOOL _isReachable = NO;
}] resume];
}
#pragma mark - Update existing feeds -
/**
Get date of next update schedule and start @c updateTimer.
@param forceUpdate If @c YES all feeds will be downloaded regardless of scheduled date.
*/
+ (void)scheduleNextUpdateForced:(BOOL)forceUpdate {
static NSTimer *_updateTimer;
@synchronized (_updateTimer) { // TODO: dig into analyzer warning
if (_updateTimer) {
[_updateTimer invalidate];
_updateTimer = nil;
}
}
if (!_isReachable) return; // cancel timer entirely (will be restarted once connection exists)
NSDate *nextTime = [NSDate dateWithTimeIntervalSinceNow:0.2];
if (!forceUpdate) {
nextTime = [StoreCoordinator nextScheduledUpdate];
if (!nextTime) return; // no timer means no feeds to update
if ([nextTime timeIntervalSinceNow] < 0) { // mostly, if app was closed for a long time
nextTime = [NSDate dateWithTimeIntervalSinceNow:2]; // TODO: retry in 2 sec?
}
}
NSTimeInterval tolerance = [nextTime timeIntervalSinceNow] * 0.15;
_updateTimer = [NSTimer timerWithTimeInterval:0 target:[self class] selector:@selector(scheduledUpdateTimer:) userInfo:@(forceUpdate) repeats:NO];
_updateTimer.fireDate = nextTime;
_updateTimer.tolerance = (tolerance < 1 ? 1 : tolerance); // at least 1 sec
[[NSRunLoop mainRunLoop] addTimer:_updateTimer forMode:NSRunLoopCommonModes];
}
/**
Called when schedule timer has run out (earliest scheduled date). Or if forced by user request.
@param timer @c NSTimer @c .userInfo should contain a @c BOOL value whether to force an update of all feeds @c (YES).
*/
+ (void)scheduledUpdateTimer:(NSTimer*)timer {
NSLog(@"fired");
BOOL forceAll = [timer.userInfo boolValue];
// TODO: check internet connection
// TODO: disable menu item 'update all' during update
__block NSManagedObjectContext *childContext = [StoreCoordinator createChildContext];
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:forceAll inContext:childContext];
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 scheduleNextUpdateForced:NO]; // NO, since forceAll will get ALL items and shouldn't be 0
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 scheduleNextUpdateForced:NO]; // after forced update, continue regular cycle
});
}
/**
Start download request with existing @c Feed object. Reuses etag and modified headers.
@@ -149,56 +199,41 @@ static BOOL _isReachable = NO;
@param group Mutex to count completion of all downloads.
*/
+ (void)downloadFeed:(Feed*)feed group:(dispatch_group_t)group {
if (!_isReachable) return;
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) {
[feed.managedObjectContext performBlock:^{
// core data block inside of url session block; otherwise access will EXC_BAD_INSTRUCTION
if (error) {
[feed.meta setErrorAndPostponeSchedule];
// TODO: remove logging
NSLog(@"Error loading: %@ (%d)", response.URL, feed.meta.errorCount);
} else {
feed.meta.errorCount = 0; // reset counter
[self downloadSuccessful:data forFeed:feed response:(NSHTTPURLResponse*)response];
[feed.meta setEtagAndModified:(NSHTTPURLResponse*)response];
[feed.meta calculateAndSetScheduled];
if ([(NSHTTPURLResponse*)response statusCode] != 304) { // only parse if modified
// should be fine to call synchronous since dataTask is already in the background (always? proof?)
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:feed.meta.url];
RSParsedFeed *parsed = RSParseFeedSync(xml, NULL);
if (parsed && parsed.articles.count > 0) {
[feed updateWithRSS:parsed postUnreadCountChange:YES];
feed.meta.errorCount = 0; // reset counter
} else {
[feed.meta setErrorAndPostponeSchedule]; // replaces date of 'calculateAndSetScheduled'
}
}
// TODO: save changes for this feed only?
//[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:feed.objectID];
}
dispatch_group_leave(group);
}];
}] resume];
}
/**
Parse RSS feed data and save to persistent store. If HTTP 304 (not modified) skip feed evaluation.
@param data Raw data from request.
@param feed @c Feed on which the update is executed.
@param http Download response containing the statusCode and etag / modified headers.
*/
+ (void)downloadSuccessful:(NSData*)data forFeed:(Feed*)feed response:(NSHTTPURLResponse*)http {
if ([http statusCode] != 304) {
// should be fine to call synchronous since dataTask is already in the background (always? proof?)
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:feed.meta.url];
RSParsedFeed *parsed = RSParseFeedSync(xml, NULL);
if (parsed) {
// TODO: add support for media player?
// <enclosure url="https://url.mp3" length="63274022" type="audio/mpeg" />
[feed updateWithRSS:parsed postUnreadCountChange:YES];
}
}
[feed.meta setEtag:[http allHeaderFields][@"Etag"] modified:[http allHeaderFields][@"Date"]]; // @"Expires", @"Last-Modified"
// Don't update redirected url since it happened in the background; User may not recognize url
[feed.meta calculateAndSetScheduled];
// TODO: save changes for this feed only?
// [[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:feed.objectID];
}
#pragma mark - Network Connection & Reachability -
#pragma mark - Network Connection -
/// External getter to check wheter current network state is reachable.
+ (BOOL)isNetworkReachable { return _isReachable; }
/// Set callback on @c self to listen for network reachability changes.
+ (void)registerNetworkChangeNotification {
// https://stackoverflow.com/questions/11240196/notification-when-wifi-connected-os-x
@@ -229,18 +264,16 @@ static BOOL _isReachable = NO;
/// Called when network interface or reachability changes.
static void networkReachabilityCallback(SCNetworkReachabilityRef target, SCNetworkConnectionFlags flags, void *object) {
if (_reachability == NULL)
return;
if (_reachability == NULL) return;
_isReachable = [FeedDownload hasConnectivity:flags];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationNetworkStatusChanged
object:[NSNumber numberWithBool:_isReachable]];
if (_isReachable) {
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationNetworkStatusChanged object:@(_isReachable)];
if (_isReachable) {
NSLog(@"reachable");
[FeedDownload resumeUpdates];
} else {
NSLog(@"not reachable");
[FeedDownload pauseUpdates];
}
// schedule regardless of state (if not reachable timer will be canceled)
[FeedDownload scheduleNextUpdateForced:NO];
}
/// @return @c YES if network connection established.

View File

@@ -181,7 +181,6 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
FeedGroup *fg = [children objectAtIndex:i].representedObject;
if (fg.sortIndex != (int32_t)i)
fg.sortIndex = (int32_t)i;
NSLog(@"%@ - %d", fg.name, fg.sortIndex);
[fg iterateSorted:NO overDescendantFeeds:^(Feed *feed, BOOL *cancel) {
[feed calculateAndSetIndexPathString];
}];
@@ -259,13 +258,14 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
BOOL isFeed = (fg.typ == FEED);
BOOL isSeperator = (fg.typ == SEPARATOR);
BOOL isRefreshColumn = [tableColumn.identifier isEqualToString:@"RefreshColumn"];
BOOL refreshDisabled = (!isFeed || fg.refreshStr.length == 0 || [fg.refreshStr characterAtIndex:0] == '0');
NSString *cellIdent = (isRefreshColumn ? @"cellRefresh" : (isSeperator ? @"cellSeparator" : @"cellFeed"));
// owner is nil to prohibit repeated awakeFromNib calls
NSTableCellView *cellView = [self.outlineView makeViewWithIdentifier:cellIdent owner:nil];
if (isRefreshColumn) {
cellView.textField.stringValue = (isFeed && fg.refreshStr.length > 0 ? fg.refreshStr : @"");
cellView.textField.stringValue = (refreshDisabled ? (isFeed ? @"--" : @"") : fg.refreshStr);
} else if (isSeperator) {
return cellView; // the refresh cell is already skipped with the above if condition
} else {
@@ -281,10 +281,8 @@ static NSString *dragNodeType = @"baRSS-feed-drag";
cellView.imageView.image = defaultRSSIcon;
}
}
if (isFeed) {// also for refresh column
BOOL feedDisbaled = (fg.refreshStr.length == 0 || [fg.refreshStr characterAtIndex:0] == '0');
cellView.textField.textColor = (feedDisbaled ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
}
// also for refresh column
cellView.textField.textColor = (isFeed && refreshDisabled ? [NSColor disabledControlTextColor] : [NSColor controlTextColor]);
return cellView;
}

View File

@@ -89,7 +89,7 @@
} else {
self.barItem.title = @"";
}
// BOOL hasNet = [FeedDownload isNetworkReachable];
// BOOL hasNet = [FeedDownload allowNetworkConnection];
if (self.unreadCountTotal > 0 && [UserPrefs defaultYES:@"tintMenuBarIcon"]) {
self.barItem.image = [RSSIcon templateIcon:16 tint:[NSColor rssOrange]];
} else {
@@ -281,13 +281,14 @@
Insert default menu items for the main menu only. Like 'Pause Updates', 'Update all feeds', 'Preferences' and 'Quit'.
*/
- (void)insertMainMenuHeader:(NSMenu*)menu {
NSMenuItem *item1 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Pause Updates", nil)
action:@selector(pauseUpdates:) target:self tag:TagPauseUpdates];
NSMenuItem *item1 = [NSMenuItem itemWithTitle:@"" action:@selector(pauseUpdates:) target:self tag:TagPauseUpdates];
NSMenuItem *item2 = [NSMenuItem itemWithTitle:NSLocalizedString(@"Update all feeds", nil)
action:@selector(updateAllFeeds:) target:self tag:TagUpdateFeed];
item1.title = ([FeedDownload isPaused] ?
NSLocalizedString(@"Resume Updates", nil) : NSLocalizedString(@"Pause Updates", nil));
if ([UserPrefs defaultYES:@"globalUpdateAll"] == NO)
item2.hidden = YES;
if (![FeedDownload isNetworkReachable])
if (![FeedDownload allowNetworkConnection])
item2.enabled = NO;
[menu insertItem:item1 atIndex:0];
[menu insertItem:item2 atIndex:1];
@@ -325,22 +326,21 @@
- (void)preferencesClosed:(id)sender {
[[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillCloseNotification object:self.prefWindow.window];
self.prefWindow = nil;
[FeedDownload scheduleNextUpdateForced:NO];
[FeedDownload scheduleUpdateForUpcomingFeeds];
}
/**
Called when user clicks on 'Pause Updates' in the main menu (only).
*/
- (void)pauseUpdates:(NSMenuItem*)sender {
NSLog(@"1pause");
[FeedDownload setPaused:![FeedDownload isPaused]];
}
/**
Called when user clicks on 'Update all feeds' in the main menu (only).
*/
- (void)updateAllFeeds:(NSMenuItem*)sender {
// TODO: Disable 'update all' menu item during update?
[FeedDownload scheduleNextUpdateForced:YES];
[FeedDownload forceUpdateAllFeeds];
}
/**