260 lines
12 KiB
Swift

import Foundation
import WidgetKit
import CocoaLumberjackSwift
@objc(WMFWidgetController)
public final class WidgetController: NSObject {
// MARK: Nested Types
public enum SupportedWidget: String {
case featuredArticle = "org.wikimedia.wikipedia.widgets.featuredArticle"
case onThisDay = "org.wikimedia.wikipedia.widgets.onThisDay"
case pictureOfTheDay = "org.wikimedia.wikipedia.widgets.potd"
case topRead = "org.wikimedia.wikipedia.widgets.topRead"
public var identifier: String {
return self.rawValue
}
}
// MARK: Properties
@objc public static let shared = WidgetController()
private let sharedCache = SharedContainerCache<WidgetCache>(fileName: SharedContainerCacheCommonNames.widgetCache, defaultCache: { WidgetCache(settings: .default, featuredContent: nil) })
// MARK: Public
@objc public func reloadAllWidgetsIfNecessary() {
guard !Bundle.main.isAppExtension else {
return
}
let dataStore = MWKDataStore.shared()
let appLanguage = dataStore.languageLinkController.appLanguage
if let siteURL = appLanguage?.siteURL, let languageCode = appLanguage?.languageCode {
let updatedWidgetSettings = WidgetSettings(siteURL: siteURL, languageCode: languageCode, languageVariantCode: appLanguage?.languageVariantCode)
updateCacheWith(settings: updatedWidgetSettings)
}
WidgetCenter.shared.reloadAllTimelines()
}
public func reloadFeaturedArticleWidgetIfNecessary() {
guard !Bundle.main.isAppExtension else {
return
}
let dataStore = MWKDataStore.shared()
let appLanguage = dataStore.languageLinkController.appLanguage
if let siteURL = appLanguage?.siteURL, let languageCode = appLanguage?.languageCode {
let updatedWidgetSettings = WidgetSettings(siteURL: siteURL, languageCode: languageCode, languageVariantCode: appLanguage?.languageVariantCode)
updateCacheWith(settings: updatedWidgetSettings)
}
WidgetCenter.shared.reloadTimelines(ofKind: SupportedWidget.featuredArticle.rawValue)
}
/// For requesting background time from widgets
/// - Parameter userCompletion: the completion block to call with the result
/// - Parameter task: block that takes the `MWKDataStore` to use for updates and the completion block to call when done as parameters
public func startWidgetUpdateTask<T>(_ userCompletion: @escaping (T) -> Void, _ task: @escaping (MWKDataStore, @escaping (T) -> Void) -> Void) {
getRetainedSharedDataStore { dataStore in
task(dataStore, { result in
DispatchQueue.main.async {
self.releaseSharedDataStore {
userCompletion(result)
}
}
})
}
}
public func fetchNewestWidgetContentGroup(with kind: WMFContentGroupKind, in dataStore: MWKDataStore, isNetworkFetchAllowed: Bool, isAnyLanguageAllowed: Bool = false, completion: @escaping (WMFContentGroup?) -> Void) {
fetchCachedWidgetContentGroup(with: kind, isAnyLanguageAllowed: isAnyLanguageAllowed, in: dataStore) { (contentGroup) in
guard let todaysContentGroup = contentGroup, todaysContentGroup.isForToday else {
guard isNetworkFetchAllowed else {
completion(contentGroup)
return
}
self.updateFeedContent(in: dataStore) {
// if there's no cached content group after update, return nil
self.fetchCachedWidgetContentGroup(with: kind, isAnyLanguageAllowed: isAnyLanguageAllowed, in: dataStore, completion: completion)
}
return
}
completion(todaysContentGroup)
}
}
private func fetchCachedWidgetContentGroup(with kind: WMFContentGroupKind, isAnyLanguageAllowed: Bool, in dataStore: MWKDataStore, completion: @escaping (WMFContentGroup?) -> Void) {
assert(Thread.isMainThread, "Cached widget content group must be fetched from the main queue")
let moc = dataStore.viewContext
let siteURL = isAnyLanguageAllowed ? dataStore.primarySiteURL : nil
completion(moc.newestGroup(of: kind, forSiteURL: siteURL))
}
public func updateFeedContent(in dataStore: MWKDataStore, completion: @escaping () -> Void) {
dataStore.feedContentController.performDeduplicatedFetch(completion)
}
private var dataStoreRetainCount: Int = 0
private var _dataStore: MWKDataStore?
private var completions: [(MWKDataStore) -> Void] = []
private var isCreatingDataStore: Bool = false
private var backgroundActivity: NSObjectProtocol?
/// Returns a `MWKDataStore`for use with widget updates.
/// Manages a shared instance and a reference count for use by multiple widgets.
/// Call `releaseSharedDataStore()` when finished with the data store.
private func getRetainedSharedDataStore(completion: @escaping (MWKDataStore) -> Void) {
assert(Thread.isMainThread, "Data store must be obtained from the main queue")
dataStoreRetainCount += 1
if let dataStore = _dataStore {
completion(dataStore)
return
}
completions.append(completion)
guard !isCreatingDataStore else {
return
}
isCreatingDataStore = true
backgroundActivity = ProcessInfo.processInfo.beginActivity(options: [.background, .suddenTerminationDisabled, .automaticTerminationDisabled], reason: "Wikipedia Extension - " + UUID().uuidString)
let dataStore = MWKDataStore()
dataStore.performLibraryUpdates {
DispatchQueue.main.async {
self._dataStore = dataStore
self.isCreatingDataStore = false
self.completions.forEach { $0(dataStore) }
self.completions.removeAll()
}
} needsMigrateBlock: {
DDLogDebug("Needed a migration from the widgets")
}
}
/// Releases the shared `MWKDataStore` returned by `getRetainedSharedDataStore()`.
private func releaseSharedDataStore(completion: @escaping () -> Void) {
assert(Thread.isMainThread, "Data store must be released from the main queue")
dataStoreRetainCount -= 1
guard dataStoreRetainCount <= 0 else {
completion()
return
}
dataStoreRetainCount = 0
let asyncBackgroundActivity = backgroundActivity
defer {
backgroundActivity = nil
}
let finishBackgroundActivity = {
if let asyncBackgroundActivity = asyncBackgroundActivity {
ProcessInfo.processInfo.endActivity(asyncBackgroundActivity)
}
}
guard let dataStoreToTeardown = _dataStore else {
completion()
finishBackgroundActivity()
return
}
_dataStore = nil
dataStoreToTeardown.teardown {
completion()
finishBackgroundActivity()
#if DEBUG
guard !self.isCreatingDataStore, self._dataStore == nil else { // Don't check open files if another MWKDataStore was created after this one was destroyed
return
}
let openFiles = self.openFilePaths()
let openSqliteFile = openFiles.first(where: { $0.hasSuffix(".sqlite") })
assert(openSqliteFile == nil, "There should be no open sqlite files (which in our case are Core Data persistent stores) in the shared app container after the data store is released. The widget still has a lock on these files: \(openFiles)")
#endif
}
}
#if DEBUG
/// From https://developer.apple.com/forums/thread/655225?page=2
func openFilePaths() -> [String] {
(0..<getdtablesize()).compactMap { fd in
// Return nil for invalid file descriptors.
var flags: CInt = 0
guard fcntl(fd, F_GETFL, &flags) >= 0 else {
return nil
}
// Return "?" for file descriptors not associated with a path, for
// example, a socket.
var path = [CChar](repeating: 0, count: Int(MAXPATHLEN))
guard fcntl(fd, F_GETPATH, &path) >= 0 else {
return "?"
}
return String(cString: path)
}
}
#endif
}
/// When the old widget data loading model is removed, this should be moved out of this extension into the class itself and refactored (e.g. the properties here don't need to be computed).
public extension WidgetController {
/// This is currently unused. It will be useful when we update the main app to also update the widget's cache when it performs any updates to the featured content in the explore feed.
func updateCacheWith(featuredContent: WidgetFeaturedContent) {
var updatedCache = sharedCache.loadCache()
updatedCache.featuredContent = featuredContent
sharedCache.saveCache(updatedCache)
}
func updateCacheWith(settings: WidgetSettings) {
var updatedCache = sharedCache.loadCache()
updatedCache.settings = settings
sharedCache.saveCache(updatedCache)
}
// MARK: - Featured Article Widget
func fetchFeaturedContent(isSnapshot: Bool = false, completion: @escaping (WidgetContentFetcher.FeaturedContentResult) -> Void) {
func performCompletion(result: WidgetContentFetcher.FeaturedContentResult) {
DispatchQueue.main.async {
completion(result)
}
}
let fetcher = WidgetContentFetcher.shared
var widgetCache = sharedCache.loadCache()
guard !isSnapshot else {
let previewSnapshot = widgetCache.featuredContent ?? WidgetFeaturedContent.previewContent() ?? WidgetFeaturedContent(featuredArticle: nil)
performCompletion(result: .success(previewSnapshot))
return
}
// If cached data is still relevant, use it
if let cachedContent = widgetCache.featuredContent, let fetchDate = cachedContent.fetchDate, Calendar.current.isDateInToday(fetchDate), let cachedLanguageCode = cachedContent.featuredArticle?.languageCode, cachedLanguageCode == widgetCache.settings.languageCode, widgetCache.settings.languageVariantCode == cachedContent.fetchedLanguageVariantCode {
performCompletion(result: .success(cachedContent))
return
}
// Fetch fresh feed content from network
fetcher.fetchFeaturedContent(forDate: Date(), siteURL: widgetCache.settings.siteURL, languageCode: widgetCache.settings.languageCode, languageVariantCode: widgetCache.settings.languageVariantCode) { result in
switch result {
case .success(var featuredContent):
if let featuredArticleThumbnailImageSource = featuredContent.featuredArticle?.thumbnailImageSource {
fetcher.fetchImageDataFrom(imageSource: featuredArticleThumbnailImageSource) { imageResult in
featuredContent.featuredArticle?.thumbnailImageSource?.data = try? imageResult.get()
widgetCache.featuredContent = featuredContent
self.sharedCache.saveCache(widgetCache)
performCompletion(result: .success(featuredContent))
}
} else {
widgetCache.featuredContent = featuredContent
self.sharedCache.saveCache(widgetCache)
performCompletion(result: .success(featuredContent))
}
case .failure(let error):
self.sharedCache.saveCache(widgetCache)
performCompletion(result: .failure(error))
}
}
}
}