Merge pull request #661 from omnivore-app/feature/background-fetch

iOS background fetch
This commit is contained in:
Satindar Dhillon 2022-05-19 17:09:57 -07:00 committed by GitHub
commit 9f85adc02d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 229 additions and 13 deletions

View file

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.omnivore.fetchLinkedItems</string>
</array>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@ -48,6 +52,10 @@
<string>Images from documents and annotations can be saved to the photo library.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Images from your photo library can be chosen by you to send as feedback.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View file

@ -1,8 +1,15 @@
import BackgroundTasks
import Foundation
import Models
import OSLog
import Services
import Utils
public final class Services {
static let fetchTaskID = "app.omnivore.fetchLinkedItems"
static let secondsToWaitBeforeNextBackgroundRefresh: TimeInterval = isDebug ? 0 : 3600 // 1 hour
static let logger = Logger(subsystem: "app.omnivore", category: "services-class")
public let authenticator: Authenticator
public let dataService: DataService
@ -12,3 +19,62 @@ public final class Services {
self.dataService = DataService(appEnvironment: appEnvironment, networker: networker)
}
}
// Background fetching functions
extension Services {
public static func registerBackgroundFetch() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: fetchTaskID, using: nil) { task in
if let task = task as? BGAppRefreshTask {
EventTracker.trackForDebugging("executing app.omnivore.fetchLinkedItems bg task")
logger.debug("in background task register closure")
performBackgroundFetch(task: task)
}
}
}
static func scheduleBackgroundFetch() {
BGTaskScheduler.shared.cancelAllTaskRequests()
let taskRequest = BGAppRefreshTaskRequest(identifier: fetchTaskID)
taskRequest.earliestBeginDate = Date(timeIntervalSinceNow: secondsToWaitBeforeNextBackgroundRefresh)
do {
try BGTaskScheduler.shared.submit(taskRequest)
logger.debug("\(fetchTaskID) task scheduled")
} catch {
logger.debug("task scheduling failed: \(fetchTaskID)")
}
}
static func performBackgroundFetch(task: BGAppRefreshTask) {
Services.logger.debug("starting background fetch")
scheduleBackgroundFetch()
let services = Services()
task.expirationHandler = {
EventTracker.trackForDebugging("background fetch expiration handler called")
logger.debug("handling background fetch expiration")
}
guard services.authenticator.hasValidAuthToken else {
EventTracker.trackForDebugging("background fetch failed: user does not have a valid auth token")
Services.logger.debug("background fetch failed: user does not habe a valid auth token")
task.setTaskCompleted(success: false)
return
}
Task {
do {
try await services.dataService.fetchLinkedItemsBackgroundTask()
logger.debug("fetch complete")
EventTracker.trackForDebugging("background fetch task completed successfully")
task.setTaskCompleted(success: true)
} catch {
logger.debug("fetch failed")
EventTracker.trackForDebugging("background fetch task failed")
task.setTaskCompleted(success: false)
}
}
}
}
// e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"app.omnivore.fetchLinkedItems"]

View file

@ -98,7 +98,9 @@ import Views
isLoading = false
receivedIdx = thisSearchIdx
cursor = queryResult.cursor
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID))
if let username = dataService.currentViewer?.username {
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
}
} else {
updateFetchController(dataService: dataService)
}

View file

@ -5,6 +5,7 @@ import Utils
import Views
public struct RootView: View {
@Environment(\.scenePhase) var scenePhase
let pdfViewerProvider: ((URL, PDFViewerViewModel) -> AnyView)?
@StateObject private var viewModel = RootViewModel()
@ -31,6 +32,11 @@ public struct RootView: View {
viewModel.configurePDFProvider(pdfViewerProvider: pdfViewerProvider)
}
}
.onChange(of: scenePhase) { phase in
if phase == .background {
Services.scheduleBackgroundFetch()
}
}
}
}

View file

@ -0,0 +1,57 @@
import CoreData
import Foundation
import Models
import SwiftGraphQL
extension DataService {
public func fetchLinkedItemsBackgroundTask() async throws {
// Query the server for item IDs and compare against CoreData
// to see what we're missing
let missingItemIds = try await fetchMissingItemIDs()
guard !missingItemIds.isEmpty else { return }
let username: String? = await backgroundContext.perform(schedule: .immediate) {
let fetchRequest: NSFetchRequest<Models.Viewer> = Viewer.fetchRequest()
fetchRequest.fetchLimit = 1 // we should only have one viewer saved
return try? self.backgroundContext.fetch(fetchRequest).first?.username
}
guard let username = username else {
throw BasicError.message(messageText: "could not retrieve username from core data")
}
// Fetch the items
for itemID in missingItemIds { // TOOD: run these in parallel
logger.debug("fetching item with ID: \(itemID)")
_ = try await articleContent(username: username, itemID: itemID, useCache: false)
logger.debug("done fetching item with ID: \(itemID)")
}
}
func fetchMissingItemIDs(previouslyFetchedIDs: [String] = [], cursor: String? = nil) async throws -> [String] {
logger.debug("fetching more IDS: \(cursor ?? "no cursor")")
let maxItemCount = 30
let fetchResult = try await fetchLinkedItemIDs(limit: 10, cursor: cursor)
let newItemsToFetch = await itemsNotInStore(from: fetchResult.itemIDs)
let itemsToFetch = previouslyFetchedIDs + newItemsToFetch
if newItemsToFetch.isEmpty || itemsToFetch.count > maxItemCount || fetchResult.cursor == nil {
return itemsToFetch
} else {
return try await fetchMissingItemIDs(previouslyFetchedIDs: itemsToFetch, cursor: fetchResult.cursor)
}
}
func itemsNotInStore(from itemIDs: [String]) async -> [String] {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id IN %@", itemIDs)
return await backgroundContext.perform(schedule: .immediate) {
if let foundIDs = (try? self.backgroundContext.fetch(fetchRequest).map(\.unwrappedID)) {
return itemIDs.filter { !foundIDs.contains($0) }
} else {
return []
}
}
}
}

