mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #661 from omnivore-app/feature/background-fetch
iOS background fetch
This commit is contained in:
commit
9f85adc02d
11 changed files with 229 additions and 13 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue