@@ -9,6 +9,9 @@ import Foundation
99
1010/// Class for managing manifest files: downlaoding, caching, clearing cache.
1111class ManifestManager {
12+ /// Serial queue for thread-safe access to mutable state
13+ let queue = DispatchQueue ( label: " com.crowdin.sdk.manifestmanager " , attributes: [ ] )
14+
1215 /// Dictionary with manifest state for hashes.
1316 fileprivate var state : ManifestState = . none
1417 /// Dictionary with manifest completion handlers array for hashes.
@@ -29,7 +32,9 @@ class ManifestManager {
2932 }
3033
3134 var fileTimestampStorage : FileTimestampStorage
32- var available : Bool { state == . downloaded || state == . local }
35+ var available : Bool {
36+ queue. sync { state == . downloaded || state == . local }
37+ }
3338 let hash : String
3439 let sourceLanguage : String
3540 let organizationName : String ?
@@ -55,64 +60,157 @@ class ManifestManager {
5560 manifestMap [ hash] ?? ManifestManager ( hash: hash, sourceLanguage: sourceLanguage, organizationName: organizationName, minimumManifestUpdateInterval: minimumManifestUpdateInterval)
5661 }
5762
58- var languages : [ String ] ? { manifest? . languages }
59- var files : [ String ] ? { manifest? . files }
60- var timestamp : TimeInterval ? { manifest? . timestamp }
61- var customLanguages : [ CustomLangugage ] { manifest? . customLanguages ?? [ ] }
62- var mappingFiles : [ String ] { manifest? . mapping ?? [ ] }
63- var xcstringsLanguage : String { languages? . first ?? sourceLanguage }
63+ var languages : [ String ] ? {
64+ queue. sync { manifest? . languages }
65+ }
66+
67+ var files : [ String ] ? {
68+ queue. sync { manifest? . files }
69+ }
70+
71+ var timestamp : TimeInterval ? {
72+ queue. sync { manifest? . timestamp }
73+ }
74+
75+ var customLanguages : [ CustomLangugage ] {
76+ queue. sync { manifest? . customLanguages ?? [ ] }
77+ }
78+
79+ var mappingFiles : [ String ] {
80+ queue. sync { manifest? . mapping ?? [ ] }
81+ }
82+
83+ var xcstringsLanguage : String {
84+ queue. sync { manifest? . languages? . first ?? sourceLanguage }
85+ }
6486
6587 var iOSLanguages : [ String ] {
66- return self . languages? . compactMap ( { self . iOSLanguageCode ( for: $0) } ) ?? [ ]
88+ return queue. sync {
89+ guard let languages = self . manifest? . languages else { return [ ] }
90+
91+ var resolvedLanguages = [ String] ( )
92+ var unresolvedLanguages = [ String] ( )
93+
94+ // Get all languages once to avoid nested queue.sync calls
95+ let crowdinLanguages : [ CrowdinLanguage ] = crowdinSupportedLanguages. supportedLanguages? . data. map ( { $0. data } ) ?? [ ]
96+ let customLaguages : [ CrowdinLanguage ] = manifest? . customLanguages ?? [ ]
97+ let allLangs : [ CrowdinLanguage ] = crowdinLanguages + customLaguages
98+
99+ // Try to resolve each language through the language mapping
100+ for language in languages {
101+ if let resolved = allLangs. first ( where: { $0. id == language } ) ? . iOSLanguageCode {
102+ resolvedLanguages. append ( resolved)
103+ } else {
104+ unresolvedLanguages. append ( language)
105+ }
106+ }
107+
108+ // For any unresolved languages, use them directly as fallback
109+ // This handles cases where:
110+ // 1. The language mapping hasn't loaded yet or failed
111+ // 2. The language is a simple code like "en" that might not need complex resolution
112+ // 3. The language is already in iOS format
113+ resolvedLanguages. append ( contentsOf: unresolvedLanguages)
114+
115+ return resolvedLanguages
116+ }
67117 }
68118
69119 func contentFiles( for language: String ) -> [ String ] {
70- guard let crowdinLanguage = crowdinLanguageCode ( for: language) else { return [ ] }
71- var files = manifest? . content [ crowdinLanguage] ?? [ ]
72- if language != xcstringsLanguage {
73- let xcstrings = manifest? . content [ xcstringsLanguage] ? . filter ( { $0. isXcstrings } ) ?? [ ]
74- files. append ( contentsOf: xcstrings)
120+ return queue. sync {
121+ // Get crowdin language code inline to avoid nested queue.sync
122+ let crowdinLanguages : [ CrowdinLanguage ] = crowdinSupportedLanguages. supportedLanguages? . data. map ( { $0. data } ) ?? [ ]
123+ let customLaguages : [ CrowdinLanguage ] = manifest? . customLanguages ?? [ ]
124+ let allLangs : [ CrowdinLanguage ] = crowdinLanguages + customLaguages
125+
126+ var crowdinLanguageCandidate = allLangs. first ( where: { $0. iOSLanguageCode == language } )
127+ if crowdinLanguageCandidate == nil {
128+ let alternateiOSLocaleCode = language. replacingOccurrences ( of: " _ " , with: " - " )
129+ crowdinLanguageCandidate = allLangs. first ( where: { $0. iOSLanguageCode == alternateiOSLocaleCode } )
130+ }
131+ if crowdinLanguageCandidate == nil {
132+ let alternateiOSLocaleCode = language. split ( separator: " _ " ) . map ( { String ( $0) } ) . first
133+ crowdinLanguageCandidate = allLangs. first ( where: { $0. iOSLanguageCode == alternateiOSLocaleCode } )
134+ }
135+
136+ guard let crowdinLanguage = crowdinLanguageCandidate? . id else { return [ ] }
137+
138+ var files = manifest? . content [ crowdinLanguage] ?? [ ]
139+ let xcstringsLang = manifest? . languages? . first ?? sourceLanguage
140+ if language != xcstringsLang {
141+ let xcstrings = manifest? . content [ xcstringsLang] ? . filter ( { $0. isXcstrings } ) ?? [ ]
142+ files. append ( contentsOf: xcstrings)
143+ }
144+ return files
75145 }
76- return files
77146 }
78147
79148 func download( completion: @escaping ( ) -> Void ) {
80- let lastUpdateTimestamp = lastManifestUpdateInterval ?? 0
81- let currentTime = Date ( ) . timeIntervalSince1970
82- let minimumInterval = minimumManifestUpdateInterval
83- guard currentTime - lastUpdateTimestamp >= minimumInterval else {
84- completion ( )
85- return
149+ enum DownloadAction { case start, wait, completeImmediately }
150+ let action : DownloadAction = queue. sync {
151+ let lastUpdateTimestamp = self . lastManifestUpdateInterval ?? 0
152+ let currentTime = Date ( ) . timeIntervalSince1970
153+ let minimumInterval = self . minimumManifestUpdateInterval
154+
155+ // If minimum interval not reached OR already downloaded -> just complete immediately (no new network call)
156+ if currentTime - lastUpdateTimestamp < minimumInterval || self . state == . downloaded {
157+ return . completeImmediately
158+ }
159+
160+ // If already downloading, add completion and wait for active download to finish
161+ if self . state == . downlaoding {
162+ self . addCompletion ( completion: completion, for: self . hash)
163+ return . wait
164+ }
165+
166+ // Start new download
167+ self . addCompletion ( completion: completion, for: self . hash)
168+ self . state = . downlaoding
169+ return . start
86170 }
87- guard state != . downloaded else {
171+
172+ switch action {
173+ case . completeImmediately:
174+ // Nothing to download, call completion directly
88175 completion ( )
89176 return
177+ case . wait:
178+ // A download is already in progress; completion will be invoked when that finishes
179+ return
180+ case . start:
181+ break // Proceed to perform download below
90182 }
91-
92- addCompletion ( completion: completion, for: hash)
93- guard state != . downlaoding else { return }
94- state = . downlaoding
183+
184+ let currentTime = Date ( ) . timeIntervalSince1970
95185 contentDeliveryAPI. getManifest { [ weak self] manifest, manifestURL, error in
96186 guard let self = self else { return }
97- if let manifest = manifest {
98- self . manifest = manifest
99- self . manifestURL = manifestURL
100- self . save ( manifestResponse: manifest)
101- self . state = . downloaded
102- self . lastManifestUpdateInterval = currentTime
103- } else if let error = error {
104- LocalizationUpdateObserver . shared. notifyError ( with: [ error] )
105- } else {
106- LocalizationUpdateObserver . shared. notifyError ( with: [ NSError ( domain: " Unknown error while downloading manifest " , code: defaultCrowdinErrorCode, userInfo: nil ) ] )
187+ let completions : [ ( ) -> Void ] ? = self . queue. sync {
188+ if let manifest = manifest {
189+ self . manifest = manifest
190+ self . manifestURL = manifestURL
191+ self . save ( manifestResponse: manifest)
192+ self . state = . downloaded
193+ self . lastManifestUpdateInterval = currentTime
194+ } else if let error = error {
195+ LocalizationUpdateObserver . shared. notifyError ( with: [ error] )
196+ self . state = . none
197+ } else {
198+ LocalizationUpdateObserver . shared. notifyError ( with: [ NSError ( domain: " Unknown error while downloading manifest " , code: defaultCrowdinErrorCode, userInfo: nil ) ] )
199+ self . state = . none
200+ }
201+ let completions = self . completionsMap [ self . hash]
202+ self . completionsMap. removeValue ( forKey: self . hash)
203+ return completions
107204 }
108- self . callCompletions ( for: self . hash)
109- self . removeCompletions ( for: self . hash)
205+ completions? . forEach { $0 ( ) }
110206 }
111207 }
112208
113209 func hasFileChanged( filePath: String , localization: String ) -> Bool {
114- guard let currentTimestamp = manifest? . timestamp else { return false }
115- return fileTimestampStorage. timestamp ( for: localization, filePath: filePath) != currentTimestamp
210+ return queue. sync {
211+ guard let currentTimestamp = manifest? . timestamp else { return false }
212+ return fileTimestampStorage. timestamp ( for: localization, filePath: filePath) != currentTimestamp
213+ }
116214 }
117215
118216 private func updateFileTimestamps( manifest: ManifestResponse ) {
0 commit comments