View file

@ -9,9 +9,9 @@ extension DataService {
let retryCount: Int
}
public func prefetchPages(itemIDs: [String]) async {
guard let username = currentViewer?.username else { return }
public func prefetchPages(itemIDs: [String], username: String) async {
// TODO: make this concurrent
// TODO: make a non-pending page option for BG tasks
for itemID in itemIDs {
await prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
}
@ -183,9 +183,8 @@ extension DataService {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "id == %@", item.id)
let linkedItem = try? self.backgroundContext.fetch(fetchRequest).first
guard let linkedItem = linkedItem else { return }
let existingItem = try? self.backgroundContext.fetch(fetchRequest).first
let linkedItem = existingItem ?? LinkedItem(entity: LinkedItem.entity(), insertInto: self.backgroundContext)
let highlightObjects = highlights.map {
$0.asManagedObject(context: self.backgroundContext)

View file

@ -0,0 +1,67 @@
import Foundation
import Models
import SwiftGraphQL
extension DataService {
struct LinkedItemIDFetchResult {
let itemIDs: [String]
let cursor: String?
}
func fetchLinkedItemIDs(limit: Int, cursor: String?) async throws -> LinkedItemIDFetchResult {
enum QueryResult {
case success(result: LinkedItemIDFetchResult)
case error(error: String)
}
let articleIDSelection = Selection.SearchItemEdge {
try $0.node(selection: Selection.SearchItem { try $0.id() })
}
let selection = Selection<QueryResult, Unions.SearchResult> {
try $0.on(
searchError: .init {
QueryResult.error(error: try $0.errorCodes().description)
},
searchSuccess: .init {
QueryResult.success(
result: LinkedItemIDFetchResult(
itemIDs: try $0.edges(selection: articleIDSelection.list),
cursor: try $0.pageInfo(selection: Selection.PageInfo {
try $0.endCursor()
})
)
)
}
)
}
let query = Selection.Query {
try $0.search(
after: OptionalArgument(cursor),
first: OptionalArgument(limit),
query: OptionalArgument(nil),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(query, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .success(result: result):
continuation.resume(returning: result)
case .error:
continuation.resume(throwing: BasicError.message(messageText: "LinkedItem fetch error"))
}
}
}
}
}

View file

@ -54,7 +54,6 @@ struct InternalLinkedItem {
}
extension Sequence where Element == InternalLinkedItem {
// TODO: -optimization use batch update?
func persist(context: NSManagedObjectContext) -> [LinkedItem]? {
var linkedItems: [LinkedItem]?
context.performAndWait {

View file

@ -7,6 +7,12 @@ public enum EventTracker {
_ = segment?.version()
}
public static func trackForDebugging(_ message: String) {
#if DEBUG
track(.debugMessage(message: message))
#endif
}
public static func track(_ event: TrackableEvent) {
segment?.track(name: event.name, properties: event.properties)
}

View file

@ -2,6 +2,7 @@ import Foundation
public enum TrackableEvent {
case linkRead(linkID: String, slug: String, originalArticleURL: String)
case debugMessage(message: String)
}
public extension TrackableEvent {
@ -9,6 +10,8 @@ public extension TrackableEvent {
switch self {
case .linkRead:
return "link_read"
case .debugMessage:
return "debug_message"
}
}
@ -20,6 +23,8 @@ public extension TrackableEvent {
"slug": slug,
"url": originalArticleURL
]
case let .debugMessage(message: message):
return ["message": message]
}
}
}

View file

@ -1,12 +1,17 @@
import OSLog
#if os(macOS)
import AppKit
#elseif os(iOS)
import App
import Intercom
import UIKit
import Utils
import Views
#endif
private let logger = Logger(subsystem: "app.omnivore", category: "app-delegate")
#if os(macOS)
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_: Notification) {
@ -20,11 +25,6 @@
#elseif os(iOS)
class AppDelegate: NSObject, UIApplicationDelegate {
// override init() {
// super.init()
// UIColor.classInit
// }
// swiftlint:disable:next line_length
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
#if DEBUG
@ -45,6 +45,7 @@
}
}
Services.registerBackgroundFetch()
configurePushNotifications()
return true
}