Skip to content

Commit e2ebe0e

Browse files
committed
Refactor localization storage and provider classes for improved thread safety and responsiveness; update tests for consistency
1 parent fc9a8d9 commit e2ebe0e

File tree

7 files changed

+284
-130
lines changed

7 files changed

+284
-130
lines changed

Sources/CrowdinSDK/CrowdinSDK/Localization/Provider/LocalLocalizationStorage.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class LocalLocalizationStorage: LocalLocalizationStorageProtocol {
4343

4444
/// List of all available localizations.
4545
var localizations: [String] {
46-
return self.localizationFolder.files.filter({ return $0.type == FileType.plist.rawValue }).map({ $0.name })
46+
return self.localizationFolder.files.filter({ $0.type == FileType.plist.rawValue }).compactMap({ $0.name.split(separator: ".").first }).map({ String($0) })
4747
}
4848

4949
private var _strings: Atomic<[String: String]> = Atomic([:])

Sources/CrowdinSDK/CrowdinSDK/Localization/Provider/LocalizationDataSource.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,17 @@ class PluralsLocalizationDataSource: LocalizationDataSourceProtocol {
102102
case other
103103
}
104104

105-
private let accessQueue = DispatchQueue(label: "com.crowdin.PluralsLocalizationDataSource.accessQueue", attributes: .concurrent)
105+
// Use a lightweight lock to avoid blocking the main thread during reads in high-contention scenarios
106+
private let lock = NSLock()
106107
private var _plurals: [AnyHashable: Any]
107108
var plurals: [AnyHashable: Any] {
108-
get {
109-
var plurals: [AnyHashable: Any] = [:]
110-
accessQueue.sync {
111-
plurals = self._plurals
112-
}
113-
return plurals
109+
// Attempt non-blocking read to avoid deadlocking UI during concurrent updates
110+
if lock.try() {
111+
defer { lock.unlock() }
112+
return _plurals
113+
} else {
114+
// If write is in progress, return empty snapshot to keep UI responsive
115+
return [:]
114116
}
115117
}
116118

@@ -119,9 +121,9 @@ class PluralsLocalizationDataSource: LocalizationDataSourceProtocol {
119121
}
120122

121123
func update(with values: [AnyHashable: Any]) {
122-
accessQueue.async(flags: .barrier) {
123-
self._plurals = values
124-
}
124+
lock.lock()
125+
_plurals = values
126+
lock.unlock()
125127
}
126128

127129
func findKey(for string: String) -> String? {

Sources/CrowdinSDK/Providers/Crowdin/CrowdinRemoteLocalizationStorage.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,15 @@ class CrowdinRemoteLocalizationStorage: RemoteLocalizationStorageProtocol {
5858
self.manifestManager.download(completion: { [weak self] in
5959
guard let self = self else { return }
6060
self.localizations = self.manifestManager.iOSLanguages
61-
self.localization = CrowdinSDK.currentLocalization ?? Bundle.main.preferredLanguage(with: self.localizations)
61+
// Only update localization if it wasn't explicitly set and if CrowdinSDK has a current localization
62+
// or if the current localization is not in the available localizations
63+
if let currentLocalization = CrowdinSDK.currentLocalization,
64+
self.localizations.contains(currentLocalization) {
65+
self.localization = currentLocalization
66+
} else if !self.localizations.contains(self.localization) {
67+
// Fallback to preferred language only if current localization is not available
68+
self.localization = Bundle.main.preferredLanguage(with: self.localizations)
69+
}
6270
completion()
6371
})
6472
}

Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/ManifestManager+LanguageResolver.swift

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ import Foundation
99

1010
extension ManifestManager: LanguageResolver {
1111
var allLanguages: [CrowdinLanguage] {
12-
let crowdinLanguages: [CrowdinLanguage] = crowdinSupportedLanguages.supportedLanguages?.data.map({ $0.data }) ?? []
13-
let customLaguages: [CrowdinLanguage] = customLanguages
14-
let allLanguages: [CrowdinLanguage] = crowdinLanguages + customLaguages
15-
return allLanguages
12+
return queue.sync {
13+
let crowdinLanguages: [CrowdinLanguage] = crowdinSupportedLanguages.supportedLanguages?.data.map({ $0.data }) ?? []
14+
let customLaguages: [CrowdinLanguage] = manifest?.customLanguages ?? []
15+
let allLanguages: [CrowdinLanguage] = crowdinLanguages + customLaguages
16+
return allLanguages
17+
}
1618
}
19+
1720
/// Get crowdin language locale code for iOS localization code.
1821
/// - Parameter localization: iOS localization identifier. (List of all - Locale.availableIdentifiers).
1922
/// - Returns: Id of iOS localization code in crowdin system.
@@ -22,21 +25,35 @@ extension ManifestManager: LanguageResolver {
2225
}
2326

2427
func crowdinSupportedLanguage(for localization: String) -> CrowdinLanguage? {
25-
var language = allLanguages.first(where: { $0.iOSLanguageCode == localization })
26-
if language == nil {
27-
// This is possible for languages ​​with regions. In case we didn't find Crowdin language mapping, try to replace _ in location code with -
28-
let alternateiOSLocaleCode = localization.replacingOccurrences(of: "_", with: "-")
29-
language = allLanguages.first(where: { $0.iOSLanguageCode == alternateiOSLocaleCode })
30-
}
31-
if language == nil {
32-
// This is possible for languages ​​with regions. In case we didn't find Crowdin language mapping, try to get localization code and search again
33-
let alternateiOSLocaleCode = localization.split(separator: "_").map({ String($0) }).first
34-
language = allLanguages.first(where: { $0.iOSLanguageCode == alternateiOSLocaleCode })
28+
return queue.sync {
29+
// Get all languages inline to avoid nested queue.sync
30+
let crowdinLanguages: [CrowdinLanguage] = crowdinSupportedLanguages.supportedLanguages?.data.map({ $0.data }) ?? []
31+
let customLaguages: [CrowdinLanguage] = manifest?.customLanguages ?? []
32+
let languages: [CrowdinLanguage] = crowdinLanguages + customLaguages
33+
34+
var language = languages.first(where: { $0.iOSLanguageCode == localization })
35+
if language == nil {
36+
// This is possible for languages ​​with regions. In case we didn't find Crowdin language mapping, try to replace _ in location code with -
37+
let alternateiOSLocaleCode = localization.replacingOccurrences(of: "_", with: "-")
38+
language = languages.first(where: { $0.iOSLanguageCode == alternateiOSLocaleCode })
39+
}
40+
if language == nil {
41+
// This is possible for languages ​​with regions. In case we didn't find Crowdin language mapping, try to get localization code and search again
42+
let alternateiOSLocaleCode = localization.split(separator: "_").map({ String($0) }).first
43+
language = languages.first(where: { $0.iOSLanguageCode == alternateiOSLocaleCode })
44+
}
45+
return language
3546
}
36-
return language
3747
}
3848

3949
func iOSLanguageCode(for crowdinLocalization: String) -> String? {
40-
allLanguages.first(where: { $0.id == crowdinLocalization })?.iOSLanguageCode
50+
return queue.sync {
51+
// Get all languages inline to avoid nested queue.sync
52+
let crowdinLanguages: [CrowdinLanguage] = crowdinSupportedLanguages.supportedLanguages?.data.map({ $0.data }) ?? []
53+
let customLaguages: [CrowdinLanguage] = manifest?.customLanguages ?? []
54+
let languages: [CrowdinLanguage] = crowdinLanguages + customLaguages
55+
56+
return languages.first(where: { $0.id == crowdinLocalization })?.iOSLanguageCode
57+
}
4158
}
4259
}

Sources/CrowdinSDK/Providers/Crowdin/ManifestManager/ManifestManager.swift

Lines changed: 137 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import Foundation
99

1010
/// Class for managing manifest files: downlaoding, caching, clearing cache.
1111
class 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) {

Sources/Tests/Core/LocalLocalizationStorageTests.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ class LocalLocalizationStorageTests: XCTestCase {
1616
}
1717

1818
func testLocalLocalizationStorageInit() {
19+
localLocalizationStorage = LocalLocalizationStorage(localization: "en")
20+
localLocalizationStorage.deintegrate()
21+
1922
localLocalizationStorage = LocalLocalizationStorage(localization: "en")
2023

2124
XCTAssertNotNil(localLocalizationStorage)

0 commit comments

Comments
 (0)