deep-linking-sample/Apps/Wikipedia/WMF Framework/WMFExploreFeedContentController.m
Javier Cicchelli 9bcdaa697b [Setup] Basic project structure (#1)
This PR contains all the work related to setting up this project as required to implement the [Assignment](https://repo.rock-n-code.com/rock-n-code/deep-linking-assignment/wiki/Assignment) on top, as intended.

To summarise this work:
- [x] created a new **Xcode** project;
- [x] cloned the `Wikipedia` app and inserted it into the **Xcode** project;
- [x] created the `Locations` app and also, its `Libraries` package;
- [x] created the `Shared` package to share dependencies between the apps;
- [x] added a `Makefile` file and implemented some **environment** and **help** commands.

Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
Reviewed-on: rock-n-code/deep-linking-assignment#1
2023-04-08 18:37:13 +00:00

865 lines
43 KiB
Objective-C

#import <WMF/WMFExploreFeedContentController.h>
#import <WMF/WMFRelatedPagesContentSource.h>
#import <WMF/WMFNearbyContentSource.h>
#import <WMF/WMFContinueReadingContentSource.h>
#import <WMF/WMFFeedContentSource.h>
#import <WMF/WMFRandomContentSource.h>
#import <WMF/WMFAnnouncementsContentSource.h>
#import <WMF/WMFOnThisDayContentSource.h>
#import <WMF/WMFAssertions.h>
#import <WMF/WMF-Swift.h>
NSString *const WMFExploreFeedContentControllerBusyStateDidChange = @"WMFExploreFeedContentControllerBusyStateDidChange";
const NSInteger WMFExploreFeedMaximumNumberOfDays = 30;
static const NSTimeInterval WMFFeedRefreshTimeoutInterval = 60;
static NSTimeInterval WMFFeedRefreshBackgroundTimeout = 30;
static const NSString *kvo_WMFExploreFeedContentController_operationQueue_operationCount = @"kvo_WMFExploreFeedContentController_operationQueue_operationCount";
// Explore feed preferences dictionary keys
NSString *const WMFExploreFeedPreferencesKey = @"WMFExploreFeedPreferencesKey";
NSString *const WMFExploreFeedPreferencesGlobalCardsKey = @"WMFExploreFeedPreferencesGlobalCardsKey";
// Explore feed preferences notifications
NSString *const WMFExploreFeedPreferencesDidChangeNotification = @"WMFExploreFeedPreferencesDidChangeNotification";
NSString *const WMFExploreFeedPreferencesDidSaveNotification = @"WMFExploreFeedPreferencesDidSaveNotification";
NSString *const WMFNewExploreFeedPreferencesWereRejectedNotification = @"WMFNewExploreFeedPreferencesWereRejectedNotification";
@interface WMFExploreFeedContentController () <WMFBackgroundFetcher>
@property (nonatomic, strong) NSArray<id<WMFContentSource>> *contentSources;
@property (nonatomic, strong) NSOperationQueue *operationQueue;
@property (nonatomic, weak) MWKDataStore *dataStore;
@property (nonatomic, strong) NSDictionary *exploreFeedPreferences;
@property (nonatomic, copy, readonly) NSArray<NSURL *> *preferredSiteURLs;
@property (nonatomic, strong) ExploreFeedPreferencesUpdateCoordinator *exploreFeedPreferencesUpdateCoordinator;
@property (nonatomic, nullable) NSNumber *cachedCountOfVisibleContentGroupKinds;
@property (nonatomic, strong) NSDictionary<NSString *, NSNumber *> *sortOrderByContentLanguageCode;
@end
@implementation WMFExploreFeedContentController
@synthesize exploreFeedPreferences = _exploreFeedPreferences;
- (instancetype)initWithDataStore:(MWKDataStore *)dataStore {
self = [super init];
if (self) {
self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.dataStore = dataStore;
}
return self;
}
- (void)dealloc {
self.operationQueue = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)setOperationQueue:(NSOperationQueue *)operationQueue {
if (_operationQueue == operationQueue) {
return;
}
if (_operationQueue) {
[_operationQueue removeObserver:self forKeyPath:@"operationCount"];
}
_operationQueue = operationQueue;
if (_operationQueue) {
[_operationQueue addObserver:self forKeyPath:@"operationCount" options:NSKeyValueObservingOptionNew context:&kvo_WMFExploreFeedContentController_operationQueue_operationCount];
}
}
- (void)setDataStore:(MWKDataStore *)dataStore {
_dataStore = dataStore;
self.exploreFeedPreferencesUpdateCoordinator = [[ExploreFeedPreferencesUpdateCoordinator alloc] initWithFeedContentController:self];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateExploreFeedPreferencesFromDidSaveNotification:) name:WMFViewContextDidSave object:nil];
}
- (NSDictionary *)exploreFeedPreferences {
assert([NSThread isMainThread]);
if (!_exploreFeedPreferences) {
_exploreFeedPreferences = [self exploreFeedPreferencesInManagedObjectContext:self.dataStore.viewContext];
}
return _exploreFeedPreferences;
}
- (void)setExploreFeedPreferences:(NSDictionary *)exploreFeedPreferences {
assert([NSThread isMainThread]);
_exploreFeedPreferences = exploreFeedPreferences;
}
- (NSArray<NSURL *> *)preferredSiteURLs {
return [self.dataStore.languageLinkController.preferredSiteURLs copy];
}
#pragma mark - Content Sources
- (WMFFeedContentSource *)feedContentSource {
return [self.contentSources wmf_match:^BOOL(id<WMFContentSource> obj) {
return [obj isKindOfClass:[WMFFeedContentSource class]];
}];
}
- (WMFRandomContentSource *)randomContentSource {
return [self.contentSources wmf_match:^BOOL(id<WMFContentSource> obj) {
return [obj isKindOfClass:[WMFRandomContentSource class]];
}];
}
- (WMFNearbyContentSource *)nearbyContentSource {
return [self.contentSources wmf_match:^BOOL(id<WMFContentSource> obj) {
return [obj isKindOfClass:[WMFNearbyContentSource class]];
}];
}
- (WMFFeedContentSource *)onThisDayContentSource {
return [self.contentSources wmf_match:^BOOL(id<WMFContentSource> obj) {
return [obj isKindOfClass:[WMFOnThisDayContentSource class]];
}];
}
- (NSArray<id<WMFContentSource>> *)contentSources {
NSParameterAssert(self.dataStore);
WMFSession *session = self.dataStore.session;
WMFConfiguration *configuration = self.dataStore.configuration;
NSParameterAssert(session);
NSParameterAssert(configuration);
if (!_contentSources) {
NSArray<NSURL *>*siteURLs = self.preferredSiteURLs;
NSParameterAssert(siteURLs);
NSMutableArray *mutableContentSources = [NSMutableArray arrayWithCapacity:2 + siteURLs.count * 7];
[mutableContentSources addObject:[[WMFRelatedPagesContentSource alloc] init]];
[mutableContentSources addObject:[[WMFContinueReadingContentSource alloc] initWithUserDataStore:self.dataStore]];
for (NSURL *siteURL in siteURLs) {
WMFFeedContentSource *feedContentSource = [[WMFFeedContentSource alloc] initWithSiteURL:siteURL
userDataStore:self.dataStore];
[mutableContentSources addObjectsFromArray: @[[[WMFNearbyContentSource alloc] initWithSiteURL:siteURL dataStore:self.dataStore],
feedContentSource,
[[WMFRandomContentSource alloc] initWithSiteURL:siteURL session:session configuration:configuration],
[[WMFAnnouncementsContentSource alloc] initWithSiteURL:siteURL userDataStore:self.dataStore],
[[WMFOnThisDayContentSource alloc] initWithSiteURL:siteURL session:session configuration:configuration]]];
}
_contentSources = [mutableContentSources copy];
}
return _contentSources;
}
#pragma mark - Update / Start / Stop
- (void)updateContentSources {
NSArray<NSURL *> *siteURLs = self.preferredSiteURLs;
NSMutableDictionary<NSString *, NSNumber *> *updatedSortOrder = [NSMutableDictionary dictionaryWithCapacity:siteURLs.count];
NSInteger i = 0;
for (NSURL *siteURL in siteURLs) {
updatedSortOrder[siteURL.wmf_contentLanguageCode] = @(i);
i++;
}
self.sortOrderByContentLanguageCode = updatedSortOrder;
if ([_contentSources count] == 0) {
return;
}
[self stopContentSources];
self.contentSources = nil;
[self startContentSources];
[self updateFeedSourcesUserInitiated:NO completion:NULL];
}
- (void)startContentSources {
[self.contentSources enumerateObjectsUsingBlock:^(id<WMFContentSource> _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
if ([obj conformsToProtocol:@protocol(WMFAutoUpdatingContentSource)]) {
[(id<WMFAutoUpdatingContentSource>)obj startUpdating];
}
}];
}
- (void)stopContentSources {
[self.contentSources enumerateObjectsUsingBlock:^(id<WMFContentSource> _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
if ([obj conformsToProtocol:@protocol(WMFAutoUpdatingContentSource)]) {
[(id<WMFAutoUpdatingContentSource>)obj stopUpdating];
}
}];
}
#pragma mark - Updating
- (void)updateFeedSourcesUserInitiated:(BOOL)wasUserInitiated completion:(nullable dispatch_block_t)completion {
[self updateFeedSourcesWithDate:nil userInitiated:wasUserInitiated completion:completion];
}
- (void)updateFeedSourcesWithDate:(nullable NSDate *)date userInitiated:(BOOL)wasUserInitiated completion:(nullable dispatch_block_t)completion {
WMFAssertMainThread(@"updateFeedSources: must be called on the main thread");
WMFAsyncBlockOperation *op = [[WMFAsyncBlockOperation alloc] initWithAsyncBlock:^(WMFAsyncBlockOperation *_Nonnull op) {
dispatch_async(dispatch_get_main_queue(), ^{
NSManagedObjectContext *moc = self.dataStore.feedImportContext;
WMFTaskGroup *group = [WMFTaskGroup new];
#if DEBUG
NSMutableArray *entered = [NSMutableArray arrayWithCapacity:self.contentSources.count];
#endif
[self.contentSources enumerateObjectsUsingBlock:^(id<WMFContentSource> _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
[group enter];
#if DEBUG
NSString *classString = NSStringFromClass([obj class]);
@synchronized(self) {
[entered addObject:classString];
}
#endif
dispatch_block_t contentSourceCompletion = ^{
#if DEBUG
@synchronized(self) {
NSInteger index = [entered indexOfObject:classString];
assert(index != NSNotFound);
[entered removeObjectAtIndex:index];
}
#endif
[group leave];
};
if ([obj conformsToProtocol:@protocol(WMFOptionalNewContentSource)]) {
NSDate *optionalDate = date ? date : [NSDate date];
id<WMFOptionalNewContentSource> optional = (id<WMFOptionalNewContentSource>)obj;
[optional loadContentForDate:optionalDate inManagedObjectContext:moc force:NO addNewContent:wasUserInitiated completion:contentSourceCompletion];
} else if (date && [obj conformsToProtocol:@protocol(WMFDateBasedContentSource)]) {
id<WMFDateBasedContentSource> dateBased = (id<WMFDateBasedContentSource>)obj;
[dateBased loadContentForDate:date inManagedObjectContext:moc force:NO completion:contentSourceCompletion];
} else if (!date) {
[obj loadNewContentInManagedObjectContext:moc force:NO completion:contentSourceCompletion];
} else {
contentSourceCompletion();
}
}];
[group waitInBackgroundWithTimeout:WMFFeedRefreshTimeoutInterval
completion:^{
[moc performBlock:^{
NSError *saveError = nil;
if ([moc hasChanges]) {
[self applyExploreFeedPreferencesToAllObjectsInManagedObjectContext:moc];
if (![moc save:&saveError]) {
DDLogError(@"Error saving: %@", saveError);
}
}
dispatch_async(dispatch_get_main_queue(), ^{
[self.dataStore teardownFeedImportContext];
[[NSUserDefaults standardUserDefaults] wmf_setFeedRefreshDate:[NSDate date]];
[[WMFWidgetController shared] reloadAllWidgetsIfNecessary];
if (completion) {
completion();
}
[op finish];
});
}];
#if DEBUG
if ([entered count] > 0) {
DDLogError(@"Didn't leave: %@", entered);
}
#endif
}];
});
}];
[self.operationQueue addOperation:op];
}
- (void)updateContentSource:(Class)class force:(BOOL)force completion:(nullable dispatch_block_t)completion {
WMFAssertMainThread(@"updateContentSource: must be called on the main thread");
NSManagedObjectContext *moc = self.dataStore.feedImportContext;
WMFTaskGroup *group = [WMFTaskGroup new];
WMFAsyncBlockOperation *op = [[WMFAsyncBlockOperation alloc] initWithAsyncBlock:^(WMFAsyncBlockOperation *_Nonnull op) {
[self.contentSources enumerateObjectsUsingBlock:^(id<WMFContentSource> _Nonnull obj, NSUInteger idx, BOOL *_Nonnull stop) {
if ([obj isKindOfClass:class]) {
[group enter];
[obj loadNewContentInManagedObjectContext:moc
force:force
completion:^{
[group leave];
}];
}
}];
[group waitInBackgroundWithTimeout:WMFFeedRefreshTimeoutInterval
completion:^{
[moc performBlock:^{
[self applyExploreFeedPreferencesToAllObjectsInManagedObjectContext:moc];
NSError *saveError = nil;
if ([moc hasChanges] && ![moc save:&saveError]) {
DDLogError(@"Error saving: %@", saveError);
}
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) {
completion();
}
[op finish];
});
}];
}];
}];
[self.operationQueue addOperation:op];
}
- (void)updateBackgroundSourcesWithCompletion:(void (^_Nonnull)(UIBackgroundFetchResult))completionHandler {
WMFAssertMainThread(@"updateBackgroundSourcesWithCompletion: must be called on the main thread");
NSManagedObjectContext *moc = self.dataStore.viewContext;
NSFetchRequest *beforeFetchRequest = [WMFContentGroup fetchRequest];
NSInteger beforeCount = [moc countForFetchRequest:beforeFetchRequest error:nil];
WMFAsyncBlockOperation *op = [[WMFAsyncBlockOperation alloc] initWithAsyncBlock:^(WMFAsyncBlockOperation *_Nonnull op) {
WMFTaskGroup *group = [WMFTaskGroup new];
[group enter];
[[self feedContentSource] loadNewContentInManagedObjectContext:moc
force:NO
completion:^{
[group leave];
}];
[group enter];
[[self randomContentSource] loadNewContentInManagedObjectContext:moc
force:NO
completion:^{
[group leave];
}];
[group enter];
[[self onThisDayContentSource] loadNewContentInManagedObjectContext:moc
force:NO
completion:^{
[group leave];
}];
[group waitInBackgroundWithTimeout:WMFFeedRefreshBackgroundTimeout
completion:^{
[moc performBlock:^{
BOOL didUpdate = NO;
if ([moc hasChanges]) {
[self applyExploreFeedPreferencesToAllObjectsInManagedObjectContext:moc];
NSFetchRequest *afterFetchRequest = [WMFContentGroup fetchRequest];
NSInteger afterCount = [moc countForFetchRequest:afterFetchRequest error:nil];
didUpdate = afterCount != beforeCount;
NSError *saveError = nil;
if (![moc save:&saveError]) {
DDLogError(@"Error saving background source update: %@", saveError);
}
}
dispatch_async(dispatch_get_main_queue(), ^{
[[WMFWidgetController shared] reloadAllWidgetsIfNecessary];
if (completionHandler) {
completionHandler(didUpdate ? UIBackgroundFetchResultNewData : UIBackgroundFetchResultNoData);
}
[op finish];
});
}];
}];
}];
[self.operationQueue addOperation:op];
}
#pragma mark - Preferences
- (void)updateExploreFeedPreferencesFromDidSaveNotification:(NSNotification *)note {
NSDictionary *userInfo = note.userInfo;
NSArray<NSString *> *keys = @[NSInsertedObjectsKey, NSUpdatedObjectsKey, NSDeletedObjectsKey, NSRefreshedObjectsKey, NSInvalidatedObjectsKey];
for (NSString *key in keys) {
NSSet<NSManagedObject *> *savedObjects = userInfo[key];
for (NSManagedObject *object in savedObjects) {
if (![object isKindOfClass:[WMFKeyValue class]]) {
continue;
}
WMFKeyValue *keyValue = (WMFKeyValue *)object;
if (![keyValue.key isEqualToString:WMFExploreFeedPreferencesKey]) {
continue;
}
NSDictionary *newExploreFeedPreferences = (NSDictionary *)keyValue.value;
dispatch_async(dispatch_get_main_queue(), ^{
if (self.exploreFeedPreferences == newExploreFeedPreferences) {
return;
}
self.exploreFeedPreferences = newExploreFeedPreferences;
[NSNotificationCenter.defaultCenter postNotificationName:WMFExploreFeedPreferencesDidSaveNotification object:self.exploreFeedPreferences];
});
self.cachedCountOfVisibleContentGroupKinds = nil;
}
}
}
- (BOOL)anyContentGroupsVisibleInTheFeedForSiteURL:(NSURL *)siteURL {
return [self.exploreFeedPreferences objectForKey:siteURL.wmf_contentLanguageCode] != nil;
}
- (NSArray<NSString *> *)contentLanguageCodesForContentGroupKind:(WMFContentGroupKind)contentGroupKind {
NSMutableArray *contentLanguageCodes = [NSMutableArray new];
[self.exploreFeedPreferences enumerateKeysAndObjectsUsingBlock:^(NSString *key, id _Nonnull value, BOOL * _Nonnull stop) {
if (![value isKindOfClass:[NSDictionary class]] && [value containsObject:@(contentGroupKind)]) {
[contentLanguageCodes addObject:key];
}
}];
return contentLanguageCodes;
}
+ (NSSet<NSNumber *> *)customizableContentGroupKindNumbers {
static NSSet *customizableContentGroupKindNumbers;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
customizableContentGroupKindNumbers = [NSSet setWithArray:@[@(WMFContentGroupKindFeaturedArticle), @(WMFContentGroupKindNews), @(WMFContentGroupKindTopRead), @(WMFContentGroupKindOnThisDay), @(WMFContentGroupKindLocation), @(WMFContentGroupKindLocationPlaceholder), @(WMFContentGroupKindRandom), @(WMFContentGroupKindNotification)]];
});
return customizableContentGroupKindNumbers;
}
+ (NSSet<NSNumber *> *)globalContentGroupKindNumbers {
static NSSet *globalContentGroupKindNumbers;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
globalContentGroupKindNumbers = [NSSet setWithArray:@[@(WMFContentGroupKindPictureOfTheDay), @(WMFContentGroupKindContinueReading), @(WMFContentGroupKindRelatedPages)]];
});
return globalContentGroupKindNumbers;
}
- (BOOL)isGlobalContentGroupKindInFeed:(WMFContentGroupKind)contentGroupKind {
NSAssert([self isGlobal:contentGroupKind], @"Content group kind is not global");
NSNumber *globalCardPreferenceNumber = [self.globalCardPreferences objectForKey:@(contentGroupKind)];
return [globalCardPreferenceNumber boolValue];
}
- (BOOL)isGlobal:(WMFContentGroupKind)contentGroupKind {
return [[WMFExploreFeedContentController globalContentGroupKindNumbers] containsObject:@(contentGroupKind)];
}
- (NSDictionary<NSNumber*, NSNumber*> *)globalCardPreferences {
NSDictionary<NSNumber*, NSNumber*> *globalCardPreferences = [self.exploreFeedPreferences objectForKey:WMFExploreFeedPreferencesGlobalCardsKey] ?: [self defaultGlobalCardsPreferences];
return globalCardPreferences;
}
- (BOOL)areGlobalContentGroupKindsInFeed {
for (NSNumber *globalCardPreferenceNumber in [self.globalCardPreferences allValues]) {
if ([globalCardPreferenceNumber boolValue]) {
return true;
}
continue;
}
return false;
}
- (NSDictionary *)exploreFeedPreferencesInManagedObjectContext:(NSManagedObjectContext *)moc {
WMFKeyValue *keyValue = [moc wmf_keyValueForKey:WMFExploreFeedPreferencesKey];
NSDictionary *exploreFeedPreferences = (NSDictionary *)keyValue.value;
if (exploreFeedPreferences && [exploreFeedPreferences objectForKey:WMFExploreFeedPreferencesGlobalCardsKey]) {
return exploreFeedPreferences;
}
[moc wmf_setValue:[self defaultExploreFeedPreferences] forKey:WMFExploreFeedPreferencesKey];
[self save:moc];
NSDictionary *preferences = (NSDictionary *)[moc wmf_keyValueForKey:WMFExploreFeedPreferencesKey].value;
assert(preferences);
return preferences;
}
- (NSDictionary *)defaultExploreFeedPreferences {
NSMutableDictionary *defaultExploreFeedPreferences = [NSMutableDictionary dictionaryWithCapacity:self.preferredSiteURLs.count + 1];
for (NSURL *siteURL in self.preferredSiteURLs) {
[defaultExploreFeedPreferences setObject:[WMFExploreFeedContentController customizableContentGroupKindNumbers] forKey:siteURL.wmf_contentLanguageCode];
}
[defaultExploreFeedPreferences setObject:[self defaultGlobalCardsPreferences] forKey:WMFExploreFeedPreferencesGlobalCardsKey];
return defaultExploreFeedPreferences;
}
- (NSDictionary<NSNumber*, NSNumber*> *)defaultGlobalCardsPreferences {
NSMutableDictionary<NSNumber*, NSNumber*> *defaultGlobalCardsPreferences = [NSMutableDictionary new];
for (NSNumber *globalContentGroupKindNumber in [WMFExploreFeedContentController globalContentGroupKindNumbers]) {
[defaultGlobalCardsPreferences setObject:[NSNumber numberWithBool:YES] forKey:globalContentGroupKindNumber];
}
return defaultGlobalCardsPreferences;
}
- (void)toggleContentGroupOfKind:(WMFContentGroupKind)contentGroupKind isOn:(BOOL)isOn updateFeed:(BOOL)updateFeed {
[self toggleContentGroupOfKind:contentGroupKind forSiteURLs:self.preferredSiteURLs isOn:isOn waitForCallbackFromCoordinator:YES apply:YES updateFeed:updateFeed];
}
- (void)toggleContentGroupOfKind:(WMFContentGroupKind)contentGroupKind isOn:(BOOL)isOn waitForCallbackFromCoordinator:(BOOL)waitForCallbackFromCoordinator apply:(BOOL)apply updateFeed:(BOOL)updateFeed {
[self toggleContentGroupOfKind:contentGroupKind forSiteURLs:self.preferredSiteURLs isOn:isOn waitForCallbackFromCoordinator:waitForCallbackFromCoordinator apply:apply updateFeed:updateFeed];
}
- (void)toggleContentGroupOfKind:(WMFContentGroupKind)contentGroupKind isOn:(BOOL)isOn forSiteURL:(NSURL *)siteURL updateFeed:(BOOL)updateFeed {
[self toggleContentGroupOfKind:contentGroupKind forSiteURLs:[NSArray arrayWithObject:siteURL] isOn:isOn waitForCallbackFromCoordinator:YES apply:YES updateFeed:updateFeed];
}
- (void)toggleAllContentGroupKinds:(BOOL)on updateFeed:(BOOL)updateFeed {
[self updateExploreFeedPreferences:^NSDictionary *(NSDictionary *oldPreferences) {
if (on) {
return self.defaultExploreFeedPreferences;
} else {
NSMutableDictionary<NSString *, id> *allTurnedOff = [NSMutableDictionary new];
NSMutableDictionary<NSNumber*, NSNumber*> *globalCardPreferences = [NSMutableDictionary new];
for (NSNumber *globalContentGroupKindNumber in [WMFExploreFeedContentController globalContentGroupKindNumbers]) {
[globalCardPreferences setObject:[NSNumber numberWithBool:NO] forKey:globalContentGroupKindNumber];
}
[allTurnedOff setObject:globalCardPreferences forKey:WMFExploreFeedPreferencesGlobalCardsKey];
return allTurnedOff;
}
} willTurnOnContentGroupOrLanguage:on waitForCallbackFromCoordinator:NO apply:YES updateFeed:updateFeed];
}
-(void)toggleContentForSiteURL:(NSURL *)siteURL isOn:(BOOL)isOn waitForCallbackFromCoordinator:(BOOL)waitForCallbackFromCoordinator updateFeed:(BOOL)updateFeed {
[self updateExploreFeedPreferences:^NSDictionary *(NSDictionary *oldPreferences) {
NSString *key = siteURL.wmf_contentLanguageCode;
NSMutableDictionary *newPreferences = [oldPreferences mutableCopy];
if (isOn) {
[newPreferences setObject:[WMFExploreFeedContentController customizableContentGroupKindNumbers] forKey:key];
} else {
if ([newPreferences objectForKey:key]) {
[newPreferences removeObjectForKey:key];
}
}
return newPreferences;
} willTurnOnContentGroupOrLanguage:isOn waitForCallbackFromCoordinator:YES apply:YES updateFeed:updateFeed];
}
- (void)toggleContentGroupOfKind:(WMFContentGroupKind)contentGroupKind forSiteURLs:(NSArray<NSURL *> *)siteURLs isOn:(BOOL)isOn waitForCallbackFromCoordinator:(BOOL)waitForCallbackFromCoordinator apply:(BOOL)apply updateFeed:(BOOL)updateFeed {
[self updateExploreFeedPreferences:^NSDictionary *(NSDictionary *oldPreferences) {
NSMutableDictionary *newPreferences = [oldPreferences mutableCopy];
if ([self isGlobal:contentGroupKind]) {
NSDictionary<NSNumber*, NSNumber*> *oldGlobalCardPreferences = [newPreferences objectForKey:WMFExploreFeedPreferencesGlobalCardsKey] ?: [self defaultGlobalCardsPreferences];
NSMutableDictionary<NSNumber*, NSNumber*> *newGlobalCardPreferences = [oldGlobalCardPreferences mutableCopy];
[newGlobalCardPreferences setObject:[NSNumber numberWithBool:isOn] forKey:@(contentGroupKind)];
[newPreferences setObject:newGlobalCardPreferences forKey:WMFExploreFeedPreferencesGlobalCardsKey];
} else {
for (NSURL *siteURL in siteURLs) {
NSString *key = siteURL.wmf_contentLanguageCode;
NSSet *oldVisibleContentGroupKindNumbers = [newPreferences objectForKey:key];
NSMutableSet *newVisibleContentGroupKindNumbers;
if (oldVisibleContentGroupKindNumbers) {
newVisibleContentGroupKindNumbers = [oldVisibleContentGroupKindNumbers mutableCopy];
} else {
newVisibleContentGroupKindNumbers = [NSMutableSet set];
}
if (isOn) {
[newVisibleContentGroupKindNumbers addObject:@(contentGroupKind)];
} else {
[newVisibleContentGroupKindNumbers removeObject:@(contentGroupKind)];
}
BOOL isPlaces = contentGroupKind == WMFContentGroupKindLocation || contentGroupKind == WMFContentGroupKindLocationPlaceholder;
if (isPlaces) {
WMFContentGroupKind otherPlacesContentGroupKind = contentGroupKind == WMFContentGroupKindLocation ? WMFContentGroupKindLocationPlaceholder : WMFContentGroupKindLocation;
if (isOn) {
[newVisibleContentGroupKindNumbers addObject:@(otherPlacesContentGroupKind)];
} else {
[newVisibleContentGroupKindNumbers removeObject:@(otherPlacesContentGroupKind)];
}
}
if (newVisibleContentGroupKindNumbers.count == 0) {
[newPreferences removeObjectForKey:key];
} else {
[newPreferences setObject:newVisibleContentGroupKindNumbers forKey:key];
}
}
}
return newPreferences;
} willTurnOnContentGroupOrLanguage:isOn waitForCallbackFromCoordinator:waitForCallbackFromCoordinator apply:apply updateFeed:updateFeed];
}
- (void)toggleGlobalContentGroupKinds:(BOOL)on updateFeed:(BOOL)updateFeed{
[self updateExploreFeedPreferences:^NSDictionary *(NSDictionary *oldPreferences) {
NSMutableDictionary *newPreferences = [oldPreferences mutableCopy];
NSDictionary<NSNumber*, NSNumber*> *oldGlobalCardPreferences = [newPreferences objectForKey:WMFExploreFeedPreferencesGlobalCardsKey] ?: [self defaultGlobalCardsPreferences];
NSMutableDictionary<NSNumber*, NSNumber*> *newGlobalCardPreferences = [oldGlobalCardPreferences mutableCopy];
for (id key in newGlobalCardPreferences.allKeys) {
[newGlobalCardPreferences setObject:[NSNumber numberWithBool:on] forKey:key];
}
[newPreferences setObject:newGlobalCardPreferences forKey:WMFExploreFeedPreferencesGlobalCardsKey];
return newPreferences;
} willTurnOnContentGroupOrLanguage:on waitForCallbackFromCoordinator:YES apply:YES updateFeed:updateFeed];
}
- (void)saveNewExploreFeedPreferences:(NSDictionary *)newExploreFeedPreferences apply:(BOOL)apply updateFeed:(BOOL)updateFeed {
WMFAssertMainThread(@"Saving explore feed preferences should be performed on the main thread");
[self.dataStore.viewContext wmf_setValue:newExploreFeedPreferences forKey:WMFExploreFeedPreferencesKey];
[self save:self.dataStore.viewContext];
if (apply) {
WMFAsyncBlockOperation *op = [[WMFAsyncBlockOperation alloc] initWithAsyncBlock:^(WMFAsyncBlockOperation *_Nonnull op) {
dispatch_async(dispatch_get_main_queue(), ^{
NSManagedObjectContext *moc = self.dataStore.feedImportContext;
[moc performBlock:^{
[self applyExploreFeedPreferencesToAllObjectsInManagedObjectContext:moc];
[self save:moc];
dispatch_async(dispatch_get_main_queue(), ^{
[op finish];
if (updateFeed) {
[self updateFeedSourcesUserInitiated:NO completion:nil];
} else {
[[WMFWidgetController shared] reloadAllWidgetsIfNecessary];
}
});
}];
});
}];
[self.operationQueue addOperation:op];
}
}
- (void)rejectNewExploreFeedPreferences {
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:WMFNewExploreFeedPreferencesWereRejectedNotification object:nil];
});
}
- (void)updateExploreFeedPreferences:(NSDictionary *(^)(NSDictionary *newPreferences))update willTurnOnContentGroupOrLanguage:(BOOL)willTurnOnContentGroupOrLanguage waitForCallbackFromCoordinator:(BOOL)waitForCallbackFromCoordinator apply:(BOOL)apply updateFeed:(BOOL)updateFeed {
dispatch_async(dispatch_get_main_queue(), ^{
NSManagedObjectContext *moc = self.dataStore.viewContext;
NSDictionary *oldPreferences = [self exploreFeedPreferencesInManagedObjectContext:moc];
assert(oldPreferences);
NSDictionary *newPreferences = update(oldPreferences);
if (waitForCallbackFromCoordinator) {
[self.exploreFeedPreferencesUpdateCoordinator configureWithOldExploreFeedPreferences:oldPreferences newExploreFeedPreferences:newPreferences willTurnOnContentGroupOrLanguage:willTurnOnContentGroupOrLanguage updateFeed:updateFeed];
[[NSNotificationCenter defaultCenter] postNotificationName:WMFExploreFeedPreferencesDidChangeNotification object:self.exploreFeedPreferencesUpdateCoordinator];
} else {
[self saveNewExploreFeedPreferences:newPreferences apply:apply updateFeed:updateFeed];
}
});
}
- (void)dismissCollapsedContentGroups {
WMFAsyncBlockOperation *op = [[WMFAsyncBlockOperation alloc] initWithAsyncBlock:^(WMFAsyncBlockOperation *_Nonnull op) {
dispatch_async(dispatch_get_main_queue(), ^{
NSManagedObjectContext *moc = self.dataStore.feedImportContext;
[moc performBlock:^{
NSFetchRequest *fetchRequest = [WMFContentGroup fetchRequest];
fetchRequest.predicate = [NSPredicate predicateWithFormat:@"undoTypeInteger != 0"];
NSError *error = nil;
NSArray<WMFContentGroup *> *contentGroups = [moc executeFetchRequest:fetchRequest error:&error];
if (error) {
DDLogError(@"Error fetching WMFContentGroup: %@", error);
}
for (WMFContentGroup *contentGroup in contentGroups) {
if (contentGroup.undoType == WMFContentGroupUndoTypeContentGroup) {
[contentGroup markDismissed];
}
contentGroup.isVisible = NO;
contentGroup.undoType = WMFContentGroupUndoTypeNone;
}
[self save:moc];
dispatch_async(dispatch_get_main_queue(), ^{
[op finish];
});
}];
});
}];
[self.operationQueue addOperation:op];
}
- (NSInteger)countOfVisibleContentGroupKinds {
if (self.cachedCountOfVisibleContentGroupKinds) {
return self.cachedCountOfVisibleContentGroupKinds.integerValue;
}
NSInteger count = 0;
for (NSNumber *isGlobalCardVisible in [self.globalCardPreferences allValues]) {
if (!isGlobalCardVisible.boolValue) {
continue;
}
count++;
}
for (id value in self.exploreFeedPreferences.allValues) {
if ([value isKindOfClass:[NSSet class]]) {
NSSet<NSNumber *> *contentGroupKindNumbers = (NSSet<NSNumber *> *)value;
count += contentGroupKindNumbers.count;
break;
}
}
self.cachedCountOfVisibleContentGroupKinds = [NSNumber numberWithInteger:count];
return count;
}
- (void)applyExploreFeedPreferencesToAllObjectsInManagedObjectContext:(NSManagedObjectContext *)moc {
NSFetchRequest *fetchRequest = [WMFContentGroup fetchRequest];
NSError *error = nil;
NSArray<WMFContentGroup *> *contentGroups = [moc executeFetchRequest:fetchRequest error:&error];
if (error) {
DDLogError(@"Error fetching WMFContentGroup: %@", error);
}
[self applyExploreFeedPreferencesToObjects:contentGroups inManagedObjectContext:moc];
}
- (void)applyExploreFeedPreferencesToObjects:(id<NSFastEnumeration>)objects inManagedObjectContext:(NSManagedObjectContext *)moc {
NSDictionary *exploreFeedPreferences = [self exploreFeedPreferencesInManagedObjectContext:moc];
for (NSManagedObject *object in objects) {
if (![object isKindOfClass:[WMFContentGroup class]]) {
continue;
}
WMFContentGroup *contentGroup = (WMFContentGroup *)object;
[contentGroup updateDailySortPriorityWithSortOrderByContentLanguageCode:self.sortOrderByContentLanguageCode];
// Skip collapsed cards, let them be visible
if (contentGroup.undoType != WMFContentGroupUndoTypeNone) {
continue;
}
// Do not let preferences affect the notifications card
if (contentGroup.contentGroupKind == WMFContentGroupKindNotification) {
continue;
}
BOOL isVisible;
if ([self isGlobal:contentGroup.contentGroupKind]) {
NSDictionary *globalCardPreferences = [exploreFeedPreferences objectForKey:WMFExploreFeedPreferencesGlobalCardsKey];
BOOL isGlobalCardVisible = [[globalCardPreferences objectForKey:@(contentGroup.contentGroupKind)] boolValue];
isVisible = isGlobalCardVisible && !contentGroup.wasDismissed;
} else {
NSSet<NSNumber *> *visibleContentGroupKinds = [exploreFeedPreferences objectForKey:contentGroup.siteURL.wmf_contentLanguageCode];
NSNumber *contentGroupNumber = @(contentGroup.contentGroupKindInteger);
if (![[WMFExploreFeedContentController customizableContentGroupKindNumbers] containsObject:contentGroupNumber]) {
continue;
}
if ([visibleContentGroupKinds containsObject:contentGroupNumber]) {
isVisible = !contentGroup.wasDismissed;
} else {
isVisible = NO;
}
}
if (isVisible != contentGroup.isVisible) {
contentGroup.isVisible = isVisible;
}
}
}
- (void)save:(NSManagedObjectContext *)moc {
NSError *error = nil;
if (moc.hasChanges && ![moc save:&error]) {
DDLogError(@"Error saving WMFExploreFeedContentController managedObjectContext");
}
}
#pragma mark - Debug
#if DEBUG
- (void)debugChaos {
BOOL needsTeardown = arc4random_uniform(2) > 0;
NSManagedObjectContext *moc = needsTeardown ? self.dataStore.feedImportContext : self.dataStore.viewContext;
WMFAsyncBlockOperation *op = [[WMFAsyncBlockOperation alloc] initWithAsyncBlock:^(WMFAsyncBlockOperation *_Nonnull op) {
[moc performBlock:^{
NSFetchRequest *request = [WMFContentGroup fetchRequest];
NSInteger count = [moc countForFetchRequest:request error:nil];
request.fetchLimit = (NSUInteger)arc4random_uniform((uint32_t)count);
request.fetchOffset = (NSUInteger)arc4random_uniform((uint32_t)(count - request.fetchLimit));
NSArray *results = [moc executeFetchRequest:request error:nil];
for (WMFContentGroup *group in results) {
uint32_t seed = arc4random_uniform(5);
int32_t random = (15 - (int32_t)arc4random_uniform(30));
switch (seed) {
case 0:
group.midnightUTCDate = [group.midnightUTCDate dateByAddingTimeInterval:86400 * random];
group.contentMidnightUTCDate = [group.contentMidnightUTCDate dateByAddingTimeInterval:86400 * random];
group.date = [group.date dateByAddingTimeInterval:86400 * random];
break;
case 1:
[moc deleteObject:group];
case 2:
group.dailySortPriority = group.dailySortPriority + random;
default:
break;
}
}
NSError *saveError = nil;
if ([moc hasChanges] && ![moc save:&saveError]) {
DDLogError(@"chaos error: %@", saveError);
}
dispatch_async(dispatch_get_main_queue(), ^{
if (needsTeardown) {
[self.dataStore teardownFeedImportContext];
}
[op finish];
});
}];
}];
[self.operationQueue addOperation:op];
}
#endif
- (void)cancelAllFetches {
[self.operationQueue cancelAllOperations];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey, id> *)change context:(void *)context {
if (context == &kvo_WMFExploreFeedContentController_operationQueue_operationCount) {
if (self.operationQueue.operationCount == 0 && self.isBusy) {
self.busy = NO;
[[NSNotificationCenter defaultCenter] postNotificationName:WMFExploreFeedContentControllerBusyStateDidChange object:self];
} else if (self.operationQueue.operationCount > 0 && !self.isBusy) {
self.busy = YES;
[[NSNotificationCenter defaultCenter] postNotificationName:WMFExploreFeedContentControllerBusyStateDidChange object:self];
}
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)performBackgroundFetch:(void (^)(UIBackgroundFetchResult))completion {
[self updateBackgroundSourcesWithCompletion:completion];
}
- (void)performDeduplicatedFetch:(nullable dispatch_block_t)completion {
WMFAssertMainThread(@"performDeduplicatedFetch: must be called on the main thread");
if (self.operationQueue.operationCount > 0) {
NSAssert(self.operationQueue.maxConcurrentOperationCount == 1, @"The strategy of adding this block as a completion doesn't work if the maxConcurrentOperationCount != 1");
if (completion) {
[self.operationQueue addOperationWithBlock:completion];
}
return;
}
[self performBackgroundFetch:^(UIBackgroundFetchResult fetchResult) {
if (completion) {
completion();
}
}];
}
@end
@implementation WMFExploreFeedContentController (LanguageVariantMigration)
/// The expected dictionary uses language codes as the key with the value being the desired language variant code for that language.
/// Move from siteURL-based to contentLanguageCode-based keys to support language variants
- (void)migrateExploreFeedSettingsToLanguageVariants:(NSDictionary<NSString *, NSString *> *)languageMapping inManagedObjectContext:(NSManagedObjectContext *)moc{
WMFKeyValue *keyValue = [moc wmf_keyValueForKey:WMFExploreFeedPreferencesKey];
NSDictionary *originalPreferences = (NSDictionary *)keyValue.value;
NSMutableDictionary *migratedPreferences = [[NSMutableDictionary alloc] init];
for (NSString *key in originalPreferences.allKeys) {
// Just pass the global key along as-is
if ([key isEqualToString:WMFExploreFeedPreferencesGlobalCardsKey]) {
[migratedPreferences setValue:[originalPreferences valueForKey:key] forKey:key];
}
else {
NSString *languageCode = nil;
// Remaining keys should be site URL strings prior to migration
if ([key hasPrefix:@"http"]) {
NSURL *oldKeyURL = [NSURL URLWithString:key];
languageCode = oldKeyURL.wmf_languageCode;
}
// Interim code for migration may have been previously run
// Allow for that case as well
else {
languageCode = key;
}
if (languageCode) {
NSString *languageVariantCode = languageMapping[languageCode];
NSString *newKey = languageVariantCode ? : languageCode;
[migratedPreferences setValue:[originalPreferences valueForKey:key] forKey:newKey];
}
}
}
[moc wmf_setValue:migratedPreferences forKey:WMFExploreFeedPreferencesKey];
[self save:moc];
__unused NSDictionary *preferences = (NSDictionary *)[moc wmf_keyValueForKey:WMFExploreFeedPreferencesKey].value;
assert(preferences);
}
@end