diff --git a/Kiwix.xcodeproj/project.pbxproj b/Kiwix.xcodeproj/project.pbxproj index ae5c9977..c235f380 100644 --- a/Kiwix.xcodeproj/project.pbxproj +++ b/Kiwix.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 9768C23B1F4B7F6300FD499B /* Preference.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9768C23A1F4B7F6300FD499B /* Preference.swift */; }; 976A65B32659489F009A97C6 /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976A65B22659489F009A97C6 /* SearchResult.swift */; }; 976A65B42659489F009A97C6 /* SearchResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976A65B22659489F009A97C6 /* SearchResult.swift */; }; + 977959C026D494C500D48E4A /* FaviconDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 977959BF26D494C500D48E4A /* FaviconDownloadService.swift */; }; 9779A5B22456793600F6F6FF /* LibraryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9779A59C2456793500F6F6FF /* LibraryService.swift */; }; 9779A5B42456793600F6F6FF /* BookmarkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9779A59D2456793500F6F6FF /* BookmarkService.swift */; }; 9779A5B52456793600F6F6FF /* BookmarkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9779A59D2456793500F6F6FF /* BookmarkService.swift */; }; @@ -234,6 +235,7 @@ 9768C2341F4B7BAC00FD499B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 9768C23A1F4B7F6300FD499B /* Preference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preference.swift; sourceTree = ""; }; 976A65B22659489F009A97C6 /* SearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = ""; }; + 977959BF26D494C500D48E4A /* FaviconDownloadService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconDownloadService.swift; sourceTree = ""; }; 9779A59C2456793500F6F6FF /* LibraryService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryService.swift; sourceTree = ""; }; 9779A59D2456793500F6F6FF /* BookmarkService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarkService.swift; sourceTree = ""; }; 9779A59F2456793500F6F6FF /* LibraryScanOperation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryScanOperation.swift; sourceTree = ""; }; @@ -526,6 +528,7 @@ children = ( 9779A5A62456793500F6F6FF /* ZimFileService */, 9779A5D52456796A00F6F6FF /* DownloadService.swift */, + 977959BF26D494C500D48E4A /* FaviconDownloadService.swift */, 9779A59C2456793500F6F6FF /* LibraryService.swift */, 9779A59D2456793500F6F6FF /* BookmarkService.swift */, 9793548A257D86370076E94A /* Queries.swift */, @@ -1086,6 +1089,7 @@ 9779A7AF2456796B00F6F6FF /* Settings.swift in Sources */, 97225A982618B2A200D8CB32 /* LibraryLanguageView.swift in Sources */, 972521012009616B00B60A80 /* EmptyContentView.swift in Sources */, + 977959C026D494C500D48E4A /* FaviconDownloadService.swift in Sources */, 9797435C257BF33600D30F03 /* WebViewController.swift in Sources */, 97A36C571F8D5FCD0079B452 /* LibraryController.swift in Sources */, 97A36C321F8C21210079B452 /* AppDelegate.swift in Sources */, @@ -1424,7 +1428,7 @@ CODE_SIGN_ENTITLEMENTS = iOS/Support/Kiwix.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = L7HWM3SP3L; ENABLE_BITCODE = YES; GCC_C_LANGUAGE_STANDARD = c11; @@ -1464,7 +1468,7 @@ CODE_SIGN_ENTITLEMENTS = iOS/Support/Kiwix.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = L7HWM3SP3L; ENABLE_BITCODE = YES; GCC_C_LANGUAGE_STANDARD = c11; @@ -1556,7 +1560,7 @@ CODE_SIGN_ENTITLEMENTS = iOS/BookmarksWidget/Bookmarks.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = L7HWM3SP3L; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = iOS/BookmarksWidget/Info.plist; @@ -1588,7 +1592,7 @@ CODE_SIGN_ENTITLEMENTS = iOS/BookmarksWidget/Bookmarks.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 66; + CURRENT_PROJECT_VERSION = 68; DEVELOPMENT_TEAM = L7HWM3SP3L; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = iOS/BookmarksWidget/Info.plist; diff --git a/Model/Operations/OPDSRefreshOperation/OPDSRefreshOperation.swift b/Model/Operations/OPDSRefreshOperation/OPDSRefreshOperation.swift index 676f229a..42c633cb 100644 --- a/Model/Operations/OPDSRefreshOperation/OPDSRefreshOperation.swift +++ b/Model/Operations/OPDSRefreshOperation/OPDSRefreshOperation.swift @@ -6,7 +6,6 @@ // Copyright © 2020 Chris Li. All rights reserved. // -import CoreData import os import Defaults import RealmSwift diff --git a/Model/Services/FaviconDownloadService.swift b/Model/Services/FaviconDownloadService.swift new file mode 100644 index 00000000..af664d24 --- /dev/null +++ b/Model/Services/FaviconDownloadService.swift @@ -0,0 +1,77 @@ +// +// FaviconDownloadService.swift +// Kiwix +// +// Created by Chris Li on 8/23/21. +// Copyright © 2021 Chris Li. All rights reserved. +// + +import os +import RealmSwift + +class FaviconDownloadService: NSObject, URLSessionDelegate, URLSessionTaskDelegate, URLSessionDataDelegate { + static let shared = FaviconDownloadService() + + private let queue = DispatchQueue(label: "org.kiwix.faviconDownload") + private var cache = [String: Data]() + private var retryCounter = [String: Int]() + + private lazy var session: URLSession = { + var configuration = URLSessionConfiguration.default + configuration.httpMaximumConnectionsPerHost = 1 + let operationQueue = OperationQueue() + operationQueue.underlyingQueue = queue + return URLSession(configuration: configuration, delegate: self, delegateQueue: operationQueue) + }() + + private override init() { } + + func download(zimFile: ZimFile) { + guard let faviconURL = zimFile.faviconURL, let url = URL(string: faviconURL) else { return } + let task = session.dataTask(with: url) + task.taskDescription = zimFile.fileID + task.resume() + } + + private func retry(zimFileID: String) { + guard retryCounter[zimFileID, default: 0] < 3 else { return } + os_log("Retry downloading favicon data: %@.", log: Log.FaviconDownloadService, type: .info, zimFileID) + retryCounter[zimFileID, default: 0] += 1 + queue.asyncAfter(deadline: DispatchTime.now() + 5) { + guard let database = try? Realm(), + let zimFile = database.object(ofType: ZimFile.self, forPrimaryKey: zimFileID) else { return } + self.download(zimFile: zimFile) + } + } + + // MARK: - Delegates + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard let zimFileID = dataTask.taskDescription else { return } + var faviconData = cache[zimFileID, default: Data()] + faviconData.append(data) + cache[zimFileID] = faviconData + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let zimFileID = task.taskDescription else { return } + defer { cache[zimFileID] = nil } + + // retry download later if request was unsuccessful + guard let response = task.response as? HTTPURLResponse, response.statusCode == 200 else { + retry(zimFileID: zimFileID) + return + } + + // save favicon data to database + do { + let database = try Realm() + try database.write { + guard let zimFile = database.object(ofType: ZimFile.self, forPrimaryKey: zimFileID) else { return } + zimFile.faviconData = cache[zimFileID] + } + } catch { + os_log("Failed to save favicon data.", log: Log.FaviconDownloadService, type: .error) + } + } +} diff --git a/Model/Services/LibraryService.swift b/Model/Services/LibraryService.swift index a0f8e6eb..8ab93d8d 100644 --- a/Model/Services/LibraryService.swift +++ b/Model/Services/LibraryService.swift @@ -88,31 +88,4 @@ class LibraryService { } } catch {} } - - // MARK: - Favicon Download - - /// Download and save favicon data of a zim file. - /// - Parameters: - /// - zimFileID: ID of a zim file - /// - url: URL of the favicon data - @available(iOS 13.0, *) - func downloadFavicons(zimFiles: [ZimFile]) { - let urls = Set(zimFiles.compactMap { $0.faviconURL }.compactMap { URL(string: $0) }) - let publishers = urls.map { URLSession.shared.dataTaskPublisher(for: $0) } - faviconDownloadPipeline = Combine.Publishers.MergeMany(publishers) - .collect(5) - .sink(receiveCompletion: { _ in }, receiveValue: { results in - do { - let database = try Realm() - try database.write { - for result in results { - guard let url = result.response.url else { continue } - database.objects(ZimFile.self) - .filter("faviconURL = %@", url.absoluteString) - .forEach { zimFile in zimFile.faviconData = result.data } - } - } - } catch {} - }) - } } diff --git a/Model/Utilities/Log.swift b/Model/Utilities/Log.swift index 8dfe6286..fd992028 100644 --- a/Model/Utilities/Log.swift +++ b/Model/Utilities/Log.swift @@ -12,6 +12,7 @@ private let subsystem = "org.kiwix.kiwix" struct Log { static let DownloadService = OSLog(subsystem: subsystem, category: "DownloadService") + static let FaviconDownloadService = OSLog(subsystem: subsystem, category: "FaviconDownloadService") static let LibraryService = OSLog(subsystem: subsystem, category: "LibraryService") static let OPDS = OSLog(subsystem: subsystem, category: "OPDS") static let URLSchemeHandler = OSLog(subsystem: subsystem, category: "URLSchemeHandler") diff --git a/iOS/SwiftUI/Library/LibraryCategoryView.swift b/iOS/SwiftUI/Library/LibraryCategoryView.swift index 240e677f..e29e47d1 100644 --- a/iOS/SwiftUI/Library/LibraryCategoryView.swift +++ b/iOS/SwiftUI/Library/LibraryCategoryView.swift @@ -64,26 +64,33 @@ struct LibraryCategoryView: View { @Published private(set) var zimFiles = [String: [ZimFile]]() let category: ZimFile.Category + private let database = try? Realm() private let queue = DispatchQueue(label: "org.kiwix.library.category", qos: .userInitiated) - private var defaultsSubscriber: AnyCancellable? + private var languageCodeObserver: Defaults.Observation? private var collectionSubscriber: AnyCancellable? init(category: ZimFile.Category) { self.category = category - defaultsSubscriber = Defaults.publisher(.libraryLanguageCodes) - .sink(receiveValue: { languageCodes in - self.loadData(languageCodes: languageCodes.newValue) - self.downloadFavicon(languageCodes: languageCodes.newValue) - }) + languageCodeObserver = Defaults.observe(.libraryLanguageCodes) { languageCodes in + self.loadData(languageCodes: languageCodes.newValue) + self.database?.objects(ZimFile.self) + .filter(NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "categoryRaw = %@", category.rawValue), + NSPredicate(format: "languageCode IN %@", languageCodes.newValue), + NSPredicate(format: "faviconData = nil"), + NSPredicate(format: "faviconURL != nil"), + ])) + .forEach { FaviconDownloadService.shared.download(zimFile: $0) } + } } private func loadData(languageCodes: [String]) { - let database = try? Realm() + var predicates = [NSPredicate(format: "categoryRaw = %@", category.rawValue)] + if !languageCodes.isEmpty { + predicates.append(NSPredicate(format: "languageCode IN %@", languageCodes)) + } collectionSubscriber = database?.objects(ZimFile.self) - .filter(NSCompoundPredicate(andPredicateWithSubpredicates: [ - NSPredicate(format: "categoryRaw = %@", category.rawValue), - NSPredicate(format: "languageCode IN %@", languageCodes), - ])) + .filter(NSCompoundPredicate(andPredicateWithSubpredicates: predicates)) .sorted(by: [ SortDescriptor(keyPath: "title", ascending: true), SortDescriptor(keyPath: "size", ascending: false) @@ -91,6 +98,7 @@ struct LibraryCategoryView: View { .collectionPublisher .subscribe(on: queue) .freeze() + .throttle(for: 0.2, scheduler: queue, latest: true) .map { zimFiles in var results = [String: [ZimFile]]() for zimFile in zimFiles { @@ -109,19 +117,5 @@ struct LibraryCategoryView: View { } }) } - - private func downloadFavicon(languageCodes: [String]) { - do { - let database = try Realm() - let zimFiles = database.objects(ZimFile.self) - .filter(NSCompoundPredicate(andPredicateWithSubpredicates: [ - NSPredicate(format: "categoryRaw = %@", category.rawValue), - NSPredicate(format: "languageCode IN %@", languageCodes), - NSPredicate(format: "faviconData = nil"), - NSPredicate(format: "faviconURL != nil"), - ])) - LibraryService.shared.downloadFavicons(zimFiles: Array(zimFiles)) - } catch {} - } } } diff --git a/iOS/SwiftUI/Library/LibrarySearchResultView.swift b/iOS/SwiftUI/Library/LibrarySearchResultView.swift index c5279e2d..af7e7565 100644 --- a/iOS/SwiftUI/Library/LibrarySearchResultView.swift +++ b/iOS/SwiftUI/Library/LibrarySearchResultView.swift @@ -39,6 +39,6 @@ struct LibrarySearchResultView: View { NSPredicate(format: "title CONTAINS[cd] %@", searchText), NSPredicate(format: "languageCode IN %@", Defaults[.libraryLanguageCodes]), ]) - LibraryService.shared.downloadFavicons(zimFiles: zimFiles.filter { $0.faviconData == nil }) +// LibraryService.shared.downloadFavicons(zimFiles: zimFiles.filter { $0.faviconData == nil }) } }