diff --git a/apps/ios/GuideDogs.xcodeproj/project.pbxproj b/apps/ios/GuideDogs.xcodeproj/project.pbxproj index 1e63453a1..faa41ec38 100644 --- a/apps/ios/GuideDogs.xcodeproj/project.pbxproj +++ b/apps/ios/GuideDogs.xcodeproj/project.pbxproj @@ -532,6 +532,7 @@ 914BAAFD2AD7483300CB2171 /* AudioEngineTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914BAAFC2AD7483300CB2171 /* AudioEngineTest.swift */; }; 914BAB012AD7490100CB2171 /* NavigationControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914BAB002AD7490100CB2171 /* NavigationControllerTest.swift */; }; D4A72B0131A1000100C0FFEE /* URLResourceIdentifierTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A72B0031A1000100C0FFEE /* URLResourceIdentifierTest.swift */; }; + D4A72B1131A2000100C0FFEE /* LocalizationContextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A72B1031A2000100C0FFEE /* LocalizationContextTest.swift */; }; 915FF9F62ADE4BAF002B3690 /* AuthoredActivityContentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 915FF9F42ADE3F91002B3690 /* AuthoredActivityContentTest.swift */; }; 91C82AAD2A5DCF040086D126 /* GeolocationManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91C82AAC2A5DCF040086D126 /* GeolocationManagerTest.swift */; }; 91C82ABE2A6B08500086D126 /* RouteGuidanceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91C82ABD2A6B08500086D126 /* RouteGuidanceTest.swift */; }; @@ -1324,6 +1325,7 @@ 914BAAFC2AD7483300CB2171 /* AudioEngineTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioEngineTest.swift; sourceTree = ""; }; 914BAB002AD7490100CB2171 /* NavigationControllerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationControllerTest.swift; sourceTree = ""; }; D4A72B0031A1000100C0FFEE /* URLResourceIdentifierTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLResourceIdentifierTest.swift; sourceTree = ""; }; + D4A72B1031A2000100C0FFEE /* LocalizationContextTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalizationContextTest.swift; sourceTree = ""; }; 914DEBCD2A3CE6B9007B161C /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 914DEBDC2A3CE901007B161C /* GeometryUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeometryUtilsTest.swift; sourceTree = ""; }; 915FF9F42ADE3F91002B3690 /* AuthoredActivityContentTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthoredActivityContentTest.swift; sourceTree = ""; }; @@ -3510,12 +3512,21 @@ 914BAAFB2AD747C200CB2171 /* Audio */, 91C82ABB2A6B03790086D126 /* Behaviors */, 91C82AB52A67182E0086D126 /* Data */, + D4A72B1231A2000100C0FFEE /* Language */, 91C82AA62A4F56A70086D126 /* Sensors */, 914DEBD92A3CE7E6007B161C /* App */, ); path = UnitTests; sourceTree = ""; }; + D4A72B1231A2000100C0FFEE /* Language */ = { + isa = PBXGroup; + children = ( + D4A72B1031A2000100C0FFEE /* LocalizationContextTest.swift */, + ); + path = Language; + sourceTree = ""; + }; 914DEBD92A3CE7E6007B161C /* App */ = { isa = PBXGroup; children = ( @@ -4732,6 +4743,7 @@ 666CA3EAAA904E6EBFA10A53 /* NaviLensWakeOnForegroundTest.swift in Sources */, 914BAB012AD7490100CB2171 /* NavigationControllerTest.swift in Sources */, D4A72B0131A1000100C0FFEE /* URLResourceIdentifierTest.swift in Sources */, + D4A72B1131A2000100C0FFEE /* LocalizationContextTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/apps/ios/GuideDogs/Code/Language/Localization/LocalizationContext.swift b/apps/ios/GuideDogs/Code/Language/Localization/LocalizationContext.swift index a1cac095e..7400836af 100644 --- a/apps/ios/GuideDogs/Code/Language/Localization/LocalizationContext.swift +++ b/apps/ios/GuideDogs/Code/Language/Localization/LocalizationContext.swift @@ -3,6 +3,7 @@ // Soundscape // // Copyright (c) Microsoft Corporation. +// Copyright (c) Soundscape Community Contributors. // Licensed under the MIT License. // @@ -79,36 +80,24 @@ class LocalizationContext { } /// The current app locale. This will return one of the following: - /// 1. The user selected app locale (if a selection has been made) - /// 2. The device locale (if supported by the app) - /// 3. The default English locale + /// 1. The user-selected app locale (if a valid selection has been made) + /// 2. The localization selected by iOS from the user's preferred languages + /// 3. The app's development locale static var currentAppLocale: Locale { get { - let currentAppLocale: Locale - - // Check if the user has selected a locale - if let locale = SettingsContext.shared.locale { - currentAppLocale = locale - } else if let firstSupportedLocale = firstSupportedLocale { - // If not, check if the device's locale is supported - currentAppLocale = firstSupportedLocale - } else { - // If not, fallback to the default English locale (en-US or en-GB) - currentAppLocale = deviceLocale.defaultEnglish - } - - // Store the locale and bundle - if currentAppLocale != _currentAppLocale { - _currentAppLocale = currentAppLocale - currentAppLocaleBundle = Bundle(locale: currentAppLocale) - } - - return currentAppLocale + return currentLocalization().locale } set(newLocale) { + guard let bundle = Bundle(locale: newLocale) else { + DDLogError("Localization error: Unsupported app locale \"\(newLocale.identifier)\"") + return + } + + localizationLock.lock() + cachedLocalization = AppLocalization(locale: newLocale, bundle: bundle) + localizationLock.unlock() + SettingsContext.shared.locale = newLocale - _currentAppLocale = newLocale - currentAppLocaleBundle = Bundle(locale: newLocale)! configureAccessibilityLanguage() @@ -119,11 +108,14 @@ class LocalizationContext { } } } - - static private var _currentAppLocale: Locale? - static private var currentAppLocaleBundle: Bundle? - - static private let defaultEnglishBundle = Bundle(locale: deviceLocale.defaultEnglish)! + + private struct AppLocalization { + let locale: Locale + let bundle: Bundle + } + + private static let localizationLock = NSLock() + private static var cachedLocalization: AppLocalization? static var currentLanguageCode: String { return currentAppLocale.languageCode ?? defaultLanguageCode @@ -134,44 +126,64 @@ class LocalizationContext { } /// The development app locale ("en-US") - static var developmentLocale = Bundle.main.developmentLocale! - + static let developmentLocale = Bundle.main.developmentLocale ?? Locale.enUS + static private let defaultDevelopmentBundle = Bundle(locale: developmentLocale)! - - private static var _firstSupportedLocale: Locale? - - static var firstSupportedLocale: Locale? { - if let firstSupportedLocale = _firstSupportedLocale { - return firstSupportedLocale + + private static func currentLocalization() -> AppLocalization { + let developmentBundle = defaultDevelopmentBundle + + localizationLock.lock() + defer { localizationLock.unlock() } + + if let cachedLocalization { + return cachedLocalization } - - for preferredLocale in Locale.preferredLocales { - if preferredLocale == Locale.enUS { - _firstSupportedLocale = Locale.enUS - break - } else if preferredLocale == Locale.enGB { - _firstSupportedLocale = Locale.enGB - break - } else if preferredLocale.languageCode == Locale.en.languageCode { - _firstSupportedLocale = deviceLocale.defaultEnglish - break - } else if let locale = supportedLocales.first(where: { $0 == preferredLocale }) { - _firstSupportedLocale = locale - break - } + + let locale = resolveAppLocale( + selectedLocale: SettingsContext.shared.locale, + supportedLocales: supportedLocales, + preferredLocalization: Bundle.main.preferredLocalizations.first, + developmentLocalization: Bundle.main.developmentLocalization + ) + let localization = AppLocalization( + locale: locale, + bundle: Bundle(locale: locale) ?? developmentBundle + ) + cachedLocalization = localization + + return localization + } + + static func resolveAppLocale( + selectedLocale: Locale?, + supportedLocales: [Locale], + preferredLocalization: String?, + developmentLocalization: String? + ) -> Locale { + if let selectedLocale, + let supportedLocale = supportedLocale(matching: selectedLocale.identifier, in: supportedLocales) { + return supportedLocale } - // Fallback: match only on language part if no exact match found - if _firstSupportedLocale == nil { - for preferredLocale in Locale.preferredLocales { - if let languageCode = preferredLocale.languageCode, - let locale = supportedLocales.first(where: { $0.languageCode == languageCode }) { - _firstSupportedLocale = locale - break - } - } + + if let preferredLocalization, + let supportedLocale = supportedLocale(matching: preferredLocalization, in: supportedLocales) { + return supportedLocale + } + + if let developmentLocalization { + return supportedLocale(matching: developmentLocalization, in: supportedLocales) + ?? Locale(identifier: developmentLocalization) + } + + return Locale.enUS + } + + private static func supportedLocale(matching identifier: String, in supportedLocales: [Locale]) -> Locale? { + let normalizedIdentifier = Locale(identifier: identifier).identifierHyphened.lowercased() + return supportedLocales.first { + $0.identifierHyphened.lowercased() == normalizedIdentifier } - - return _firstSupportedLocale } // MARK: Properties @@ -217,36 +229,36 @@ class LocalizationContext { DDLogWarn("Localized string error: key is nil") return key } - - // If the current language does not contain the translation for the key, use the fallback language - return NSLocalizedString(key, - bundle: currentAppLocaleBundle ?? defaultEnglishBundle, - value: localizedStringFallbackLanguage(key), - comment: "") + + let localization = currentLocalization() + + return localizedString( + key, + bundle: localization.bundle, + fallbackBundle: defaultDevelopmentBundle + ) } - - private static func localizedStringFallbackLanguage(_ key: String) -> String { + + static func localizedString(_ key: String, bundle: Bundle, fallbackBundle: Bundle) -> String { guard !key.isEmpty else { DDLogWarn("Localized string error: key is nil") return key } - - let defaultEnglishLocale = deviceLocale.defaultEnglish - - // Try the default English language ("en-US" or "en-GB") - if let string = nsLocalizedString(key, bundle: defaultEnglishBundle) { - return string - } - - // If the default English language was "en-GB", try "en-US" (the base development language) - if defaultEnglishLocale != developmentLocale, - let string = nsLocalizedString(key, bundle: defaultDevelopmentBundle) { - return string + + let fallback: String + if let developmentString = nsLocalizedString(key, bundle: fallbackBundle) { + fallback = developmentString + } else { + DDLogWarn("Localization error: Localized string not found for key \"\(key)\"") + fallback = key } - - DDLogWarn("Localization error: Localized string not found for key \"\(key)\"") - return key + return NSLocalizedString( + key, + bundle: bundle, + value: fallback, + comment: "" + ) } /// Returns the localized string using the native `NSLocalizedString` function, @@ -321,29 +333,12 @@ extension Bundle { } extension Locale { - /// The locale for English (without region) - static let en = Locale(identifier: "en") - /// The locale for English (United States) static let enUS = Locale(identifier: "en-US") - /// The locale for English (United Kingdom) - static let enGB = Locale(identifier: "en-GB") - /// Locale for French (France) static let frFr = Locale(identifier: "fr-FR") - /// Returns the `preferredLanguages` objects as `Locale` objects - static var preferredLocales: [Locale] { - return Locale.preferredLanguages.map { Locale(identifier: $0) } - } - - /// The default English locale. This follows the suggested standard were users in the US will default to - /// "English (United States)", and users outside the US will default to "English (United Kingdom)". - var defaultEnglish: Locale { - return self.identifier == Locale.enUS.identifier ? Locale.enUS : Locale.enGB - } - /// Returns the identifier with a hyphen ("-"), if an underscore ("_") is used, e.g. "en_US" -> "en-US". /// /// Information about locale identifiers: diff --git a/apps/ios/UnitTests/Language/LocalizationContextTest.swift b/apps/ios/UnitTests/Language/LocalizationContextTest.swift new file mode 100644 index 000000000..24e3165db --- /dev/null +++ b/apps/ios/UnitTests/Language/LocalizationContextTest.swift @@ -0,0 +1,140 @@ +// +// LocalizationContextTest.swift +// UnitTests +// +// Copyright (c) Soundscape Community Contributors. +// Licensed under the MIT License. +// + +import XCTest +@testable import Soundscape + +final class LocalizationContextTest: XCTestCase { + private let supportedLocales = [ + Locale(identifier: "en-US"), + Locale(identifier: "es-ES"), + Locale(identifier: "es-419") + ] + + func testSelectedAppLocaleOverridesSystemPreference() { + let locale = LocalizationContext.resolveAppLocale( + selectedLocale: Locale(identifier: "es-ES"), + supportedLocales: supportedLocales, + preferredLocalization: "es-419", + developmentLocalization: "en-US" + ) + + XCTAssertEqual(locale.identifierHyphened, "es-ES") + } + + func testSystemPreferenceIsUsedWithoutAppSelection() { + let locale = LocalizationContext.resolveAppLocale( + selectedLocale: nil, + supportedLocales: supportedLocales, + preferredLocalization: "es-419", + developmentLocalization: "en-US" + ) + + XCTAssertEqual(locale.identifierHyphened, "es-419") + } + + func testUnsupportedAppSelectionUsesSystemPreference() { + let locale = LocalizationContext.resolveAppLocale( + selectedLocale: Locale(identifier: "cy-GB"), + supportedLocales: supportedLocales, + preferredLocalization: "es-419", + developmentLocalization: "en-US" + ) + + XCTAssertEqual(locale.identifierHyphened, "es-419") + } + + func testDevelopmentLocalizationIsFinalLocaleFallback() { + let locale = LocalizationContext.resolveAppLocale( + selectedLocale: nil, + supportedLocales: supportedLocales, + preferredLocalization: nil, + developmentLocalization: "en-US" + ) + + XCTAssertEqual(locale.identifierHyphened, "en-US") + } + + func testFoundationSelectsExpectedSpanishLocalization() { + let availableLocalizations = supportedLocales.map(\.identifierHyphened) + let expectedLocalizations = [ + "es-US": "es-419", + "es-MX": "es-419", + "es-AR": "es-419", + "es-ES": "es-ES", + "es": "es-ES" + ] + + for (preference, expectedLocalization) in expectedLocalizations { + let localization = Bundle.preferredLocalizations( + from: availableLocalizations, + forPreferences: [preference] + ).first + + XCTAssertEqual(localization, expectedLocalization, "Unexpected localization for \(preference)") + } + } + + func testLocalizedStringUsesDevelopmentFallback() throws { + let activeBundle = try makeLocalizationBundle(strings: [ + "translated": "Traducción" + ]) + let developmentBundle = try makeLocalizationBundle(strings: [ + "translated": "Translation", + "missing_in_active": "English fallback" + ]) + defer { + try? FileManager.default.removeItem(at: activeBundle.bundleURL) + try? FileManager.default.removeItem(at: developmentBundle.bundleURL) + } + + XCTAssertEqual( + LocalizationContext.localizedString( + "translated", + bundle: activeBundle, + fallbackBundle: developmentBundle + ), + "Traducción" + ) + XCTAssertEqual( + LocalizationContext.localizedString( + "missing_in_active", + bundle: activeBundle, + fallbackBundle: developmentBundle + ), + "English fallback" + ) + XCTAssertEqual( + LocalizationContext.localizedString( + "missing_everywhere", + bundle: activeBundle, + fallbackBundle: developmentBundle + ), + "missing_everywhere" + ) + } + + private func makeLocalizationBundle(strings: [String: String]) throws -> Bundle { + let bundleURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("lproj") + try FileManager.default.createDirectory(at: bundleURL, withIntermediateDirectories: true) + + let contents = strings + .sorted { $0.key < $1.key } + .map { "\"\($0.key)\" = \"\($0.value)\";" } + .joined(separator: "\n") + try contents.write( + to: bundleURL.appendingPathComponent("Localizable.strings"), + atomically: true, + encoding: .utf8 + ) + + return try XCTUnwrap(Bundle(path: bundleURL.path)) + } +}