From 8c5af244f8a67d4e5cd6bf5e8870f235b7878ce8 Mon Sep 17 00:00:00 2001 From: Jojo Feng Date: Sun, 17 Sep 2023 17:33:25 -0700 Subject: [PATCH] fix offline mode. (#72) --- Shared/AppDelegate.swift | 63 ++++++++++++++++++++++ Shared/Models/Stores/ItemStore.swift | 5 +- Shared/Models/Stores/StoryStore.swift | 7 +-- Shared/Services/OfflineRepository.swift | 69 ++++++++++++++++--------- Shared/Utilities/Constants.swift | 4 ++ Shared/Utilities/NetworkMonitor.swift | 2 +- Shared/Views/Components/ItemRow.swift | 3 +- Shared/Views/HomeView.swift | 5 +- Shared/ZCombinatorApp.swift | 31 ++--------- ZCombinator--iOS--Info.plist | 1 + ZCombinator.xcodeproj/project.pbxproj | 8 ++- 11 files changed, 135 insertions(+), 63 deletions(-) create mode 100644 Shared/AppDelegate.swift diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift new file mode 100644 index 0000000..9968b49 --- /dev/null +++ b/Shared/AppDelegate.swift @@ -0,0 +1,63 @@ +import BackgroundTasks +import Foundation +import SwiftUI +import HackerNewsKit +import UserNotifications + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + BGTaskScheduler.shared.register(forTaskWithIdentifier: Constants.Download.backgroundTaskId, + using: nil) { task in + task.expirationHandler = { + task.setTaskCompleted(success: false) + } + + Task { + await OfflineRepository.shared.downloadAllStories() + + task.setTaskCompleted(success: true) + } + + OfflineRepository.shared.scheduleBackgroundDownload() + } + } + + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Register SceneDelegate. + let sceneConfig = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + sceneConfig.delegateClass = SceneDelegate.self + return sceneConfig + } + + private func configureUserNotifications() { + UNUserNotificationCenter.current().delegate = self + } +} + +class SceneDelegate: NSObject, UIWindowSceneDelegate { + func sceneDidEnterBackground(_ scene: UIScene) { + OfflineRepository.shared.scheduleBackgroundDownload() + } +} + +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .sound, .list]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + Task { + let content = response.notification.request.content + if let id = Int(content.targetContentIdentifier ?? ""), + id != 0, + let item = await StoriesRepository.shared.fetchComment(id) { + Router.shared.to(item) + } + } + } +} diff --git a/Shared/Models/Stores/ItemStore.swift b/Shared/Models/Stores/ItemStore.swift index b74e1fc..7d036bd 100644 --- a/Shared/Models/Stores/ItemStore.swift +++ b/Shared/Models/Stores/ItemStore.swift @@ -20,8 +20,9 @@ extension ItemView { private var cancellable: AnyCancellable? init() { - cancellable = NetworkMonitor.shared.networkStatus.sink { isConnected in - self.isConnectedToNetwork = isConnected + cancellable = NetworkMonitor.shared.networkStatus + .sink { isConnected in + self.isConnectedToNetwork = isConnected ?? false } } diff --git a/Shared/Models/Stores/StoryStore.swift b/Shared/Models/Stores/StoryStore.swift index 1244a3a..2fd2fe2 100644 --- a/Shared/Models/Stores/StoryStore.swift +++ b/Shared/Models/Stores/StoryStore.swift @@ -17,9 +17,10 @@ extension HomeView { private var cancellable: AnyCancellable? init() { - cancellable = NetworkMonitor.shared.networkStatus.sink { isConnected in - self.isConnectedToNetwork = isConnected - } + cancellable = NetworkMonitor.shared.networkStatus + .sink { isConnected in + self.isConnectedToNetwork = isConnected ?? false + } } func fetchStories(status: Status = .inProgress) async { diff --git a/Shared/Services/OfflineRepository.swift b/Shared/Services/OfflineRepository.swift index 9c8db3b..f4233ea 100644 --- a/Shared/Services/OfflineRepository.swift +++ b/Shared/Services/OfflineRepository.swift @@ -1,4 +1,6 @@ import Alamofire +import BackgroundTasks +import Combine import Foundation import SwiftUI import SwiftData @@ -12,15 +14,35 @@ public class OfflineRepository: ObservableObject { @Published var isDownloading = false @Published var completionCount = 0 + lazy var lastFetchedAt = { + guard let date = UserDefaults.standard.object(forKey: lastDownloadAtKey) as? Date else { return "" } + let df = DateFormatter() + df.dateFormat = "MM/dd/yyyy HH:mm" + return df.string(from: date) + }() + var isInMemory = false + private let storiesRepository = StoriesRepository.shared private let container = try! ModelContainer(for: StoryCollection.self, CommentCollection.self) private let downloadOrder = [StoryType.top, .ask, .best] + private let lastDownloadAtKey = "lastDownloadedAt" private var stories = [StoryType: [Story]]() private var comments = [Int: [Comment]]() + private var cancellable: AnyCancellable? public static let shared: OfflineRepository = .init() - private init() { + init() { + cancellable = NetworkMonitor.shared.networkStatus + .dropFirst() + .sink { isConnected in + if let isConnected = isConnected, !self.isInMemory && !isConnected { + self.loadIntoMemory() + } + } + } + + public func loadIntoMemory() { let context = container.mainContext // Fetch all cached stories. @@ -39,6 +61,21 @@ public class OfflineRepository: ObservableObject { comments[collection.parentId] = collection.comments } } + + isInMemory = true + } + + public func scheduleBackgroundDownload() { + let downloadTask = BGProcessingTaskRequest(identifier: Constants.Download.backgroundTaskId) + // Set earliestBeginDate to be 1 hr from now. + downloadTask.earliestBeginDate = Date(timeIntervalSinceNow: 3600) + downloadTask.requiresNetworkConnectivity = true + downloadTask.requiresExternalPower = true + do { + try BGTaskScheduler.shared.submit(downloadTask) + } catch { + debugPrint("Unable to submit task: \(error.localizedDescription)") + } } // MARK: - Story related. @@ -46,6 +83,8 @@ public class OfflineRepository: ObservableObject { public func downloadAllStories() async -> Void { isDownloading = true + UserDefaults.standard.set(Date.now, forKey: lastDownloadAtKey) + let context = container.mainContext var completedStoryId = Set() @@ -93,29 +132,11 @@ public class OfflineRepository: ObservableObject { } public func fetchAllStories(from storyType: StoryType) -> [Story] { - return stories[storyType] ?? [Story]() - } - - public func fetchStoryIds(from storyType: StoryType) async -> [Int] { - return [Int]() - } - - public func fetchStoryIds(from storyType: String) async -> [Int] { - return [Int]() - } - - public func fetchStory(_ id: Int) async -> Story? { - return nil -// let context = container.mainContext -// var descriptor = FetchDescriptor( -// predicate: #Predicate { $0.id == id } -// ) -// descriptor.fetchLimit = 1 -// if let results = try? context.fetch(descriptor) { -// return results.first?.story -// } else { -// return nil -// } + guard let stories = stories[storyType] else { return [Story]() } + let storiesWithCommentsDownloaded = stories.filter { story in + comments[story.id].isNotNullOrEmpty + } + return storiesWithCommentsDownloaded } // MARK: - Comment related. diff --git a/Shared/Utilities/Constants.swift b/Shared/Utilities/Constants.swift index a5333bf..a9ccf4a 100644 --- a/Shared/Utilities/Constants.swift +++ b/Shared/Utilities/Constants.swift @@ -22,4 +22,8 @@ struct Constants { static let lastFetchedAtKey = "lastFetchedAt" static let backgroundTaskId = "fetchReplies" } + + struct Download { + static let backgroundTaskId = "download" + } } diff --git a/Shared/Utilities/NetworkMonitor.swift b/Shared/Utilities/NetworkMonitor.swift index fa4a055..61b8c41 100644 --- a/Shared/Utilities/NetworkMonitor.swift +++ b/Shared/Utilities/NetworkMonitor.swift @@ -2,7 +2,7 @@ import Network import Combine final class NetworkMonitor { - let networkStatus = CurrentValueSubject(true) + let networkStatus = CurrentValueSubject(nil) var onWifi = true var onCellular = true diff --git a/Shared/Views/Components/ItemRow.swift b/Shared/Views/Components/ItemRow.swift index 1486899..a81043f 100644 --- a/Shared/Views/Components/ItemRow.swift +++ b/Shared/Views/Components/ItemRow.swift @@ -44,10 +44,9 @@ struct ItemRow: View { Label("View on Hacker News", systemImage: "safari") } } label: { - Image(systemName: "ellipsis") + Label(String(), systemImage: "ellipsis") .padding(.leading) .padding(.bottom, 12) - .padding(.trailing) .foregroundColor(.orange) } } diff --git a/Shared/Views/HomeView.swift b/Shared/Views/HomeView.swift index 6f107ae..b648425 100644 --- a/Shared/Views/HomeView.swift +++ b/Shared/Views/HomeView.swift @@ -120,9 +120,12 @@ struct HomeView: View { Text("\(offlineRepository.completionCount) completed") } else { Label("Download all stories", systemImage: "square.and.arrow.down") + if offlineRepository.lastFetchedAt.isNotEmpty { + Text("last downloaded at \(offlineRepository.lastFetchedAt)") + } } } - .disabled(offlineRepository.isDownloading) + .disabled(offlineRepository.isDownloading || !storyStore.isConnectedToNetwork) Divider() AuthButton(showLoginDialog: $showLoginDialog, showLogoutDialog: $showLogoutDialog) Button { diff --git a/Shared/ZCombinatorApp.swift b/Shared/ZCombinatorApp.swift index a01cb0c..0125358 100644 --- a/Shared/ZCombinatorApp.swift +++ b/Shared/ZCombinatorApp.swift @@ -1,3 +1,5 @@ +import BackgroundTasks +import Foundation import SwiftUI import SwiftData import HackerNewsKit @@ -9,6 +11,7 @@ struct ZCombinatorApp: App { @Environment(\.scenePhase) private var phase let auth: Authentication = .shared let notification: AppNotification = .shared + let offlineRepository: OfflineRepository = .shared var body: some Scene { WindowGroup { @@ -29,31 +32,3 @@ struct ZCombinatorApp: App { } } } - -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { - UNUserNotificationCenter.current().delegate = self - return true - } - - private func configureUserNotifications() { - UNUserNotificationCenter.current().delegate = self - } -} - -extension AppDelegate: UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { - completionHandler([.banner, .sound, .list]) - } - - func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - Task { - let content = response.notification.request.content - if let id = Int(content.targetContentIdentifier ?? ""), - id != 0, - let item = await StoriesRepository.shared.fetchComment(id) { - Router.shared.to(item) - } - } - } -} diff --git a/ZCombinator--iOS--Info.plist b/ZCombinator--iOS--Info.plist index bbbba59..0e5f17a 100644 --- a/ZCombinator--iOS--Info.plist +++ b/ZCombinator--iOS--Info.plist @@ -4,6 +4,7 @@ BGTaskSchedulerPermittedIdentifiers + download fetchReplies CFBundleURLTypes diff --git a/ZCombinator.xcodeproj/project.pbxproj b/ZCombinator.xcodeproj/project.pbxproj index 8410010..d39c76e 100644 --- a/ZCombinator.xcodeproj/project.pbxproj +++ b/ZCombinator.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ E5A2CECF2A08B94200D683C6 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A2CECE2A08B94200D683C6 /* ShareViewController.swift */; }; E5A2CED22A08B94200D683C6 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E5A2CED02A08B94200D683C6 /* MainInterface.storyboard */; }; E5A2CED62A08B94200D683C6 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E5A2CECC2A08B94200D683C6 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + E5B5B91F2AB78748005CE503 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5B5B91E2AB78748005CE503 /* AppDelegate.swift */; }; E5BF9E6B2A0CAA5C0030E588 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = E5BF9E6A2A0CAA5C0030E588 /* SwiftSoup */; }; E5C448DB2AB6B17A006C023B /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C448DA2AB6B17A006C023B /* NetworkMonitor.swift */; }; E5C448E02AB6CD64006C023B /* CommentCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5C448DF2AB6CD64006C023B /* CommentCollection.swift */; }; @@ -233,6 +234,7 @@ E5A2CECE2A08B94200D683C6 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; E5A2CED12A08B94200D683C6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; E5A2CED32A08B94200D683C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E5B5B91E2AB78748005CE503 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; E5C448DA2AB6B17A006C023B /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; E5C448DF2AB6CD64006C023B /* CommentCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentCollection.swift; sourceTree = ""; }; E5C7B9792A086C9A00CA6066 /* CommentTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentTile.swift; sourceTree = ""; }; @@ -369,6 +371,7 @@ E50FB9CA2886738B00AA325C /* ZCombinatorApp.swift */, E50FB9CD2886738D00AA325C /* Assets.xcassets */, E50FB9C82886738B00AA325C /* ZCombinator.xcdatamodeld */, + E5B5B91E2AB78748005CE503 /* AppDelegate.swift */, ); path = Shared; sourceTree = ""; @@ -843,6 +846,7 @@ E5C7B97E2A0887D600CA6066 /* ProfileView.swift in Sources */, E5687FAD289E194A00901F1F /* TextView.swift in Sources */, E57640322A0302A10075F5D1 /* FlagButton.swift in Sources */, + E5B5B91F2AB78748005CE503 /* AppDelegate.swift in Sources */, E54B013A29FC9FB500F7F0A0 /* View+ShareSheet.swift in Sources */, E50FB9F32886738D00AA325C /* ZCombinator.xcdatamodeld in Sources */, E5C7B97C2A08706000CA6066 /* View+GetColor.swift in Sources */, @@ -1081,7 +1085,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "ZCombinator (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = QMWX3X2NF7; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1119,7 +1123,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "ZCombinator (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = QMWX3X2NF7; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES;