├── .gitignore ├── CONTRIBUTING.md ├── Classes ├── AppDelegate.swift ├── Categories │ ├── NSArray+Filtering.h │ ├── NSArray+Filtering.m │ ├── NSDictionary+TypeSafety.h │ ├── NSDictionary+TypeSafety.m │ ├── NSMutableArray+Safety.h │ ├── NSMutableArray+Safety.m │ └── UIScrollView+Helper.swift ├── Constants.swift ├── Controllers │ ├── ComicListViewController.h │ ├── ComicListViewController.m │ ├── FAQViewController.h │ ├── FAQViewController.m │ ├── SingleComicViewController.h │ └── SingleComicViewController.m ├── CoreDataStack.swift ├── FetchComicFromWeb.h ├── FetchComicFromWeb.m ├── FetchComicImageFromWeb.h ├── FetchComicImageFromWeb.m ├── Models │ ├── Comic.h │ ├── Comic.m │ ├── FCOpenInChromeActivity.h │ ├── FCOpenInChromeActivity.m │ ├── FCOpenInSafariActivity.h │ ├── FCOpenInSafariActivity.m │ ├── NSString_HTML.h │ └── NSString_HTML.m ├── NewComicFetcher.h ├── NewComicFetcher.m ├── Preferences.swift ├── Protocols │ ├── NewComicFetcherDelegate.h │ └── SingleComicImageFetcherDelegate.h ├── SingleComicImageFetcher.h ├── SingleComicImageFetcher.m ├── XkcdErrorCodes.h └── xkcd-Bridging-Header.h ├── LICENSE ├── Other Sources ├── main.m └── xkcd_Prefix.pch ├── README.md ├── Resources ├── CoreData │ ├── comics.sqlite │ └── xkcd.xcdatamodeld │ │ ├── .xccurrentversion │ │ ├── xkcd 2.xcdatamodel │ │ ├── elements │ │ └── layout │ │ └── xkcd.xcdatamodel │ │ ├── elements │ │ └── layout ├── LaunchStoryboard.storyboard ├── Media.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-Small.png │ │ ├── Icon-Small@2x.png │ │ ├── Icon-Small@3x.png │ │ ├── Icon-Spotlight@2x.png │ │ ├── Icon-Spotlight@3x.png │ │ ├── Icon.png │ │ ├── Icon2@2x.png │ │ ├── Icon2@3x.png │ │ ├── Icon@2x.png │ │ └── iTunesArtwork@2x.jpg │ ├── Contents.json │ ├── download.imageset │ │ ├── 98_Download-1.png │ │ ├── 98_Download-2.png │ │ ├── 98_Download.png │ │ └── Contents.json │ ├── next.imageset │ │ ├── 177_^-1.png │ │ ├── 177_^-2.png │ │ ├── 177_^.png │ │ └── Contents.json │ ├── previous.imageset │ │ ├── 178_v-1.png │ │ ├── 178_v-2.png │ │ ├── 178_v.png │ │ └── Contents.json │ └── random.imageset │ │ ├── 238_Shuffle-(alt)-1.png │ │ ├── 238_Shuffle-(alt)-2.png │ │ ├── 238_Shuffle-(alt).png │ │ └── Contents.json ├── System │ ├── Settings.bundle │ │ ├── Root.plist │ │ └── en.lproj │ │ │ └── Root.strings │ ├── ad-hoc-entitlements.plist │ └── xkcd-Info.plist └── faq.plist ├── TLCommon ├── CGGeometry_TLCommon.h ├── TLMacros.h ├── TLMersenneTwister.h ├── TLMersenneTwister.m ├── TLModalActivityIndicatorView.h ├── TLModalActivityIndicatorView.m ├── TLNavigationController.h ├── TLNavigationController.m ├── UIActivityIndicatorView_TLCommon.h ├── UIActivityIndicatorView_TLCommon.m ├── UIBarButtonItem_TLCommon.h ├── UIBarButtonItem_TLCommon.m ├── UITableView_TLCommon.h └── UITableView_TLCommon.m ├── Tests ├── Info.plist └── TestNSString_HTML.m └── xkcd.xcodeproj ├── project.pbxproj ├── project.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ └── IDEWorkspaceChecks.plist └── xcshareddata └── xcschemes ├── UnitTests.xcscheme └── xkcd.xcscheme /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | 3 | *.xcodeproj/*.mode2v3 4 | *.xcodeproj/*.mode1v3 5 | *.xcodeproj/*.pbxuser 6 | *.xcodeproj/xcuserdata/* 7 | *.xcodeproj/project.xcworkspace/xcuserdata/* 8 | 9 | .DS_Store 10 | **/*.pbxuser 11 | **/*.perspectivev* 12 | 13 | xkcd.xcodeproj/project.xcworkspace/xcshareddata/xkcd.xccheckout 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributions are welcome! A few suggestions: 2 | 3 | * Looking for something to work on? Check out the issues. 4 | * If you're working on something with UI changes, it's 5 | a good idea to share the proposed UI before going too 6 | deep on implementing it. Issues are a good place to discuss. 7 | * Please try to match local code style. This matters to me, a lot. 8 | -------------------------------------------------------------------------------- /Classes/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 1/24/16. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | /// This class acts as the UIApplicationDelegate of the application. 12 | final class AppDelegate: NSObject, UIApplicationDelegate { 13 | 14 | // MARK: - UIApplicationDelegate 15 | 16 | var window: UIWindow? 17 | 18 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]?) -> Bool { 19 | Comic.synchronizeDownloadedImages() 20 | 21 | let listViewController = ComicListViewController(style: .plain) 22 | 23 | if let launchURL = launchOptions?[.url] as? NSURL { 24 | guard launchURL.scheme == "xkcd" else { 25 | return false 26 | } 27 | 28 | if 29 | let host = launchURL.host, 30 | let launchedComicNumber = Int(host), 31 | launchedComicNumber > 0 32 | { 33 | listViewController.requestedLaunchComic = launchedComicNumber 34 | } 35 | } 36 | 37 | let navigationController = TLNavigationController(rootViewController: listViewController) 38 | 39 | window = UIWindow(frame: UIScreen.main.bounds) 40 | 41 | window?.rootViewController = navigationController 42 | window?.makeKeyAndVisible() 43 | 44 | return true 45 | } 46 | 47 | func applicationDidBecomeActive(_ application: UIApplication) { 48 | Comic.synchronizeDownloadedImages() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Classes/Categories/NSArray+Filtering.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSArray+Filtering.h 3 | // xkcd 4 | // 5 | // Created by Josh Bleecher Snyder on 6/19/12. 6 | // 7 | // 8 | 9 | #import 10 | 11 | typedef BOOL (^ObjectTestBlock)(id obj); 12 | 13 | @interface NSArray (Filtering) 14 | 15 | - (BOOL)containsObjectPassingTest:(ObjectTestBlock)test; 16 | - (NSArray *)objectsPassingTest:(ObjectTestBlock)test; 17 | 18 | - (BOOL)containsObjectOfKindOfClass:(Class)cls; 19 | - (NSArray *)objectsOfKindOfClass:(Class)cls; 20 | 21 | @end 22 | -------------------------------------------------------------------------------- /Classes/Categories/NSArray+Filtering.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSArray+Filtering.m 3 | // xkcd 4 | // 5 | // Created by Josh Bleecher Snyder on 6/19/12. 6 | // 7 | // 8 | 9 | #import "NSArray+Filtering.h" 10 | 11 | @implementation NSArray (Filtering) 12 | 13 | 14 | - (BOOL)containsObjectPassingTest:(ObjectTestBlock)test { 15 | NSUInteger index = [self indexOfObjectPassingTest:^(id obj, NSUInteger idx, BOOL *stop) { 16 | BOOL passesTest = test(obj); 17 | if (passesTest) { 18 | *stop = YES; 19 | } 20 | return passesTest; 21 | }]; 22 | 23 | return index != NSNotFound; 24 | } 25 | 26 | - (NSArray *)objectsPassingTest:(ObjectTestBlock)test { 27 | NSIndexSet *indices = [self indexesOfObjectsPassingTest:^(id obj, NSUInteger index, BOOL *stop) { 28 | return test(obj); 29 | }]; 30 | 31 | return [self objectsAtIndexes:indices]; 32 | } 33 | 34 | - (BOOL)containsObjectOfKindOfClass:(Class)cls { 35 | return [self containsObjectPassingTest:^BOOL(id obj) { 36 | return [obj isKindOfClass:cls]; 37 | }]; 38 | } 39 | 40 | - (NSArray *)objectsOfKindOfClass:(Class)cls { 41 | return [self objectsPassingTest:^BOOL(id obj) { 42 | return [obj isKindOfClass:cls]; 43 | }]; 44 | } 45 | 46 | @end 47 | -------------------------------------------------------------------------------- /Classes/Categories/NSDictionary+TypeSafety.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSDictionary+TypeSafety.h 3 | // xkcd 4 | // 5 | // Created by Josh Bleecher Snyder on 6/22/12. 6 | // 7 | // 8 | 9 | #import 10 | 11 | @interface NSDictionary (TypeSafety) 12 | 13 | - (NSString *)stringForKey:(id)aKey; 14 | - (id)objectForKey:(id)aKey ofKindOfClass:(Class)requiredClass; 15 | 16 | @end 17 | -------------------------------------------------------------------------------- /Classes/Categories/NSDictionary+TypeSafety.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSDictionary+TypeSafety.m 3 | // xkcd 4 | // 5 | // Created by Josh Bleecher Snyder on 6/22/12. 6 | // 7 | // 8 | 9 | #import "NSDictionary+TypeSafety.h" 10 | 11 | @implementation NSDictionary (TypeSafety) 12 | 13 | - (NSString *)stringForKey:(id)aKey { 14 | return (NSString *)[self objectForKey:aKey ofKindOfClass:[NSString class]]; 15 | } 16 | 17 | - (id)objectForKey:(id)aKey ofKindOfClass:(Class)requiredClass { 18 | id obj = self[aKey]; 19 | return [obj isKindOfClass:requiredClass] ? obj : nil; 20 | } 21 | 22 | @end 23 | -------------------------------------------------------------------------------- /Classes/Categories/NSMutableArray+Safety.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSMutableArray+Safety.h 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 3/8/20. 6 | // 7 | 8 | #import 9 | 10 | NS_ASSUME_NONNULL_BEGIN 11 | 12 | @interface NSMutableArray (Safety) 13 | 14 | - (void)safelyAddObject:(T)object; 15 | 16 | @end 17 | 18 | NS_ASSUME_NONNULL_END 19 | -------------------------------------------------------------------------------- /Classes/Categories/NSMutableArray+Safety.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSMutableArray+Safety.m 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 3/8/20. 6 | // 7 | 8 | #import "NSMutableArray+Safety.h" 9 | 10 | @implementation NSMutableArray (Safety) 11 | 12 | - (void)safelyAddObject:(id)object { 13 | if (object) { 14 | [self addObject:object]; 15 | } 16 | } 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Classes/Categories/UIScrollView+Helper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIScrollView+Helper.swift 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 1/24/16. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | extension UIScrollView { 12 | 13 | /** 14 | Sets the zoom `scale` of the scroll view, and centers it on the `centerPoint`. 15 | 16 | - parameter scale: The target scale of the scroll view. 17 | - parameter centerPoint: The point in which the scroll view will be centered. 18 | - parameter animated: Whether the zoom should be animated. 19 | */ 20 | @objc func setZoomScale(scale: CGFloat, centerPoint: CGPoint, animated: Bool) { 21 | // convert scroll view point to content point 22 | if let contentView = delegate?.viewForZooming?(in: self) { 23 | let contentCenter = convert(centerPoint, to: contentView) 24 | 25 | let visibleWidth = self.frame.width / scale 26 | let visibleHeight = self.frame.height / scale 27 | 28 | // make the target content point the center of the resulting view 29 | let leftX = (contentCenter.x - (visibleWidth / 2)) 30 | let topY = (contentCenter.y - (visibleHeight / 2)) 31 | 32 | zoom(to: CGRect(x: leftX, y: topY, width: visibleWidth, height: visibleHeight), animated: animated) 33 | } 34 | else { 35 | fatalError("It is expected that UIScrollViews that are being zoomed have a delegate that responds to -viewForZoomingInScrollView:") 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Classes/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 1/24/16. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | final class Constants: NSObject { 12 | @objc static func userAgent() -> String { 13 | return "xkcd iPhone app (feedback@xkcdapp.com; http://bit.ly/xkcdapp). Thank you for the API!" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Classes/Controllers/ComicListViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // ComicListViewController.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 8/25/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | @import CoreData; 10 | @import MessageUI; 11 | @import UIKit; 12 | 13 | #import "NewComicFetcherDelegate.h" 14 | #import "SingleComicImageFetcherDelegate.h" 15 | #import "TLModalActivityIndicatorView.h" 16 | 17 | @class NewComicFetcher; 18 | @class SingleComicImageFetcher; 19 | 20 | @interface ComicListViewController : UITableViewController 22 | 23 | @property (nonatomic) NSInteger requestedLaunchComic; 24 | 25 | - (NSIndexPath *)indexPathForComicNumbered:(NSInteger)comicNumber; 26 | - (NSFetchedResultsController *)activeFetchedResultsController; 27 | 28 | @end 29 | -------------------------------------------------------------------------------- /Classes/Controllers/ComicListViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // ComicListViewController.m 3 | // 4 | 5 | #import "ComicListViewController.h" 6 | #import "Comic.h" 7 | #import "NewComicFetcher.h" 8 | #import "XkcdErrorCodes.h" 9 | #import "SingleComicViewController.h" 10 | #import "SingleComicImageFetcher.h" 11 | #import "CGGeometry_TLCommon.h" 12 | #import "UIBarButtonItem_TLCommon.h" 13 | #import "UIActivityIndicatorView_TLCommon.h" 14 | #import "UITableView_TLCommon.h" 15 | #import "FAQViewController.h" 16 | #import "TLMacros.h" 17 | #import "xkcd-Swift.h" 18 | 19 | #define kTableViewBackgroundColor [UIColor colorWithRed:0.69f green:0.737f blue:0.80f alpha:0.5f] 20 | #define kUserDefaultsSavedTopVisibleComicKey @"topVisibleComic" 21 | 22 | #pragma mark - 23 | 24 | static UIImage *__downloadImage = nil; 25 | 26 | #pragma mark - 27 | 28 | @interface ComicListViewController () 29 | 30 | @property (nonatomic) NewComicFetcher *fetcher; 31 | @property (nonatomic) SingleComicImageFetcher *imageFetcher; 32 | @property (nonatomic) NSFetchedResultsController *fetchedResultsController; 33 | @property (nonatomic) NSFetchedResultsController *searchFetchedResultsController; 34 | @property (nonatomic) UISearchController *searchController; 35 | 36 | @end 37 | 38 | #pragma mark - 39 | 40 | @implementation ComicListViewController 41 | 42 | + (UIImage *)downloadImage { 43 | if (!__downloadImage) { 44 | __downloadImage = [[UIImage imageNamed:@"download"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; 45 | } 46 | 47 | return __downloadImage; 48 | } 49 | 50 | - (instancetype)initWithStyle:(UITableViewStyle)style { 51 | if (self = [super initWithStyle:style]) { 52 | self.title = NSLocalizedString(@"xkcd", @"Title of main view"); 53 | self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil]; 54 | self.searchController.searchResultsUpdater = self; 55 | self.searchController.obscuresBackgroundDuringPresentation = NO; 56 | } 57 | return self; 58 | } 59 | 60 | - (void)addSearchBarTableHeader { 61 | UISearchBar *searchBar = self.searchController.searchBar; 62 | searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth; 63 | [searchBar sizeToFit]; 64 | searchBar.placeholder = NSLocalizedString(@"Search xkcd", @"Search bar placeholder text"); 65 | searchBar.autocapitalizationType = UITextAutocapitalizationTypeNone; 66 | self.tableView.tableHeaderView = searchBar; 67 | } 68 | 69 | - (void)addRefreshControl { 70 | UIRefreshControl *refreshControl = [[UIRefreshControl alloc] init]; 71 | 72 | [refreshControl addTarget:self action:@selector(checkForNewComics) forControlEvents:UIControlEventValueChanged]; 73 | 74 | self.refreshControl = refreshControl; 75 | 76 | if (@available(iOS 13.0, *)) { 77 | refreshControl.tintColor = [UIColor systemFillColor]; 78 | refreshControl.backgroundColor = [UIColor systemBackgroundColor]; 79 | } 80 | 81 | } 82 | 83 | - (void)addNavigationBarButtons { 84 | UIBarButtonItem *systemItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAction 85 | target:self 86 | action:@selector(systemAction:) 87 | ]; 88 | self.navigationItem.leftBarButtonItem = systemItem; 89 | 90 | self.navigationItem.rightBarButtonItem = self.editButtonItem; 91 | self.navigationItem.rightBarButtonItem.target = self; 92 | self.navigationItem.rightBarButtonItem.action = @selector(edit:); 93 | 94 | #if GENERATE_DEFAULT_PNG 95 | self.navigationItem.leftBarButtonItem.enabled = NO; 96 | self.navigationItem.rightBarButtonItem.enabled = NO; 97 | #endif 98 | } 99 | 100 | - (void)viewDidLoad { 101 | [super viewDidLoad]; 102 | 103 | [self addRefreshControl]; 104 | [self addNavigationBarButtons]; 105 | [self addSearchBarTableHeader]; 106 | [self setFetchedResultsController]; 107 | 108 | [self reloadAllData]; 109 | [self scrollToComicAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:0]]; 110 | 111 | // Set up new comic fetcher 112 | if (!self.fetcher) { 113 | self.fetcher = [[NewComicFetcher alloc] init]; 114 | self.fetcher.delegate = self; 115 | } 116 | 117 | // Set up image fetcher, for the future 118 | if (!self.imageFetcher) { 119 | self.imageFetcher = [[SingleComicImageFetcher alloc] initWithURLSession:[NSURLSession sharedSession]]; 120 | self.imageFetcher.delegate = self; 121 | } 122 | 123 | [self checkForNewComics]; 124 | 125 | if (self.requestedLaunchComic) { 126 | NSIndexPath *indexPath = [self indexPathForComicNumbered:self.requestedLaunchComic]; 127 | if (indexPath) { 128 | [self scrollToComicAtIndexPath:indexPath]; 129 | Comic *launchComic = [Comic comicNumbered:self.requestedLaunchComic]; 130 | [self viewComic:launchComic]; 131 | } 132 | self.requestedLaunchComic = 0; 133 | } 134 | else { 135 | [self restoreScrollPosition]; 136 | } 137 | } 138 | 139 | - (void)viewWillAppear:(BOOL)animated { 140 | [super viewWillAppear:animated]; 141 | [self.navigationController setToolbarHidden:YES animated:NO]; 142 | } 143 | 144 | - (void)scrollToComicAtIndexPath:(NSIndexPath *)indexPath { 145 | @try { 146 | [self.tableView scrollToRowAtIndexPath:indexPath 147 | atScrollPosition:UITableViewScrollPositionTop 148 | animated:NO]; 149 | } @catch (NSException *e) { 150 | NSLog(@"Scroll error: %@", e); 151 | } 152 | } 153 | 154 | - (NSFetchedResultsController *)fetchedResultsControllerWithSearchString:(NSString *)searchString { 155 | // Set up table data fetcher 156 | NSFetchRequest *fetchRequest = [[NSFetchRequest alloc] init]; 157 | fetchRequest.entity = [Comic entityDescription]; 158 | if (searchString) { 159 | fetchRequest.predicate = [NSPredicate predicateWithFormat:@"name CONTAINS[cd] %@ OR titleText CONTAINS[cd] %@ OR transcript CONTAINS[cd] %@ OR number = %@", 160 | searchString, searchString, searchString, @([searchString integerValue])]; 161 | } 162 | fetchRequest.sortDescriptors = @[[[NSSortDescriptor alloc] initWithKey:@"number" ascending:NO]]; 163 | 164 | NSFetchedResultsController *aFetchedResultsController = [[NSFetchedResultsController alloc] initWithFetchRequest:fetchRequest 165 | managedObjectContext:[CoreDataStack sharedCoreDataStack].managedObjectContext 166 | sectionNameKeyPath:nil 167 | cacheName:nil]; 168 | aFetchedResultsController.delegate = self; 169 | return aFetchedResultsController; 170 | } 171 | 172 | - (void)setFetchedResultsController { 173 | self.fetchedResultsController = [self fetchedResultsControllerWithSearchString:nil]; 174 | 175 | NSError *fetchError = nil; 176 | BOOL success = [self.fetchedResultsController performFetch:&fetchError]; 177 | if (!success) { 178 | NSLog(@"List fetch failed"); 179 | } 180 | } 181 | 182 | - (void)setSearchFetchedResultsControllerWithSearchString:(NSString *)searchString { 183 | self.searchFetchedResultsController = [self fetchedResultsControllerWithSearchString:searchString]; 184 | 185 | NSError *fetchError = nil; 186 | BOOL success = [self.searchFetchedResultsController performFetch:&fetchError]; 187 | if (!success) { 188 | NSLog(@"Search list fetch failed"); 189 | } 190 | } 191 | 192 | - (void)viewComic:(Comic *)comic { 193 | SingleComicViewController *singleComicViewController = [[SingleComicViewController alloc] initWithComic:comic]; 194 | 195 | if (self.searchController.isActive) { 196 | [self.searchController dismissViewControllerAnimated:YES completion:nil]; 197 | } 198 | 199 | [self.navigationController pushViewController:singleComicViewController animated:YES]; 200 | } 201 | 202 | - (void)checkForNewComics { 203 | [self didStartRefreshing]; 204 | [UIApplication sharedApplication].networkActivityIndicatorVisible = YES; 205 | [self.fetcher fetch]; 206 | } 207 | 208 | - (void)fetchImageForComic:(Comic *)comic { 209 | BOOL openAfterDownloadPreferenceSet = [Preferences defaultPreferences].openAfterDownload; 210 | BOOL isLaunchComic = (self.requestedLaunchComic && ([comic.number integerValue] == self.requestedLaunchComic)); 211 | 212 | if (isLaunchComic) { 213 | self.requestedLaunchComic = 0; 214 | } 215 | 216 | BOOL openAfterDownload = openAfterDownloadPreferenceSet || isLaunchComic; 217 | [self.imageFetcher fetchImageForComic:comic context:@(openAfterDownload)]; 218 | } 219 | 220 | - (void)reloadAllData { 221 | [self.tableView reloadData]; 222 | } 223 | 224 | - (void)systemAction:(UIBarButtonItem *)sender { 225 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; 226 | 227 | if ([MFMailComposeViewController canSendMail]) { 228 | [alertController addAction: 229 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Email the app developer", @"Action sheet title") 230 | style:UIAlertActionStyleDefault 231 | handler:^(UIAlertAction * _Nonnull action) { 232 | [self emailDeveloper]; 233 | }] 234 | ]; 235 | } 236 | 237 | [alertController addAction: 238 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Read the FAQ", @"Action sheet title") 239 | style:UIAlertActionStyleDefault 240 | handler:^(UIAlertAction * _Nonnull action) { 241 | FAQViewController *faqViewController = [[FAQViewController alloc] init]; 242 | UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:faqViewController]; 243 | [self presentViewController:navigationController animated:YES completion:nil]; 244 | }] 245 | ]; 246 | 247 | [alertController addAction: 248 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Write App Store review", @"Action sheet title") 249 | style:UIAlertActionStyleDefault 250 | handler:^(UIAlertAction * _Nonnull action) { 251 | NSURL *appStoreReviewURL = [NSURL URLWithString:@"http://itunes.apple.com/WebObjects/MZStore.woa/wa/viewContentsUserReviews?id=303688284&pageNumber=0&sortOrdering=1&type=Purple+Software&mt=8"]; 252 | [[UIApplication sharedApplication] openURL:appStoreReviewURL options:@{} completionHandler:nil]; 253 | }] 254 | ]; 255 | 256 | [alertController addAction: 257 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Share link to this app", @"Action sheet title") 258 | style:UIAlertActionStyleDefault 259 | handler:^(UIAlertAction * _Nonnull action) { 260 | NSURL *appUrl = [NSURL URLWithString:@"http://bit.ly/xkcdapp"]; 261 | UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:@[appUrl] 262 | applicationActivities:nil]; 263 | 264 | [self presentViewController:activityViewController animated:YES completion:nil]; 265 | }] 266 | ]; 267 | 268 | [alertController addAction: 269 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel system action button") 270 | style:UIAlertActionStyleCancel 271 | handler:nil] 272 | ]; 273 | 274 | [self presentViewController:alertController animated:YES completion:nil]; 275 | } 276 | 277 | - (void)edit:(UIBarButtonItem *)sender { 278 | [self setEditing:YES animated:YES]; 279 | [self.tableView setEditing:YES animated:YES]; 280 | 281 | CGFloat searchBarHeight = self.tableView.tableHeaderView.bounds.size.height; 282 | [self.tableView setContentOffset:CGPointByAddingYOffset(self.tableView.contentOffset, -searchBarHeight)]; 283 | self.tableView.tableHeaderView = nil; 284 | self.refreshControl = nil; 285 | 286 | self.navigationItem.rightBarButtonItem.action = @selector(doneEditing:); 287 | [self.navigationController setToolbarHidden:NO animated:YES]; 288 | UIBarButtonItem *downloadAll = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Download all", @"Button") 289 | style:UIBarButtonItemStylePlain 290 | target:self 291 | action:@selector(downloadAll:)]; 292 | UIBarButtonItem *deleteAll = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Delete all", @"Button") 293 | style:UIBarButtonItemStylePlain 294 | target:self 295 | action:@selector(deleteAll:)]; 296 | deleteAll.tintColor = [UIColor redColor]; 297 | 298 | UIBarButtonItem *cancelDownloadAll = [[UIBarButtonItem alloc] initWithTitle:NSLocalizedString(@"Cancel download all", @"Button") 299 | style:UIBarButtonItemStylePlain 300 | target:self 301 | action:@selector(cancelDownloadAll:)]; 302 | NSArray *toolbarItems = nil; 303 | if ([self.imageFetcher downloadingAll]) { 304 | toolbarItems = @[[UIBarButtonItem flexibleSpaceBarButtonItem], cancelDownloadAll]; 305 | } 306 | else { 307 | toolbarItems = @[deleteAll, [UIBarButtonItem flexibleSpaceBarButtonItem], downloadAll]; 308 | } 309 | [self setToolbarItems:toolbarItems animated:YES]; 310 | self.navigationItem.leftBarButtonItem.enabled = NO; 311 | } 312 | 313 | - (void)doneEditing:(UIBarButtonItem *)sender { 314 | [self setEditing:NO animated:YES]; 315 | [self.tableView setEditing:NO animated:YES]; 316 | [self addRefreshControl]; 317 | [self addSearchBarTableHeader]; 318 | [self.tableView setContentOffset: 319 | CGPointByAddingYOffset(self.tableView.contentOffset, self.tableView.tableHeaderView.bounds.size.height)]; 320 | self.navigationItem.rightBarButtonItem.action = @selector(edit:); 321 | [self.navigationController setToolbarHidden:YES animated:YES]; 322 | 323 | self.navigationItem.leftBarButtonItem.enabled = YES; 324 | } 325 | 326 | - (void)downloadAll:(UIBarButtonItem *)sender { 327 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Download all", @"Download all warning alert title.") 328 | message:NSLocalizedString(@"Downloading all images may take up considerable space on your device.", @"Download all warning") 329 | preferredStyle:UIAlertControllerStyleActionSheet]; 330 | [alertController addAction: 331 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Download all images", @"Confirm download all button") 332 | style:UIAlertActionStyleDefault 333 | handler:^(UIAlertAction * _Nonnull action) { 334 | [self downloadAllComicImages]; 335 | }] 336 | ]; 337 | 338 | [alertController addAction: 339 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel downloading all button") 340 | style:UIAlertActionStyleCancel 341 | handler:nil] 342 | ]; 343 | [self presentViewController:alertController animated:YES completion:nil]; 344 | } 345 | 346 | - (void)cancelDownloadAll:(UIBarButtonItem *)sender { 347 | [self.imageFetcher cancelDownloadAll]; 348 | [self doneEditing:nil]; 349 | [self reloadAllData]; 350 | } 351 | 352 | - (void)deleteAll:(UIBarButtonItem *)sender { 353 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Delete all", @"Delete all warning alert title.") 354 | message:nil 355 | preferredStyle:UIAlertControllerStyleActionSheet]; 356 | [alertController addAction: 357 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Delete all images", @"Confirm delete all button") 358 | style:UIAlertActionStyleDestructive 359 | handler:^(UIAlertAction * _Nonnull action) { 360 | [self deleteAllComicImages]; 361 | }] 362 | ]; 363 | 364 | [alertController addAction: 365 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Cancel", @"Cancel deleting all button") 366 | style:UIAlertActionStyleCancel 367 | handler:nil] 368 | ]; 369 | 370 | [self presentViewController:alertController animated:YES completion:nil]; 371 | } 372 | 373 | #pragma mark - 374 | #pragma mark NewComicFetcherDelegate methods 375 | 376 | - (void)newComicFetcher:(NewComicFetcher *)fetcher didFetchComic:(Comic *)comic { 377 | [[CoreDataStack sharedCoreDataStack] save]; // write new comic to disk so that CoreData can clear its memory as needed 378 | if ([Preferences defaultPreferences].downloadNewComics) { 379 | [self fetchImageForComic:comic]; 380 | } 381 | } 382 | 383 | - (void)newComicFetcherDidFinishFetchingAllComics:(NewComicFetcher *)fetcher { 384 | [self didFinishRefreshing]; 385 | [UIApplication sharedApplication].networkActivityIndicatorVisible = NO; 386 | } 387 | 388 | - (void)newComicFetcher:(NewComicFetcher *)comicFetcher didFailWithError:(NSError *)error { 389 | if ([error.domain isEqualToString:kXkcdErrorDomain]) { 390 | NSLog(@"Internal error: %@", error); 391 | } 392 | [UIApplication sharedApplication].networkActivityIndicatorVisible = NO; 393 | [self didFinishRefreshing]; 394 | // TODO: Show in the UI that the fetch failed? e.g. modal indication a la tweetie 2? 395 | } 396 | 397 | #pragma mark - 398 | #pragma mark SingleComicImageFetcherDelegate methods 399 | 400 | - (void)singleComicImageFetcher:(SingleComicImageFetcher *)fetcher 401 | didFetchImageForComic:(Comic *)comic 402 | context:(id)context { 403 | if ([context boolValue] && (self.navigationController.topViewController == self)) { // context boolvalue == open after download 404 | [self viewComic:comic]; 405 | } 406 | } 407 | 408 | - (void)singleComicImageFetcher:(SingleComicImageFetcher *)fetcher 409 | didFailWithError:(NSError *)error 410 | onComic:(Comic *)comic { 411 | // Tell the user 412 | NSString *localizedFormatString; 413 | 414 | if ([error.domain isEqualToString:kXkcdErrorDomain]) { 415 | // internal error 416 | localizedFormatString = NSLocalizedString(@"Could not download xkcd %i.", 417 | @"Text of unknown error image download fail alert"); 418 | } 419 | else { 420 | localizedFormatString = NSLocalizedString(@"Could not download xkcd %i -- no internet connection.", 421 | @"Text of image download fail alert due to connectivity"); 422 | } 423 | 424 | NSString *failAlertMessage = [NSString stringWithFormat:localizedFormatString, comic.number.integerValue]; 425 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Whoops", @"Title of image download fail alert") 426 | message:failAlertMessage 427 | preferredStyle:UIAlertControllerStyleAlert]; 428 | [alertController addAction: 429 | [UIAlertAction actionWithTitle:NSLocalizedString(@"Ok", @"Confirmation action title.") 430 | style:UIAlertActionStyleDefault 431 | handler:^(UIAlertAction * _Nonnull action) {}] 432 | ]; 433 | 434 | [self presentViewController:alertController animated:YES completion:nil]; 435 | } 436 | 437 | 438 | #pragma mark - 439 | #pragma mark UITableViewDelegate and UITableViewDataSource and supporting methods 440 | 441 | - (NSIndexPath *)indexPathForComicNumbered:(NSInteger)comicNumber { 442 | NSInteger lastKnownComicNumber = [Comic lastKnownComic].number.integerValue; 443 | if (lastKnownComicNumber >= comicNumber) { 444 | return [NSIndexPath indexPathForRow:(lastKnownComicNumber - comicNumber) inSection:0]; 445 | } 446 | return nil; 447 | } 448 | 449 | - (Comic *)comicAtIndexPath:(NSIndexPath *)indexPath inTableView:(UITableView *)aTableView { 450 | Comic *comic = [[self activeFetchedResultsController] objectAtIndexPath:indexPath]; 451 | return comic; 452 | } 453 | 454 | - (NSFetchedResultsController *)activeFetchedResultsController { 455 | NSFetchedResultsController *fetchedResultsController = self.searchController.searchBar.text.length > 0 ? self.searchFetchedResultsController : self.fetchedResultsController; 456 | return fetchedResultsController; 457 | } 458 | 459 | - (UITableViewCell *)tableView:(UITableView *)aTableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { 460 | UITableViewCell *cell = nil; 461 | 462 | // Comic cell 463 | static NSString *comicCellIdentifier = @"comicCell"; 464 | UITableViewCell *comicCell = [self.tableView dequeueReusableCellWithIdentifier:comicCellIdentifier]; 465 | if (!comicCell) { 466 | comicCell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:comicCellIdentifier]; 467 | } 468 | 469 | #if GENERATE_DEFAULT_PNG 470 | return comicCell; 471 | #endif 472 | 473 | Comic *comic = [self comicAtIndexPath:indexPath inTableView:aTableView]; 474 | comicCell.textLabel.text = [NSString stringWithFormat:@"%li. %@", (long)[comic.number integerValue], comic.name]; 475 | comicCell.textLabel.font = [UIFont systemFontOfSize:16]; 476 | comicCell.textLabel.adjustsFontSizeToFitWidth = YES; 477 | 478 | if ([comic.number integerValue] == 404) { 479 | // Handle comic 404 specially...sigh 480 | comicCell.accessoryView = nil; 481 | comicCell.accessoryType = UITableViewCellAccessoryNone; 482 | comicCell.accessibilityHint = nil; 483 | } 484 | else { 485 | if (comic.downloaded) { 486 | comicCell.accessoryView = nil; 487 | comicCell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; 488 | comicCell.accessibilityHint = NSLocalizedString(@"Opens the comic", @"downloaded_comic_accessibility_hint"); 489 | } 490 | else if ([comic.loading boolValue] || [self.imageFetcher downloadingAll]) { 491 | UIActivityIndicatorViewStyle activityIndicatorViewStyle = UIActivityIndicatorViewStyleGray; 492 | if (@available(iOS 13.0, *)) { 493 | activityIndicatorViewStyle = UIActivityIndicatorViewStyleMedium; 494 | } 495 | 496 | comicCell.accessoryView = [UIActivityIndicatorView animatingActivityIndicatorViewWithStyle:activityIndicatorViewStyle]; 497 | comicCell.accessibilityHint = NSLocalizedString(@"Waiting for download", @"downloading_comic_accessibility_hint"); 498 | } 499 | else { 500 | UIImageView *downloadImageView = [[UIImageView alloc] initWithImage:[ComicListViewController downloadImage]]; 501 | downloadImageView.opaque = YES; 502 | comicCell.accessoryView = downloadImageView; 503 | comicCell.accessibilityHint = NSLocalizedString(@"Downloads the comic", @"undownloaded_comic_accessibility_hint"); 504 | } 505 | } 506 | 507 | comicCell.editingAccessoryView = [[UIView alloc] initWithFrame:CGRectZero]; 508 | 509 | cell = comicCell; 510 | return cell; 511 | } 512 | 513 | - (void)tableView:(UITableView *)aTableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { 514 | Comic *selectedComic = [self comicAtIndexPath:indexPath inTableView:aTableView]; 515 | 516 | BOOL shouldDeselect = YES; 517 | if ([selectedComic.number integerValue] != 404) { 518 | if (selectedComic.downloaded) { 519 | [self viewComic:selectedComic]; 520 | shouldDeselect = NO; 521 | } 522 | else if (!([selectedComic.loading boolValue] || [self.imageFetcher downloadingAll])) { 523 | [self fetchImageForComic:selectedComic]; 524 | } 525 | } 526 | 527 | if (shouldDeselect) { 528 | [aTableView deselectRowAtIndexPath:indexPath animated:NO]; 529 | } 530 | } 531 | 532 | - (NSString *)tableView:(UITableView *)aTableView titleForDeleteConfirmationButtonForRowAtIndexPath:(NSIndexPath *)indexPath { 533 | return NSLocalizedString(@"Delete", @"Delete button title"); 534 | } 535 | 536 | - (BOOL)tableView:(UITableView *)aTableView canEditRowAtIndexPath:(NSIndexPath *)indexPath { 537 | return !self.searchController.isActive; 538 | } 539 | 540 | - (UITableViewCellEditingStyle)tableView:(UITableView *)aTableView editingStyleForRowAtIndexPath:(NSIndexPath *)indexPath { 541 | if (self.searchController.isActive) { 542 | return UITableViewCellEditingStyleNone; 543 | } 544 | Comic *comic = [self comicAtIndexPath:indexPath inTableView:aTableView]; 545 | return comic.downloaded ? UITableViewCellEditingStyleDelete : UITableViewCellEditingStyleNone; 546 | } 547 | 548 | - (BOOL)tableView:(UITableView *)aTableView shouldIndentWhileEditingRowAtIndexPath:(NSIndexPath *)indexPath { 549 | if (self.searchController.isActive) { 550 | return NO; 551 | } 552 | Comic *comic = [self comicAtIndexPath:indexPath inTableView:aTableView]; 553 | return comic.downloaded; 554 | } 555 | 556 | - (void)tableView:(UITableView *)aTableView commitEditingStyle:(UITableViewCellEditingStyle)editingStyle forRowAtIndexPath:(NSIndexPath *)indexPath { 557 | if (editingStyle == UITableViewCellEditingStyleDelete) { 558 | Comic *comic = [self comicAtIndexPath:indexPath inTableView:aTableView]; 559 | [comic deleteImage]; 560 | [self.tableView reloadRowAtIndexPath:indexPath withRowAnimation:UITableViewRowAnimationFade]; 561 | } 562 | } 563 | 564 | - (NSInteger)tableView:(UITableView *)aTableView numberOfRowsInSection:(NSInteger)section { 565 | NSFetchedResultsController *fetchedResults = [self activeFetchedResultsController]; 566 | NSArray *sections = [fetchedResults sections]; 567 | NSUInteger numberOfRows = 0; 568 | if ([sections count] > 0) { 569 | id sectionInfo = sections[section]; 570 | numberOfRows = [sectionInfo numberOfObjects]; 571 | } 572 | return numberOfRows; 573 | } 574 | 575 | - (NSInteger)numberOfSectionsInTableView:(UITableView *)aTableView { 576 | NSFetchedResultsController *fetchedResults = [self activeFetchedResultsController]; 577 | NSUInteger numberOfSections = [[fetchedResults sections] count]; 578 | if (numberOfSections == 0) { 579 | numberOfSections = 1; 580 | } 581 | return numberOfSections; 582 | } 583 | 584 | #pragma mark - 585 | #pragma mark TLActionSheetController supporting methods 586 | 587 | - (void)emailDeveloper { 588 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Do me a favor?", @"Alert title") 589 | message:NSLocalizedString(@"Take a look at the FAQ before emailing. Thanks!", @"Alert body") 590 | preferredStyle:UIAlertControllerStyleAlert]; 591 | [alertController addAction: 592 | [UIAlertAction actionWithTitle:NSLocalizedString(@"View the FAQ", @"Alert action that allows the user to view the FAQ.") 593 | style:UIAlertActionStyleCancel 594 | handler:^(UIAlertAction * _Nonnull action) { 595 | FAQViewController *faqViewController = [[FAQViewController alloc] init]; 596 | UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:faqViewController]; 597 | [self presentViewController:navigationController animated:YES completion:nil]; 598 | }] 599 | ]; 600 | 601 | [alertController addAction: 602 | [UIAlertAction actionWithTitle:NSLocalizedString(@"I’ve read it already.", @"Alert action that allows the user to email the developer.") 603 | style:UIAlertActionStyleDefault 604 | handler:^(UIAlertAction * _Nonnull action) { 605 | MFMailComposeViewController *emailViewController = [[MFMailComposeViewController alloc] initWithNibName:nil bundle:nil]; 606 | emailViewController.mailComposeDelegate = self; 607 | emailViewController.subject = [NSString stringWithFormat:NSLocalizedString(@"Feedback on xkcd app (version %@)", @"Subject of feedback email"), 608 | [[[NSBundle mainBundle] infoDictionary] valueForKey:@"CFBundleVersion"]]; 609 | emailViewController.toRecipients = @[@"feedback@xkcdapp.com"]; 610 | 611 | [self presentViewController:emailViewController animated:YES completion:nil]; 612 | }] 613 | ]; 614 | 615 | [self presentViewController:alertController animated:YES completion:nil]; 616 | } 617 | 618 | - (void)downloadAllComicImages { 619 | [self.imageFetcher fetchImagesForAllComics]; 620 | [self doneEditing:nil]; 621 | [self reloadAllData]; // so that all the spinners start up 622 | } 623 | 624 | - (void)deleteAllComicImages { 625 | [self doneEditing:nil]; 626 | 627 | TLModalActivityIndicatorView *modalSpinner = [[TLModalActivityIndicatorView alloc] initWithText:NSLocalizedString(@"Deleting...", @"Modal spinner text")]; 628 | [modalSpinner show]; 629 | 630 | NSSet *downloadedImages = [Comic downloadedImages]; 631 | 632 | dispatch_queue_t deletionQueue = dispatch_queue_create("com.treelinelabs.xkcd.delete_images", NULL); 633 | dispatch_async(deletionQueue, ^{ 634 | // delete each comic, one by one 635 | for (NSString *downloadedImage in downloadedImages) { 636 | // for each one, yield to the main thread, to keep the ui responsive (don't flood) 637 | dispatch_sync(dispatch_get_main_queue(), ^{ 638 | [Comic deleteDownloadedImage:downloadedImage]; 639 | }); 640 | } 641 | 642 | // done doing work 643 | dispatch_async(dispatch_get_main_queue(), ^{ 644 | // reflect the deletions in the UI 645 | [self reloadAllData]; 646 | [modalSpinner dismiss]; 647 | }); 648 | }); 649 | } 650 | 651 | #pragma mark - 652 | #pragma mark MFMailComposeViewControllerDelegate methods 653 | 654 | - (void)mailComposeController:(MFMailComposeViewController*)controller didFinishWithResult:(MFMailComposeResult)result error:(NSError*)error { 655 | [controller dismissViewControllerAnimated:YES completion:^{}]; 656 | } 657 | 658 | #pragma mark - 659 | #pragma mark NSFetchedResultsControllerDelegate methods 660 | 661 | - (void)controllerWillChangeContent:(NSFetchedResultsController *)controller { 662 | if (controller != [self activeFetchedResultsController]) { 663 | return; 664 | } 665 | 666 | [self.tableView beginUpdates]; 667 | } 668 | 669 | - (void)controller:(NSFetchedResultsController *)controller 670 | didChangeSection:(id )sectionInfo 671 | atIndex:(NSUInteger)sectionIndex 672 | forChangeType:(NSFetchedResultsChangeType)type { 673 | if (controller != [self activeFetchedResultsController]) { 674 | return; 675 | } 676 | 677 | switch(type) { 678 | case NSFetchedResultsChangeInsert: 679 | [self.tableView insertSections:[NSIndexSet indexSetWithIndex:sectionIndex] 680 | withRowAnimation:UITableViewRowAnimationAutomatic]; 681 | break; 682 | case NSFetchedResultsChangeDelete: 683 | [self.tableView deleteSections:[NSIndexSet indexSetWithIndex:sectionIndex] 684 | withRowAnimation:UITableViewRowAnimationAutomatic]; 685 | break; 686 | case NSFetchedResultsChangeMove: 687 | case NSFetchedResultsChangeUpdate: 688 | break; 689 | } 690 | } 691 | 692 | - (void)controller:(NSFetchedResultsController *)controller 693 | didChangeObject:(id)anObject 694 | atIndexPath:(NSIndexPath *)indexPath 695 | forChangeType:(NSFetchedResultsChangeType)type 696 | newIndexPath:(NSIndexPath *)newIndexPath { 697 | 698 | if (controller != [self activeFetchedResultsController]) { 699 | return; 700 | } 701 | 702 | switch(type) { 703 | case NSFetchedResultsChangeInsert: 704 | [self.tableView insertRowsAtIndexPaths:@[newIndexPath] 705 | withRowAnimation:UITableViewRowAnimationAutomatic]; 706 | break; 707 | 708 | case NSFetchedResultsChangeDelete: 709 | [self.tableView deleteRowsAtIndexPaths:@[indexPath] 710 | withRowAnimation:UITableViewRowAnimationFade]; 711 | break; 712 | 713 | case NSFetchedResultsChangeUpdate: 714 | [self.tableView reloadRowAtIndexPath:indexPath withRowAnimation:UITableViewRowAnimationFade]; 715 | break; 716 | 717 | case NSFetchedResultsChangeMove: 718 | [self.tableView deleteRowsAtIndexPaths:@[indexPath] 719 | withRowAnimation:UITableViewRowAnimationFade]; 720 | [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:newIndexPath.section] 721 | withRowAnimation:UITableViewRowAnimationFade]; 722 | break; 723 | } 724 | 725 | } 726 | 727 | - (void)controllerDidChangeContent:(NSFetchedResultsController *)controller { 728 | if (controller != [self activeFetchedResultsController]) { 729 | return; 730 | } 731 | 732 | [self.tableView endUpdates]; 733 | } 734 | 735 | #pragma mark - UISearchResultsUpdating 736 | 737 | - (void)updateSearchResultsForSearchController:(UISearchController *)searchController { 738 | if (searchController.searchBar.text.length) { 739 | [self setSearchFetchedResultsControllerWithSearchString:searchController.searchBar.text]; 740 | } 741 | else { 742 | [self setFetchedResultsController]; 743 | } 744 | 745 | [self reloadAllData]; 746 | } 747 | 748 | #pragma mark - 749 | #pragma mark Pull to refresh methods 750 | 751 | - (void)didStartRefreshing { 752 | [self.refreshControl beginRefreshing]; 753 | } 754 | 755 | - (void)didFinishRefreshing { 756 | [self.refreshControl endRefreshing]; 757 | } 758 | 759 | #pragma mark - 760 | #pragma mark Scroll position saving/restoring 761 | 762 | - (void)saveScrollPosition { 763 | NSArray *visibleIndexPaths = [self.tableView indexPathsForVisibleRows]; 764 | if (visibleIndexPaths.count > 0) { 765 | NSIndexPath *topIndexPath = visibleIndexPaths[0]; 766 | Comic *topComic = [self comicAtIndexPath:topIndexPath inTableView:self.tableView]; 767 | NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; 768 | [userDefaults setInteger:topComic.number.integerValue forKey:kUserDefaultsSavedTopVisibleComicKey]; 769 | [userDefaults synchronize]; 770 | } 771 | } 772 | 773 | - (void)restoreScrollPosition { 774 | NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; 775 | NSInteger topVisibleComic = [userDefaults integerForKey:kUserDefaultsSavedTopVisibleComicKey]; 776 | [self scrollToComicAtIndexPath:[self indexPathForComicNumbered:topVisibleComic]]; 777 | } 778 | 779 | 780 | #pragma mark - 781 | #pragma mark UIScrollViewDelegate methods 782 | 783 | - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate { 784 | if (!self.searchController.isActive) { 785 | if (!decelerate) { 786 | [self saveScrollPosition]; 787 | } 788 | } 789 | } 790 | 791 | - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView { 792 | if (!self.searchController.isActive) { 793 | [self saveScrollPosition]; 794 | } 795 | } 796 | 797 | @end 798 | -------------------------------------------------------------------------------- /Classes/Controllers/FAQViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // FAQViewController.h 3 | // 4 | 5 | #import 6 | 7 | @interface FAQViewController : UIViewController 8 | 9 | @end 10 | -------------------------------------------------------------------------------- /Classes/Controllers/FAQViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // FAQViewController.m 3 | // 4 | 5 | #import "FAQViewController.h" 6 | 7 | @interface FAQViewController () 8 | 9 | @property (nonatomic) UITextView *textView; 10 | 11 | @end 12 | 13 | @implementation FAQViewController 14 | 15 | - (instancetype) init { 16 | self = [super init]; 17 | if (self) { 18 | self.title = NSLocalizedString(@"FAQ", @"FAQ"); 19 | self.textView = [[UITextView alloc] init]; 20 | self.textView.translatesAutoresizingMaskIntoConstraints = false; 21 | } 22 | 23 | return self; 24 | } 25 | 26 | - (void)viewDidLoad { 27 | [super viewDidLoad]; 28 | [self.view addSubview:self.textView]; 29 | 30 | NSArray *constraints = @[ 31 | [self.textView.leadingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor], 32 | [self.textView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], 33 | [self.textView.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor], 34 | [self.textView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor] 35 | ]; 36 | 37 | [NSLayoutConstraint activateConstraints:constraints]; 38 | 39 | self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone 40 | target:self 41 | action:@selector(done)]; 42 | NSString *faqPath = [[NSBundle mainBundle] pathForResource:@"faq" ofType:@"plist"]; 43 | NSArray *faqArray = [NSArray arrayWithContentsOfFile:faqPath]; 44 | 45 | NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; 46 | paragraphStyle.firstLineHeadIndent = 8.0; 47 | paragraphStyle.headIndent = 8.0; 48 | paragraphStyle.tailIndent = -8.0; 49 | 50 | NSMutableAttributedString *display = [[NSMutableAttributedString alloc] init]; 51 | 52 | UIFont *boldFont = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline]; 53 | NSMutableDictionary *qAttributes = [@{ 54 | NSFontAttributeName: boldFont, 55 | NSParagraphStyleAttributeName: paragraphStyle 56 | } mutableCopy]; 57 | 58 | 59 | 60 | UIFont *regularFont = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; 61 | NSMutableDictionary *aAttributes = [@{ 62 | NSFontAttributeName: regularFont, 63 | NSParagraphStyleAttributeName: paragraphStyle 64 | } mutableCopy]; 65 | 66 | if (@available(iOS 13.0, *)) { 67 | [qAttributes addEntriesFromDictionary:@{ NSForegroundColorAttributeName: [UIColor labelColor] }]; 68 | [aAttributes addEntriesFromDictionary:@{ NSForegroundColorAttributeName: [UIColor labelColor] }]; 69 | } 70 | 71 | for (NSDictionary *faqEntry in faqArray) { 72 | NSString *q = faqEntry[@"Q"]; 73 | NSString *a = faqEntry[@"A"]; 74 | 75 | NSAttributedString *attributedQ = [[NSAttributedString alloc] initWithString:q attributes:qAttributes]; 76 | [display appendAttributedString:attributedQ]; 77 | 78 | [display appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n"]]; 79 | 80 | NSAttributedString *attributedA = [[NSAttributedString alloc] initWithString:a attributes:aAttributes]; 81 | [display appendAttributedString:attributedA]; 82 | 83 | [display appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n"]]; 84 | [display appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n"]]; 85 | } 86 | 87 | self.textView.attributedText = display; 88 | 89 | /* 90 | Good ol' iOS 7+ scroll view behavior. Too hard to debug UIViewController, so here's a hack to make 91 | sure the content doesn't underlap the nav bar when the view first appears. Cool. 92 | */ 93 | self.textView.contentOffset = CGPointMake(0, -self.view.safeAreaLayoutGuide.layoutFrame.origin.y); 94 | } 95 | 96 | - (void)done { 97 | [self dismissViewControllerAnimated:YES completion:^{}]; 98 | } 99 | 100 | @end 101 | -------------------------------------------------------------------------------- /Classes/Controllers/SingleComicViewController.h: -------------------------------------------------------------------------------- 1 | // 2 | // SingleComicViewController.h 3 | // xkcd 4 | // 5 | 6 | #import 7 | #import "SingleComicImageFetcherDelegate.h" 8 | 9 | @class Comic; 10 | @class SingleComicImageFetcher; 11 | 12 | @interface SingleComicViewController : UIViewController 13 | 14 | - (instancetype)initWithComic:(Comic *)comicToView; 15 | 16 | @property (nonatomic, readonly) Comic *comic; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Classes/Controllers/SingleComicViewController.m: -------------------------------------------------------------------------------- 1 | // 2 | // SingleComicViewController.m 3 | // xkcd 4 | // 5 | 6 | #import "SingleComicViewController.h" 7 | #import "Comic.h" 8 | #import "CGGeometry_TLCommon.h" 9 | #import "SingleComicImageFetcher.h" 10 | #import "ComicListViewController.h" 11 | #import "UIBarButtonItem_TLCommon.h" 12 | #import "TLMersenneTwister.h" 13 | #import "FCOpenInSafariActivity.h" 14 | #import "FCOpenInChromeActivity.h" 15 | #import "XkcdErrorCodes.h" 16 | #import "xkcd-Swift.h" 17 | 18 | #pragma mark - 19 | 20 | @interface SingleComicViewController () 21 | 22 | - (void)toggleToolbarsAnimated:(BOOL)animated; 23 | - (void)goToPreviousComic; 24 | - (void)goToRandomComic; 25 | - (void)goToNextComic; 26 | - (void)displayComicImage; 27 | - (void)setupToolbar; 28 | - (void)displayLoadingView; 29 | - (void)goToComicNumbered:(NSUInteger)comicNumber; 30 | - (void)calculateZoomScaleAndAnimate:(BOOL)animate; 31 | 32 | @property (nonatomic) Comic *comic; 33 | @property (nonatomic) UIImageView *comicImageView; 34 | @property (nonatomic) UIScrollView *imageScroller; 35 | @property (nonatomic) UIActivityIndicatorView *loadingView; 36 | @property (nonatomic) SingleComicImageFetcher *imageFetcher; 37 | @property (nonatomic) BOOL hidingToolbars; 38 | 39 | @end 40 | 41 | #pragma mark - 42 | 43 | @implementation SingleComicViewController 44 | 45 | - (instancetype)initWithComic:(Comic *)comicToView { 46 | if (self = [super initWithNibName:nil bundle:nil]) { 47 | _comic = comicToView; 48 | self.title = [NSString stringWithFormat:@"%li. %@", (long)_comic.number.integerValue, _comic.name]; 49 | } 50 | return self; 51 | } 52 | 53 | - (void)loadView { 54 | // Scroll view 55 | self.imageScroller = [[UIScrollView alloc] initWithFrame:CGRectZero]; 56 | self.imageScroller.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; 57 | self.imageScroller.backgroundColor = [UIColor whiteColor]; 58 | self.imageScroller.delaysContentTouches = NO; 59 | self.imageScroller.alwaysBounceVertical = YES; 60 | self.imageScroller.alwaysBounceHorizontal = YES; 61 | self.imageScroller.delegate = self; 62 | self.imageScroller.bouncesZoom = YES; 63 | self.imageScroller.scrollEnabled = YES; 64 | self.imageScroller.scrollsToTop = NO; 65 | 66 | self.view = self.imageScroller; 67 | } 68 | 69 | - (void)viewDidLoad { 70 | [super viewDidLoad]; 71 | self.view.autoresizingMask = UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleWidth; 72 | 73 | [self setupToolbar]; 74 | 75 | if (self.comic.downloaded) { 76 | [self displayComicImage]; 77 | } else { 78 | [self displayLoadingView]; 79 | self.imageFetcher = [[SingleComicImageFetcher alloc] initWithURLSession:[NSURLSession sharedSession]]; 80 | self.imageFetcher.delegate = self; 81 | [self.imageFetcher fetchImageForComic:self.comic context:nil]; 82 | } 83 | } 84 | 85 | - (void)viewWillAppear:(BOOL)animated { 86 | [super viewWillAppear:animated]; 87 | 88 | // For some reason, in iOS 13, I need to set this meaningless value. Without it, iOS applies some default insets to 89 | // the scroll indicators, which look horrible. No, UIEdgeInsetsZero doesn't work. 90 | self.imageScroller.horizontalScrollIndicatorInsets = UIEdgeInsetsMake(0, 1, 0, 1); 91 | 92 | [self calculateZoomScaleAndAnimate:NO]; 93 | 94 | if ([Preferences defaultPreferences].openZoomedOut) { 95 | [self.imageScroller setZoomScale:self.imageScroller.minimumZoomScale animated:NO]; 96 | } 97 | else { 98 | CGFloat defaultZoom = ([Comic potentiallyHasRetinaImage:self.comic] ? 0.5 : 1.0); 99 | defaultZoom = MAX(defaultZoom, self.imageScroller.minimumZoomScale); 100 | [self.imageScroller setZoomScale:defaultZoom animated:NO]; 101 | } 102 | } 103 | 104 | - (void)viewDidAppear:(BOOL)animated { 105 | [super viewDidAppear:animated]; 106 | 107 | [self calculateZoomScaleAndAnimate:animated]; 108 | } 109 | 110 | - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { 111 | [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; 112 | 113 | [coordinator animateAlongsideTransition:^(id _Nonnull context) { 114 | [self calculateZoomScaleAndAnimate:YES]; 115 | } completion:nil]; 116 | } 117 | 118 | - (void)setupToolbar { 119 | UIBarButtonItem *systemActionItem = [UIBarButtonItem barButtonSystemItem:UIBarButtonSystemItemAction 120 | target:self 121 | action:@selector(systemAction:)]; 122 | 123 | UIBarButtonItem *previousItem = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"previous"] 124 | style:UIBarButtonItemStylePlain 125 | target:self 126 | action:@selector(goToPreviousComic)]; 127 | previousItem.accessibilityLabel = NSLocalizedString(@"Older comic", @"older_comic_accessibility_label"); 128 | previousItem.enabled = (self.comic.number.unsignedIntegerValue != kMinComicNumber); 129 | 130 | UIBarButtonItem *randomItem = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"random"] 131 | style:UIBarButtonItemStylePlain 132 | target:self 133 | action:@selector(goToRandomComic)]; 134 | randomItem.accessibilityLabel = NSLocalizedString(@"Random comic", @"random_comic_accessibility_label"); 135 | 136 | UIBarButtonItem *nextItem = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"next"] 137 | style:UIBarButtonItemStylePlain 138 | target:self 139 | action:@selector(goToNextComic)]; 140 | nextItem.accessibilityLabel = NSLocalizedString(@"Newer comic", @"newer_comic_accessibility_label"); 141 | nextItem.enabled = (self.comic.number.unsignedIntegerValue != [Comic lastKnownComic].number.unsignedIntegerValue); 142 | 143 | NSArray *toolbarItems = @[systemActionItem, 144 | [UIBarButtonItem flexibleSpaceBarButtonItem], 145 | [UIBarButtonItem flexibleSpaceBarButtonItem], 146 | [UIBarButtonItem flexibleSpaceBarButtonItem], 147 | [UIBarButtonItem flexibleSpaceBarButtonItem], 148 | previousItem, 149 | [UIBarButtonItem flexibleSpaceBarButtonItem], 150 | randomItem, 151 | [UIBarButtonItem flexibleSpaceBarButtonItem], 152 | nextItem]; 153 | 154 | [self setToolbarItems:toolbarItems animated:NO]; 155 | [self.navigationController setToolbarHidden:NO animated:NO]; 156 | } 157 | 158 | - (void)displayComicImage { 159 | // Load up the comic image/view 160 | UIImage *comicImage = self.comic.image; 161 | self.comicImageView = [[UIImageView alloc] initWithImage:comicImage]; 162 | [self.imageScroller addSubview:self.comicImageView]; 163 | 164 | UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(showTitleText:)]; 165 | longPress.minimumPressDuration = 0.5f; 166 | [self.view addGestureRecognizer:longPress]; 167 | 168 | UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDetectDoubleTap:)]; 169 | doubleTap.numberOfTapsRequired = 2; 170 | [self.view addGestureRecognizer:doubleTap]; 171 | 172 | UITapGestureRecognizer *singleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didDetectSingleTap:)]; 173 | [singleTap requireGestureRecognizerToFail:doubleTap]; 174 | [self.view addGestureRecognizer:singleTap]; 175 | 176 | self.view.isAccessibilityElement = YES; 177 | self.view.accessibilityHint = nil; 178 | 179 | if (self.comic.transcript.length == 0) { 180 | self.view.accessibilityLabel = @"Transcript not available"; 181 | NSLog(@"Missing transcript for comic %li", (long)self.comic.number.integerValue); 182 | } else { 183 | self.view.accessibilityLabel = self.comic.transcript; // TODO: Clean up the transcript some for a more pleasant listening experience 184 | } 185 | } 186 | 187 | - (void)calculateZoomScaleAndAnimate:(BOOL)animate { 188 | CGSize contentSize = self.comic.image.size; 189 | 190 | self.imageScroller.contentSize = [Comic potentiallyHasRetinaImage:self.comic] ? CGSizeMake(contentSize.width / 2, contentSize.height / 2) : contentSize; 191 | self.imageScroller.maximumZoomScale = 2; 192 | 193 | CGFloat xMinZoom = self.imageScroller.frame.size.width / contentSize.width; 194 | CGFloat yMinZoom = (self.imageScroller.frame.size.height - (self.navigationController.navigationBar.frame.size.height + self.navigationController.toolbar.frame.size.height)) / contentSize.height; 195 | self.imageScroller.minimumZoomScale = MIN(MIN(xMinZoom, yMinZoom), 1); 196 | 197 | if (self.imageScroller.zoomScale < self.imageScroller.minimumZoomScale) { 198 | [self.imageScroller setZoomScale:self.imageScroller.minimumZoomScale animated:animate]; 199 | } 200 | } 201 | 202 | - (void)displayLoadingView { 203 | self.loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleGray]; 204 | [self.view addSubview:self.loadingView]; 205 | [self.loadingView startAnimating]; 206 | self.loadingView.translatesAutoresizingMaskIntoConstraints = NO; 207 | NSLayoutConstraint *centerX = [NSLayoutConstraint constraintWithItem:self.loadingView 208 | attribute:NSLayoutAttributeCenterX 209 | relatedBy:NSLayoutRelationEqual 210 | toItem:self.view 211 | attribute:NSLayoutAttributeCenterX 212 | multiplier:1.0 213 | constant:0]; 214 | NSLayoutConstraint *centerY = [NSLayoutConstraint constraintWithItem:self.loadingView 215 | attribute:NSLayoutAttributeCenterY 216 | relatedBy:NSLayoutRelationEqual 217 | toItem:self.view 218 | attribute:NSLayoutAttributeCenterY 219 | multiplier:1.0 220 | constant:0]; 221 | [self.view addConstraint:centerX]; 222 | [self.view addConstraint:centerY]; 223 | } 224 | 225 | - (void)toggleToolbarsAnimated:(BOOL)animated { 226 | self.hidingToolbars = !self.navigationController.toolbarHidden; 227 | [self.navigationController setToolbarHidden:self.hidingToolbars animated:animated]; 228 | [self.navigationController setNavigationBarHidden:self.hidingToolbars animated:animated]; 229 | 230 | [self setNeedsStatusBarAppearanceUpdate]; 231 | } 232 | 233 | - (BOOL)prefersStatusBarHidden { 234 | return self.hidingToolbars 235 | || self.traitCollection.verticalSizeClass == UIUserInterfaceSizeClassCompact; 236 | } 237 | 238 | - (void)systemAction:(UIBarButtonItem *)sender { 239 | FCOpenInSafariActivity *safariActivity = [[FCOpenInSafariActivity alloc] init]; 240 | FCOpenInChromeActivity *chromeActivity = [[FCOpenInChromeActivity alloc] init]; 241 | 242 | NSMutableArray *activityItems = [NSMutableArray arrayWithCapacity:2]; 243 | NSURL *comicUrl = [NSURL URLWithString:self.comic.websiteURL]; 244 | [activityItems addObject:comicUrl]; 245 | if (self.comic.downloaded && self.comic.image) { 246 | [activityItems addObject:self.comic.image]; 247 | } 248 | 249 | UIActivityViewController *activityViewController = [[UIActivityViewController alloc] initWithActivityItems:activityItems 250 | applicationActivities:@[safariActivity, chromeActivity]]; 251 | activityViewController.excludedActivityTypes = @[UIActivityTypeAssignToContact]; 252 | [self presentViewController:activityViewController animated:YES completion:^{}]; 253 | } 254 | 255 | - (void)goToPreviousComic { 256 | [self goToComicNumbered:([self.comic.number unsignedIntegerValue] - 1)]; 257 | } 258 | 259 | - (void)goToRandomComic { 260 | NSUInteger maxComicNumber = [[Comic lastKnownComic].number unsignedIntegerValue]; 261 | long randNumber = [TLMersenneTwister randInt31]; 262 | NSUInteger randomComicNumber = randNumber % (maxComicNumber - kMinComicNumber) + kMinComicNumber; 263 | [self goToComicNumbered:randomComicNumber]; 264 | } 265 | 266 | - (void)goToNextComic { 267 | [self goToComicNumbered:([self.comic.number unsignedIntegerValue] + 1)]; 268 | } 269 | 270 | - (void)goToComicNumbered:(NSUInteger)comicNumber { 271 | NSMutableArray *viewControllerStack = [self.navigationController.viewControllers mutableCopy]; 272 | Comic *newComic = [Comic comicNumbered:comicNumber]; 273 | SingleComicViewController *newSingleComicViewController = [[SingleComicViewController alloc] initWithComic:newComic]; 274 | viewControllerStack[[viewControllerStack count] - 1] = newSingleComicViewController; 275 | [self.navigationController setViewControllers:viewControllerStack animated:NO]; 276 | 277 | ComicListViewController *comicList = viewControllerStack[0]; 278 | 279 | NSIndexPath* indexPath = [[comicList activeFetchedResultsController] 280 | indexPathForObject:newComic]; 281 | if (indexPath) { 282 | [comicList.tableView selectRowAtIndexPath:indexPath 283 | animated:NO 284 | scrollPosition:UITableViewScrollPositionMiddle]; 285 | } 286 | } 287 | 288 | #pragma mark - Gesture recognizer callbacks 289 | 290 | - (void)didDetectDoubleTap:(UITapGestureRecognizer *)recognizer { 291 | CGFloat newZoomScale = 1.0f; 292 | if (self.imageScroller.zoomScale == self.imageScroller.minimumZoomScale) { 293 | newZoomScale = (self.imageScroller.minimumZoomScale * 2) > self.imageScroller.maximumZoomScale ? self.imageScroller.maximumZoomScale : (self.imageScroller.minimumZoomScale * 2); 294 | // zoom towards the user's double tap 295 | CGPoint centerPoint = [recognizer locationInView:self.imageScroller]; 296 | NSLog(@"scale = %f, point = %@", newZoomScale, NSStringFromCGPoint(centerPoint)); 297 | [self.imageScroller setZoomScaleWithScale:newZoomScale centerPoint:centerPoint animated:YES]; 298 | } else { 299 | newZoomScale = self.imageScroller.minimumZoomScale; 300 | NSLog(@"scale = %f", newZoomScale); 301 | [self.imageScroller setZoomScale:newZoomScale animated:YES]; 302 | } 303 | } 304 | 305 | - (void)didDetectSingleTap:(UITapGestureRecognizer *)recognizer { 306 | [self toggleToolbarsAnimated:YES]; 307 | } 308 | 309 | - (void)showTitleText:(UILongPressGestureRecognizer *)recognizer { 310 | if (recognizer.state == UIGestureRecognizerStateBegan) { 311 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:self.comic.name message:self.comic.titleText preferredStyle:UIAlertControllerStyleAlert]; 312 | [alertController addAction: 313 | [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"Confirmation action title.") 314 | style:UIAlertActionStyleCancel 315 | handler:^(UIAlertAction * _Nonnull action) {}] 316 | ]; 317 | [self presentViewController:alertController animated:YES completion:nil]; 318 | } 319 | } 320 | 321 | #pragma mark - SingleComicImageFetcherDelegate methods 322 | 323 | - (void)singleComicImageFetcher:(SingleComicImageFetcher *)fetcher 324 | didFetchImageForComic:(Comic *)comic 325 | context:(id)context { 326 | self.imageFetcher = nil; 327 | [self.loadingView removeFromSuperview]; 328 | [self displayComicImage]; 329 | [self calculateZoomScaleAndAnimate:NO]; 330 | } 331 | 332 | - (void)singleComicImageFetcher:(SingleComicImageFetcher *)fetcher 333 | didFailWithError:(NSError *)error 334 | onComic:(Comic *)comic { 335 | // Tell the user 336 | NSString *localizedFormatString; 337 | 338 | if ([error.domain isEqualToString:kXkcdErrorDomain]) { 339 | // internal error 340 | localizedFormatString = NSLocalizedString(@"Could not download xkcd %i.", 341 | @"Text of unknown error image download fail alert"); 342 | } 343 | else { 344 | localizedFormatString = NSLocalizedString(@"Could not download xkcd %i -- no internet connection.", 345 | @"Text of image download fail alert due to connectivity"); 346 | } 347 | 348 | NSString *failAlertMessage = [NSString stringWithFormat:localizedFormatString, comic.number.integerValue]; 349 | UIAlertController *alertController = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Whoops", @"Title of image download fail alert") 350 | message:failAlertMessage 351 | preferredStyle:UIAlertControllerStyleAlert]; 352 | [alertController addAction: 353 | [UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"Title of a confirmation dialog") 354 | style:UIAlertActionStyleDefault 355 | handler:^(UIAlertAction * _Nonnull action) { 356 | [self.navigationController popViewControllerAnimated:YES]; 357 | }] 358 | ]; 359 | 360 | [self presentViewController:alertController animated:YES completion:nil]; 361 | } 362 | 363 | #pragma mark - UIScrollViewDelegate methods 364 | 365 | - (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView { 366 | return self.comicImageView; 367 | } 368 | 369 | @end 370 | -------------------------------------------------------------------------------- /Classes/CoreDataStack.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataStack.swift 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 1/24/16. 6 | // 7 | // 8 | 9 | import CoreData 10 | 11 | /// A class that sets up the Core Data stack used in the application. 12 | final class CoreDataStack: NSObject { 13 | 14 | /// The directory where the application stores its documents. 15 | @objc var applicationsDocumentsDirectory: String 16 | 17 | /// The context where all of the Core Data objects are managed in the application. 18 | @objc var managedObjectContext: NSManagedObjectContext 19 | 20 | private var managedObjectModel: NSManagedObjectModel 21 | private var persistentStoreCoordinator: NSPersistentStoreCoordinator 22 | 23 | /// Holds the singleton returned by `sharedCoreDataStack()`. 24 | private static var sharedCoreDataStackStorage: CoreDataStack? 25 | 26 | /** 27 | A singleton Core Data stack instance that is used across the application. 28 | 29 | - note: Ideally we would use dependency injection instead of obfuscating the dependency graph like this. 30 | On the other hand, shipping is better than perfect. 31 | 32 | - returns: A fully initialized `CoreDataStack`. 33 | */ 34 | @objc class func sharedCoreDataStack() -> CoreDataStack { 35 | if let coreDataStack = CoreDataStack.sharedCoreDataStackStorage { 36 | return coreDataStack 37 | } 38 | else { 39 | let coreDataStack = CoreDataStack() 40 | CoreDataStack.sharedCoreDataStackStorage = coreDataStack 41 | return coreDataStack 42 | } 43 | } 44 | 45 | /** 46 | Initializes a `CoreDataStack`. 47 | 48 | - returns: A fully initialized `CoreDataStack`. 49 | */ 50 | override init() { 51 | let fileManager = FileManager.default 52 | guard let applicationsDocumentsDirectory = NSSearchPathForDirectoriesInDomains(.documentDirectory, [.userDomainMask], true).first else { 53 | fatalError("Unable to get the applications documents directory.") 54 | } 55 | 56 | guard let managedObjectModel = NSManagedObjectModel.mergedModel(from: nil) else { 57 | fatalError("Unable to create a managed object model.") 58 | } 59 | 60 | // Clean up the old file from pervious versions 61 | let oldStorePath = (applicationsDocumentsDirectory as NSString).appendingPathComponent("xkcd.sqlite") 62 | if fileManager.fileExists(atPath: oldStorePath) { 63 | do { 64 | try fileManager.removeItem(atPath: oldStorePath) 65 | } 66 | catch let error as NSError { 67 | print("Error removing old SQLite file at \(oldStorePath): \(error.description)") 68 | } 69 | } 70 | 71 | let storePath = (applicationsDocumentsDirectory as NSString).appendingPathComponent("comics.sqlite") 72 | if !fileManager.fileExists(atPath: storePath) { 73 | if let bundledPath = Bundle.main.path(forResource: "comics", ofType: "sqlite") { 74 | if fileManager.fileExists(atPath: bundledPath) { 75 | do { 76 | try fileManager.copyItem(atPath: bundledPath, toPath: storePath) 77 | } 78 | catch let error as NSError { 79 | print("The SQLite database does not exist, and the sample one in the bundle is not able to be copied: \(error.description)") 80 | } 81 | } 82 | } 83 | } 84 | 85 | let storeURL = NSURL.fileURL(withPath: storePath) 86 | let persistentStoreCoordinator = NSPersistentStoreCoordinator(managedObjectModel: managedObjectModel) 87 | do { 88 | try persistentStoreCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeURL, options: [NSMigratePersistentStoresAutomaticallyOption: true, NSInferMappingModelAutomaticallyOption: true]) 89 | managedObjectContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType) 90 | managedObjectContext.persistentStoreCoordinator = persistentStoreCoordinator 91 | } 92 | catch let error as NSError { 93 | fatalError("Unable to add the SQLite store to the persistent store coordinator: \(error.description)") 94 | } 95 | 96 | self.persistentStoreCoordinator = persistentStoreCoordinator 97 | self.applicationsDocumentsDirectory = applicationsDocumentsDirectory 98 | self.managedObjectModel = managedObjectModel 99 | 100 | super.init() 101 | 102 | NotificationCenter.default.addObserver(self, selector: #selector(UIApplicationDelegate.applicationWillTerminate(_:)), name: UIApplication.willTerminateNotification, object: nil) 103 | } 104 | 105 | // MARK: - Saving 106 | 107 | /** 108 | Saves the managed object context. 109 | */ 110 | @objc func save() { 111 | assert(Thread.isMainThread, "This Core Data stack only supports main thread concurrency.") 112 | 113 | if managedObjectContext.hasChanges { 114 | do { 115 | try managedObjectContext.save() 116 | } 117 | catch let error as NSError { 118 | print("Could not save CoreData changes: \(error.description)") 119 | } 120 | } 121 | } 122 | 123 | // MARK: - Notifications 124 | 125 | /** 126 | Called when the application will terminate. Do not call this directly. 127 | 128 | - parameter notification: The notification that triggered this method call. 129 | */ 130 | @objc func applicationWillTerminate(notification: NSNotification) { 131 | if managedObjectContext.hasChanges { 132 | do { 133 | try managedObjectContext.save() 134 | } 135 | catch let error as NSError { 136 | print("Could not save CoreData changes: \(error.description)") 137 | exit(EXIT_FAILURE) 138 | } 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /Classes/FetchComicFromWeb.h: -------------------------------------------------------------------------------- 1 | // 2 | // FetchComicFromWeb.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/1/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface FetchComicFromWeb : NSOperation 12 | 13 | - (instancetype)initWithComicNumber:(NSInteger)comicNumberToFetch 14 | URLSession:(NSURLSession *)URLSession 15 | completionTarget:(id)completionTarget 16 | action:(SEL)completionAction; 17 | 18 | @property (nonatomic, readonly) NSInteger comicNumber; 19 | @property (nonatomic, readonly) NSString *comicName; 20 | @property (nonatomic, readonly) NSString *comicTitleText; 21 | @property (nonatomic, readonly) NSString *comicImageURL; 22 | @property (nonatomic, readonly) NSString *comicTranscript; 23 | @property (nonatomic, readonly) NSString *link; 24 | @property (nonatomic, readonly) NSError *error; 25 | @property (nonatomic, readonly) BOOL got404; 26 | 27 | @end 28 | -------------------------------------------------------------------------------- /Classes/FetchComicFromWeb.m: -------------------------------------------------------------------------------- 1 | // 2 | // FetchComicFromWeb.m 3 | // 4 | 5 | #import "FetchComicFromWeb.h" 6 | #import "Comic.h" 7 | #import "NSString_HTML.h" 8 | #import "NSDictionary+TypeSafety.h" 9 | #import "xkcd-Swift.h" 10 | 11 | #pragma mark - 12 | 13 | @interface FetchComicFromWeb () 14 | 15 | @property (nonatomic) NSInteger comicNumber; 16 | @property (nonatomic) NSString *comicName; 17 | @property (nonatomic) NSString *comicTitleText; 18 | @property (nonatomic) NSString *comicImageURL; 19 | @property (nonatomic) NSString *comicTranscript; 20 | @property (nonatomic) NSString *link; 21 | @property (nonatomic, weak) id target; 22 | @property (nonatomic) SEL action; 23 | @property (nonatomic) NSError *error; 24 | @property (nonatomic) BOOL got404; 25 | @property (nonatomic) NSURLSession *URLSession; 26 | @property (nonatomic) NSURLSessionDataTask *dataTask; 27 | 28 | @end 29 | 30 | #pragma mark - 31 | 32 | @implementation FetchComicFromWeb 33 | 34 | - (instancetype)initWithComicNumber:(NSInteger)comicNumberToFetch 35 | URLSession:(NSURLSession *)URLSession 36 | completionTarget:(id)completionTarget 37 | action:(SEL)completionAction { 38 | if (self = [super init]) { 39 | _comicNumber = comicNumberToFetch; 40 | _target = completionTarget; 41 | _action = completionAction; 42 | _URLSession = URLSession; 43 | } 44 | return self; 45 | } 46 | 47 | - (void)main { 48 | if (self.comicNumber == 404) { 49 | // Smart ass :) 50 | self.comicName = @"Not found"; 51 | self.comicTitleText = @""; 52 | self.comicImageURL = @"http://imgs.xkcd.com/static/xkcdLogo.png"; // anything... 53 | self.comicTranscript = @""; 54 | 55 | if (![self isCancelled]) { 56 | [self.target performSelectorOnMainThread:self.action withObject:self waitUntilDone:NO]; 57 | } 58 | } 59 | else { 60 | NSURL *comicURL = [NSURL URLWithString:[NSString stringWithFormat:@"http://www.xkcd.com/%li/info.0.json", (long)self.comicNumber]]; 61 | NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:comicURL]; 62 | [request setValue:[Constants userAgent] forHTTPHeaderField:@"User-Agent"]; 63 | 64 | self.dataTask = [self.URLSession dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { 65 | if ([response isKindOfClass:[NSHTTPURLResponse class]]) { 66 | NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode]; 67 | self.got404 = statusCode == 404; 68 | } 69 | 70 | if (!self.got404) { 71 | self.error = error; 72 | if (!error) { 73 | NSError *parseError = nil; 74 | NSDictionary *comicDictionary = [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseError]; 75 | self.error = parseError; 76 | if (!parseError && [comicDictionary isKindOfClass:[NSDictionary class]]) { 77 | self.comicName = [NSString stringByCleaningHTML:[comicDictionary stringForKey:@"title"]]; 78 | self.comicTitleText = [NSString stringByCleaningHTML:[comicDictionary stringForKey:@"alt"]]; 79 | self.comicImageURL = [NSString stringByCleaningHTML:[comicDictionary stringForKey:@"img"]]; 80 | self.comicTranscript = [comicDictionary stringForKey:@"transcript"]; 81 | self.link = [comicDictionary stringForKey:@"link"]; 82 | } 83 | } 84 | } 85 | 86 | if (![self isCancelled]) { 87 | [self.target performSelectorOnMainThread:self.action withObject:self waitUntilDone:NO]; 88 | } 89 | }]; 90 | 91 | [self.dataTask resume]; 92 | } 93 | } 94 | 95 | @end 96 | -------------------------------------------------------------------------------- /Classes/FetchComicImageFromWeb.h: -------------------------------------------------------------------------------- 1 | // 2 | // FetchComicImageFromWeb.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/2/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | @interface FetchComicImageFromWeb : NSOperation 12 | 13 | - (instancetype)initWithComicNumber:(NSInteger)number 14 | imageURLs:(NSArray *)imageURLs 15 | URLSession:(NSURLSession *)session 16 | completionTarget:(id)completionTarget 17 | action:(SEL)completionAction 18 | context:(id)context; 19 | 20 | @property (nonatomic, readonly) NSData *comicImageData; 21 | @property (nonatomic, readonly) BOOL isRetinaImage; 22 | @property (nonatomic, readonly) NSInteger comicNumber; 23 | @property (nonatomic, readonly) NSError *error; 24 | @property (nonatomic, readonly) id context; 25 | 26 | @end 27 | -------------------------------------------------------------------------------- /Classes/FetchComicImageFromWeb.m: -------------------------------------------------------------------------------- 1 | // 2 | // FetchComicImageFromWeb.m 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/2/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import "FetchComicImageFromWeb.h" 10 | #import "TLMacros.h" 11 | #import "xkcd-Swift.h" 12 | 13 | #pragma mark - 14 | 15 | @interface FetchComicImageFromWeb () 16 | 17 | @property (nonatomic) NSInteger comicNumber; 18 | @property (nonatomic) NSArray *comicImageURLs; 19 | @property (nonatomic) NSData *comicImageData; 20 | @property (nonatomic) BOOL isRetinaImage; 21 | @property (nonatomic, weak) id target; 22 | @property (nonatomic) SEL action; 23 | @property (nonatomic) NSError *error; 24 | @property (nonatomic) id context; 25 | @property (nonatomic) NSURLSession *URLSession; 26 | 27 | @property (nonatomic) NSUInteger currentImageURLIndex; 28 | 29 | @end 30 | 31 | #pragma mark - 32 | 33 | @implementation FetchComicImageFromWeb 34 | 35 | - (instancetype)initWithComicNumber:(NSInteger)number 36 | imageURLs:(NSArray *)imageURLs 37 | URLSession:(NSURLSession *)session 38 | completionTarget:(id)completionTarget 39 | action:(SEL)completionAction 40 | context:(id)aContext { 41 | if (self = [super init]) { 42 | _comicNumber = number; 43 | _comicImageURLs = imageURLs; 44 | _target = completionTarget; 45 | _action = completionAction; 46 | _context = aContext; 47 | _URLSession = session; 48 | _currentImageURLIndex = 0; 49 | } 50 | 51 | return self; 52 | } 53 | 54 | - (void)main { 55 | self.currentImageURLIndex = 0; 56 | [self requestNextImage]; 57 | } 58 | 59 | - (void)requestNextImage { 60 | [self requestImage:self.comicImageURLs[self.currentImageURLIndex] completion:^(NSData *data, NSURLResponse *response, NSError *error) { 61 | if ([response isKindOfClass:[NSHTTPURLResponse class]]) { 62 | NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; 63 | 64 | if (httpResponse.statusCode != 200 || error) { 65 | TLDebugLog(@"Image fetch for %@ failed with error: %@", self.comicImageURLs[self.currentImageURLIndex], self.error); 66 | 67 | self.currentImageURLIndex++; 68 | 69 | if (self.currentImageURLIndex < self.comicImageURLs.count) { 70 | // If this failed and we have more imageURLs to use, try the next one. 71 | [self requestNextImage]; 72 | } else { 73 | // If this failed and we're at the end of the list, fail the operation. 74 | [self completeRequestWithComicImageData:nil error:error]; 75 | } 76 | } else { 77 | [self completeRequestWithComicImageData:data error:nil]; 78 | } 79 | } 80 | }]; 81 | } 82 | 83 | - (void)requestImage:(NSURL *)imageURL completion:(void (^)(NSData *, NSURLResponse *, NSError *))completion { 84 | NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:imageURL 85 | cachePolicy:NSURLRequestUseProtocolCachePolicy 86 | timeoutInterval:180.0f]; 87 | [request setValue:[Constants userAgent] forHTTPHeaderField:@"User-Agent"]; 88 | 89 | TLDebugLog(@"Fetching image at %@", imageURL); 90 | 91 | [[self.URLSession dataTaskWithRequest:request 92 | completionHandler:completion 93 | ] resume]; 94 | } 95 | 96 | - (void)completeRequestWithComicImageData:(NSData *)data error:(NSError *)error { 97 | self.comicImageData = data; 98 | self.error = error; 99 | 100 | if (self.error) { 101 | TLDebugLog(@"Image fetch completed with error: %@", self.error); 102 | } 103 | 104 | if(![self isCancelled]) { 105 | [self.target performSelectorOnMainThread:self.action 106 | withObject:self 107 | waitUntilDone:NO]; 108 | } 109 | } 110 | 111 | - (BOOL)shouldAttemptToDownloadRetinaImage { 112 | // https://xkcd.com/1053/ is the first comic that shows up with a retina version 113 | return self.comicNumber >= 1053 114 | // these comics don't work via the API at all, so don't bother trying to download the retina image 115 | && self.comicNumber != 1663 // https://xkcd.com/1663/ 116 | && self.comicNumber != 1608; // https://xkcd.com/1608/ 117 | } 118 | 119 | @end 120 | -------------------------------------------------------------------------------- /Classes/Models/Comic.h: -------------------------------------------------------------------------------- 1 | // 2 | // Comic.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/1/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import 10 | 11 | #define kMinComicNumber 1 12 | 13 | @import CoreData; 14 | @import UIKit; 15 | 16 | @interface Comic : NSManagedObject 17 | 18 | + (Comic *)comic; // use this, not init/etc. 19 | 20 | + (void)deleteAllComics; // for total recreation from scratch 21 | + (NSArray *)allComics; // use sparingly!! 22 | + (NSArray *)comicsWithoutImages; 23 | 24 | + (Comic *)lastKnownComic; // highest numbered comic that has been fetched 25 | 26 | + (Comic *)comicNumbered:(NSInteger)comicNumber; 27 | + (BOOL)potentiallyHasRetinaImage:(Comic *)comic; 28 | 29 | - (void)saveImageData:(NSData *)imageData; 30 | - (BOOL)downloaded; 31 | + (NSEntityDescription *)entityDescription; 32 | - (void)deleteImage; 33 | - (NSString *)websiteURL; 34 | + (void)synchronizeDownloadedImages; 35 | + (NSSet *)downloadedImages; 36 | + (void)deleteDownloadedImage:(NSString *)downloadedImage; // strings drawn from +downloadedImages 37 | + (NSString *)imagePathForImageFilename:(NSString *)imageFilename; 38 | 39 | @property (nonatomic, readonly) UIImage *image; 40 | @property (nonatomic) NSNumber *loading; 41 | @property (nonatomic) NSString *imageURL; 42 | @property (nonatomic) NSString *name; 43 | @property (nonatomic) NSString *titleText; 44 | @property (nonatomic) NSString *transcript; 45 | @property (nonatomic) NSString *link; 46 | @property (nonatomic) NSNumber *number; 47 | 48 | @property (nonatomic, readonly) NSArray *imageURLs; 49 | 50 | @end 51 | -------------------------------------------------------------------------------- /Classes/Models/Comic.m: -------------------------------------------------------------------------------- 1 | // 2 | // Comic.m 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/1/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import "Comic.h" 10 | #import "NSArray+Filtering.h" 11 | #import "NSMutableArray+Safety.h" 12 | #import "TLMacros.h" 13 | #import "xkcd-Swift.h" 14 | 15 | #pragma mark - 16 | 17 | #define kAttributeNumber @"number" 18 | #define kAttributeImageURL @"imageURL" 19 | #define kAttributeLoading @"loading" 20 | #define kAttributeName @"name" 21 | #define kAttributeTitleText @"titleText" 22 | 23 | #pragma mark - 24 | 25 | static NSEntityDescription *comicEntityDescription = nil; 26 | static NSMutableSet *downloadedImages = nil; 27 | 28 | #pragma mark - 29 | 30 | @interface Comic () 31 | 32 | @property (nonatomic, readonly) NSString *imagePath; 33 | @property (nonatomic, readonly) NSString *imageFilename; 34 | 35 | @end 36 | 37 | #pragma mark - 38 | 39 | @implementation Comic 40 | 41 | @dynamic name; 42 | @dynamic titleText; 43 | @dynamic transcript; 44 | @dynamic imageURL; 45 | @dynamic number; 46 | @dynamic loading; 47 | @dynamic link; 48 | 49 | + (void)initialize { 50 | if ([self class] == [Comic class]) { 51 | if (!comicEntityDescription) { 52 | comicEntityDescription = [NSEntityDescription entityForName:@"Comic" inManagedObjectContext:[CoreDataStack sharedCoreDataStack].managedObjectContext]; 53 | } 54 | } 55 | } 56 | 57 | + (void)synchronizeDownloadedImages { 58 | NSFileManager *fileManager = [NSFileManager defaultManager]; 59 | NSError *error = nil; 60 | TLDebugLog(@"Starting synchronization of downloaded images"); 61 | NSArray *allDocuments = [fileManager contentsOfDirectoryAtPath:[CoreDataStack sharedCoreDataStack].applicationsDocumentsDirectory error:&error]; 62 | if (!error) { 63 | NSArray *imageDataPaths = [allDocuments objectsPassingTest:^BOOL (id obj) { 64 | NSString *path = (NSString *)obj; 65 | return [path hasSuffix:@".imagedata"]; 66 | }]; 67 | downloadedImages = [NSMutableSet setWithArray:imageDataPaths]; 68 | TLDebugLog(@"Synchronized downloaded images: %lu images", downloadedImages.count); 69 | } 70 | } 71 | 72 | + (Comic *)comic { 73 | Comic *comic = [[Comic alloc] initWithEntity:comicEntityDescription insertIntoManagedObjectContext:[CoreDataStack sharedCoreDataStack].managedObjectContext]; 74 | return comic; 75 | } 76 | 77 | + (Comic *)lastKnownComic { 78 | NSFetchRequest *request = [[NSFetchRequest alloc] init]; 79 | request.entity = comicEntityDescription; 80 | request.sortDescriptors = @[[[NSSortDescriptor alloc] initWithKey:kAttributeNumber ascending:NO]]; 81 | request.fetchLimit = 1; 82 | 83 | NSError *error = nil; 84 | CoreDataStack *coreDataStack = [CoreDataStack sharedCoreDataStack]; 85 | NSArray *array = [coreDataStack.managedObjectContext executeFetchRequest:request error:&error]; 86 | 87 | Comic *lastKnownComic = nil; 88 | if (error || !array || array.count == 0) { 89 | NSLog(@"Couldn't find last comic, error: %@", error); 90 | } 91 | else { 92 | lastKnownComic = array[0]; 93 | } 94 | return lastKnownComic; 95 | } 96 | 97 | + (Comic *)comicNumbered:(NSInteger)comicNumber { 98 | NSFetchRequest *request = [[NSFetchRequest alloc] init]; 99 | request.entity = comicEntityDescription; 100 | 101 | request.predicate = [NSPredicate predicateWithFormat:kAttributeNumber @" = %@", @(comicNumber)]; 102 | request.fetchLimit = 1; 103 | 104 | NSError *error = nil; 105 | NSArray *array = [[CoreDataStack sharedCoreDataStack].managedObjectContext executeFetchRequest:request error:&error]; 106 | 107 | Comic *comic = nil; 108 | if (error || array.count == 0) { 109 | NSLog(@"Couldn't find comic numbered %li, error: %@", (long)comicNumber, error); 110 | } 111 | else { 112 | comic = array[0]; 113 | } 114 | return comic; 115 | } 116 | 117 | + (NSArray *)allComics { 118 | NSFetchRequest *request = [[NSFetchRequest alloc] init]; 119 | request.entity = comicEntityDescription; 120 | request.sortDescriptors = @[[[NSSortDescriptor alloc] initWithKey:kAttributeNumber ascending:NO]]; 121 | 122 | NSError *error = nil; 123 | NSArray *allComics = [[CoreDataStack sharedCoreDataStack].managedObjectContext executeFetchRequest:request error:&error]; 124 | return allComics; 125 | } 126 | 127 | + (NSArray *)comicsWithoutImages { 128 | NSFetchRequest *request = [[NSFetchRequest alloc] init]; 129 | request.entity = comicEntityDescription; 130 | 131 | // This is pretty lame, but for now, it gets the job down. Someday, fix this ugly hack. 132 | NSMutableSet *downloadedImageNumbers = [NSMutableSet setWithCapacity:downloadedImages.count]; 133 | for (NSString *downloadedImageFilename in downloadedImages) { 134 | NSNumber *downloadedImageNumber = @([downloadedImageFilename integerValue]); 135 | [downloadedImageNumbers addObject:downloadedImageNumber]; 136 | } 137 | 138 | request.predicate = [NSPredicate predicateWithFormat:@"NOT (" kAttributeNumber " IN %@)", downloadedImageNumbers]; 139 | request.sortDescriptors = @[[[NSSortDescriptor alloc] initWithKey:kAttributeNumber ascending:YES]]; 140 | 141 | NSError *error = nil; 142 | NSArray *comics = [[CoreDataStack sharedCoreDataStack].managedObjectContext executeFetchRequest:request error:&error]; 143 | return comics; 144 | } 145 | 146 | - (void)deleteImage { 147 | [[self class] deleteDownloadedImage:self.imageFilename]; 148 | } 149 | 150 | + (void)deleteAllComics { 151 | // No need to be efficient, this is only done during development 152 | for (Comic *comic in [self allComics]) { 153 | [[CoreDataStack sharedCoreDataStack].managedObjectContext deleteObject:comic]; 154 | } 155 | [[CoreDataStack sharedCoreDataStack] save]; 156 | } 157 | 158 | - (void)saveImageData:(NSData *)imageData { 159 | NSString *path = self.imagePath; 160 | 161 | [imageData writeToFile:path atomically:YES]; 162 | [downloadedImages addObject:self.imageFilename]; 163 | 164 | // mark as iCloud do-not-backup (since it can be redownloaded as needed) 165 | NSURL *fileURL = [NSURL fileURLWithPath:path]; 166 | NSError *error = nil; 167 | [fileURL setResourceValue:@YES 168 | forKey:NSURLIsExcludedFromBackupKey 169 | error:&error]; 170 | if (error) { 171 | TLDebugLog(@"Error setting do-not-backup for %@: %@", path, error); 172 | } 173 | } 174 | 175 | + (NSEntityDescription *)entityDescription { 176 | return comicEntityDescription; 177 | } 178 | 179 | - (NSString *)websiteURL { 180 | return [NSString stringWithFormat:@"http://xkcd.com/%li", (long)[self.number integerValue]]; 181 | } 182 | 183 | + (NSSet *)downloadedImages { 184 | return [downloadedImages copy]; 185 | } 186 | 187 | + (void)deleteDownloadedImage:(NSString *)imageFilename { 188 | NSString *imagePath = [self imagePathForImageFilename:imageFilename]; 189 | NSLog(@"Deleting %@ (at %@)", imageFilename, imagePath); 190 | NSError *deleteError = nil; 191 | [[NSFileManager defaultManager] removeItemAtPath:imagePath error:&deleteError]; 192 | if (!deleteError) { 193 | [downloadedImages removeObject:imageFilename]; 194 | } 195 | if (deleteError && ([deleteError code] != NSFileNoSuchFileError)) { 196 | NSLog(@"Delete fail %@: %@", deleteError, deleteError.userInfo); 197 | } 198 | } 199 | 200 | + (NSString *)imagePathForImageFilename:(NSString *)imageFilename { 201 | return [[CoreDataStack sharedCoreDataStack].applicationsDocumentsDirectory stringByAppendingPathComponent:imageFilename]; 202 | } 203 | 204 | + (BOOL)hasLinkedToImage:(Comic *)comic { 205 | // I have this hardcoded list here because these are the comics that are known to have image URLs in their link field. 206 | // Because I'm doing the CoreData migration to support the link attribute long after these comics were published, 207 | // checking the link attribute won't work. Most users will already have these images downloaded, but some may clear 208 | // out their images and redownload all of them (because I added support for huge images, large images, and 2x images). 209 | NSArray *comicsThatHaveLinkedToImages = @[@273, @256]; 210 | 211 | if ([comicsThatHaveLinkedToImages containsObject:comic.number]) { 212 | return YES; 213 | } else if (comic.link) { 214 | // All of the links for linked-to images seem to end with .png. If this comic has a link like that, we will use that URL 215 | // to try to download it. (See: -imageURLs computed property below) 216 | NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\b.+(\\.png)\\b" options:0 error:nil]; 217 | return [regex numberOfMatchesInString:comic.link options:0 range:NSMakeRange(0, comic.link.length)] > 0; 218 | } 219 | 220 | return NO; 221 | } 222 | 223 | + (BOOL)potentiallyHasHugeImage:(Comic *)comic { 224 | // I have this hardcoded list here because these are the comics that are known to have huge images. 225 | // Because I'm doing the CoreData migration to support the link attribute long after these comics were published, 226 | // checking the link attribute won't work. Most users will already have these images downloaded, but some may clear 227 | // out their images and redownload all of them (because I added support for huge images, large images, and 2x images). 228 | NSArray *comicsThatHaveHugeImages = @[@980]; 229 | if ([comicsThatHaveHugeImages containsObject:comic.number]) { 230 | return YES; 231 | } else if (comic.link) { 232 | // All of the links for huge images seem to end with /huge. If this comic has a link like that, we will guess at a huge image 233 | // url and try to download it. (See: -imageURLs computed property below) 234 | NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\b.+(/huge/)\\b" options:0 error:nil]; 235 | return [regex numberOfMatchesInString:comic.link options:0 range:NSMakeRange(0, comic.link.length)] > 0; 236 | } 237 | 238 | return NO; 239 | } 240 | 241 | + (BOOL)potentiallyHasLargeImage:(Comic *)comic { 242 | // I have this hardcoded list here because these are the comics that are known to have large images. 243 | // Because I'm doing the CoreData migration to support the link attribute long after these comics were published, 244 | // checking the link attribute won't work. Most users will already have these images downloaded, but some may clear 245 | // out their images and redownload all of them (because I added support for large images and 2x images). 246 | NSArray *comicsThatHaveLargeImages = @[@1970, @1939, @1688, @1509, @1491, @1461, @1407, @1392, @1389, @1298, @1256, @1212, 247 | @1196, @1127, @1080, @1079, @1071, @1040, @1000, @930, @850, @832, @802, @681, @657]; 248 | if ([comicsThatHaveLargeImages containsObject:comic.number]) { 249 | return YES; 250 | } else if (comic.link) { 251 | // All of the links for large images seem to end with _large or /large. If this comic has a link like that, we will guess at a large image 252 | // url and try to download it. (See: -imageURLs computed property below) 253 | NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\b.+([_/]large/)\\b" options:0 error:nil]; 254 | return [regex numberOfMatchesInString:comic.link options:0 range:NSMakeRange(0, comic.link.length)] > 0; 255 | } 256 | 257 | return NO; 258 | } 259 | 260 | + (BOOL)potentiallyHasRetinaImage:(Comic *)comic { 261 | NSUInteger comicNumber = comic.number.unsignedIntegerValue; 262 | // https://xkcd.com/1053/ is the first comic that shows up with a retina version 263 | return comicNumber >= 1053 264 | // these comics don't work via the API at all, so don't bother trying to download the retina image 265 | && comicNumber != 1663 // https://xkcd.com/1663/ 266 | && comicNumber != 1608; // https://xkcd.com/1608/ 267 | } 268 | 269 | #pragma mark - 270 | #pragma mark Properties 271 | 272 | - (NSString *)imagePath { 273 | return [[self class] imagePathForImageFilename:self.imageFilename]; 274 | } 275 | 276 | - (UIImage *)image { 277 | return [UIImage imageWithContentsOfFile:self.imagePath]; 278 | } 279 | 280 | - (NSString *)imageFilename { 281 | NSInteger comicNumber = [[self valueForKey:kAttributeNumber] integerValue]; 282 | return [NSString stringWithFormat:@"%li.imagedata", (long)comicNumber]; 283 | } 284 | 285 | - (BOOL)downloaded { 286 | return [downloadedImages containsObject:self.imageFilename]; 287 | } 288 | 289 | - (NSURL *)linkedToImageURL { 290 | if (self.link) { 291 | return [[NSURL alloc] initWithString:self.link]; 292 | } 293 | // These two cases are hardcoded for people that downloaded the comic before link was parsed into CoreData. 294 | else if ([self.number isEqualToNumber:@273]) { 295 | return [[NSURL alloc] initWithString:@"https://imgs.xkcd.com/comics/electromagnetic_spectrum.png"]; 296 | } 297 | else if ([self.number isEqualToNumber:@256]) { 298 | return [[NSURL alloc] initWithString:@"https://imgs.xkcd.com/comics/online_communities.png"]; 299 | } 300 | 301 | return nil; 302 | } 303 | 304 | - (NSURL *)potentialHugeImageURL { 305 | NSString *originalImageURL = self.imageURL; 306 | NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\b(.+)(\\.\\w+)\\b" options:0 error:nil]; 307 | 308 | // This takes URLs that look like https://imgs.xkcd.com/comics/money.png and converts them to https://imgs.xkcd.com/comics/money_huge.png 309 | NSString *potentialLargeImageURLString = [regex stringByReplacingMatchesInString:originalImageURL options:0 range:NSMakeRange(0, originalImageURL.length) withTemplate:@"$1_huge$2"]; 310 | return [[NSURL alloc] initWithString:potentialLargeImageURLString]; 311 | } 312 | 313 | - (NSURL *)potentialLargeImageURL { 314 | NSString *originalImageURL = self.imageURL; 315 | NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\b(.+)(\\.\\w+)\\b" options:0 error:nil]; 316 | 317 | // This takes URLs that look like https://imgs.xkcd.com/comics/movie_narrative_charts.png and converts them to https://imgs.xkcd.com/comics/movie_narrative_charts_large.png 318 | NSString *potentialLargeImageURLString = [regex stringByReplacingMatchesInString:originalImageURL options:0 range:NSMakeRange(0, originalImageURL.length) withTemplate:@"$1_large$2"]; 319 | return [[NSURL alloc] initWithString:potentialLargeImageURLString]; 320 | } 321 | 322 | - (NSURL *)potentialRetinaImageURL { 323 | NSString *originalImageURL = self.imageURL; 324 | NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"\\b(.+)(\\.\\w+)\\b" options:0 error:nil]; 325 | 326 | // This takes URLs that look like https://imgs.xkcd.com/comics/business_greetings.png and converts them to https://imgs.xkcd.com/comics/business_greetings_2x.png 327 | NSString *potentialRetinaImageURLString = [regex stringByReplacingMatchesInString:originalImageURL options:0 range:NSMakeRange(0, originalImageURL.length) withTemplate:@"$1_2x$2"]; 328 | return [[NSURL alloc] initWithString:potentialRetinaImageURLString]; 329 | } 330 | 331 | - (NSArray *)imageURLs { 332 | if (!self.imageURL) { 333 | return nil; 334 | } 335 | 336 | NSMutableArray *imageURLs = [[NSMutableArray alloc] init]; 337 | 338 | if ([Comic hasLinkedToImage:self]) { 339 | [imageURLs safelyAddObject:self.linkedToImageURL]; 340 | } 341 | 342 | if ([Comic potentiallyHasHugeImage:self]) { 343 | [imageURLs safelyAddObject:[self potentialHugeImageURL]]; 344 | } 345 | 346 | if ([Comic potentiallyHasLargeImage:self]) { 347 | [imageURLs safelyAddObject:[self potentialLargeImageURL]]; 348 | } 349 | 350 | if ([Comic potentiallyHasRetinaImage:self]) { 351 | [imageURLs safelyAddObject:[self potentialRetinaImageURL]]; 352 | } 353 | 354 | [imageURLs addObject:[[NSURL alloc] initWithString:self.imageURL]]; 355 | 356 | return [imageURLs copy]; 357 | } 358 | 359 | @end 360 | -------------------------------------------------------------------------------- /Classes/Models/FCOpenInChromeActivity.h: -------------------------------------------------------------------------------- 1 | // 2 | // FCOpenInChromeActivity.h 3 | // Part of FCUtilities by Marco Arment. See included LICENSE file for BSD license. 4 | // 5 | 6 | #import 7 | 8 | FOUNDATION_EXPORT NSString *const FCActivityTypeOpenInChrome; 9 | 10 | @interface FCOpenInChromeActivity : UIActivity 11 | 12 | - (instancetype)initWithSourceName:(NSString *)xCallbackSource successCallbackURL:(NSURL *)xCallbackURL; 13 | 14 | @end 15 | -------------------------------------------------------------------------------- /Classes/Models/FCOpenInChromeActivity.m: -------------------------------------------------------------------------------- 1 | // 2 | // FCOpenInChromeActivity.m 3 | // Part of FCUtilities by Marco Arment. See included LICENSE file for BSD license. 4 | // 5 | 6 | #import "FCOpenInChromeActivity.h" 7 | 8 | NSString *const FCActivityTypeOpenInChrome = @"FCActivityTypeOpenInChrome"; 9 | 10 | @interface FCOpenInChromeActivity () 11 | @property (nonatomic, copy) NSString *callbackSource; 12 | @property (nonatomic) NSURL *URL; 13 | @property (nonatomic) NSURL *successCallbackURL; 14 | @end 15 | 16 | @implementation FCOpenInChromeActivity 17 | 18 | + (NSString *)conservativelyPercentEscapeString:(NSString *)str 19 | { 20 | static NSMutableCharacterSet *allowedCharacters = nil; 21 | if (! allowedCharacters) { 22 | allowedCharacters = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy]; 23 | [allowedCharacters removeCharactersInString:@"?=&+:;@/$!'()\",*"]; 24 | } 25 | return [str stringByAddingPercentEncodingWithAllowedCharacters:allowedCharacters]; 26 | } 27 | 28 | - (instancetype)initWithSourceName:(NSString *)xCallbackSource successCallbackURL:(NSURL *)xCallbackURL 29 | { 30 | if ( (self = [super init]) ) { 31 | self.callbackSource = xCallbackSource; 32 | self.successCallbackURL = xCallbackURL; 33 | } 34 | return self; 35 | } 36 | 37 | - (NSString *)activityType { return FCActivityTypeOpenInChrome; } 38 | - (NSString *)activityTitle { return NSLocalizedString(@"Open in Chrome", NULL); } 39 | 40 | - (UIImage *)activityImage 41 | { 42 | return [self.class chromeLogoWithHeight:33]; 43 | } 44 | 45 | + (UIImage *)chromeLogoWithHeight:(CGFloat)outputHeight 46 | { 47 | CGSize outputSize = CGSizeMake(outputHeight, outputHeight); 48 | UIGraphicsBeginImageContextWithOptions(outputSize, NO, UIScreen.mainScreen.scale); 49 | 50 | CGContextRef ctx = UIGraphicsGetCurrentContext(); 51 | CGContextSaveGState(ctx); 52 | CGFloat scale = outputHeight / 31.0f; 53 | CGContextConcatCTM(ctx, CGAffineTransformMakeScale(scale, scale)); 54 | 55 | [UIColor.blackColor setStroke]; 56 | 57 | UIBezierPath* ovalPath = [UIBezierPath bezierPathWithOvalInRect: CGRectMake(0.5, 0.5, 30, 30)]; 58 | [ovalPath stroke]; 59 | 60 | UIBezierPath* oval2Path = [UIBezierPath bezierPathWithOvalInRect: CGRectMake(9.5, 9.5, 12, 12)]; 61 | [oval2Path stroke]; 62 | 63 | UIBezierPath* bezierPath = [UIBezierPath bezierPath]; 64 | [bezierPath moveToPoint: CGPointMake(16.5, 9.5)]; 65 | [bezierPath addCurveToPoint: CGPointMake(29.5, 9.5) controlPoint1: CGPointMake(28.5, 9.5) controlPoint2: CGPointMake(29.5, 9.5)]; 66 | [bezierPath stroke]; 67 | 68 | UIBezierPath* bezier2Path = [UIBezierPath bezierPath]; 69 | [bezier2Path moveToPoint: CGPointMake(20.5, 18.5)]; 70 | [bezier2Path addLineToPoint: CGPointMake(14.5, 30.5)]; 71 | [bezier2Path stroke]; 72 | 73 | UIBezierPath* bezier3Path = [UIBezierPath bezierPath]; 74 | [bezier3Path moveToPoint: CGPointMake(9.5, 17.5)]; 75 | [bezier3Path addLineToPoint: CGPointMake(3.5, 6.5)]; 76 | [bezier3Path stroke]; 77 | 78 | CGContextRestoreGState(ctx); 79 | UIImage *finalImage = UIGraphicsGetImageFromCurrentImageContext(); 80 | UIGraphicsEndImageContext(); 81 | return finalImage; 82 | } 83 | 84 | - (BOOL)canPerformWithActivityItems:(NSArray *)activityItems 85 | { 86 | if (! [UIApplication.sharedApplication canOpenURL:[NSURL URLWithString:@"googlechrome-x-callback://"]]) return NO; 87 | 88 | for (id item in activityItems) { 89 | if ([item isKindOfClass:NSString.class]) { 90 | NSURL *u = [NSURL URLWithString:item]; 91 | if (u && ! u.isFileURL) return YES; 92 | } 93 | else if ([item isKindOfClass:NSURL.class]) { 94 | if (! ((NSURL *)item).isFileURL) return YES; 95 | } 96 | } 97 | 98 | return NO; 99 | } 100 | 101 | - (void)prepareWithActivityItems:(NSArray *)activityItems 102 | { 103 | NSURL *stringURL = nil; 104 | NSURL *URL = nil; 105 | for (id item in activityItems) { 106 | if ([item isKindOfClass:NSString.class]) { 107 | NSURL *u = [NSURL URLWithString:item]; 108 | if (u && ! u.isFileURL) stringURL = u; 109 | } 110 | else if (! URL && [item isKindOfClass:NSURL.class]) { 111 | if (! ((NSURL *)item).isFileURL) URL = item; 112 | } 113 | } 114 | 115 | self.URL = URL ?: stringURL; 116 | } 117 | 118 | - (void)performActivity 119 | { 120 | [UIApplication.sharedApplication openURL:[NSURL URLWithString:[NSString stringWithFormat: 121 | @"googlechrome-x-callback://x-callback-url/open/?url=%@&x-success=%@&x-source=%@", 122 | [self.class conservativelyPercentEscapeString:self.URL.absoluteString], 123 | [self.class conservativelyPercentEscapeString:(self.successCallbackURL ? self.successCallbackURL.absoluteString : @"")], 124 | [self.class conservativelyPercentEscapeString:(self.callbackSource ?: @"")] 125 | ]] options:@{} completionHandler:^(BOOL success) { 126 | [self activityDidFinish:success]; 127 | }]; 128 | } 129 | 130 | @end 131 | -------------------------------------------------------------------------------- /Classes/Models/FCOpenInSafariActivity.h: -------------------------------------------------------------------------------- 1 | // 2 | // FCOpenInSafariActivity.h 3 | // Part of FCUtilities by Marco Arment. See included LICENSE file for BSD license. 4 | // 5 | 6 | #import 7 | 8 | extern NSString *const FCActivityTypeOpenInSafari; 9 | 10 | @interface FCOpenInSafariActivity : UIActivity 11 | 12 | + (UIImage *)alphaSafariIconWithWidth:(CGFloat)width scale:(CGFloat)scale; 13 | 14 | @end 15 | -------------------------------------------------------------------------------- /Classes/Models/FCOpenInSafariActivity.m: -------------------------------------------------------------------------------- 1 | // 2 | // FCOpenInSafariActivity.m 3 | // Part of FCUtilities by Marco Arment. See included LICENSE file for BSD license. 4 | // 5 | 6 | #import "FCOpenInSafariActivity.h" 7 | 8 | NSString *const FCActivityTypeOpenInSafari = @"FCActivityTypeOpenInSafari"; 9 | 10 | @interface FCOpenInSafariActivity () 11 | @property (nonatomic) NSURL *URL; 12 | @end 13 | 14 | @implementation FCOpenInSafariActivity 15 | 16 | - (NSString *)activityType { return FCActivityTypeOpenInSafari; } 17 | - (NSString *)activityTitle { return NSLocalizedString(@"Open in Safari", NULL); } 18 | - (UIImage *)activityImage { return [self.class alphaSafariIconWithWidth:52 scale:UIScreen.mainScreen.scale]; } 19 | 20 | - (BOOL)canPerformWithActivityItems:(NSArray *)activityItems 21 | { 22 | for (id item in activityItems) { 23 | if ([item isKindOfClass:NSString.class]) { 24 | NSURL *u = [NSURL URLWithString:item]; 25 | if (u && ! u.isFileURL) return YES; 26 | } 27 | else if ([item isKindOfClass:NSURL.class]) { 28 | if (! ((NSURL *)item).isFileURL) return YES; 29 | } 30 | } 31 | 32 | return NO; 33 | } 34 | 35 | - (void)prepareWithActivityItems:(NSArray *)activityItems 36 | { 37 | NSURL *stringURL = nil; 38 | NSURL *URL = nil; 39 | for (id item in activityItems) { 40 | if ([item isKindOfClass:NSString.class]) { 41 | NSURL *u = [NSURL URLWithString:item]; 42 | if (u && ! u.isFileURL) stringURL = u; 43 | } 44 | else if (! URL && [item isKindOfClass:NSURL.class]) { 45 | if (! ((NSURL *)item).isFileURL) URL = item; 46 | } 47 | } 48 | 49 | self.URL = URL ?: stringURL; 50 | } 51 | 52 | - (void)performActivity { 53 | [UIApplication.sharedApplication openURL:self.URL options:@{} completionHandler:^(BOOL success) { 54 | [self activityDidFinish:success]; 55 | }]; 56 | } 57 | 58 | + (UIImage *)alphaSafariIconWithWidth:(CGFloat)width scale:(CGFloat)scale 59 | { 60 | CGFloat halfWidth = width / 2.0f; 61 | CGFloat triangleTipToCircleGap = ceilf(0.012 * width); 62 | CGFloat triangleBaseHalfWidth = ceilf(0.125 * width) / 2.0; 63 | CGFloat tickMarkToCircleGap = ceilf(0.0325 * width); 64 | CGFloat tickMarkLengthLong = ceilf(0.08 * width); 65 | CGFloat tickMarkLengthShort = ceilf(0.045 * width); 66 | CGFloat tickMarkWidth = 1.0f / scale; 67 | CGFloat tickMarkHalfWidth = tickMarkWidth / 2.0f; 68 | 69 | UIGraphicsBeginImageContextWithOptions(CGSizeMake(width, width), NO, scale); 70 | CGContextRef context = UIGraphicsGetCurrentContext(); 71 | 72 | // Outer circle with gradient fill 73 | CGFloat colors[] = { 74 | 0.0, 0.0, 0.0, 0.25, 75 | 0.0, 0.0, 0.0, 0.50 76 | }; 77 | CGColorSpaceRef baseSpace = CGColorSpaceCreateDeviceRGB(); 78 | CGGradientRef gradient = CGGradientCreateWithColorComponents(baseSpace, colors, NULL, 2); 79 | CGColorSpaceRelease(baseSpace); 80 | CGContextSaveGState(context); 81 | { 82 | CGContextAddEllipseInRect(context, CGRectMake(0, 0, width, width)); 83 | CGContextClip(context); 84 | CGContextDrawLinearGradient(context, gradient, CGPointMake(halfWidth, 0), CGPointMake(halfWidth, width), 0); 85 | CGGradientRelease(gradient); 86 | } 87 | CGContextRestoreGState(context); 88 | 89 | // Tick lines around the circle 90 | [[UIColor colorWithWhite:0.0 alpha:0.5] setStroke]; 91 | int numTickLines = 72; 92 | for (int i = 0; i < numTickLines; i++) { 93 | CGContextSaveGState(context); 94 | { 95 | CGContextSetBlendMode(context, kCGBlendModeClear); 96 | 97 | CGContextTranslateCTM(context, halfWidth, halfWidth); 98 | CGContextRotateCTM(context, 2 * M_PI * ((float) i / numTickLines)); 99 | CGContextTranslateCTM(context, -halfWidth, -halfWidth); 100 | 101 | UIBezierPath *tickLine = UIBezierPath.bezierPath; 102 | [tickLine moveToPoint:CGPointMake(halfWidth - tickMarkHalfWidth, tickMarkToCircleGap)]; 103 | [tickLine addLineToPoint:CGPointMake(halfWidth - tickMarkHalfWidth, tickMarkToCircleGap + (i % 2 == 1 ? tickMarkLengthShort : tickMarkLengthLong))]; 104 | tickLine.lineWidth = tickMarkWidth; 105 | [tickLine stroke]; 106 | } 107 | CGContextRestoreGState(context); 108 | } 109 | 110 | // "Needle" triangles 111 | CGContextSaveGState(context); 112 | { 113 | CGContextTranslateCTM(context, halfWidth, halfWidth); 114 | CGContextRotateCTM(context, M_PI + M_PI_4); 115 | CGContextTranslateCTM(context, -halfWidth, -halfWidth); 116 | 117 | [UIColor.blackColor setFill]; 118 | 119 | UIBezierPath *topTriangle = UIBezierPath.bezierPath; 120 | [topTriangle moveToPoint:CGPointMake(halfWidth, triangleTipToCircleGap)]; 121 | [topTriangle addLineToPoint:CGPointMake(halfWidth - triangleBaseHalfWidth, halfWidth)]; 122 | [topTriangle addLineToPoint:CGPointMake(halfWidth + triangleBaseHalfWidth, halfWidth)]; 123 | [topTriangle closePath]; 124 | 125 | CGContextSetBlendMode(context, kCGBlendModeClear); 126 | [topTriangle fill]; 127 | 128 | UIBezierPath *bottomTriangle = UIBezierPath.bezierPath; 129 | [bottomTriangle moveToPoint:CGPointMake(halfWidth, width - triangleTipToCircleGap)]; 130 | [bottomTriangle addLineToPoint:CGPointMake(halfWidth - triangleBaseHalfWidth, halfWidth)]; 131 | [bottomTriangle addLineToPoint:CGPointMake(halfWidth + triangleBaseHalfWidth, halfWidth)]; 132 | [bottomTriangle closePath]; 133 | 134 | CGContextSetBlendMode(context, kCGBlendModeNormal); 135 | [bottomTriangle fill]; 136 | } 137 | CGContextRestoreGState(context); 138 | 139 | UIImage *finalImage = UIGraphicsGetImageFromCurrentImageContext(); 140 | UIGraphicsEndImageContext(); 141 | return finalImage; 142 | } 143 | 144 | @end 145 | -------------------------------------------------------------------------------- /Classes/Models/NSString_HTML.h: -------------------------------------------------------------------------------- 1 | // 2 | // NSString_HTML.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 2/17/10. 6 | // 7 | 8 | #import 9 | 10 | 11 | @interface NSString (HTML) 12 | 13 | + (NSString *)stringByCleaningHTML:(NSString *)HTMLSnippet; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /Classes/Models/NSString_HTML.m: -------------------------------------------------------------------------------- 1 | // 2 | // NSString_HTML.m 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 2/17/10. 6 | // 7 | 8 | #import "NSString_HTML.h" 9 | #import "TLMacros.h" 10 | 11 | static NSArray *characterEntityArray = nil; 12 | 13 | #pragma mark - 14 | 15 | @interface HTMLStringCleaner : NSObject 16 | 17 | + (NSString *)cleanedStringFromHTMLSnippet:(NSString *)HTMLSnippet; 18 | 19 | @end 20 | 21 | #pragma mark - 22 | 23 | @interface HTMLStringCleaner () 24 | 25 | + (NSString *)stringByDecodingCharacterEntitiesIn:(NSString *)source; 26 | - (instancetype)initWithHTMLSnippet:(NSString *)HTMLSnippet; 27 | - (NSString *)cleanedString; 28 | 29 | @property (nonatomic) NSString *snippet; 30 | @property (nonatomic) NSMutableString *resultAccumulator; 31 | @property (nonatomic) BOOL parseErrorEncountered; 32 | 33 | @end 34 | 35 | #pragma mark - 36 | 37 | @implementation HTMLStringCleaner 38 | 39 | + (NSString *)cleanedStringFromHTMLSnippet:(NSString *)HTMLSnippet { 40 | HTMLStringCleaner *cleaner = [[self alloc] initWithHTMLSnippet:HTMLSnippet]; 41 | return [cleaner cleanedString]; 42 | } 43 | 44 | // borrowed from http://www.thinkmac.co.uk/blog/2005/05/removing-entities-from-html-in-cocoa.html 45 | // ...and fixed a little 46 | + (NSString *)stringByDecodingCharacterEntitiesIn:(NSString *)source { 47 | if (!source) { 48 | return nil; 49 | } 50 | 51 | NSMutableString *escaped = [NSMutableString stringWithString:source]; 52 | if (!characterEntityArray) { 53 | characterEntityArray = @[@" ", @"¡", @"¢", @"£", @"¤", @"¥", @"¦", 54 | @"§", @"¨", @"©", @"ª", @"«", @"¬", @"­", @"®", 55 | @"¯", @"°", @"±", @"²", @"³", @"´", @"µ", 56 | @"¶", @"·", @"¸", @"¹", @"º", @"»", @"¼", 57 | @"½", @"¾", @"¿", @"À", @"Á", @"Â", 58 | @"Ã", @"Ä", @"Å", @"Æ", @"Ç", @"È", 59 | @"É", @"Ê", @"Ë", @"Ì", @"Í", @"Î", @"Ï", 60 | @"Ð", @"Ñ", @"Ò", @"Ó", @"Ô", @"Õ", @"Ö", 61 | @"×", @"Ø", @"Ù", @"Ú", @"Û", @"Ü", @"Ý", 62 | @"Þ", @"ß", @"à", @"á", @"â", @"ã", @"ä", 63 | @"å", @"æ", @"ç", @"è", @"é", @"ê", @"ë", 64 | @"ì", @"í", @"î", @"ï", @"ð", @"ñ", @"ò", 65 | @"ó", @"ô", @"õ", @"ö", @"÷", @"ø", @"ù", 66 | @"ú", @"û", @"ü", @"ý", @"þ", @"ÿ"]; 67 | } 68 | 69 | NSUInteger i; 70 | NSUInteger count = [characterEntityArray count]; 71 | 72 | // Html 73 | for (i = 0; i < count; i++) { 74 | NSRange range = [source rangeOfString:characterEntityArray[i]]; 75 | if (range.location != NSNotFound) { 76 | [escaped replaceOccurrencesOfString:characterEntityArray[i] 77 | withString:[NSString stringWithFormat:@"%C", (unsigned short)(160 + i)] 78 | options:NSLiteralSearch 79 | range:NSMakeRange(0, [escaped length])]; 80 | } 81 | } 82 | 83 | // Decimal & Hex 84 | NSRange start, finish, searchRange = NSMakeRange(0, [escaped length]); 85 | i = 0; 86 | 87 | while(i < [escaped length]) { 88 | start = [escaped rangeOfString:@"&#" 89 | options:NSCaseInsensitiveSearch 90 | range:searchRange]; 91 | 92 | finish = [escaped rangeOfString:@";" 93 | options:NSCaseInsensitiveSearch 94 | range:searchRange]; 95 | 96 | if (start.location != NSNotFound && 97 | finish.location != NSNotFound && 98 | finish.location > start.location) { 99 | 100 | NSRange entityRange = NSMakeRange(start.location, (finish.location - start.location) + 1); 101 | NSString *entity = [escaped substringWithRange:entityRange]; 102 | NSString *value = [entity substringWithRange:NSMakeRange(2, [entity length] - 2)]; 103 | 104 | [escaped deleteCharactersInRange:entityRange]; 105 | 106 | if ([value hasPrefix: @"x"]) { 107 | unsigned int tempInt = 0; 108 | NSScanner *scanner = [NSScanner scannerWithString:[value substringFromIndex:1]]; 109 | [scanner scanHexInt:&tempInt]; 110 | [escaped insertString: [NSString stringWithFormat:@"%C", (unsigned short)tempInt] atIndex: entityRange.location]; 111 | } 112 | else { 113 | [escaped insertString: [NSString stringWithFormat:@"%C", (unsigned short)[value intValue]] atIndex: entityRange.location]; 114 | } 115 | i = start.location; 116 | } 117 | else { 118 | i++; 119 | } 120 | searchRange = NSMakeRange(i, [escaped length] - i); 121 | } 122 | 123 | return escaped; 124 | } 125 | 126 | - (instancetype)initWithHTMLSnippet:(NSString *)HTMLSnippet { 127 | if (self = [super init]) { 128 | _snippet = HTMLSnippet; 129 | } 130 | return self; 131 | } 132 | 133 | - (NSString *)cleanedString { 134 | self.resultAccumulator = [NSMutableString string]; 135 | NSString *wrappedSnippet = [NSString stringWithFormat:@"%@", self.snippet]; 136 | NSXMLParser *parser = [[NSXMLParser alloc] initWithData:[wrappedSnippet dataUsingEncoding:NSUTF8StringEncoding]]; 137 | [parser setDelegate:self]; 138 | [parser parse]; 139 | NSString *tagFreeString = self.parseErrorEncountered ? self.snippet : self.resultAccumulator; 140 | NSString *cleanedString = [[self class] stringByDecodingCharacterEntitiesIn:tagFreeString]; 141 | return cleanedString; 142 | } 143 | 144 | #pragma mark - 145 | #pragma mark NSXMLParser delegate functions 146 | 147 | - (void)parser:(NSXMLParser *)parser foundCharacters:(NSString *)string { 148 | [self.resultAccumulator appendString:string]; 149 | } 150 | 151 | - (void)parser:(NSXMLParser *)parser parseErrorOccurred:(NSError *)parseError { 152 | // TLDebugLog(@"HTMLStringCleaner encountered parse error %@ on snippet %@", parseError, self.snippet); 153 | self.parseErrorEncountered = YES; 154 | } 155 | 156 | @end 157 | 158 | 159 | @implementation NSString (HTML) 160 | 161 | + (NSString *)stringByCleaningHTML:(NSString *)HTMLSnippet { 162 | return [HTMLStringCleaner cleanedStringFromHTMLSnippet:HTMLSnippet]; 163 | } 164 | 165 | @end 166 | -------------------------------------------------------------------------------- /Classes/NewComicFetcher.h: -------------------------------------------------------------------------------- 1 | // 2 | // NewComicFetcher.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/1/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import 10 | #import "NewComicFetcherDelegate.h" 11 | 12 | @interface NewComicFetcher : NSObject 13 | 14 | - (void)fetch; 15 | 16 | @property (nonatomic, weak) id delegate; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Classes/NewComicFetcher.m: -------------------------------------------------------------------------------- 1 | // 2 | // NewComicFetcher.m 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/1/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #define RECREATE_FROM_SCRATCH 0 10 | 11 | // When [comicsToInsert count] reaches kInsertChunkSize, 12 | // comics will be inserted in bulk. 13 | // Inserting one at a time creates a crappy ux. 14 | #define kInsertChunkSize 25 15 | 16 | #import "NewComicFetcher.h" 17 | #import "FetchComicFromWeb.h" 18 | #import "Comic.h" 19 | #import "XkcdErrorCodes.h" 20 | #import "TLMacros.h" 21 | 22 | #pragma mark - 23 | 24 | @interface NewComicFetcher () 25 | 26 | - (void)fetchComic:(NSInteger)comicNumber; 27 | 28 | @property (nonatomic) NSOperationQueue *fetchQueue; 29 | @property (nonatomic) NSMutableArray *comicsToInsert; 30 | @property (nonatomic) NSURLSession *URLSession; 31 | 32 | @end 33 | 34 | #pragma mark - 35 | 36 | @implementation NewComicFetcher 37 | 38 | - (instancetype)init { 39 | if (self = [super init]) { 40 | _fetchQueue = [[NSOperationQueue alloc] init]; 41 | _comicsToInsert = [NSMutableArray arrayWithCapacity:kInsertChunkSize]; 42 | _URLSession = [NSURLSession sharedSession]; 43 | } 44 | return self; 45 | } 46 | 47 | - (void)fetchComic:(NSInteger)comicNumber { 48 | FetchComicFromWeb *fetchOperation = [[FetchComicFromWeb alloc] initWithComicNumber:comicNumber 49 | URLSession:self.URLSession 50 | completionTarget:self 51 | action:@selector(didCompleteFetchOperation:)]; 52 | [self.fetchQueue addOperation:fetchOperation]; 53 | } 54 | 55 | - (void)fetch { 56 | Comic *lastKnownComic = [Comic lastKnownComic]; 57 | if (lastKnownComic) { 58 | NSInteger comicToFetch = [lastKnownComic.number integerValue] + 1; 59 | [self fetchComic:comicToFetch]; 60 | } 61 | else { 62 | #if RECREATE_FROM_SCRATCH 63 | TLDebugLog(@"RECREATE_FROM_SCRATCH: Fetching comic 1"); 64 | [Comic deleteAllComics]; 65 | [self fetchComic:1]; 66 | #else 67 | [self.delegate newComicFetcher:self 68 | didFailWithError:[NSError errorWithDomain:kXkcdErrorDomain 69 | code:kXkcdErrorCodeCouldNotFindLastComic 70 | userInfo:nil]]; 71 | #endif 72 | } 73 | } 74 | 75 | - (void)insertComics { 76 | for (FetchComicFromWeb *fetchOperation in self.comicsToInsert) { 77 | Comic *newComic = [Comic comic]; 78 | newComic.number = @(fetchOperation.comicNumber); 79 | newComic.name = fetchOperation.comicName; 80 | newComic.titleText = fetchOperation.comicTitleText; 81 | newComic.imageURL = fetchOperation.comicImageURL; 82 | newComic.transcript = fetchOperation.comicTranscript; 83 | newComic.link = fetchOperation.link; 84 | [self.delegate newComicFetcher:self didFetchComic:newComic]; 85 | } 86 | [self.comicsToInsert removeAllObjects]; 87 | } 88 | 89 | - (void)didCompleteFetchOperation:(FetchComicFromWeb *)fetchOperation { 90 | if (fetchOperation.got404) { 91 | // all done! 92 | [self insertComics]; 93 | [self.delegate newComicFetcherDidFinishFetchingAllComics:self]; 94 | } 95 | else if (fetchOperation.error) { 96 | // Network fail? Change in API? 97 | [self insertComics]; 98 | [self.delegate newComicFetcher:self didFailWithError:fetchOperation.error]; 99 | } 100 | else if (fetchOperation.comicName && fetchOperation.comicTitleText && fetchOperation.comicImageURL && fetchOperation.comicTranscript) { 101 | // Got a comic -- store it and keep going 102 | [self.comicsToInsert addObject:fetchOperation]; 103 | [self fetchComic:(fetchOperation.comicNumber + 1)]; 104 | if (fetchOperation.comicNumber % kInsertChunkSize == 0) { 105 | [self insertComics]; 106 | } 107 | } 108 | else { 109 | // wtf? 110 | [self insertComics]; 111 | } 112 | } 113 | 114 | @end 115 | -------------------------------------------------------------------------------- /Classes/Preferences.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Preferences.swift 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 1/24/16. 6 | // 7 | // 8 | 9 | import UIKit 10 | 11 | /// This class provides a thin wrapper over `NSUserDefaults` for managing users' settings. 12 | final class Preferences: NSObject { 13 | private enum AppPreference: String { 14 | case OpenZoomedOut = "zoomed_out" 15 | case AutoDownload = "autodownload" 16 | case OpenAfterDownload = "autoopen" 17 | } 18 | 19 | /// Whether comics open at their minimum zoom scale, or at native resolution in the single comic viewer. 20 | @objc var openZoomedOut: Bool { 21 | return boolValueOfAppPreference(appPreference: .OpenZoomedOut) 22 | } 23 | 24 | /// Whether new comics images should be automatically downloaded when the comic JSON is received. 25 | @objc var downloadNewComics: Bool { 26 | return boolValueOfAppPreference(appPreference: .AutoDownload) 27 | } 28 | 29 | /// Whether comics should be opened after their images are downloaded. 30 | @objc var openAfterDownload: Bool { 31 | return boolValueOfAppPreference(appPreference: .OpenAfterDownload, defaultValue: true) 32 | } 33 | 34 | /// The instance of `NSUserDefaults` that this object reads and writes from. 35 | private let userDefaults: UserDefaults 36 | 37 | /// Holds the singleton returned by `defaultPreferences()`. 38 | private static var defaultPreferenceStorage: Preferences? 39 | 40 | /** 41 | A singleton `Preferences` instance that is used across the application. 42 | 43 | - note: Ideally we would use dependency injection instead of obfuscating the dependency graph like this. 44 | On the other hand, shipping is better than perfect. 45 | 46 | - returns: A fully initialized `Preferences`. 47 | */ 48 | @objc class var defaultPreferences: Preferences { 49 | if let preferences = Preferences.defaultPreferenceStorage { 50 | return preferences 51 | } 52 | else { 53 | let preferences = Preferences(userDefaults: UserDefaults.standard) 54 | Preferences.defaultPreferenceStorage = preferences 55 | return preferences 56 | } 57 | } 58 | 59 | /** 60 | Initializes a `Preferences` object. 61 | 62 | - parameter userDefaults: The instance of `NSUserDefaults` to read from and write to. 63 | 64 | - returns: A fully initialized `Preferences` object. 65 | */ 66 | init(userDefaults: UserDefaults) { 67 | self.userDefaults = userDefaults 68 | 69 | super.init() 70 | 71 | NotificationCenter.default.addObserver(self, selector: #selector(UIApplicationDelegate.applicationWillTerminate(_:)), name: UIApplication.willTerminateNotification, object: nil) 72 | } 73 | 74 | // MARK: - Notifications 75 | 76 | @objc func applicationWillTerminate(notification: NSNotification?) { 77 | userDefaults.synchronize() 78 | } 79 | 80 | // MARK: - Private 81 | 82 | private func boolValueOfAppPreference(appPreference: AppPreference, defaultValue: Bool = false) -> Bool { 83 | if let value = userDefaults.dictionaryRepresentation()[appPreference.rawValue] as? Bool { 84 | return value 85 | } 86 | else { 87 | userDefaults.set(defaultValue, forKey: appPreference.rawValue) 88 | return defaultValue 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Classes/Protocols/NewComicFetcherDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // NewComicFetcherDelegate.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/1/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | @class Comic; 10 | @class NewComicFetcher; 11 | 12 | @protocol NewComicFetcherDelegate 13 | 14 | - (void)newComicFetcher:(NewComicFetcher *)fetcher didFetchComic:(Comic *)comic; 15 | - (void)newComicFetcherDidFinishFetchingAllComics:(NewComicFetcher *)fetcher; 16 | - (void)newComicFetcher:(NewComicFetcher *)fetcher didFailWithError:(NSError *)error; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /Classes/Protocols/SingleComicImageFetcherDelegate.h: -------------------------------------------------------------------------------- 1 | // 2 | // SingleComicImageFetcherDelegate.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/1/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | @class Comic; 10 | @class SingleComicImageFetcher; 11 | 12 | @protocol SingleComicImageFetcherDelegate 13 | 14 | - (void)singleComicImageFetcher:(SingleComicImageFetcher *)fetcher didFetchImageForComic:(Comic *)comic context:(id)context; 15 | - (void)singleComicImageFetcher:(SingleComicImageFetcher *)fetcher didFailWithError:(NSError *)error onComic:(Comic *)comic; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /Classes/SingleComicImageFetcher.h: -------------------------------------------------------------------------------- 1 | // 2 | // SingleComicImageFetcher.h 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/2/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import 10 | #import "SingleComicImageFetcherDelegate.h" 11 | 12 | @interface SingleComicImageFetcher : NSObject 13 | 14 | /** 15 | * Initializes a @c SingleComicImageFetcher. 16 | * 17 | * @param session The @c NSURLSession to use when downloading the image. 18 | * 19 | * @return An initialized @c SingleComicImageFetcher. 20 | */ 21 | - (instancetype)initWithURLSession:(NSURLSession *)session; 22 | 23 | /** 24 | * Fetches an image for the supplied @c Comic. 25 | * 26 | * @param comic The @c Comic to fetch an image for. 27 | * @param context An arbitrary @c id type. 28 | * 29 | * @note @c context is currently used as a flag to open the comic after it is downloaded. 30 | * Probably want to rethink this architecture in the future. 31 | */ 32 | - (void)fetchImageForComic:(Comic *)comic context:(id)context; 33 | - (void)fetchImagesForAllComics; 34 | - (BOOL)downloadingAll; 35 | - (void)cancelDownloadAll; 36 | 37 | @property (nonatomic, weak) id delegate; 38 | 39 | @end 40 | -------------------------------------------------------------------------------- /Classes/SingleComicImageFetcher.m: -------------------------------------------------------------------------------- 1 | // 2 | // SingleComicImageFetcher.m 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/2/09. 6 | // Copyright 2009 Treeline Labs. All rights reserved. 7 | // 8 | 9 | #import "SingleComicImageFetcher.h" 10 | #import "FetchComicImageFromWeb.h" 11 | #import "Comic.h" 12 | #import "XkcdErrorCodes.h" 13 | 14 | #pragma mark - 15 | 16 | @interface SingleComicImageFetcher () 17 | 18 | - (void)didCompleteFetchOperation:(FetchComicImageFromWeb *)fetchOperation; 19 | - (void)enqueueMoreDownloadAllComics; 20 | - (void)didFailWithError:(NSError *)error onComic:(Comic *)comic; 21 | 22 | @property (nonatomic) id keepInMemory; 23 | @property (nonatomic) NSOperationQueue *fetchQueue; 24 | @property (nonatomic) NSMutableArray *comicsRemainingDuringDownloadAll; 25 | @property (nonatomic) NSURLSession *URLSession; 26 | 27 | @end 28 | 29 | #pragma mark - 30 | 31 | @implementation SingleComicImageFetcher 32 | 33 | - (instancetype)initWithURLSession:(NSURLSession *)session { 34 | if (self = [super init]) { 35 | _fetchQueue = [[NSOperationQueue alloc] init]; 36 | _URLSession = session; 37 | } 38 | return self; 39 | } 40 | 41 | - (void)fetchImageForComic:(Comic *)comic context:(id)context { 42 | if (comic.imageURLs) { 43 | FetchComicImageFromWeb *fetchOperation = [[FetchComicImageFromWeb alloc] initWithComicNumber:[comic.number integerValue] 44 | imageURLs:comic.imageURLs 45 | URLSession:self.URLSession 46 | completionTarget:self 47 | action:@selector(didCompleteFetchOperation:) 48 | context:context]; 49 | comic.loading = @YES; 50 | self.keepInMemory = self; 51 | [self.fetchQueue addOperation:fetchOperation]; 52 | } else { 53 | [self didFailWithError:[NSError errorWithDomain:kXkcdErrorDomain 54 | code:kXkcdErrorCodeBlankImageURL 55 | userInfo:nil] 56 | onComic:comic]; 57 | } 58 | } 59 | 60 | - (void)fetchImagesForAllComics { 61 | // don't start afresh if there's a download-all ongoing! 62 | if (!self.comicsRemainingDuringDownloadAll) { 63 | self.comicsRemainingDuringDownloadAll = [[Comic comicsWithoutImages] mutableCopy]; 64 | [self enqueueMoreDownloadAllComics]; 65 | } 66 | } 67 | 68 | - (void)enqueueMoreDownloadAllComics { 69 | NSUInteger comicsRemainingCount = [self.comicsRemainingDuringDownloadAll count]; 70 | if (comicsRemainingCount == 0) { 71 | // done! 72 | self.comicsRemainingDuringDownloadAll = nil; 73 | } else { 74 | // not done...start another 75 | Comic *comic = [self.comicsRemainingDuringDownloadAll lastObject]; 76 | [self fetchImageForComic:comic context:@NO]; // open after download: NO 77 | [self.comicsRemainingDuringDownloadAll removeLastObject]; 78 | } 79 | } 80 | 81 | - (BOOL)downloadingAll { 82 | return (self.comicsRemainingDuringDownloadAll != nil); 83 | } 84 | 85 | - (void)cancelDownloadAll { 86 | self.comicsRemainingDuringDownloadAll = nil; 87 | self.keepInMemory = nil; 88 | } 89 | 90 | - (void)didCompleteFetchOperation:(FetchComicImageFromWeb *)fetchOperation { 91 | Comic *comic = [Comic comicNumbered:fetchOperation.comicNumber]; 92 | comic.loading = @NO; 93 | if (!fetchOperation.error && fetchOperation.comicImageData) { 94 | [comic saveImageData:fetchOperation.comicImageData]; 95 | [self.delegate singleComicImageFetcher:self 96 | didFetchImageForComic:comic 97 | context:fetchOperation.context]; 98 | } else { 99 | [self didFailWithError:fetchOperation.error onComic:comic]; 100 | } 101 | 102 | if (self.comicsRemainingDuringDownloadAll) { 103 | [self enqueueMoreDownloadAllComics]; 104 | } 105 | 106 | self.keepInMemory = nil; 107 | } 108 | 109 | - (void)didFailWithError:(NSError *)error onComic:(Comic *)comic { 110 | // Tell the delegate 111 | [self.delegate singleComicImageFetcher:self 112 | didFailWithError:error 113 | onComic:comic]; 114 | } 115 | 116 | - (void)dealloc { 117 | [self.fetchQueue cancelAllOperations]; 118 | self.keepInMemory = nil; 119 | } 120 | 121 | @end 122 | -------------------------------------------------------------------------------- /Classes/XkcdErrorCodes.h: -------------------------------------------------------------------------------- 1 | #define kXkcdErrorDomain @"xkcd" 2 | #define kXkcdErrorCodeCouldNotFindLastComic -1 3 | #define kXkcdErrorCodeBlankImageURL -2 4 | -------------------------------------------------------------------------------- /Classes/xkcd-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "Comic.h" 6 | #import "ComicListViewController.h" 7 | #import "TLNavigationController.h" 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) <2012> 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Other Sources/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // xkcd 4 | // 5 | // Created by Joshua Bleecher Snyder on 8/25/09. 6 | // Copyright Treeline Labs 2009. All rights reserved. 7 | // 8 | 9 | #import 10 | #import "xkcd-Swift.h" 11 | 12 | int main(int argc, char *argv[]) { 13 | @autoreleasepool { 14 | return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Other Sources/xkcd_Prefix.pch: -------------------------------------------------------------------------------- 1 | // 2 | // Prefix header for all source files of the 'xkcd' target in the 'xkcd' project 3 | // 4 | #import 5 | 6 | #ifndef __IPHONE_9_0 7 | #error "This project uses features only available in iPhone SDK 9.0 and later." 8 | #endif 9 | 10 | #ifdef __OBJC__ 11 | #import 12 | #import 13 | #import 14 | #import 15 | #import 16 | #import 17 | #endif 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is the repo behind http://bit.ly/xkcdapp, an iOS xkcd reader. 2 | 3 | The code started as a learning project back on iOS 2.0 and has been through a lot since then! Just fair warning. :) 4 | 5 | 6 | Thoughts on the App Store 7 | ========================= 8 | 9 | This is released under an MIT license, in case any of the code here is useful to you. However, out of respect for the 10 | users of the App Store (who already must choose from lots of xkcd apps), I would ask that you not submit new xkcd apps 11 | based on this one, but instead contribute back code. I know the code base is not as clean as it could be...but it's 12 | also not as bad as it could be! 13 | 14 | 15 | Contact 16 | ======= 17 | 18 | The best way to discuss, contribute, etc. is via GitHub Issues and Pull Requests. But if you want to reach me 19 | about this project, I am available at `xkcdapp@treelinelabs.com`. 20 | -------------------------------------------------------------------------------- /Resources/CoreData/comics.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/CoreData/comics.sqlite -------------------------------------------------------------------------------- /Resources/CoreData/xkcd.xcdatamodeld/.xccurrentversion: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | _XCCurrentVersionName 6 | xkcd 2.xcdatamodel 7 | 8 | 9 | -------------------------------------------------------------------------------- /Resources/CoreData/xkcd.xcdatamodeld/xkcd 2.xcdatamodel/elements: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/CoreData/xkcd.xcdatamodeld/xkcd 2.xcdatamodel/elements -------------------------------------------------------------------------------- /Resources/CoreData/xkcd.xcdatamodeld/xkcd 2.xcdatamodel/layout: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/CoreData/xkcd.xcdatamodeld/xkcd 2.xcdatamodel/layout -------------------------------------------------------------------------------- /Resources/CoreData/xkcd.xcdatamodeld/xkcd.xcdatamodel/elements: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/CoreData/xkcd.xcdatamodeld/xkcd.xcdatamodel/elements -------------------------------------------------------------------------------- /Resources/CoreData/xkcd.xcdatamodeld/xkcd.xcdatamodel/layout: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/CoreData/xkcd.xcdatamodeld/xkcd.xcdatamodel/layout -------------------------------------------------------------------------------- /Resources/LaunchStoryboard.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "size" : "29x29", 15 | "idiom" : "iphone", 16 | "filename" : "Icon-Small.png", 17 | "scale" : "1x" 18 | }, 19 | { 20 | "size" : "29x29", 21 | "idiom" : "iphone", 22 | "filename" : "Icon-Small@2x.png", 23 | "scale" : "2x" 24 | }, 25 | { 26 | "size" : "29x29", 27 | "idiom" : "iphone", 28 | "filename" : "Icon-Small@3x.png", 29 | "scale" : "3x" 30 | }, 31 | { 32 | "size" : "40x40", 33 | "idiom" : "iphone", 34 | "filename" : "Icon-Spotlight@2x.png", 35 | "scale" : "2x" 36 | }, 37 | { 38 | "size" : "40x40", 39 | "idiom" : "iphone", 40 | "filename" : "Icon-Spotlight@3x.png", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "size" : "57x57", 45 | "idiom" : "iphone", 46 | "filename" : "Icon.png", 47 | "scale" : "1x" 48 | }, 49 | { 50 | "size" : "57x57", 51 | "idiom" : "iphone", 52 | "filename" : "Icon@2x.png", 53 | "scale" : "2x" 54 | }, 55 | { 56 | "size" : "60x60", 57 | "idiom" : "iphone", 58 | "filename" : "Icon2@2x.png", 59 | "scale" : "2x" 60 | }, 61 | { 62 | "size" : "60x60", 63 | "idiom" : "iphone", 64 | "filename" : "Icon2@3x.png", 65 | "scale" : "3x" 66 | }, 67 | { 68 | "size" : "1024x1024", 69 | "idiom" : "ios-marketing", 70 | "filename" : "iTunesArtwork@2x.jpg", 71 | "scale" : "1x" 72 | } 73 | ], 74 | "info" : { 75 | "version" : 1, 76 | "author" : "xcode" 77 | } 78 | } -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon-Small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon-Small.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon-Spotlight@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon-Spotlight@2x.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon-Spotlight@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon-Spotlight@3x.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon2@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon2@2x.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon2@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon2@3x.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/Icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/Icon@2x.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/AppIcon.appiconset/iTunesArtwork@2x.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/AppIcon.appiconset/iTunesArtwork@2x.jpg -------------------------------------------------------------------------------- /Resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Resources/Media.xcassets/download.imageset/98_Download-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/download.imageset/98_Download-1.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/download.imageset/98_Download-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/download.imageset/98_Download-2.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/download.imageset/98_Download.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/download.imageset/98_Download.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/download.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "98_Download.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "98_Download-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "98_Download-2.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Media.xcassets/next.imageset/177_^-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/next.imageset/177_^-1.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/next.imageset/177_^-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/next.imageset/177_^-2.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/next.imageset/177_^.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/next.imageset/177_^.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/next.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "177_^.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "177_^-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "177_^-2.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Media.xcassets/previous.imageset/178_v-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/previous.imageset/178_v-1.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/previous.imageset/178_v-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/previous.imageset/178_v-2.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/previous.imageset/178_v.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/previous.imageset/178_v.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/previous.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "178_v-2.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "178_v-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "178_v.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/Media.xcassets/random.imageset/238_Shuffle-(alt)-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/random.imageset/238_Shuffle-(alt)-1.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/random.imageset/238_Shuffle-(alt)-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/random.imageset/238_Shuffle-(alt)-2.png -------------------------------------------------------------------------------- /Resources/Media.xcassets/random.imageset/238_Shuffle-(alt).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/Media.xcassets/random.imageset/238_Shuffle-(alt).png -------------------------------------------------------------------------------- /Resources/Media.xcassets/random.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "238_Shuffle-(alt).png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "238_Shuffle-(alt)-1.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "238_Shuffle-(alt)-2.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Resources/System/Settings.bundle/Root.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | StringsTable 6 | Root 7 | PreferenceSpecifiers 8 | 9 | 10 | Type 11 | PSGroupSpecifier 12 | Title 13 | UI 14 | 15 | 16 | Type 17 | PSToggleSwitchSpecifier 18 | Title 19 | Open zoomed out 20 | Key 21 | zoomed_out 22 | DefaultValue 23 | 24 | 25 | 26 | Type 27 | PSToggleSwitchSpecifier 28 | Title 29 | Download new images 30 | Key 31 | autodownload 32 | DefaultValue 33 | 34 | 35 | 36 | Type 37 | PSToggleSwitchSpecifier 38 | Title 39 | Open after download 40 | Key 41 | autoopen 42 | DefaultValue 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /Resources/System/Settings.bundle/en.lproj/Root.strings: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulrehkugler/xkcd/5d5e5bfd8da7693d68a09e9df1d4af2cfe7c2849/Resources/System/Settings.bundle/en.lproj/Root.strings -------------------------------------------------------------------------------- /Resources/System/ad-hoc-entitlements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | get-task-allow 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Resources/System/xkcd-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleDisplayName 8 | ${PRODUCT_NAME} 9 | CFBundleExecutable 10 | ${EXECUTABLE_NAME} 11 | CFBundleIcons 12 | 13 | CFBundleIcons~ipad 14 | 15 | CFBundleIdentifier 16 | $(PRODUCT_BUNDLE_IDENTIFIER) 17 | CFBundleInfoDictionaryVersion 18 | 6.0 19 | CFBundleName 20 | ${PRODUCT_NAME} 21 | CFBundlePackageType 22 | APPL 23 | CFBundleShortVersionString 24 | $(MARKETING_VERSION) 25 | CFBundleSignature 26 | ???? 27 | CFBundleURLTypes 28 | 29 | 30 | CFBundleURLName 31 | xkcd 32 | CFBundleURLSchemes 33 | 34 | xkcd 35 | 36 | 37 | 38 | CFBundleVersion 39 | $(CURRENT_PROJECT_VERSION) 40 | LSApplicationQueriesSchemes 41 | 42 | googlechrome-x-callback 43 | googlechrome 44 | 45 | LSRequiresIPhoneOS 46 | 47 | NSAppTransportSecurity 48 | 49 | NSExceptionDomains 50 | 51 | xkcd.com 52 | 53 | NSExceptionAllowsInsecureHTTPLoads 54 | 55 | NSIncludesSubdomains 56 | 57 | 58 | 59 | 60 | NSPhotoLibraryAddUsageDescription 61 | To save xkcd images to your photo library. 62 | UILaunchStoryboardName 63 | LaunchStoryboard 64 | UIRequiresPersistentWiFi 65 | 66 | UIStatusBarStyle 67 | UIStatusBarStyleDefault 68 | 69 | 70 | -------------------------------------------------------------------------------- /Resources/faq.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Q 7 | How do you view the title text? 8 | A 9 | Touch and hold on the comic image. 10 | 11 | 12 | Q 13 | How do you search? 14 | A 15 | There's a search bar above the list of comics. 16 | 17 | 18 | Q 19 | I like the app. 20 | A 21 | Yay! Please share the app, leave a nice review in the app store, or write me a short email saying so. It makes me happy. 22 | 23 | 24 | Q 25 | I don't like the app. 26 | A 27 | Bummer. Good thing it was free, eh? 28 | 29 | 30 | Q 31 | I found a bug. I want a new feature. 32 | A 33 | Are you a developer? File or +1 an issue (or, better, send a pull request) at https://github.com/paulrehkugler/xkcd. Not a developer? Send me a feedback email, but be sure to read the rest of the FAQ first. 34 | 35 | 36 | Q 37 | Do you write the comic? 38 | A 39 | No, Randall Munroe writes xkcd. I don't know how to get ahold of him. 40 | 41 | 42 | Q 43 | What's up with comic 404? 44 | A 45 | http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html 46 | 47 | 48 | Q 49 | What's up with comic 1037? 50 | A 51 | http://www.reddit.com/r/comics/comments/rnpiw/mindboggling_xkcd_april_fools_comic/ 52 | 53 | 54 | Q 55 | I sent you feedback. Why haven't you replied to me? 56 | A 57 | I apologize; I'm very busy. I read and digest every single email I receive, but I don't always reply to all of them. Oh, and I don't write back to rude people. If you want a reply, be nice. :) 58 | 59 | 60 | Q 61 | I want an iPad version. 62 | A 63 | Yeah, me too. 64 | 65 | 66 | Q 67 | Will you write an app for my (other) favorite web comic? 68 | A 69 | Nope. This app is a labor of love (free, no ads), and I don't love any other webcomics. 70 | 71 | 72 | Q 73 | Who do I have to thank for this app? 74 | A 75 | So nice of you to ask! Contributors include: Chris Anthony, Josh Bleecher Snyder, Stuart McHattie, and Paul Rehkugler. 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /TLCommon/CGGeometry_TLCommon.h: -------------------------------------------------------------------------------- 1 | // 2 | // CGGeometry_TLCommon.h 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 8/31/09. 6 | // 7 | 8 | #import 9 | #import 10 | 11 | static inline CGRect CGRectZeroWithSize(CGSize size) { 12 | return CGRectMake(0.0f, 0.0f, size.width, size.height); 13 | } 14 | 15 | static inline CGRect CGRectZeroWithSquareSize(CGFloat size) { 16 | return CGRectMake(0.0f, 0.0f, size, size); 17 | } 18 | 19 | static inline CGRect CGRectZeroWithWidthAndHeight(CGFloat width, CGFloat height) { 20 | return CGRectMake(0.0f, 0.0f, width, height); 21 | } 22 | 23 | static inline CGRect CGRectWithOriginAndSize(CGPoint origin, CGSize size) { 24 | return CGRectMake(origin.x, origin.y, size.width, size.height); 25 | } 26 | 27 | static inline CGRect CGRectWithOriginAndSquareSize(CGPoint origin, CGFloat size) { 28 | return CGRectMake(origin.x, origin.y, size, size); 29 | } 30 | 31 | static inline CGRect CGRectWithXYAndSize(CGFloat xOrigin, CGFloat yOrigin, CGSize size) { 32 | return CGRectMake(xOrigin, yOrigin, size.width, size.height); 33 | } 34 | 35 | static inline CGRect CGRectWithXYAndSquareSize(CGFloat xOrigin, CGFloat yOrigin, CGFloat size) { 36 | return CGRectMake(xOrigin, yOrigin, size, size); 37 | } 38 | 39 | static inline CGRect CGRectWithCenterAndSize(CGPoint center, CGSize size) { 40 | return CGRectMake(center.x - size.width / 2.0f, center.y - size.height / 2.0f, size.width, size.height); 41 | } 42 | 43 | static inline CGRect CGRectWithCenterAndSquareSize(CGPoint center, CGFloat size) { 44 | return CGRectMake(center.x - size / 2.0f, center.y - size / 2.0f, size, size); 45 | } 46 | 47 | static inline CGSize CGSizeMakeSquare(CGFloat widthAndHeight) { 48 | return CGSizeMake(widthAndHeight, widthAndHeight); 49 | } 50 | 51 | static inline CGRect CGRectByAddingXOffset(CGRect originalRect, CGFloat xOffset) { 52 | return CGRectWithXYAndSize(originalRect.origin.x + xOffset, originalRect.origin.y, originalRect.size); 53 | } 54 | 55 | static inline CGRect CGRectByAddingYOffset(CGRect originalRect, CGFloat yOffset) { 56 | return CGRectWithXYAndSize(originalRect.origin.x, originalRect.origin.y + yOffset, originalRect.size); 57 | } 58 | 59 | static inline CGRect CGRectByAddingXAndYOffset(CGRect originalRect, CGFloat xOffset, CGFloat yOffset) { 60 | return CGRectWithXYAndSize(originalRect.origin.x + xOffset, originalRect.origin.y + yOffset, originalRect.size); 61 | } 62 | 63 | static inline CGRect CGRectByAddingWidth(CGRect originalRect, CGFloat additionalWidth) { 64 | return CGRectMake(originalRect.origin.x, originalRect.origin.y, originalRect.size.width + additionalWidth, originalRect.size.height); 65 | } 66 | 67 | static inline CGRect CGRectByAddingHeight(CGRect originalRect, CGFloat additionalHeight) { 68 | return CGRectMake(originalRect.origin.x, originalRect.origin.y, originalRect.size.width, originalRect.size.height + additionalHeight); 69 | } 70 | 71 | static inline CGRect CGRectByAddingWidthAndHeight(CGRect originalRect, CGFloat additionalWidth, CGFloat additionalHeight) { 72 | return CGRectMake(originalRect.origin.x, originalRect.origin.y, originalRect.size.width + additionalWidth, originalRect.size.height + additionalHeight); 73 | } 74 | 75 | static inline CGSize CGSizeByAddingHeight(CGSize originalSize, CGFloat extraHeight) { 76 | return CGSizeMake(originalSize.width, originalSize.height + extraHeight); 77 | } 78 | 79 | static inline CGSize CGSizeByAddingWidth(CGSize originalSize, CGFloat extraWidth) { 80 | return CGSizeMake(originalSize.width + extraWidth, originalSize.height); 81 | } 82 | 83 | static inline CGPoint CGPointByAddingXOffset(CGPoint originalPoint, CGFloat xOffset) { 84 | return CGPointMake(originalPoint.x + xOffset, originalPoint.y); 85 | } 86 | 87 | static inline CGPoint CGPointByAddingYOffset(CGPoint originalPoint, CGFloat yOffset) { 88 | return CGPointMake(originalPoint.x, originalPoint.y + yOffset); 89 | } 90 | 91 | static inline CGFloat SquaredDistanceBetweenPoints(CGPoint p1, CGPoint p2) { 92 | CGFloat deltaX = p1.x - p2.x; 93 | CGFloat deltaY = p1.y - p2.y; 94 | return (deltaX * deltaX) + (deltaY * deltaY); 95 | } 96 | 97 | static inline CGFloat DistanceBetweenPoints(CGPoint p1, CGPoint p2) { 98 | return sqrt(SquaredDistanceBetweenPoints(p1, p2)); 99 | } 100 | 101 | static inline CGPoint CenterOfRect(CGRect rect) { 102 | return CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect)); 103 | } 104 | 105 | static inline double AngleBetweenPoints(CGPoint p, CGPoint q) { 106 | CGPoint deltaVector = CGPointMake(p.x - q.x, p.y - q.y); 107 | double angle = atan(deltaVector.y / deltaVector.x) + (deltaVector.x < 0 ? M_PI : 0); 108 | return angle; 109 | } 110 | 111 | static inline CGPoint MidpointBetweenPoints(CGPoint p, CGPoint q) { 112 | return CGPointMake((p.x + q.x) / 2.0f, (p.y + q.y) / 2.0f); 113 | } 114 | 115 | static inline CGPoint PointMinusPoint(CGPoint p, CGPoint q) { 116 | return CGPointMake(p.x - q.x, p.y - q.y); 117 | } 118 | 119 | static inline CGPoint PointPlusPoint(CGPoint p, CGPoint q) { 120 | return CGPointMake(p.x + q.x, p.y + q.y); 121 | } 122 | 123 | static inline CGFloat OffsetToCenterFloatInFloat(CGFloat smallerValue, CGFloat largerValue) { 124 | return (largerValue - smallerValue) / 2.0f; 125 | } 126 | 127 | static inline CGRect CenteredRectInRectWithSize(CGRect rectToCenterIn, CGSize sizeOfCenteredRect) { 128 | return CGRectWithXYAndSize(rectToCenterIn.origin.x + OffsetToCenterFloatInFloat(sizeOfCenteredRect.width, rectToCenterIn.size.width), 129 | rectToCenterIn.origin.y + OffsetToCenterFloatInFloat(sizeOfCenteredRect.height, rectToCenterIn.size.height), 130 | sizeOfCenteredRect); 131 | } 132 | 133 | static inline CGSize ScaledSize(CGSize originalSize, CGFloat scalingFactor) { 134 | return CGSizeMake(originalSize.width * scalingFactor, originalSize.height * scalingFactor); 135 | } 136 | 137 | static inline CGRect CGRectRoundedToNearestPixel(CGRect rect) { 138 | return CGRectMake(roundf(rect.origin.x), 139 | roundf(rect.origin.y), 140 | roundf(rect.size.width), 141 | roundf(rect.size.height)); 142 | } 143 | 144 | static inline CGRect CGRectFlooredToNearestPixel(CGRect rect) { 145 | return CGRectMake(floorf(rect.origin.x), 146 | floorf(rect.origin.y), 147 | floorf(rect.size.width), 148 | floorf(rect.size.height)); 149 | } 150 | -------------------------------------------------------------------------------- /TLCommon/TLMacros.h: -------------------------------------------------------------------------------- 1 | // TLDebugLog is a drop-in replacement for NSLog that logs iff the build variable TL_DEBUG is defined 2 | #ifdef TL_DEBUG 3 | #define TLDebugLog(format, args...) NSLog(format, ## args) 4 | #else 5 | #define TLDebugLog(format, args...) 6 | #endif 7 | 8 | #ifdef TL_DEVELOPMENT 9 | #define TLDevLog(format, args...) NSLog(format, ## args) 10 | #else 11 | #define TLDevLog(format, args...) 12 | #endif 13 | 14 | #define BOUND(min, val, max) MAX(min, MIN(max, val)) 15 | -------------------------------------------------------------------------------- /TLCommon/TLMersenneTwister.h: -------------------------------------------------------------------------------- 1 | // 2 | // TLMersenneTwister.h 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/11/09. 6 | // 7 | 8 | #import 9 | 10 | @interface TLMersenneTwister : NSObject 11 | 12 | + (unsigned long)randInt32; /* generates a random number on [0,0xffffffff]-interval */ 13 | + (long)randInt31; /* generates a random number on [0,0x7fffffff]-interval */ 14 | 15 | + (double)randRealClosed; /* generates a random number on [0,1]-real-interval */ 16 | + (double)randRealClopen; /* generates a random number on [0,1)-real-interval */ 17 | + (double)randRealOpen; /* generates a random number on (0,1)-real-interval */ 18 | + (double)randRealClopen53; /* generates a random number on [0,1) with 53-bit resolution*/ 19 | 20 | + (void)setSeed:(unsigned long)newSeed; // note that this is only necessary if doing reproducibility testing or better seeds are required; time(NULL) seeding happens automatically 21 | 22 | @end 23 | -------------------------------------------------------------------------------- /TLCommon/TLMersenneTwister.m: -------------------------------------------------------------------------------- 1 | // 2 | // TLMersenneTwister.m 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/11/09. 6 | // 7 | 8 | // Original license (source squeezed by me into an Obj-C wrapper): 9 | 10 | /* 11 | A C-program for MT19937, with initialization improved 2002/1/26. 12 | Coded by Takuji Nishimura and Makoto Matsumoto. 13 | 14 | Before using, initialize the state by using init_genrand(seed) 15 | or init_by_array(init_key, key_length). 16 | 17 | Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura, 18 | All rights reserved. 19 | 20 | Redistribution and use in source and binary forms, with or without 21 | modification, are permitted provided that the following conditions 22 | are met: 23 | 24 | 1. Redistributions of source code must retain the above copyright 25 | notice, this list of conditions and the following disclaimer. 26 | 27 | 2. Redistributions in binary form must reproduce the above copyright 28 | notice, this list of conditions and the following disclaimer in the 29 | documentation and/or other materials provided with the distribution. 30 | 31 | 3. The names of its contributors may not be used to endorse or promote 32 | products derived from this software without specific prior written 33 | permission. 34 | 35 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 36 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 37 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 38 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 39 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 40 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 41 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 42 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 43 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 44 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 45 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 46 | 47 | 48 | Any feedback is very welcome. 49 | http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html 50 | email: m-mat @ math.sci.hiroshima-u.ac.jp (remove space) 51 | */ 52 | 53 | #import "TLMersenneTwister.h" 54 | 55 | /* Period parameters */ 56 | #define N 624 57 | #define M 397 58 | #define MATRIX_A 0x9908b0dfUL /* constant vector a */ 59 | #define UPPER_MASK 0x80000000UL /* most significant w-r bits */ 60 | #define LOWER_MASK 0x7fffffffUL /* least significant r bits */ 61 | 62 | @implementation TLMersenneTwister 63 | 64 | static unsigned long mt[N]; /* the array for the state vector */ 65 | static int mti = N + 1; /* mti==N+1 means mt[N] is not initialized */ 66 | 67 | + (void)initialize { 68 | if (self == [TLMersenneTwister class]) { 69 | [self setSeed:time(NULL)]; 70 | } 71 | } 72 | 73 | /* initializes mt[N] with a seed */ 74 | + (void)setSeed:(unsigned long)s { 75 | mt[0]= s & 0xffffffffUL; 76 | for (mti=1; mti> 30)) + mti); 79 | /* See Knuth TAOCP Vol2. 3rd Ed. P.106 for multiplier. */ 80 | /* In the previous versions, MSBs of the seed affect */ 81 | /* only MSBs of the array mt[]. */ 82 | /* 2002/01/09 modified by Makoto Matsumoto */ 83 | mt[mti] &= 0xffffffffUL; 84 | /* for >32 bit machines */ 85 | } 86 | } 87 | 88 | + (unsigned long)randInt32 { 89 | unsigned long y; 90 | static unsigned long mag01[2]={0x0UL, MATRIX_A}; 91 | /* mag01[x] = x * MATRIX_A for x=0,1 */ 92 | 93 | if (mti >= N) { /* generate N words at one time */ 94 | int kk; 95 | 96 | for (kk=0;kk> 1) ^ mag01[y & 0x1UL]; 99 | } 100 | for (;kk> 1) ^ mag01[y & 0x1UL]; 103 | } 104 | y = (mt[N-1]&UPPER_MASK)|(mt[0]&LOWER_MASK); 105 | mt[N-1] = mt[M-1] ^ (y >> 1) ^ mag01[y & 0x1UL]; 106 | 107 | mti = 0; 108 | } 109 | 110 | y = mt[mti++]; 111 | 112 | /* Tempering */ 113 | y ^= (y >> 11); 114 | y ^= (y << 7) & 0x9d2c5680UL; 115 | y ^= (y << 15) & 0xefc60000UL; 116 | y ^= (y >> 18); 117 | 118 | return y; 119 | } 120 | 121 | + (long)randInt31 { 122 | return (long)([self randInt32] >> 1); 123 | } 124 | 125 | + (double)randRealClosed { 126 | return [self randInt32] * (1.0/4294967295.0); 127 | /* divided by 2^32-1 */ 128 | } 129 | 130 | + (double)randRealClopen { 131 | return [self randInt32] * (1.0/4294967296.0); 132 | /* divided by 2^32 */ 133 | } 134 | 135 | + (double)randRealOpen { 136 | return (((double)[self randInt32]) + 0.5)*(1.0/4294967296.0); 137 | /* divided by 2^32 */ 138 | } 139 | 140 | + (double)randRealClopen53 { 141 | unsigned long a=[self randInt32]>>5, b=[self randInt32]>>6; 142 | return(a*67108864.0+b)*(1.0/9007199254740992.0); 143 | } 144 | /* These real versions are due to Isaku Wada, 2002/01/09 added */ 145 | 146 | @end 147 | -------------------------------------------------------------------------------- /TLCommon/TLModalActivityIndicatorView.h: -------------------------------------------------------------------------------- 1 | // 2 | // TLModalActivityIndicatorView.h 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/10/09. 6 | // 7 | 8 | #import 9 | #import 10 | 11 | @interface TLModalActivityIndicatorView : UIView 12 | 13 | - (instancetype)initWithText:(NSString *)text; 14 | - (void)show; 15 | - (void)dismiss; 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /TLCommon/TLModalActivityIndicatorView.m: -------------------------------------------------------------------------------- 1 | // 2 | // TLModalActivityIndicatorView.m 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/10/09. 6 | // 7 | 8 | #import "TLModalActivityIndicatorView.h" 9 | #import "CGGeometry_TLCommon.h" 10 | 11 | #define kModalSize 160.0f 12 | #define kCornerRadius 20.0f 13 | #define kLabelHeight 30.0f 14 | #define kLabelFont [UIFont fontWithName:@"Helvetica" size:18.0f] 15 | #define kModalColor [UIColor colorWithWhite:0.3 alpha:0.8] 16 | #define kStartingScale 1.8f 17 | #define kAnimationDuration 0.25f 18 | 19 | #pragma mark - 20 | 21 | @interface TLModalActivityIndicatorView () 22 | 23 | @property (nonatomic) UIActivityIndicatorView *spinner; 24 | 25 | @end 26 | 27 | 28 | #pragma mark - 29 | 30 | @implementation TLModalActivityIndicatorView 31 | 32 | - (instancetype)initWithText:(NSString *)text { 33 | if (self = [super initWithFrame:CGRectZero]) { 34 | UIWindow *keyWindow = [UIApplication sharedApplication].keyWindow; 35 | CGSize keyWindowSize = keyWindow.bounds.size; 36 | CGPoint keyWindowCenter = CGPointMake(keyWindowSize.width / 2.0f, keyWindowSize.height / 2.0f); 37 | 38 | self.frame = CGRectZeroWithSize(keyWindowSize); 39 | self.backgroundColor = [UIColor clearColor]; 40 | 41 | CALayer *backgroundLayer = [CALayer layer]; 42 | backgroundLayer.cornerRadius = kCornerRadius; 43 | backgroundLayer.masksToBounds = YES; 44 | backgroundLayer.bounds = CGRectMake(0.0f, 0.0f, kModalSize, kModalSize); 45 | backgroundLayer.position = keyWindowCenter; 46 | backgroundLayer.backgroundColor = kModalColor.CGColor; 47 | 48 | UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(keyWindowCenter.x - kModalSize / 2.0f, 49 | keyWindowCenter.y + kModalSize / 2.0f - kLabelHeight, 50 | kModalSize, 51 | kLabelHeight)]; 52 | label.text = text; 53 | label.textColor = [UIColor whiteColor]; 54 | label.backgroundColor = [UIColor clearColor]; 55 | label.font = kLabelFont; 56 | label.textAlignment = NSTextAlignmentCenter; 57 | _spinner = [[UIActivityIndicatorView alloc] initWithFrame:CGRectZero]; 58 | _spinner.activityIndicatorViewStyle = UIActivityIndicatorViewStyleWhiteLarge; 59 | [_spinner sizeToFit]; 60 | [_spinner startAnimating]; 61 | 62 | _spinner.center = keyWindowCenter; 63 | 64 | [self.layer addSublayer:backgroundLayer]; 65 | [self addSubview:label]; 66 | [self addSubview:_spinner]; 67 | 68 | self.transform = CGAffineTransformMakeScale(kStartingScale, kStartingScale); 69 | } 70 | return self; 71 | } 72 | 73 | - (void)show { 74 | [[UIApplication sharedApplication].keyWindow addSubview:self]; 75 | [UIView beginAnimations:@"modalSpinner" context:NULL]; 76 | self.transform = CGAffineTransformIdentity; 77 | [UIView setAnimationDuration:kAnimationDuration]; 78 | [UIView commitAnimations]; 79 | } 80 | 81 | - (void)dismiss { 82 | [self removeFromSuperview]; 83 | } 84 | 85 | 86 | @end 87 | -------------------------------------------------------------------------------- /TLCommon/TLNavigationController.h: -------------------------------------------------------------------------------- 1 | // 2 | // TLNavigationController.h 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 5/28/13. 6 | // 7 | // 8 | 9 | #import 10 | 11 | @interface TLNavigationController : UINavigationController 12 | 13 | @end 14 | -------------------------------------------------------------------------------- /TLCommon/TLNavigationController.m: -------------------------------------------------------------------------------- 1 | // 2 | // TLNavigationController.m 3 | // xkcd 4 | // 5 | // Created by Paul Rehkugler on 5/28/13. 6 | // 7 | // 8 | 9 | #import "TLNavigationController.h" 10 | 11 | @implementation TLNavigationController 12 | 13 | - (UIInterfaceOrientationMask)supportedInterfaceOrientations { 14 | return UIInterfaceOrientationMaskAllButUpsideDown; 15 | } 16 | 17 | @end 18 | -------------------------------------------------------------------------------- /TLCommon/UIActivityIndicatorView_TLCommon.h: -------------------------------------------------------------------------------- 1 | // 2 | // UIActivityIndicatorView_TLCommon.h 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/17/09. 6 | // 7 | 8 | #import 9 | #import 10 | 11 | @interface UIActivityIndicatorView (TLCommon) 12 | 13 | + (UIActivityIndicatorView *)animatingActivityIndicatorViewWithStyle:(UIActivityIndicatorViewStyle)style; 14 | 15 | @end 16 | -------------------------------------------------------------------------------- /TLCommon/UIActivityIndicatorView_TLCommon.m: -------------------------------------------------------------------------------- 1 | // 2 | // UIActivityIndicatorView_TLCommon.m 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/17/09. 6 | // 7 | 8 | #import "UIActivityIndicatorView_TLCommon.h" 9 | 10 | 11 | @implementation UIActivityIndicatorView (TLCommon) 12 | 13 | + (UIActivityIndicatorView *)animatingActivityIndicatorViewWithStyle:(UIActivityIndicatorViewStyle)style { 14 | UIActivityIndicatorView *spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:style]; 15 | [spinner sizeToFit]; 16 | [spinner startAnimating]; 17 | spinner.hidesWhenStopped = YES; 18 | return spinner; 19 | } 20 | 21 | @end 22 | -------------------------------------------------------------------------------- /TLCommon/UIBarButtonItem_TLCommon.h: -------------------------------------------------------------------------------- 1 | // 2 | // UIBarButtonItem_TLCommon.h 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/10/09. 6 | // 7 | 8 | #import 9 | 10 | 11 | @interface UIBarButtonItem (TLCommon) 12 | 13 | + (UIBarButtonItem *)spinnerBarButtonItem; 14 | + (UIBarButtonItem *)flexibleSpaceBarButtonItem; 15 | + (UIBarButtonItem *)fixedSpaceBarButtonItemWithWidth:(CGFloat)spaceWidth; 16 | + (UIBarButtonItem *)barButtonSystemItem:(UIBarButtonSystemItem)systemItem target:(id)target action:(SEL)action; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /TLCommon/UIBarButtonItem_TLCommon.m: -------------------------------------------------------------------------------- 1 | // 2 | // UIBarButtonItem_TLCommon.m 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/10/09. 6 | // 7 | 8 | #import 9 | #import "UIBarButtonItem_TLCommon.h" 10 | #import "UIActivityIndicatorView_TLCommon.h" 11 | 12 | @implementation UIBarButtonItem (TLCommon) 13 | 14 | + (UIBarButtonItem *)spinnerBarButtonItem { 15 | UIActivityIndicatorView *spinner = [UIActivityIndicatorView animatingActivityIndicatorViewWithStyle:UIActivityIndicatorViewStyleWhite]; 16 | 17 | UIBarButtonItem *spinnerBarButtonItem = [[UIBarButtonItem alloc] initWithCustomView:spinner]; 18 | spinnerBarButtonItem.enabled = NO; 19 | 20 | return spinnerBarButtonItem; 21 | } 22 | 23 | + (UIBarButtonItem *)flexibleSpaceBarButtonItem { 24 | return [self barButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; 25 | } 26 | 27 | + (UIBarButtonItem *)fixedSpaceBarButtonItemWithWidth:(CGFloat)spaceWidth { 28 | UIBarButtonItem *fixedSpace = [self barButtonSystemItem:UIBarButtonSystemItemFixedSpace target:nil action:nil]; 29 | fixedSpace.width = spaceWidth; 30 | return fixedSpace; 31 | } 32 | 33 | + (UIBarButtonItem *)barButtonSystemItem:(UIBarButtonSystemItem)systemItem target:(id)target action:(SEL)action { 34 | return [[UIBarButtonItem alloc] initWithBarButtonSystemItem:systemItem target:target action:action]; 35 | } 36 | 37 | 38 | @end 39 | -------------------------------------------------------------------------------- /TLCommon/UITableView_TLCommon.h: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView_TLCommon.h 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/15/09. 6 | // 7 | 8 | #import 9 | 10 | 11 | @interface UITableView (TLCommon) 12 | 13 | - (void)reloadRowAtRow:(NSUInteger)row section:(NSUInteger)section withRowAnimation:(UITableViewRowAnimation)animation; 14 | - (void)reloadRowAtIndexPath:(NSIndexPath *)indexPath withRowAnimation:(UITableViewRowAnimation)animation; 15 | 16 | - (void)deleteRowsInRowRange:(NSRange)rowRange section:(NSUInteger)section withRowAnimation:(UITableViewRowAnimation)animation; 17 | 18 | @end 19 | -------------------------------------------------------------------------------- /TLCommon/UITableView_TLCommon.m: -------------------------------------------------------------------------------- 1 | // 2 | // UITableView_TLCommon.m 3 | // TLCommon 4 | // 5 | // Created by Joshua Bleecher Snyder on 9/15/09. 6 | // 7 | 8 | #import 9 | #import "UITableView_TLCommon.h" 10 | 11 | @implementation UITableView (TLCommon) 12 | 13 | - (void)reloadRowAtRow:(NSUInteger)row section:(NSUInteger)section withRowAnimation:(UITableViewRowAnimation)animation { 14 | [self reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:row inSection:section]] 15 | withRowAnimation:animation]; 16 | } 17 | 18 | - (void)reloadRowAtIndexPath:(NSIndexPath *)indexPath withRowAnimation:(UITableViewRowAnimation)animation { 19 | [self reloadRowsAtIndexPaths:@[indexPath] 20 | withRowAnimation:animation]; 21 | } 22 | 23 | - (void)deleteRowsInRowRange:(NSRange)rowRange section:(NSUInteger)section withRowAnimation:(UITableViewRowAnimation)animation { 24 | // there's got to be a better way... 25 | NSMutableArray *indexPathsToDelete = [NSMutableArray arrayWithCapacity:rowRange.length]; 26 | for (NSUInteger i = 0; i < rowRange.length; i++) { 27 | NSIndexPath *indexPathToDelete = [NSIndexPath indexPathForRow:rowRange.location + i inSection:section]; 28 | [indexPathsToDelete addObject:indexPathToDelete]; 29 | } 30 | [self deleteRowsAtIndexPaths:indexPathsToDelete withRowAnimation:animation]; 31 | } 32 | 33 | @end 34 | -------------------------------------------------------------------------------- /Tests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1 23 | 24 | 25 | -------------------------------------------------------------------------------- /Tests/TestNSString_HTML.m: -------------------------------------------------------------------------------- 1 | // 2 | // TestNSString_HTML.m 3 | // 4 | 5 | @import XCTest; 6 | @import UIKit; 7 | 8 | #import "NSString_HTML.h" 9 | 10 | @interface TestNSString_HTML : XCTestCase 11 | 12 | @end 13 | 14 | @implementation TestNSString_HTML 15 | 16 | - (void)testStringByCleaningHTML { 17 | NSDictionary *htmlAndCleanedStrings = @{ 18 | @"hi": @"hi", 19 | @"good > bad": @"good > bad", 20 | @""quote"": @"\"quote\"", 21 | @"clichés": @"clichés", 22 | @"House of Pancakes": @"House of Pancakes", 23 | @"bad > worse": @"bad > worse", 24 | @"I Accidentally ": @"I Accidentally ", 25 | @"<3": @"<3", 26 | @"<>": @"<>", 27 | @"RSS&M": @"RSS&M", 28 | @"(or \\;;\"\\''{\\<<[' this mouseover text": @"(or \\;;\"\\''{\\<<[' this mouseover text" 29 | }; 30 | 31 | for (NSString *snippet in htmlAndCleanedStrings) { 32 | NSString *cleaned = htmlAndCleanedStrings[snippet]; 33 | XCTAssertEqualObjects([NSString stringByCleaningHTML:snippet], cleaned, @"Snippet '%@' should result in clean string '%@'.", snippet, cleaned); 34 | } 35 | } 36 | 37 | @end 38 | -------------------------------------------------------------------------------- /xkcd.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /xkcd.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /xkcd.xcodeproj/xcshareddata/xcschemes/UnitTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /xkcd.xcodeproj/xcshareddata/xcschemes/xkcd.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 42 | 48 | 49 | 50 | 51 | 52 | 62 | 64 | 70 | 71 | 72 | 73 | 79 | 81 | 87 | 88 | 89 | 90 | 92 | 93 | 96 | 97 | 98 | --------------------------------------------------------------------------------