From d01349a60e8a97c21c38bb7cb6bfabeaf19fb52e Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Mon, 17 Apr 2023 15:58:39 +0100 Subject: [PATCH] Use iOS localization handling for strings. (#803) --- .../Sources/Application/AppCoordinator.swift | 2 - .../Generated/Strings+Untranslated.swift | 7 +- ElementX/Sources/Generated/Strings.swift | 23 ++---- .../Sources/Other/Extensions/Bundle.swift | 73 +++++++------------ .../Services/BugReport/BugReportService.swift | 6 +- .../Manager/NotificationManager.swift | 2 +- .../UITests/UITestsAppCoordinator.swift | 2 - .../NotificationServiceExtension.swift | 7 -- ...ctured-swift5-element-untranslated.stencil | 7 +- .../Strings/structured-swift5-element.stencil | 23 ++---- UITests/Sources/Application.swift | 3 - UnitTests/Sources/LocalizationTests.swift | 31 +++----- .../NotificationManagerTests.swift | 2 +- changelog.d/pr-803.change | 1 + 14 files changed, 59 insertions(+), 130 deletions(-) create mode 100644 changelog.d/pr-803.change diff --git a/ElementX/Sources/Application/AppCoordinator.swift b/ElementX/Sources/Application/AppCoordinator.swift index b93b2e530..f123a5d1b 100644 --- a/ElementX/Sources/Application/AppCoordinator.swift +++ b/ElementX/Sources/Application/AppCoordinator.swift @@ -86,8 +86,6 @@ class AppCoordinator: AppCoordinatorProtocol { ServiceLocator.shared.settings.lastVersionLaunched = currentVersion.description setupStateMachine() - - Bundle.elementFallbackLanguage = "en" observeApplicationState() observeNetworkState() diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index ff7e2dbe3..3d26c0517 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -72,15 +72,10 @@ public enum UntranslatedL10n { extension UntranslatedL10n { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { // No need to check languages, we always default to en for untranslated strings - guard let bundle = Bundle(for: BundleToken.self).lprojBundle(for: "en") else { - // no translations for the desired language - return key - } + guard let bundle = Bundle.lprojBundle(for: "en") else { return key } let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "") return String(format: format, locale: Locale(identifier: "en"), arguments: args) } } -private final class BundleToken {} - // swiftlint:enable all diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 564807204..063d0a6ad 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -802,31 +802,24 @@ public enum L10n { extension L10n { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let languages = Bundle.preferredLanguages + // Use preferredLocalizations to get a language that is in the bundle and the user's preferred list of languages. + let languages = Bundle.overrideLocalizations ?? Bundle.app.preferredLocalizations for language in languages { if let translation = trIn(language, table, key, args) { return translation - // If we can't find a translation for this language - // we check if we can find one by stripping the region - } else if let langCode = Locale(identifier: language).language.languageCode?.identifier, - let translation = trIn(langCode, table, key, args) { - return translation - } } - return key } + return Bundle.app.developmentLocalization.flatMap { trIn($0, table, key, args) } ?? key + } private static func trIn(_ language: String, _ table: String, _ key: String, _ args: CVarArg...) -> String? { - guard let bundle = Bundle(for: BundleToken.self).lprojBundle(for: language) else { - // no translations for the desired language - return nil - } + guard let bundle = Bundle.lprojBundle(for: language) else { return nil } let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "") - return String(format: format, locale: Locale(identifier: language), arguments: args) + let translation = String(format: format, locale: Locale(identifier: language), arguments: args) + guard translation != key else { return nil } + return translation } } -private final class BundleToken {} - // swiftlint:enable all diff --git a/ElementX/Sources/Other/Extensions/Bundle.swift b/ElementX/Sources/Other/Extensions/Bundle.swift index c9b838396..9ddaa8b4a 100644 --- a/ElementX/Sources/Other/Extensions/Bundle.swift +++ b/ElementX/Sources/Other/Extensions/Bundle.swift @@ -17,43 +17,7 @@ import Foundation public extension Bundle { - private static var cachedLocalizationBundles = [String: Bundle]() - - /// Get an lproj language bundle from the receiver bundle. - /// - Parameter language: The language to try to load. - /// - Returns: The lproj bundle if found otherwise nil. - func lprojBundle(for language: String) -> Bundle? { - if let bundle = Self.cachedLocalizationBundles[language] { - return bundle - } - - guard let lprojURL = Bundle.app.url(forResource: language, withExtension: "lproj") else { - return nil - } - - let bundle = Bundle(url: lprojURL) - Self.cachedLocalizationBundles[language] = bundle - - return bundle - } - - /// Preferred app language for translations. Takes the highest priority in translations. The priority list for translations: - /// - `Bundle.elementLanguage` - /// - `Locale.preferredLanguages` - /// - `Bundle.elementFallbackLanguage` - static var elementLanguage: String? { - didSet { - preferredLanguages = calculatePreferredLanguages() - } - } - - /// Preferred fallback language for translations. Only used for strings not translated neither to `elementLanguage` nor to one of the user's preferred languages. - static var elementFallbackLanguage: String? { - didSet { - preferredLanguages = calculatePreferredLanguages() - } - } - + /// The top-level bundle that contains the entire app. static var app: Bundle { var bundle = Bundle.main if bundle.bundleURL.pathExtension == "appex" { @@ -65,16 +29,29 @@ public extension Bundle { } return bundle } - - /// Preferred languages in the priority order. - private(set) static var preferredLanguages: [String] = calculatePreferredLanguages() - - private static func calculatePreferredLanguages() -> [String] { - var set = Set() - return ([Bundle.elementLanguage] + - Locale.preferredLanguages + - [Bundle.elementFallbackLanguage]) - .compactMap { $0 } - .filter { set.insert($0).inserted } + + // MARK: - Localisation + + private static var cachedLocalizationBundles = [String: Bundle]() + + /// Get an lproj language bundle from the receiver bundle. + /// - Parameter language: The language to try to load. + /// - Returns: The lproj bundle if found otherwise nil. + static func lprojBundle(for language: String) -> Bundle? { + if let bundle = cachedLocalizationBundles[language] { + return bundle + } + + guard let lprojURL = Bundle.app.url(forResource: language, withExtension: "lproj") else { + return nil + } + + let bundle = Bundle(url: lprojURL) + cachedLocalizationBundles[language] = bundle + + return bundle } + + /// Overrides `Bundle.app.preferredLocalizations` for testing translations. + static var overrideLocalizations: [String]? } diff --git a/ElementX/Sources/Services/BugReport/BugReportService.swift b/ElementX/Sources/Services/BugReport/BugReportService.swift index 693ca2f0a..7e08e07ef 100644 --- a/ElementX/Sources/Services/BugReport/BugReportService.swift +++ b/ElementX/Sources/Services/BugReport/BugReportService.swift @@ -160,9 +160,9 @@ class BugReportService: NSObject, BugReportServiceProtocol { MultipartFormData(key: "version", type: .text(value: InfoPlistReader.main.bundleShortVersionString)), MultipartFormData(key: "build", type: .text(value: InfoPlistReader.main.bundleVersion)), MultipartFormData(key: "os", type: .text(value: os)), - MultipartFormData(key: "resolved_language", type: .text(value: Bundle.preferredLanguages[0])), - MultipartFormData(key: "user_language", type: .text(value: Bundle.elementLanguage ?? "null")), - MultipartFormData(key: "fallback_language", type: .text(value: Bundle.elementFallbackLanguage ?? "null")), + MultipartFormData(key: "resolved_languages", type: .text(value: Bundle.app.preferredLocalizations.joined(separator: ", "))), + MultipartFormData(key: "user_languages", type: .text(value: Locale.preferredLanguages.joined(separator: ", "))), + MultipartFormData(key: "fallback_language", type: .text(value: Bundle.app.developmentLocalization ?? "null")), MultipartFormData(key: "local_time", type: .text(value: localTime)), MultipartFormData(key: "utc_time", type: .text(value: utcTime)), MultipartFormData(key: "base_bundle_identifier", type: .text(value: InfoPlistReader.main.baseBundleIdentifier)) diff --git a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift index dbaf5b6bc..128870c1b 100644 --- a/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift +++ b/ElementX/Sources/Services/Notification/Manager/NotificationManager.swift @@ -102,7 +102,7 @@ class NotificationManager: NSObject, NotificationManagerProtocol { appDisplayName: "\(InfoPlistReader.main.bundleDisplayName) (iOS)", deviceDisplayName: UIDevice.current.name, profileTag: pusherProfileTag(), - lang: Bundle.preferredLanguages.first ?? "en") + lang: Bundle.app.preferredLocalizations.first ?? "en") try await clientProxy.setPusher(with: configuration) MXLog.info("[NotificationManager] set pusher succeeded") return true diff --git a/ElementX/Sources/UITests/UITestsAppCoordinator.swift b/ElementX/Sources/UITests/UITestsAppCoordinator.swift index 791090c24..3ed498f80 100644 --- a/ElementX/Sources/UITests/UITestsAppCoordinator.swift +++ b/ElementX/Sources/UITests/UITestsAppCoordinator.swift @@ -34,8 +34,6 @@ class UITestsAppCoordinator: AppCoordinatorProtocol { } func start() { - Bundle.elementFallbackLanguage = "en" - guard let screenID = Tests.screenID else { fatalError("Unable to launch with unknown screen.") } let mockScreen = MockScreen(id: screenID) diff --git a/NSE/Sources/NotificationServiceExtension.swift b/NSE/Sources/NotificationServiceExtension.swift index c5f14a81e..768f027f5 100644 --- a/NSE/Sources/NotificationServiceExtension.swift +++ b/NSE/Sources/NotificationServiceExtension.swift @@ -24,13 +24,6 @@ class NotificationServiceExtension: UNNotificationServiceExtension { var handler: ((UNNotificationContent) -> Void)? var modifiedContent: UNMutableNotificationContent? - override init() { - // Use `en` as fallback language - Bundle.elementFallbackLanguage = "en" - - super.init() - } - override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { guard !DataProtectionManager.isDeviceLockedAfterReboot(containerURL: URL.appGroupContainerDirectory), diff --git a/Tools/SwiftGen/Templates/Strings/structured-swift5-element-untranslated.stencil b/Tools/SwiftGen/Templates/Strings/structured-swift5-element-untranslated.stencil index 484009ba2..02ad4c7ab 100644 --- a/Tools/SwiftGen/Templates/Strings/structured-swift5-element-untranslated.stencil +++ b/Tools/SwiftGen/Templates/Strings/structured-swift5-element-untranslated.stencil @@ -76,17 +76,12 @@ import Foundation extension {{enumName}} { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { // No need to check languages, we always default to en for untranslated strings - guard let bundle = Bundle(for: BundleToken.self).lprojBundle(for: "en") else { - // no translations for the desired language - return key - } + guard let bundle = Bundle.lprojBundle(for: "en") else { return key } let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "") return String(format: format, locale: Locale(identifier: "en"), arguments: args) } } -private final class BundleToken {} - {% else %} // No string found {% endif %} diff --git a/Tools/SwiftGen/Templates/Strings/structured-swift5-element.stencil b/Tools/SwiftGen/Templates/Strings/structured-swift5-element.stencil index 9509480b9..6f9b11b52 100644 --- a/Tools/SwiftGen/Templates/Strings/structured-swift5-element.stencil +++ b/Tools/SwiftGen/Templates/Strings/structured-swift5-element.stencil @@ -75,33 +75,26 @@ import Foundation extension {{enumName}} { static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let languages = Bundle.preferredLanguages + // Use preferredLocalizations to get a language that is in the bundle and the user's preferred list of languages. + let languages = Bundle.overrideLocalizations ?? Bundle.app.preferredLocalizations for language in languages { if let translation = trIn(language, table, key, args) { return translation - // If we can't find a translation for this language - // we check if we can find one by stripping the region - } else if let langCode = Locale(identifier: language).language.languageCode?.identifier, - let translation = trIn(langCode, table, key, args) { - return translation - } } - return key } + return Bundle.app.developmentLocalization.flatMap { trIn($0, table, key, args) } ?? key + } private static func trIn(_ language: String, _ table: String, _ key: String, _ args: CVarArg...) -> String? { - guard let bundle = Bundle(for: BundleToken.self).lprojBundle(for: language) else { - // no translations for the desired language - return nil - } + guard let bundle = Bundle.lprojBundle(for: language) else { return nil } let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "") - return String(format: format, locale: Locale(identifier: language), arguments: args) + let translation = String(format: format, locale: Locale(identifier: language), arguments: args) + guard translation != key else { return nil } + return translation } } -private final class BundleToken {} - {% else %} // No string found {% endif %} diff --git a/UITests/Sources/Application.swift b/UITests/Sources/Application.swift index cd1f4166e..be110d40a 100644 --- a/UITests/Sources/Application.swift +++ b/UITests/Sources/Application.swift @@ -24,9 +24,6 @@ struct Application { "UI_TESTS_SCREEN": identifier.rawValue ] - // Use the same fallback language as the real app so translation comparison works - Bundle.elementFallbackLanguage = "en" - app.launch() return app } diff --git a/UnitTests/Sources/LocalizationTests.swift b/UnitTests/Sources/LocalizationTests.swift index 50b4082c4..7e0efcd9a 100644 --- a/UnitTests/Sources/LocalizationTests.swift +++ b/UnitTests/Sources/LocalizationTests.swift @@ -20,21 +20,13 @@ import XCTest class LocalizationTests: XCTestCase { /// Test ElementL10n considers app language changes func testAppLanguage() { - // set app language to English - Bundle.elementLanguage = "en" + // set app language to English + Bundle.overrideLocalizations = ["en"] XCTAssertEqual(L10n.testLanguageIdentifier, "en") - // set app language to Italian - Bundle.elementLanguage = "it" - - XCTAssertEqual(L10n.testLanguageIdentifier, "it") - } - - /// Test fallback language for a language not supported at all - func testStripRegionIfRegionalTranslationIsNotAvailable() { - // set app language to something that includes also a region (it-IT) - Bundle.elementLanguage = "it-IT" + // set app language to Italian + Bundle.overrideLocalizations = ["it"] XCTAssertEqual(L10n.testLanguageIdentifier, "it") } @@ -42,8 +34,7 @@ class LocalizationTests: XCTestCase { /// Test fallback language for a language not supported at all func testFallbackOnNotSupportedLanguage() { // set app language to something Element don't support at all (chose non existing identifier) - Bundle.elementLanguage = "xx" - Bundle.elementFallbackLanguage = "en" + Bundle.overrideLocalizations = ["xx"] XCTAssertEqual(L10n.testLanguageIdentifier, "en") } @@ -51,8 +42,7 @@ class LocalizationTests: XCTestCase { /// Test fallback language for a language supported but poorly translated func testFallbackOnNotTranslatedKey() { // set app language to something Element supports but use a key that is not translated (we have a key that should never be translated) - Bundle.elementLanguage = "it" - Bundle.elementFallbackLanguage = "en" + Bundle.overrideLocalizations = ["it"] XCTAssertEqual(L10n.testLanguageIdentifier, "it") XCTAssertEqual(L10n.testUntranslatedDefaultLanguageIdentifier, "en") @@ -61,19 +51,19 @@ class LocalizationTests: XCTestCase { /// Test plurals that ElementL10n considers app language changes func testPlurals() { // set app language to English - Bundle.elementLanguage = "en" + Bundle.overrideLocalizations = ["en"] XCTAssertEqual(L10n.commonMemberCount(1), "1 member") XCTAssertEqual(L10n.commonMemberCount(2), "2 members") // set app language to Italian - Bundle.elementLanguage = "it" + Bundle.overrideLocalizations = ["it"] XCTAssertEqual(L10n.commonMemberCount(1), "1 membro") XCTAssertEqual(L10n.commonMemberCount(2), "2 membri") // // set app language to Polish -// Bundle.elementLanguage = "pl" +// Bundle.overrideLocalizations = ["pl"] // // XCTAssertEqual(L10n.commonMemberCount(1), "1 sekunda") // one // XCTAssertEqual(L10n.commonMemberCount(2), "2 sekundy") // few @@ -83,8 +73,7 @@ class LocalizationTests: XCTestCase { /// Test plurals fallback language for a language not supported at all func testPluralsFallbackOnNotSupportedLanguage() { // set app language to something Element don't support at all ("invalid identifier") - Bundle.elementLanguage = "xx" - Bundle.elementFallbackLanguage = "en" + Bundle.overrideLocalizations = ["xx"] XCTAssertEqual(L10n.commonMemberCount(1), "1 member") XCTAssertEqual(L10n.commonMemberCount(2), "2 members") diff --git a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift index 82cfab981..5e0067e5e 100644 --- a/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift +++ b/UnitTests/Sources/NotificationManager/NotificationManagerTests.swift @@ -63,7 +63,7 @@ final class NotificationManagerTests: XCTestCase { XCTAssertEqual(clientProxy.setPusherArgument?.appDisplayName, "\(InfoPlistReader.main.bundleDisplayName) (iOS)") XCTAssertEqual(clientProxy.setPusherArgument?.deviceDisplayName, UIDevice.current.name) XCTAssertNotNil(clientProxy.setPusherArgument?.profileTag) - XCTAssertEqual(clientProxy.setPusherArgument?.lang, Bundle.preferredLanguages.first) + XCTAssertEqual(clientProxy.setPusherArgument?.lang, Bundle.app.preferredLocalizations.first) guard case let .http(data) = clientProxy.setPusherArgument?.kind else { XCTFail("Http kind expected") return diff --git a/changelog.d/pr-803.change b/changelog.d/pr-803.change new file mode 100644 index 000000000..543728c6f --- /dev/null +++ b/changelog.d/pr-803.change @@ -0,0 +1 @@ +Use iOS localization handling for strings. \ No newline at end of file