diff --git a/Example/AppleReminders.xcodeproj/project.pbxproj b/Example/AppleReminders.xcodeproj/project.pbxproj index 33f056c8..c0630efa 100644 --- a/Example/AppleReminders.xcodeproj/project.pbxproj +++ b/Example/AppleReminders.xcodeproj/project.pbxproj @@ -740,6 +740,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = C66Y3DM74C; INFOPLIST_FILE = AppleReminders/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -759,6 +760,7 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = C66Y3DM74C; INFOPLIST_FILE = AppleReminders/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Example/AppleReminders/AppDelegate.swift b/Example/AppleReminders/AppDelegate.swift index 0165c195..6c2bad0e 100644 --- a/Example/AppleReminders/AppDelegate.swift +++ b/Example/AppleReminders/AppDelegate.swift @@ -7,12 +7,17 @@ // import UIKit +import DebugSwift @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. - true + + DebugSwift.setup() + DebugSwift.show() + + return true } // MARK: UISceneSession Lifecycle diff --git a/Example/AppleReminders/SceneDelegate.swift b/Example/AppleReminders/SceneDelegate.swift index 8c799157..bdc244e8 100644 --- a/Example/AppleReminders/SceneDelegate.swift +++ b/Example/AppleReminders/SceneDelegate.swift @@ -8,7 +8,6 @@ import UIKit import CrowdinSDK -import netfox class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? @@ -22,8 +21,6 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { private let clientSecret = "client_secret" func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - NFX.sharedInstance().start() - let crowdinProviderConfig = CrowdinProviderConfig(hashString: distributionHash, sourceLanguage: sourceLanguage) let loginConfig = try! CrowdinLoginConfig(clientId: clientId, diff --git a/Example/Podfile b/Example/Podfile index 030870a6..3f63e972 100644 --- a/Example/Podfile +++ b/Example/Podfile @@ -8,7 +8,7 @@ target 'AppleReminders' do pod 'RealmSwift' pod 'SwiftDate' pod 'SwiftLint' - pod 'netfox', :configurations => ['Debug'] + pod 'DebugSwift', :configurations => ['Debug'] pod 'CrowdinSDK', :path => '../' pod 'CrowdinSDK/Settings', :path => '../' diff --git a/Example/Podfile.lock b/Example/Podfile.lock index be156ba1..a043f661 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -46,7 +46,7 @@ PODS: - CrowdinSDK/RealtimeUpdate - CrowdinSDK/RefreshLocalization - CrowdinSDK/Screenshots - - netfox (1.21.0) + - DebugSwift (0.3.6) - Realm (10.42.0): - Realm/Headers (= 10.42.0) - Realm/Headers (10.42.0) @@ -59,7 +59,7 @@ PODS: DEPENDENCIES: - CrowdinSDK (from `../`) - CrowdinSDK/Settings (from `../`) - - netfox + - DebugSwift - RealmSwift - SwiftDate - SwiftLint @@ -67,7 +67,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - BaseAPI - - netfox + - DebugSwift - Realm - RealmSwift - Starscream @@ -81,13 +81,13 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: BaseAPI: 7a3abac9fa1e19147a5c87dcfbb1829a584cd1ca CrowdinSDK: 65fd7989c86e5ff79c8734979bc61510238d8725 - netfox: 9d5cc727fe7576c4c7688a2504618a156b7d44b7 + DebugSwift: f766d934affddea9ffe36b0cf2631cd28311481f Realm: 490aad28f1360e58fc22256d5d686d3a36525346 RealmSwift: f6a9b56d747bbdd7931de1835896c5f024b6898a Starscream: fb2c4510bebf908c62bd383bcf05e673720e91fd SwiftDate: bbc26e26fc8c0c33fbee8c140c5e8a68293a148a SwiftLint: 1cc5cd61ba9bacb2194e340aeb47a2a37fda00b3 -PODFILE CHECKSUM: 074b840e8445d9c503f88d22ae46cf4abf8bae59 +PODFILE CHECKSUM: 5664bd8b1580f61ed499f2165acb89a39868332c COCOAPODS: 1.15.2 diff --git a/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/Extensions/CrowdinSDK+ReatimeUpdates.swift b/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/Extensions/CrowdinSDK+ReatimeUpdates.swift index 00024709..2b9b47fe 100644 --- a/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/Extensions/CrowdinSDK+ReatimeUpdates.swift +++ b/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/Extensions/CrowdinSDK+ReatimeUpdates.swift @@ -12,7 +12,7 @@ extension CrowdinSDK { guard let config = CrowdinSDK.config else { return } let crowdinProviderConfig = config.crowdinProviderConfig ?? CrowdinProviderConfig() if config.realtimeUpdatesEnabled { - RealtimeUpdateFeature.shared = RealtimeUpdateFeature(hash: crowdinProviderConfig.hashString, sourceLanguage: crowdinProviderConfig.sourceLanguage, organizationName: config.crowdinProviderConfig?.organizationName) + RealtimeUpdateFeature.shared = RealtimeUpdateFeature(hash: crowdinProviderConfig.hashString, sourceLanguage: crowdinProviderConfig.sourceLanguage, organizationName: config.crowdinProviderConfig?.organizationName, minimumUpdateInterval: crowdinProviderConfig.minimumManifestUpdateInterval) swizzleControlMethods() } } diff --git a/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/LocalizationProvider/RURemoteLocalizationStorage.swift b/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/LocalizationProvider/RURemoteLocalizationStorage.swift index f6ad89e2..f8a8d6ca 100644 --- a/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/LocalizationProvider/RURemoteLocalizationStorage.swift +++ b/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/LocalizationProvider/RURemoteLocalizationStorage.swift @@ -20,10 +20,10 @@ class RURemoteLocalizationStorage: RemoteLocalizationStorageProtocol { let fileDownloader: RUFilesDownloader let manifestManager: ManifestManager - init(localization: String, sourceLanguage: String, hash: String, projectId: String, organizationName: String?) { + init(localization: String, sourceLanguage: String, hash: String, projectId: String, organizationName: String?, minimumManifestUpdateInterval: TimeInterval) { self.localization = localization self.hash = hash - manifestManager = ManifestManager.manifest(for: hash, sourceLanguage: sourceLanguage, organizationName: organizationName) + manifestManager = ManifestManager.manifest(for: hash, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval) self.fileDownloader = RUFilesDownloader(projectId: projectId, manifestManager: manifestManager, organizationName: organizationName) } diff --git a/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/RealtimeUpdateFeature.swift b/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/RealtimeUpdateFeature.swift index d565139a..8a131802 100644 --- a/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/RealtimeUpdateFeature.swift +++ b/Sources/CrowdinSDK/Features/RealtimeUpdateFeature/RealtimeUpdateFeature.swift @@ -20,7 +20,7 @@ protocol RealtimeUpdateFeatureProtocol { var disconnect: (() -> Void)? { get set } var enabled: Bool { get set } - init(hash: String, sourceLanguage: String, organizationName: String?) + init(hash: String, sourceLanguage: String, organizationName: String?, minimumUpdateInterval: TimeInterval) func start() func stop() @@ -29,7 +29,7 @@ protocol RealtimeUpdateFeatureProtocol { func refreshAllControls() } -class RealtimeUpdateFeature: RealtimeUpdateFeatureProtocol { +class RealtimeUpdateFeature: RealtimeUpdateFeatureProtocol { static var shared: RealtimeUpdateFeatureProtocol? var success: (() -> Void)? @@ -39,9 +39,10 @@ class RealtimeUpdateFeature: RealtimeUpdateFeatureProtocol { let localizations = Localization.current.provider.remoteStorage.localizations return CrowdinSDK.currentLocalization ?? Bundle.main.preferredLanguage(with: localizations) } - var hashString: String + let hashString: String let sourceLanguage: String let organizationName: String? + let minimumManifestUpdateInterval: TimeInterval var distributionResponse: DistributionsResponse? = nil @@ -59,11 +60,12 @@ class RealtimeUpdateFeature: RealtimeUpdateFeatureProtocol { private var socketManger: CrowdinSocketManagerProtocol? private var mappingManager: CrowdinMappingManagerProtocol - required init(hash: String, sourceLanguage: String, organizationName: String?) { + required init(hash: String, sourceLanguage: String, organizationName: String?, minimumUpdateInterval minimumManifestUpdateInterval: TimeInterval) { self.hashString = hash self.sourceLanguage = sourceLanguage self.organizationName = organizationName - self.mappingManager = CrowdinMappingManager(hash: hash, sourceLanguage: sourceLanguage, organizationName: organizationName) + self.minimumManifestUpdateInterval = minimumManifestUpdateInterval + self.mappingManager = CrowdinMappingManager(hash: hash, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval) } func downloadDistribution(with successHandler: (() -> Void)? = nil, errorHandler: ((Error) -> Void)? = nil) { @@ -120,7 +122,7 @@ class RealtimeUpdateFeature: RealtimeUpdateFeatureProtocol { } setupRealtimeUpdatesLocalizationProvider(with: projectId) { [weak self] in guard let self = self else { return } - self.setupSocketManager(with: projectId, projectWsHash: projectWsHash, userId: userId, wsUrl: wsUrl) + self.setupSocketManager(with: projectId, projectWsHash: projectWsHash, userId: userId, wsUrl: wsUrl, minimumManifestUpdateInterval: minimumManifestUpdateInterval) } } @@ -136,7 +138,7 @@ class RealtimeUpdateFeature: RealtimeUpdateFeatureProtocol { func setupRealtimeUpdatesLocalizationProvider(with projectId: String, completion: @escaping () -> Void) { oldProvider = Localization.current.provider - Localization.current.provider = LocalizationProvider(localization: self.localization, localStorage: RULocalLocalizationStorage(localization: self.localization), remoteStorage: RURemoteLocalizationStorage(localization: self.localization, sourceLanguage: sourceLanguage, hash: self.hashString, projectId: projectId, organizationName: self.organizationName)) + Localization.current.provider = LocalizationProvider(localization: self.localization, localStorage: RULocalLocalizationStorage(localization: self.localization), remoteStorage: RURemoteLocalizationStorage(localization: self.localization, sourceLanguage: sourceLanguage, hash: self.hashString, projectId: projectId, organizationName: self.organizationName, minimumManifestUpdateInterval: self.minimumManifestUpdateInterval)) Localization.current.provider.refreshLocalization { [weak self] error in guard let self = self else { return } @@ -163,13 +165,13 @@ class RealtimeUpdateFeature: RealtimeUpdateFeatureProtocol { } } - func setupSocketManager(with projectId: String, projectWsHash: String, userId: String, wsUrl: String) { + func setupSocketManager(with projectId: String, projectWsHash: String, userId: String, wsUrl: String, minimumManifestUpdateInterval: TimeInterval) { // Download manifest if it is not initialized. - let manifestManager = ManifestManager.manifest(for: hashString, sourceLanguage: sourceLanguage, organizationName: organizationName) + let manifestManager = ManifestManager.manifest(for: hashString, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval) guard manifestManager.downloaded else { manifestManager.download { [weak self] in guard let self = self else { return } - self.setupSocketManager(with: projectId, projectWsHash: projectWsHash, userId: userId, wsUrl: wsUrl) + self.setupSocketManager(with: projectId, projectWsHash: projectWsHash, userId: userId, wsUrl: wsUrl, minimumManifestUpdateInterval: minimumManifestUpdateInterval) } return } diff --git a/Sources/CrowdinSDK/Features/ScreenshotFeature/Extensions/CrowdinSDK+Screenshots.swift b/Sources/CrowdinSDK/Features/ScreenshotFeature/Extensions/CrowdinSDK+Screenshots.swift index 9393cfce..db496e51 100644 --- a/Sources/CrowdinSDK/Features/ScreenshotFeature/Extensions/CrowdinSDK+Screenshots.swift +++ b/Sources/CrowdinSDK/Features/ScreenshotFeature/Extensions/CrowdinSDK+Screenshots.swift @@ -14,7 +14,7 @@ extension CrowdinSDK { guard let config = CrowdinSDK.config else { return } if config.screenshotsEnabled { let crowdinProviderConfig = config.crowdinProviderConfig ?? CrowdinProviderConfig() - let screenshotUploader = CrowdinScreenshotUploader(organizationName: config.crowdinProviderConfig?.organizationName, hash: crowdinProviderConfig.hashString, sourceLanguage: crowdinProviderConfig.sourceLanguage) + let screenshotUploader = CrowdinScreenshotUploader(organizationName: config.crowdinProviderConfig?.organizationName, hash: crowdinProviderConfig.hashString, sourceLanguage: crowdinProviderConfig.sourceLanguage, minimumManifestUpdateInterval: crowdinProviderConfig.minimumManifestUpdateInterval) ScreenshotFeature.shared = ScreenshotFeature(screenshotUploader: screenshotUploader, screenshotProcessor: CrowdinScreenshotProcessor()) swizzleControlMethods() } diff --git a/Sources/CrowdinSDK/Features/ScreenshotFeature/ScreenshotUploader.swift b/Sources/CrowdinSDK/Features/ScreenshotFeature/ScreenshotUploader.swift index 61479ac7..c3dfc80f 100644 --- a/Sources/CrowdinSDK/Features/ScreenshotFeature/ScreenshotUploader.swift +++ b/Sources/CrowdinSDK/Features/ScreenshotFeature/ScreenshotUploader.swift @@ -29,11 +29,11 @@ class CrowdinScreenshotUploader: ScreenshotUploader { case noLocalizedStringsDetected = "There are no localized strings detected on current screen." } - init(organizationName: String?, hash: String, sourceLanguage: String) { + init(organizationName: String?, hash: String, sourceLanguage: String, minimumManifestUpdateInterval: TimeInterval) { self.organizationName = organizationName self.hash = hash self.sourceLanguage = sourceLanguage - self.mappingManager = CrowdinMappingManager(hash: hash, sourceLanguage: sourceLanguage, organizationName: organizationName) + self.mappingManager = CrowdinMappingManager(hash: hash, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval) } func loginAndGetProjectId(success: (() -> Void)? = nil, errorHandler: ((Error) -> Void)? = nil) { diff --git a/Sources/CrowdinSDK/Providers/Crowdin/Config/CrowdinProviderConfig.swift b/Sources/CrowdinSDK/Providers/Crowdin/Config/CrowdinProviderConfig.swift index d7365894..3bdd3869 100644 --- a/Sources/CrowdinSDK/Providers/Crowdin/Config/CrowdinProviderConfig.swift +++ b/Sources/CrowdinSDK/Providers/Crowdin/Config/CrowdinProviderConfig.swift @@ -11,17 +11,20 @@ import Foundation var hashString: String var sourceLanguage: String var organizationName: String? + var minimumManifestUpdateInterval: TimeInterval - public init(hashString: String, sourceLanguage: String, organizationName: String? = nil) { + public init(hashString: String, sourceLanguage: String, organizationName: String? = nil, minimumManifestUpdateInterval: TimeInterval = Constants.defaultMinimumManifestUpdateInterval) { self.hashString = hashString self.sourceLanguage = sourceLanguage self.organizationName = organizationName + self.minimumManifestUpdateInterval = minimumManifestUpdateInterval } @available(*, deprecated, renamed: "init(hashString:sourceLanguage:)") public init(hashString: String, localizations: [String], sourceLanguage: String) { self.hashString = hashString self.sourceLanguage = sourceLanguage + self.minimumManifestUpdateInterval = Constants.defaultMinimumManifestUpdateInterval } public override init() { @@ -33,5 +36,11 @@ import Foundation fatalError("Please add CrowdinPluralsFileNames key to your Info.plist file") } self.sourceLanguage = crowdinSourceLanguage + self.minimumManifestUpdateInterval = Constants.defaultMinimumManifestUpdateInterval + } + + public enum Constants { + // New default minimum interval for manifest updates + public static let defaultMinimumManifestUpdateInterval: TimeInterval = 15 * 60 // 15 minutes } } diff --git a/Sources/CrowdinSDK/Providers/Crowdin/CrowdinRemoteLocalizationStorage.swift b/Sources/CrowdinSDK/Providers/Crowdin/CrowdinRemoteLocalizationStorage.swift index 2da3a294..7b04bb4a 100644 --- a/Sources/CrowdinSDK/Providers/Crowdin/CrowdinRemoteLocalizationStorage.swift +++ b/Sources/CrowdinSDK/Providers/Crowdin/CrowdinRemoteLocalizationStorage.swift @@ -23,7 +23,7 @@ class CrowdinRemoteLocalizationStorage: RemoteLocalizationStorageProtocol { self.localization = localization self.hashString = config.hashString self.organizationName = config.organizationName - self.manifestManager = ManifestManager.manifest(for: config.hashString, sourceLanguage: config.sourceLanguage, organizationName: config.organizationName) + self.manifestManager = ManifestManager.manifest(for: config.hashString, sourceLanguage: config.sourceLanguage, organizationName: config.organizationName, minimumManifestUpdateInterval: config.minimumManifestUpdateInterval) self.crowdinDownloader = CrowdinLocalizationDownloader(manifestManager: manifestManager) self.localizations = self.manifestManager.iOSLanguages self.crowdinSupportedLanguages = CrowdinSupportedLanguages(organizationName: config.organizationName) @@ -58,13 +58,13 @@ class CrowdinRemoteLocalizationStorage: RemoteLocalizationStorageProtocol { }) } - required init(localization: String, sourceLanguage: String, organizationName: String?) { + required init(localization: String, sourceLanguage: String, organizationName: String?, minimumManifestUpdateInterval: TimeInterval) { self.localization = localization guard let hashString = Bundle.main.crowdinDistributionHash else { fatalError("Please add CrowdinDistributionHash key to your Info.plist file") } self.hashString = hashString - self.manifestManager = ManifestManager.manifest(for: hashString, sourceLanguage: sourceLanguage, organizationName: organizationName) + self.manifestManager = ManifestManager.manifest(for: hashString, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval) self.crowdinDownloader = CrowdinLocalizationDownloader(manifestManager: self.manifestManager) self.localizations = [] self.crowdinSupportedLanguages = CrowdinSupportedLanguages(organizationName: organizationName) diff --git a/Sources/CrowdinSDK/Providers/Crowdin/Extensions/CrowdinSDK+ReactNative.swift b/Sources/CrowdinSDK/Providers/Crowdin/Extensions/CrowdinSDK+ReactNative.swift index 33dd6db6..822450ea 100644 --- a/Sources/CrowdinSDK/Providers/Crowdin/Extensions/CrowdinSDK+ReactNative.swift +++ b/Sources/CrowdinSDK/Providers/Crowdin/Extensions/CrowdinSDK+ReactNative.swift @@ -74,7 +74,8 @@ extension CrowdinSDK { /// - errorHandler: Error handler. public class func localizationDictionary(for localization: String, hashString: String, completion: @escaping ([AnyHashable: Any]) -> Void, errorHandler: @escaping (Error) -> Void) { let localLocalizationStorage = LocalLocalizationStorage(localization: localization) - let remoteLocalizationStorage = CrowdinRemoteLocalizationStorage(localization: localization, config: CrowdinProviderConfig(hashString: hashString, sourceLanguage: .empty, organizationName: nil)) + // Hardcode value for minimumManifestUpdateInterval as ReactNative support will be removed. + let remoteLocalizationStorage = CrowdinRemoteLocalizationStorage(localization: localization, config: CrowdinProviderConfig(hashString: hashString, sourceLanguage: .empty, organizationName: nil, minimumManifestUpdateInterval: 15 * 60)) remoteLocalizationStorage.prepare { localizationProvider = LocalizationProvider(localization: localization, localStorage: localLocalizationStorage, remoteStorage: remoteLocalizationStorage) localizationProvider?.refreshLocalization(completion: { error in diff --git a/Sources/CrowdinSDK/Providers/Crowdin/LocalizationDownloader/CrowdinLocalizationDownloader.swift b/Sources/CrowdinSDK/Providers/Crowdin/LocalizationDownloader/CrowdinLocalizationDownloader.swift index 0119cb95..61e494ed 100644 --- a/Sources/CrowdinSDK/Providers/Crowdin/LocalizationDownloader/CrowdinLocalizationDownloader.swift +++ b/Sources/CrowdinSDK/Providers/Crowdin/LocalizationDownloader/CrowdinLocalizationDownloader.swift @@ -17,64 +17,76 @@ class CrowdinLocalizationDownloader: CrowdinDownloaderProtocol { fileprivate var errors: [Error]? = nil fileprivate var contentDeliveryAPI: CrowdinContentDeliveryAPI! fileprivate let manifestManager: ManifestManager - + init(manifestManager: ManifestManager) { self.manifestManager = manifestManager } - + func download(with hash: String, for localization: String, completion: @escaping CrowdinDownloaderCompletion) { self.completion = completion self.getFiles(for: localization) { [weak self] (files, timestamp, error) in guard let self = self else { return } if let files = files { - let strings = files.filter({ $0.isStrings }) - let plurals = files.filter({ $0.isStringsDict }) - let xliffs = files.filter({ $0.isXliff }) - let jsons = files.filter({ $0.isJson }) - let xcstrings = files.filter({ $0.isXcstrings }) - self.download(strings: strings, plurals: plurals, xliffs:xliffs, jsons: jsons, xcstrings: xcstrings, with: hash, timestamp: timestamp, for: localization) + let filesToDownload = files.filter { self.manifestManager.hasFileChanged(filePath: $0, localization: localization) } + if !filesToDownload.isEmpty { + self.download(strings: filesToDownload.filter({ $0.isStrings }), + plurals: filesToDownload.filter({ $0.isStringsDict }), + xliffs: filesToDownload.filter({ $0.isXliff }), + jsons: filesToDownload.filter({ $0.isJson }), + xcstrings: filesToDownload.filter({ $0.isXcstrings }), + with: hash, timestamp: timestamp, for: localization) + } else { + self.completion?(nil, nil, nil) + } } else if let error = error { self.errors = [error] self.completion?(nil, nil, self.errors) } } } - + func download(strings: [String], plurals: [String], xliffs: [String], jsons: [String], xcstrings: [String], with hash: String, timestamp: TimeInterval?, for localization: String) { + let timestamp = timestamp ?? Date().timeIntervalSince1970 self.operationQueue.cancelAllOperations() - + self.contentDeliveryAPI = CrowdinContentDeliveryAPI(hash: hash, session: URLSession.shared) self.strings = nil self.plurals = nil self.errors = nil - + let completionBlock = BlockOperation { [weak self] in guard let self = self else { return } self.completion?(self.strings, self.plurals, self.errors) } - + strings.forEach { filePath in let download = CrowdinStringsDownloadOperation(filePath: filePath, localization: localization, timestamp: timestamp, contentDeliveryAPI: contentDeliveryAPI) download.completion = { [weak self] (strings, error) in guard let self = self else { return } self.add(strings: strings) self.add(error: error) + if error == nil { + self.updateTimestamp(for: localization, filePath: filePath, timestamp: timestamp) + } } completionBlock.addDependency(download) operationQueue.addOperation(download) } - + plurals.forEach { filePath in let download = CrowdinPluralsDownloadOperation(filePath: filePath, localization: localization, timestamp: timestamp, contentDeliveryAPI: contentDeliveryAPI) download.completion = { [weak self] (plurals, error) in guard let self = self else { return } self.add(plurals: plurals) self.add(error: error) + if error == nil { + self.updateTimestamp(for: localization, filePath: filePath, timestamp: timestamp) + } } completionBlock.addDependency(download) operationQueue.addOperation(download) } - + xliffs.forEach { filePath in let download = CrowdinXliffDownloadOperation(filePath: filePath, localization: localization, timestamp: timestamp, contentDeliveryAPI: contentDeliveryAPI) download.completion = { [weak self] (strings, plurals, error) in @@ -82,22 +94,28 @@ class CrowdinLocalizationDownloader: CrowdinDownloaderProtocol { self.add(strings: strings) self.add(plurals: plurals) self.add(error: error) + if error == nil { + self.updateTimestamp(for: localization, filePath: filePath, timestamp: timestamp) + } } completionBlock.addDependency(download) operationQueue.addOperation(download) } - + jsons.forEach { filePath in let download = CrowdinJsonDownloadOperation(filePath: filePath, localization: localization, timestamp: timestamp, contentDeliveryAPI: contentDeliveryAPI) download.completion = { [weak self] (strings, _, error) in guard let self = self else { return } self.add(strings: strings) self.add(error: error) + if error == nil { + self.updateTimestamp(for: localization, filePath: filePath, timestamp: timestamp) + } } completionBlock.addDependency(download) operationQueue.addOperation(download) } - + xcstrings.forEach { filePath in let download = CrowdinXcstringsDownloadOperation(filePath: filePath, localization: localization, @@ -109,29 +127,32 @@ class CrowdinLocalizationDownloader: CrowdinDownloaderProtocol { self.add(strings: strings) self.add(plurals: plurals) self.add(error: error) + if error == nil { + self.updateTimestamp(for: localization, filePath: filePath, timestamp: timestamp) + } } completionBlock.addDependency(download) operationQueue.addOperation(download) } - + operationQueue.operations.forEach({ $0.qualityOfService = .userInitiated }) operationQueue.addOperation(completionBlock) } - + func getFiles(for language: String, completion: @escaping ([String]?, TimeInterval?, Error?) -> Void) { manifestManager.download { [weak self] in guard let self = self else { return } completion(self.manifestManager.contentFiles(for: language), self.manifestManager.timestamp, nil) } } - + func getLanguages(for hash: String, completion: @escaping ([String]?, Error?) -> Void) { manifestManager.download { [weak self] in guard let self = self else { return } completion(self.manifestManager.languages, nil) } } - + func add(error: Error?) { guard let error = error else { return } if self.errors != nil { @@ -140,7 +161,7 @@ class CrowdinLocalizationDownloader: CrowdinDownloaderProtocol { self.errors = [error] } } - + func add(strings: [String: String]?) { guard let strings = strings else { return } if self.strings != nil { @@ -149,7 +170,7 @@ class CrowdinLocalizationDownloader: CrowdinDownloaderProtocol { self.strings = strings } } - + func add(plurals: [AnyHashable: Any]?) { guard let plurals = plurals else { return } if self.plurals != nil { @@ -158,4 +179,9 @@ class CrowdinLocalizationDownloader: CrowdinDownloaderProtocol { self.plurals = plurals } } + + func updateTimestamp(for localization: String, filePath: String, timestamp: TimeInterval) { + manifestManager.fileTimestampStorage.updateTimestamp(for: localization, filePath: filePath, timestamp: timestamp) + manifestManager.fileTimestampStorage.saveTimestamps() + } } diff --git a/Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/FileTimestampStorage.swift b/Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/FileTimestampStorage.swift new file mode 100644 index 00000000..4ac2ea45 --- /dev/null +++ b/Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/FileTimestampStorage.swift @@ -0,0 +1,46 @@ +import Foundation + +class FileTimestampStorage { + private let hash: String + private var fileTimestamps: [String: [String: TimeInterval]] + private var storagePath: String { + return CrowdinFolder.shared.path + "/FileTimestamps/" + hash + ".json" + } + + init(hash: String) { + self.hash = hash + self.fileTimestamps = [:] + loadTimestamps() + } + + private func loadTimestamps() { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: storagePath)) else { return } + guard let timestamps = try? JSONDecoder().decode([String: [String: TimeInterval]].self, from: data) else { return } + self.fileTimestamps = timestamps + } + + func saveTimestamps() { + try? FileManager.default.createDirectory(at: URL(fileURLWithPath: CrowdinFolder.shared.path + "/FileTimestamps/"), withIntermediateDirectories: true, attributes: nil) + guard let data = try? JSONEncoder().encode(fileTimestamps) else { return } + try? data.write(to: URL(fileURLWithPath: storagePath)) + } + + func updateTimestamp(for localization: String, filePath: String, timestamp: TimeInterval?) { + if fileTimestamps[localization] == nil { + fileTimestamps[localization] = [:] + } + fileTimestamps[localization]?[filePath] = timestamp + } + + func timestamp(for localization: String, filePath: String) -> TimeInterval? { + return fileTimestamps[localization]?[filePath] + } + + func clear() { + try? FileManager.default.removeItem(atPath: storagePath) + } + + static func clear() { + try? FileManager.default.removeItem(atPath: CrowdinFolder.shared.path + "/FileTimestamps/") + } +} diff --git a/Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/ManifestManager.swift b/Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/ManifestManager.swift index a62dfaab..296b75fb 100644 --- a/Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/ManifestManager.swift +++ b/Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/ManifestManager.swift @@ -20,6 +20,20 @@ class ManifestManager { /// Dictionary with manifest managers for hashes. fileprivate static var manifestMap = [String: ManifestManager]() + private var minimumManifestUpdateInterval: TimeInterval + + private var lastManifestUpdateInterval: TimeInterval? { + get { + fileTimestampStorage.timestamp(for: "none", filePath: "manifest.json") + } + set { + fileTimestampStorage.updateTimestamp(for: "none", filePath: "manifest.json", timestamp: newValue) + fileTimestampStorage.saveTimestamps() + } + } + + var fileTimestampStorage: FileTimestampStorage + /// Download status of manifest for current hash for current app session. True - after manifest downloaded from crowdin server. var downloaded: Bool { get { @@ -54,48 +68,58 @@ class ManifestManager { let sourceLanguage: String let organizationName: String? var manifest: ManifestResponse? - + var manifestURL: String? var contentDeliveryAPI: CrowdinContentDeliveryAPI var crowdinSupportedLanguages: CrowdinSupportedLanguages - - fileprivate init(hash: String, sourceLanguage: String, organizationName: String?) { + + fileprivate init(hash: String, sourceLanguage: String, organizationName: String?, minimumManifestUpdateInterval: TimeInterval) { self.hash = hash self.sourceLanguage = sourceLanguage self.organizationName = organizationName + self.minimumManifestUpdateInterval = minimumManifestUpdateInterval self.contentDeliveryAPI = CrowdinContentDeliveryAPI(hash: hash) self.crowdinSupportedLanguages = CrowdinSupportedLanguages(organizationName: organizationName) + self.fileTimestampStorage = FileTimestampStorage(hash: hash) self.load() ManifestManager.manifestMap[self.hash] = self } - - class func manifest(for hash: String, sourceLanguage: String, organizationName: String?) -> ManifestManager { - manifestMap[hash] ?? ManifestManager(hash: hash, sourceLanguage: sourceLanguage, organizationName: organizationName) + + class func manifest(for hash: String, sourceLanguage: String, organizationName: String?, minimumManifestUpdateInterval: TimeInterval) -> ManifestManager { + manifestMap[hash] ?? ManifestManager(hash: hash, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval) } - + var languages: [String]? { manifest?.languages } var files: [String]? { manifest?.files } var timestamp: TimeInterval? { manifest?.timestamp } var customLanguages: [CustomLangugage] { manifest?.customLanguages ?? [] } var mappingFiles: [String] { manifest?.mapping ?? [] } var xcstringsLanguage: String { languages?.sorted().first ?? sourceLanguage } - + var iOSLanguages: [String] { return self.languages?.compactMap({ self.iOSLanguageCode(for: $0) }) ?? [] } - + func contentFiles(for language: String) -> [String] { guard let crowdinLanguage = crowdinLanguageCode(for: language) else { return [] } var files = manifest?.content[crowdinLanguage] ?? [] - // Add xcstrings files from source language if language != firstLanguage - if language != xcstringsLanguage { // Avoid duplications for first language in languages array. + if language != xcstringsLanguage { let xcstrings = manifest?.content[xcstringsLanguage]?.filter({ $0.isXcstrings }) ?? [] files.append(contentsOf: xcstrings) } return files } - + func download(completion: @escaping () -> Void) { + let lastUpdateTimestamp = lastManifestUpdateInterval ?? 0 + let currentTime = Date().timeIntervalSince1970 + let minimumInterval = minimumManifestUpdateInterval + + guard currentTime - lastUpdateTimestamp >= minimumInterval else { + completion() + return + } + guard downloaded == false else { completion() return @@ -110,11 +134,11 @@ class ManifestManager { guard let self = self else { return } if let manifest = manifest { self.manifest = manifest - self.manifestURL = manifestURL self.save(manifestResponse: manifest) self.loaded = true self.downloaded = true + self.lastManifestUpdateInterval = currentTime } else if let error = error { LocalizationUpdateObserver.shared.notifyError(with: [error]) } else { @@ -125,49 +149,61 @@ class ManifestManager { self.downloading = false } } - + + func hasFileChanged(filePath: String, localization: String) -> Bool { + guard let currentTimestamp = manifest?.timestamp else { return false } + return fileTimestampStorage.timestamp(for: localization, filePath: filePath) != currentTimestamp + } + + private func updateFileTimestamps(manifest: ManifestResponse) { + for file in manifest.files { + for language in manifest.languages ?? [] { + fileTimestampStorage.updateTimestamp(for: language, filePath: file, timestamp: manifest.timestamp ?? 0) + } + } + fileTimestampStorage.saveTimestamps() + } + private func addCompletion(completion: @escaping () -> Void, for hash: String) { var completions = completionsMap[hash] ?? [] completions.append(completion) completionsMap[hash] = completions } - + private func removeCompletions(for hash: String) { completionsMap.removeValue(forKey: hash) } - + private func callCompletions(for hash: String) { completionsMap[hash]?.forEach({ $0() }) } - - /// Path for current hash manifests file + private var manifestPath: String { ManifestManager.manifestsPath + hash + (organizationName ?? "") + ".json" } - - /// Root path for manifests files + static private let manifestsPath = CrowdinFolder.shared.path + "/Manifests/" - + private func save(manifestResponse: ManifestResponse) { try? FileManager.default.createDirectory(at: URL(fileURLWithPath: ManifestManager.manifestsPath), withIntermediateDirectories: true, attributes: nil) guard let data = try? JSONEncoder().encode(manifestResponse) else { return } try? data.write(to: URL(fileURLWithPath: manifestPath)) } - + private func load() { guard let data = try? Data(contentsOf: URL(fileURLWithPath: manifestPath)) else { return } guard let manifestResponse = try? JSONDecoder().decode(ManifestResponse.self, from: data) else { return } self.manifest = manifestResponse loaded = true } - - /// Removes all cached manifest data files + static func clear() { - manifestMap.removeAll() // clear all manifests - try? FileManager.default.removeItem(atPath: ManifestManager.manifestsPath) // clear all manifest cache + manifestMap.removeAll() + try? FileManager.default.removeItem(atPath: ManifestManager.manifestsPath) + FileTimestampStorage.clear() } - - /// Removes cached manifest data file for current hash + func clear() { ManifestManager.manifestMap.removeValue(forKey: hash) try? FileManager.default.removeItem(atPath: manifestPath) + fileTimestampStorage.clear() } } diff --git a/Sources/CrowdinSDK/Providers/Crowdin/MappingDownloader/CrowdinMappingManager.swift b/Sources/CrowdinSDK/Providers/Crowdin/MappingDownloader/CrowdinMappingManager.swift index 6446cb83..b0613075 100644 --- a/Sources/CrowdinSDK/Providers/Crowdin/MappingDownloader/CrowdinMappingManager.swift +++ b/Sources/CrowdinSDK/Providers/Crowdin/MappingDownloader/CrowdinMappingManager.swift @@ -25,8 +25,8 @@ public class CrowdinMappingManager: CrowdinMappingManagerProtocol { var stringsMapping: [String: String] = [:] var plurals: [AnyHashable: Any] = [:] - init(hash: String, sourceLanguage: String, organizationName: String?) { - self.manifestManager = ManifestManager.manifest(for: hash, sourceLanguage: sourceLanguage, organizationName: organizationName) + init(hash: String, sourceLanguage: String, organizationName: String?, minimumManifestUpdateInterval: TimeInterval) { + self.manifestManager = ManifestManager.manifest(for: hash, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval) self.downloader = CrowdinMappingDownloader(manifestManager: self.manifestManager) self.download(hash: hash, sourceLanguage: sourceLanguage) } diff --git a/Sources/Tests/Core/CrowdinRemoteLocalizationStorageTests.swift b/Sources/Tests/Core/CrowdinRemoteLocalizationStorageTests.swift index e2c54fa8..467fc86d 100644 --- a/Sources/Tests/Core/CrowdinRemoteLocalizationStorageTests.swift +++ b/Sources/Tests/Core/CrowdinRemoteLocalizationStorageTests.swift @@ -32,7 +32,7 @@ class CrowdinRemoteLocalizationStorageTests: XCTestCase { let preparationExpectation = XCTestExpectation(description: "Provider preperation") remoteLocalizationStorage = CrowdinRemoteLocalizationStorage(localization: "en", config: crowdinProviderConfig) - + remoteLocalizationStorage.manifestManager.clear() remoteLocalizationStorage.prepare { preparationExpectation.fulfill() } diff --git a/Sources/Tests/CrowdinProvider/ManifestManagerTests.swift b/Sources/Tests/CrowdinProvider/ManifestManagerTests.swift index 1f0ffd05..b5c24889 100644 --- a/Sources/Tests/CrowdinProvider/ManifestManagerTests.swift +++ b/Sources/Tests/CrowdinProvider/ManifestManagerTests.swift @@ -18,7 +18,7 @@ class ManifestManagerTests: XCTestCase { func testDownloadManifest() { let expectation = XCTestExpectation(description: "Manifest download expectation") - let manifest = ManifestManager.manifest(for: crowdinTestHash, sourceLanguage: sourceLanguage, organizationName: nil) + let manifest = ManifestManager.manifest(for: crowdinTestHash, sourceLanguage: sourceLanguage, organizationName: nil, minimumManifestUpdateInterval: 15 * 60) XCTAssertFalse(manifest.loaded) XCTAssertFalse(manifest.downloaded)