Refactoring Status Menu

This commit is contained in:
relikd
2019-02-10 19:39:51 +01:00
parent cd0a1a3fd7
commit f2cca57fbb
39 changed files with 1491 additions and 1254 deletions

View File

@@ -0,0 +1,57 @@
//
// 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 <Cocoa/Cocoa.h>
#import <RSXML/RSXML.h>
@class Feed;
@interface FeedDownload : NSObject
@property (class, readonly) NSDate *dateScheduled;
@property (class, readonly) BOOL allowNetworkConnection;
@property (class, readonly) BOOL isUpdating;
@property (class, setter=setPaused:) BOOL isPaused;
// Register for network change notifications
+ (void)registerNetworkChangeNotification;
+ (void)unregisterNetworkChangeNotification;
// Scheduling
+ (void)scheduleUpdateForUpcomingFeeds;
+ (void)forceUpdateAllFeeds;
// Downloading
+ (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(NSArray<RSHTMLMetadataFeedLink*> *list))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block;
+ (void)autoDownloadAndParseURL:(NSString*)urlStr;
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block;
+ (void)batchDownloadFeeds:(NSArray<Feed*> *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block;
@end
/*
Developer Tip, error logs see:
Task <..> HTTP load failed (error code: -1003 [12:8])
Task <..> finished with error - code: -1003
==> NSURLErrorCannotFindHost in #import <Foundation/NSURLError.h>
TIC TCP Conn Failed [21:0x1d417fb00]: 1:65 Err(65)
==> EHOSTUNREACH in #import <sys/errno.h>
*/

543
baRSS/Helper/FeedDownload.m Normal file
View File

@@ -0,0 +1,543 @@
//
// 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 NSTimer *_timer;
static SCNetworkReachabilityRef _reachability = NULL;
static BOOL _isReachable = NO;
static BOOL _isUpdating = NO;
static BOOL _updatePaused = NO;
static BOOL _nextUpdateIsForced = NO;
@implementation FeedDownload
#pragma mark - User Interaction -
/// @return Date when background update will fire. If updates are paused, date is @c distantFuture.
+ (NSDate *)dateScheduled { return _timer.fireDate; }
/// @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 batch update is running
+ (BOOL)isUpdating { return _isUpdating; }
/// @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 = nil, then no feeds to update
if (nextTime && [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.05]];
}
/**
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 dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_timer = [NSTimer timerWithTimeInterval:NSTimeIntervalSince1970 target:[self class] selector:@selector(updateTimerCallback) userInfo:nil repeats:YES];
[[NSRunLoop mainRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
});
if (!nextTime)
nextTime = [NSDate distantFuture];
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");
BOOL updateAll = _nextUpdateIsForced;
_nextUpdateIsForced = NO;
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
NSArray<Feed*> *list = [StoreCoordinator getListOfFeedsThatNeedUpdate:updateAll inContext:moc];
//NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early.");
[self batchDownloadFeeds:list favicons:updateAll showErrorAlert:NO finally:^{
[StoreCoordinator saveContext:moc andParent:YES]; // save parents too ...
[moc reset];
[self resumeUpdates]; // always reset the timer
}];
}
#pragma mark - Request Generator -
/// @return Base URL part. E.g., https://stackoverflow.com/a/15897956/10616114 ==> https://stackoverflow.com/
+ (NSURL*)hostURL:(NSString*)urlStr {
return [[NSURL URLWithString:@"/" relativeToURL:[self fixURL:urlStr]] absoluteURL];
}
/// Check if any scheme is set. If not, prepend 'http://'.
+ (NSURL*)fixURL:(NSString*)urlStr {
NSURL *url = [NSURL URLWithString:urlStr];
if (!url.scheme) {
url = [NSURL URLWithString:[NSString stringWithFormat:@"http://%@", urlStr]]; // usually will redirect to https if necessary
}
return url;
}
/// @return New request with no caching policy and timeout interval of 30 seconds.
+ (NSMutableURLRequest*)newRequestURL:(NSString*)urlStr {
return [NSMutableURLRequest requestWithURL:[self fixURL:urlStr]];
}
/// @return New request with etag and modified headers set (or not, if @c flag @c == @c YES ).
+ (NSURLRequest*)newRequest:(FeedMeta*)meta ignoreCache:(BOOL)flag {
NSMutableURLRequest *req = [self newRequestURL:meta.url];
if (!flag) {
if (meta.etag.length > 0)
[req setValue:meta.etag forHTTPHeaderField:@"If-None-Match"]; // ETag
else if (meta.modified.length > 0)
[req setValue:meta.modified forHTTPHeaderField:@"If-Modified-Since"];
}
if (!_nextUpdateIsForced) // any request that is not forced, is a background update
req.networkServiceType = NSURLNetworkServiceTypeBackground;
return req;
}
+ (NSURLSession*)nonCachingSession {
static NSURLSession *session = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSURLSessionConfiguration *conf = [NSURLSessionConfiguration defaultSessionConfiguration];
conf.HTTPCookieAcceptPolicy = NSHTTPCookieAcceptPolicyNever;
conf.HTTPShouldSetCookies = NO;
conf.HTTPCookieStorage = nil; // disables '~/Library/Cookies/'
conf.requestCachePolicy = NSURLRequestReloadIgnoringLocalCacheData;
conf.URLCache = nil; // disables '~/Library/Caches/de.relikd.baRSS/'
conf.HTTPAdditionalHeaders = @{ @"User-Agent": @"baRSS (macOS)",
@"Accept-Encoding": @"gzip" };
session = [NSURLSession sessionWithConfiguration:conf];
});
return session; // [NSURLSession sharedSession];
}
/// Helper method to start new @c NSURLSession. If @c (http.statusCode==304) then set @c data @c = @c nil.
+ (void)asyncRequest:(NSURLRequest*)request block:(nonnull void(^)(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response))block {
[[[self nonCachingSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSHTTPURLResponse* httpResponse = (NSHTTPURLResponse*)response;
if (error || [httpResponse statusCode] == 304)
data = nil;
block(data, error, httpResponse); // if status == 304, data & error nil
}] resume];
}
#pragma mark - Download RSS Feed -
/**
Start download session of RSS or Atom feed, parse feed and return result on the main thread.
@param xmlBlock Called immediately after @c RSXMLData is initialized. E.g., to use this data as HTML parser.
Return @c YES to to exit without calling @c feedBlock.
If @c NO and @c err @c != @c nil skip feed parsing and call @c feedBlock(nil,err,response).
@param feedBlock Called when parsing finished or an @c NSURL error occured.
If content did not change (status code 304) both, error and result will be @c nil.
Will be called on main thread.
*/
+ (void)parseFeedRequest:(NSURLRequest*)request xmlBlock:(nullable BOOL(^)(RSXMLData *xml, NSError **err))xmlBlock feedBlock:(nonnull void(^)(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response))feedBlock {
[self asyncRequest:request block:^(NSData * _Nullable data, NSError * _Nullable error, NSHTTPURLResponse *response) {
RSParsedFeed *result = nil;
if (data) { // data = nil if (error || 304)
RSXMLData *xml = [[RSXMLData alloc] initWithData:data urlString:response.URL.absoluteString];
if (xmlBlock && xmlBlock(xml, &error)) {
return;
}
if (!error) { // metaBlock may set error
RSFeedParser *parser = [RSFeedParser parserWithXMLData:xml];
result = [parser parseSync:&error];
}
}
dispatch_async(dispatch_get_main_queue(), ^{
feedBlock(result, error, response);
});
}];
}
/**
Perform feed download request from URL alone. Not updating any @c Feed item.
@note @c askUser will not be called if url is XML already.
@param urlStr XML URL or HTTP URL that will be parsed to find feed URLs.
@param askUser Use @c list to present user a list of detected feed URLs.
@param block Called after webpage has been fully parsed (including html autodetect).
*/
+ (void)newFeed:(NSString *)urlStr askUser:(nonnull NSString*(^)(NSArray<RSHTMLMetadataFeedLink*> *list))askUser block:(nonnull void(^)(RSParsedFeed *parsed, NSError *error, NSHTTPURLResponse *response))block {
[self parseFeedRequest:[self newRequestURL:urlStr] xmlBlock:^BOOL(RSXMLData *xml, NSError **err) {
if (![xml.parserClass isHTMLParser])
return NO;
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *parsedMeta = [parser parseSync:err];
if (*err)
return NO;
if (!parsedMeta || parsedMeta.feedLinks.count == 0) {
*err = RSXMLMakeErrorWrongParser(RSXMLErrorExpectingFeed, RSXMLErrorExpectingHTML);
return NO;
}
__block NSString *chosenURL = nil;
dispatch_sync(dispatch_get_main_queue(), ^{ // sync! (thread is already in background)
chosenURL = askUser(parsedMeta.feedLinks);
});
if (!chosenURL || chosenURL.length == 0)
return NO;
[self parseFeedRequest:[self newRequestURL:chosenURL] xmlBlock:nil feedBlock:block];
return YES;
} feedBlock:block];
}
/**
Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0).
@note Will post a @c kNotificationFeedUpdated notification if download was successful and @b not status code 304.
@param alert If @c YES display Error Popup to user.
@param block Parameter @c success is only @c YES if download was successful or if status code is 304 (not modified).
*/
+ (void)backgroundUpdateFeed:(Feed*)feed showErrorAlert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
if (![self allowNetworkConnection]) {
if (block) block(NO);
return;
}
NSManagedObjectID *oid = feed.objectID;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSURLRequest *req = [self newRequest:feed.meta ignoreCache:(feed.articles.count == 0)];
NSString *reqURL = req.URL.absoluteString;
[self parseFeedRequest:req xmlBlock:nil feedBlock:^(RSParsedFeed *rss, NSError *error, NSHTTPURLResponse *response) {
Feed *f = [moc objectWithID:oid];
BOOL success = NO;
BOOL needsNotification = NO;
if (error) {
if (alert) {
NSAlert *alertPopup = [NSAlert alertWithError:error];
alertPopup.informativeText = [NSString stringWithFormat:@"Error loading source: %@", reqURL];
[alertPopup runModal];
}
[f.meta setErrorAndPostponeSchedule];
} else {
success = YES;
[f.meta setSucessfulWithResponse:response];
if (rss && rss.articles.count > 0) {
[f updateWithRSS:rss postUnreadCountChange:YES];
needsNotification = YES;
}
}
[StoreCoordinator saveContext:moc andParent:NO];
if (needsNotification)
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedUpdated object:oid];
if (block) block(success);
}];
}
/**
Download feed at url and append to persistent store in root folder.
On error present user modal alert.
Creates new @c FeedGroup, @c Feed, @c FeedMeta and @c FeedArticle instances and saves them to the persistent store.
Update duration is set to the default of 30 minutes.
*/
+ (void)autoDownloadAndParseURL:(NSString*)url {
NSManagedObjectContext *moc = [StoreCoordinator createChildContext];
Feed *f = [Feed appendToRootWithDefaultIntervalInContext:moc];
f.meta.url = url;
[self backgroundUpdateBoth:f favicon:YES alert:YES finally:^(BOOL successful){
if (!successful) {
[moc deleteObject:f.group];
}
[StoreCoordinator saveContext:moc andParent:YES];
[moc reset];
}];
}
/**
Start download of feed xml, then continue with favicon download (optional).
@param fav If @c YES continue with favicon download after xml download finished.
@param alert If @c YES display Error Popup to user.
@param block Parameter @c success is @c YES if xml download succeeded (regardless of favicon result).
*/
+ (void)backgroundUpdateBoth:(Feed*)feed favicon:(BOOL)fav alert:(BOOL)alert finally:(nullable void(^)(BOOL success))block {
[self backgroundUpdateFeed:feed showErrorAlert:alert finally:^(BOOL success) {
if (fav && success) {
[self backgroundUpdateFavicon:feed replaceExisting:NO finally:^{
if (block) block(YES);
}];
} else {
if (block) block(success);
}
}];
}
/**
Start download of all feeds in list. Either with or without favicons.
@param list Download list using @c feed.meta.url as download url. (while reusing etag and modified headers)
@param fav If @c YES continue with favicon download after xml download finished.
@param alert If @c YES display Error Popup to user.
@param block Called after all downloads finished.
*/
+ (void)batchDownloadFeeds:(NSArray<Feed*> *)list favicons:(BOOL)fav showErrorAlert:(BOOL)alert finally:(nullable os_block_t)block {
_isUpdating = YES;
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationBackgroundUpdateInProgress object:@(list.count)];
dispatch_group_t group = dispatch_group_create();
for (Feed *f in list) {
dispatch_group_enter(group);
[self backgroundUpdateBoth:f favicon:fav alert:alert finally:^(BOOL success){
dispatch_group_leave(group);
}];
}
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
if (block) block();
_isUpdating = NO;
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationBackgroundUpdateInProgress object:@(0)];
});
}
#pragma mark - Download Favicon -
/**
Start favicon download request on existing @c Feed object.
@note Will post a @c kNotificationFeedIconUpdated notification if icon was updated.
@param overwrite If @c YES and icon is present already, @c block will return immediatelly.
*/
+ (void)backgroundUpdateFavicon:(Feed*)feed replaceExisting:(BOOL)overwrite finally:(nullable os_block_t)block {
if (!overwrite && feed.icon != nil) {
if (block) block();
return; // skip existing icons if replace == NO
}
NSManagedObjectID *oid = feed.objectID;
NSManagedObjectContext *moc = feed.managedObjectContext;
NSString *faviconURL = (feed.link.length > 0 ? feed.link : feed.meta.url);
[self downloadFavicon:faviconURL finished:^(NSImage *img) {
Feed *f = [moc objectWithID:oid];
if (f && [f setIconImage:img]) {
[StoreCoordinator saveContext:moc andParent:NO];
[[NSNotificationCenter defaultCenter] postNotificationName:kNotificationFeedIconUpdated object:oid];
}
if (block) block();
}];
}
/// Download favicon located at http://.../ @c favicon.ico. Callback @c block will be called on main thread.
+ (void)downloadFavicon:(NSString*)urlStr finished:(void(^)(NSImage * _Nullable img))block {
NSURL *host = [self hostURL:urlStr];
NSString *hostURL = host.absoluteString;
NSString *favURL = [host URLByAppendingPathComponent:@"favicon.ico"].absoluteString;
[self downloadImage:favURL finished:^(NSImage * _Nullable img) {
if (img) {
block(img); // is on main already (from downloadImage:)
} else {
[self downloadFaviconByParsingHTML:hostURL finished:block];
}
}];
}
/// Download html page and parse all icon urls. Starting a successive request on the url of the smallest icon.
+ (void)downloadFaviconByParsingHTML:(NSString*)hostURL finished:(void(^)(NSImage * _Nullable img))block {
[self asyncRequest:[self newRequestURL:hostURL] block:^(NSData * _Nullable htmlData, NSError * _Nullable error, NSHTTPURLResponse *response) {
if (htmlData) {
// TODO: use session delegate to stop downloading after <head>
RSXMLData *xml = [[RSXMLData alloc] initWithData:htmlData urlString:hostURL];
RSHTMLMetadataParser *parser = [RSHTMLMetadataParser parserWithXMLData:xml];
RSHTMLMetadata *meta = [parser parseSync:&error];
if (error) meta = nil;
NSString *iconURL = [self faviconUrlForMetadata:meta];
if (iconURL) {
// if everything went well we can finally start a request on the url we found.
[self downloadImage:iconURL finished:block];
return;
}
}
dispatch_async(dispatch_get_main_queue(), ^{ block(nil); }); // on failure
}];
}
/// Extract favicon URL from parsed HTML metadata.
+ (NSString*)faviconUrlForMetadata:(RSHTMLMetadata*)meta {
if (meta) {
if (meta.faviconLink.length > 0) {
return meta.faviconLink;
}
else if (meta.iconLinks.count > 0) {
// at least any url (even if all items in list have size 0)
NSString *iconURL = meta.iconLinks.firstObject.link;
// we dont need much, lets find the smallest icon ...
int smallest = 9001;
for (RSHTMLMetadataIconLink *icon in meta.iconLinks) {
int size = (int)[icon getSize].width;
if (size > 0 && size < smallest) {
smallest = size;
iconURL = icon.link;
}
}
if (iconURL && iconURL.length > 0)
return iconURL;
}
}
return nil;
}
/// Download image in a background thread and notify once finished.
+ (void)downloadImage:(NSString*)url finished:(void(^)(NSImage * _Nullable img))block {
[self asyncRequest:[self newRequestURL:url] block:^(NSData * _Nullable data, NSError * _Nullable e, NSHTTPURLResponse *r) {
NSImage *img = [[NSImage alloc] initWithData:data];
if (!img || ![img isValid])
img = nil;
// if (img.size.width > 16 || img.size.height > 16) {
// NSImage *smallImage = [NSImage imageWithSize:NSMakeSize(16, 16) flipped:NO drawingHandler:^BOOL(NSRect dstRect) {
// [img drawInRect:dstRect];
// return YES;
// }];
// if (img.TIFFRepresentation.length > smallImage.TIFFRepresentation.length)
// img = smallImage;
// }
dispatch_async(dispatch_get_main_queue(), ^{ block(img); });
}];
}
#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) {
[FeedDownload resumeUpdates];
} else {
[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

View File

@@ -1,5 +1,25 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 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 <Foundation/Foundation.h>
#import <Cocoa/Cocoa.h>
typedef int32_t Interval;
@@ -14,11 +34,12 @@ typedef NS_ENUM(int32_t, TimeUnitType) {
@interface NSDate (Ext)
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag;
+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag;
@end
@interface NSDate (RefreshControlsUI)
+ (Interval)intervalForPopup:(NSPopUpButton*)unit andField:(NSTextField*)value;
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field;
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag;
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit;
@end

View File

@@ -1,6 +1,29 @@
//
// The MIT License (MIT)
// Copyright (c) 2019 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 "NSDate+Ext.h"
#import <QuartzCore/QuartzCore.h>
static const char _shortnames[] = {'y','w','d','h','m','s'};
static const char *_names[] = {"Years", "Weeks", "Days", "Hours", "Minutes", "Seconds"};
static const TimeUnitType _values[] = {
@@ -15,6 +38,7 @@ static const TimeUnitType _values[] = {
@implementation NSDate (Ext)
/// If @c flag @c = @c YES, print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h.
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag {
if (flag) {
unsigned short i = [self floatUnitIndexForInterval:abs(intv)];
@@ -80,10 +104,13 @@ static const TimeUnitType _values[] = {
}
/// Configure both @c NSControl elements based on the provided interval @c intv.
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field {
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag {
TimeUnitType unit = [self unitForInterval:intv rounded:NO];
int num = (int)(intv / unit);
if (flag && popup.selectedTag != unit) [self animateControlSize:popup];
if (flag && field.intValue != num) [self animateControlSize:field];
[popup selectItemWithTag:unit];
field.intValue = (int)(intv / unit);
field.intValue = num;
}
/// Insert all @c TimeUnitType items into popup button. Save unit value into @c tag attribute.
@@ -98,4 +125,17 @@ static const TimeUnitType _values[] = {
[popup selectItemWithTag:unit];
}
/// Helper method to animate @c NSControl to draw user attention. View will be scalled up in a fraction of a second.
+ (void)animateControlSize:(NSView*)control {
CABasicAnimation *scale = [CABasicAnimation animationWithKeyPath:@"transform"];
CATransform3D tr = CATransform3DIdentity;
tr = CATransform3DTranslate(tr, NSMidX(control.bounds), NSMidY(control.bounds), 0);
tr = CATransform3DScale(tr, 1.1, 1.1, 1);
tr = CATransform3DTranslate(tr, -NSMidX(control.bounds), -NSMidY(control.bounds), 0);
scale.toValue = [NSValue valueWithCATransform3D:tr];
scale.duration = 0.15f;
scale.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseIn];
[control.layer addAnimation:scale forKey:scale.keyPath];
}
@end

View File

@@ -21,6 +21,7 @@
// SOFTWARE.
#import "Statistics.h"
#import "NSDate+Ext.h"
@implementation Statistics
@@ -51,56 +52,24 @@
if (differences.count == 0)
return nil;
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"intValue" ascending:YES]]];
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]];
NSUInteger i = differences.count;
NSUInteger mid = (i/2);
unsigned int med = differences[mid].unsignedIntValue;
if (i > 1 && (i % 1) == 0) { // even feed count, use median of two values
med = (med + differences[mid+1].unsignedIntValue) / 2;
NSUInteger i = (differences.count/2);
NSNumber *median = differences[i];
if ((differences.count % 2) == 0) { // even feed count, use median of two values
median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2];
}
return @{@"min" : [self stringForInterval:differences.firstObject.unsignedIntValue],
@"max" : [self stringForInterval:differences.lastObject.unsignedIntValue],
@"avg" : [self stringForInterval:[(NSNumber*)[differences valueForKeyPath:@"@avg.self"] unsignedIntValue]],
@"median" : [self stringForInterval:med],
return @{@"min" : differences.firstObject,
@"max" : differences.lastObject,
@"avg" : [differences valueForKeyPath:@"@avg.self"],
@"median" : median,
@"earliest" : earliest,
@"latest" : latest };
}
/// Print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h
+ (NSString*)stringForInterval:(unsigned int)val {
float i;
NSUInteger u = [self findAppropriateTimeUnit:val interval:&i];
return [NSString stringWithFormat:@"%1.1f%c", i, [@"smhdw" characterAtIndex:u]];
}
/// @return Unit as int @c (0-4) (0: seconds - 4: weeks). Sets division result @c intv.
+ (NSUInteger)findAppropriateTimeUnit:(unsigned int)val interval:(float*)intv {
if (val > 604800) {*intv = (val / 604800.f); return 4;} // weeks
if (val > 86400) {*intv = (val / 86400.f); return 3;} // days
if (val > 3600) {*intv = (val / 3600.f); return 2;} // hours
if (val > 60) {*intv = (val / 60.f); return 1;} // minutes
*intv = (val / 1.f);
return 0;
}
/// @return Single integer value that combines refresh interval and refresh unit. To be used as @c NSButton.tag
+ (NSInteger)buttonTagFromRefreshString:(NSString*)str {
NSInteger refresh = (NSInteger)roundf([str floatValue]) << 3;
switch ([str characterAtIndex:(str.length - 1)]) {
case 's': return 0 | refresh;
case 'm': return 1 | refresh;
case 'h': return 2 | refresh;
case 'd': return 3 | refresh;
case 'w': return 4 | refresh;
}
return 0; // error, should never happen though
}
#pragma mark - Feed Statistics UI
/**
Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date.
@@ -120,8 +89,7 @@
NSPoint origin = NSZeroPoint;
for (NSString *str in @[@"min", @"max", @"avg", @"median"]) {
NSString *title = [str stringByAppendingString:@":"];
NSString *value = [info valueForKey:str];
NSView *v = [self viewWithLabel:title andRefreshButton:value callback:callback];
NSView *v = [self viewWithLabel:title andInterval:info[str] callback:callback];
[v setFrameOrigin:origin];
[buttonsView addSubview:v];
origin.x += NSWidth(v.frame);
@@ -161,11 +129,8 @@
/**
Create view with duration button, e.g., '3.4h' and label infornt of it.
*/
+ (NSView*)viewWithLabel:(NSString*)title andRefreshButton:(NSString*)value callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
+ (NSView*)viewWithLabel:(NSString*)title andInterval:(NSNumber*)value callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
static const int buttonPadding = 5;
if (!value || value.length == 0)
return nil;
NSButton *button = [self grayInlineButton:value];
if (callback) {
button.target = callback;
@@ -194,12 +159,13 @@
/**
@return Rounded, gray inline button with tag equal to refresh interval.
*/
+ (NSButton*)grayInlineButton:(NSString*)text {
NSButton *button = [NSButton buttonWithTitle:text target:nil action:nil];
+ (NSButton*)grayInlineButton:(NSNumber*)num {
NSButton *button = [NSButton buttonWithTitle:[NSDate stringForInterval:num.intValue rounded:YES] target:nil action:nil];
button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold];
button.bezelStyle = NSBezelStyleInline;
button.controlSize = NSControlSizeSmall;
button.tag = [self buttonTagFromRefreshString:text];
TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES];
button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded inteval
[button sizeToFit];
return button;
}