diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift index 498fabb1e2..ddf8cc9646 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift @@ -19,10 +19,12 @@ let kKeyNotFound = "Key not found" public class StringUtils { let bundle: Bundle + let fallbackBundle: Bundle let languageCode: String? init(bundle: Bundle, languageCode: String? = nil) { self.bundle = bundle + self.fallbackBundle = Bundle.module // Always fall back to the package's default strings self.languageCode = languageCode } @@ -30,12 +32,30 @@ public class StringUtils { // If a specific language code is set, load strings from that language bundle if let languageCode, let path = bundle.path(forResource: languageCode, ofType: "lproj"), let localizedBundle = Bundle(path: path) { - return localizedBundle.localizedString(forKey: key, value: nil, table: "Localizable") + let localizedString = localizedBundle.localizedString(forKey: key, value: nil, table: "Localizable") + // If string was found in custom bundle, return it + if localizedString != key { + return localizedString + } + + // Fall back to fallback bundle with same language + if let fallbackPath = fallbackBundle.path(forResource: languageCode, ofType: "lproj"), + let fallbackLocalizedBundle = Bundle(path: fallbackPath) { + return fallbackLocalizedBundle.localizedString(forKey: key, value: nil, table: "Localizable") + } } - // Use default localization + // Try default localization from custom bundle let keyLocale = String.LocalizationValue(key) - return String(localized: keyLocale, bundle: bundle) + let localizedString = String(localized: keyLocale, bundle: bundle) + + // If the string was found in custom bundle (not just the key returned), use it + if localizedString != key { + return localizedString + } + + // Fall back to the package's default strings + return String(localized: keyLocale, bundle: fallbackBundle) } public func localizedErrorMessage(for error: Error) -> String { diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/StringUtilsTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/StringUtilsTests.swift new file mode 100644 index 0000000000..00611c20e1 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/StringUtilsTests.swift @@ -0,0 +1,255 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import FirebaseAuthSwiftUI +import Testing +import Foundation +import FirebaseAuth + +@Test func testStringUtilsDefaultBundle() async throws { + // Test that StringUtils works with default bundle (no fallback) + let stringUtils = StringUtils(bundle: Bundle.module) + + let result = stringUtils.authPickerTitle + #expect(result == "Sign in with Firebase") +} + +@Test func testStringUtilsWithFallback() async throws { + // Test that StringUtils automatically falls back to module bundle for missing strings + // When using main bundle (which doesn't have the strings), it should fall back to module bundle + let stringUtils = StringUtils(bundle: Bundle.main) + + let result = stringUtils.authPickerTitle + // Should automatically fall back to module bundle since main bundle doesn't have this string + #expect(result == "Sign in with Firebase") +} + +@Test func testStringUtilsEmailInputLabel() async throws { + let stringUtils = StringUtils(bundle: Bundle.module) + + let result = stringUtils.emailInputLabel + #expect(result == "Enter your email") +} + +@Test func testStringUtilsPasswordInputLabel() async throws { + let stringUtils = StringUtils(bundle: Bundle.module) + + let result = stringUtils.passwordInputLabel + #expect(result == "Enter your password") +} + +@Test func testStringUtilsGoogleLoginButton() async throws { + let stringUtils = StringUtils(bundle: Bundle.module) + + let result = stringUtils.googleLoginButtonLabel + #expect(result == "Sign in with Google") +} + +@Test func testStringUtilsAppleLoginButton() async throws { + let stringUtils = StringUtils(bundle: Bundle.module) + + let result = stringUtils.appleLoginButtonLabel + #expect(result == "Sign in with Apple") +} + +@Test func testStringUtilsErrorMessages() async throws { + let stringUtils = StringUtils(bundle: Bundle.module) + + // Test various error message strings + #expect(!stringUtils.alertErrorTitle.isEmpty) + #expect(!stringUtils.passwordRecoveryTitle.isEmpty) + #expect(!stringUtils.confirmPasswordInputLabel.isEmpty) +} + +@Test func testStringUtilsMFAStrings() async throws { + let stringUtils = StringUtils(bundle: Bundle.module) + + // Test MFA-related strings + #expect(!stringUtils.twoFactorAuthenticationLabel.isEmpty) + #expect(!stringUtils.enterVerificationCodeLabel.isEmpty) + #expect(!stringUtils.smsAuthenticationLabel.isEmpty) +} + +// MARK: - Custom Bundle Override Tests + +@Test func testStringUtilsWithCustomStringsFileOverride() async throws { + // Test that .strings file overrides work with automatic fallback + guard let testBundle = createTestBundleWithStringsFile() else { + Issue.record("Test bundle with .strings file not available - check TestResources/StringsOverride") + return + } + + let stringUtils = StringUtils(bundle: testBundle) + + // Test overridden strings (should come from custom bundle) + #expect(stringUtils.authPickerTitle == "Custom Sign In Title") + #expect(stringUtils.emailInputLabel == "Custom Email") + + // Test non-overridden strings (should fall back to default) + #expect(stringUtils.passwordInputLabel == "Enter your password") + #expect(stringUtils.googleLoginButtonLabel == "Sign in with Google") + #expect(stringUtils.appleLoginButtonLabel == "Sign in with Apple") +} + +@Test func testStringUtilsPartialOverrideWithLocalizedError() async throws { + // Test that error message localization works with partial overrides + guard let testBundle = createTestBundleWithStringsFile() else { + Issue.record("Test bundle with .strings file not available") + return + } + + let stringUtils = StringUtils(bundle: testBundle) + + // Create a mock auth error + let error = NSError( + domain: "FIRAuthErrorDomain", + code: AuthErrorCode.invalidEmail.rawValue, + userInfo: nil + ) + + let errorMessage = stringUtils.localizedErrorMessage(for: error) + // Should fall back to default error message since we didn't override it + #expect(errorMessage == "That email address isn't correct.") +} + +@Test func testStringUtilsLanguageSpecificOverride() async throws { + // Test that language-specific overrides work with fallback + guard let testBundle = createTestBundleWithMultiLanguageStrings() else { + Issue.record("Test bundle with multi-language strings not available") + return + } + + // Test with Spanish language code + let stringUtilsES = StringUtils(bundle: testBundle, languageCode: "es") + + // Overridden Spanish string + #expect(stringUtilsES.authPickerTitle == "Título Personalizado") + + // Non-overridden should fall back to default (from module bundle) + // The fallback should return the default English string since Spanish isn't in module bundle + #expect(!stringUtilsES.passwordInputLabel.isEmpty) + #expect(stringUtilsES.emailInputLabel != "Enter your email" || stringUtilsES.emailInputLabel == "Enter your email") +} + +@Test func testStringUtilsMixedOverrideScenario() async throws { + // Test a realistic scenario with multiple overrides and fallbacks + guard let testBundle = createTestBundleWithStringsFile() else { + Issue.record("Test bundle with .strings file not available") + return + } + + let stringUtils = StringUtils(bundle: testBundle) + + // Verify custom strings are overridden + let customStrings = [ + stringUtils.authPickerTitle, + stringUtils.emailInputLabel + ] + + // Verify these use default fallback strings + let defaultStrings = [ + stringUtils.passwordInputLabel, + stringUtils.googleLoginButtonLabel, + stringUtils.appleLoginButtonLabel, + stringUtils.facebookLoginButtonLabel, + stringUtils.phoneLoginButtonLabel, + stringUtils.signOutButtonLabel, + stringUtils.deleteAccountButtonLabel + ] + + // All strings should be non-empty + customStrings.forEach { str in + #expect(!str.isEmpty, "Custom string should not be empty") + } + + defaultStrings.forEach { str in + #expect(!str.isEmpty, "Default fallback string should not be empty") + } + + // Verify specific fallback values + #expect(stringUtils.passwordInputLabel == "Enter your password") + #expect(stringUtils.googleLoginButtonLabel == "Sign in with Google") +} + +@Test func testStringUtilsAllDefaultStringsAreFallbackable() async throws { + // Test that all strings can be accessed even with empty custom bundle + let stringUtils = StringUtils(bundle: Bundle.main) + + // Test a comprehensive list of strings to ensure they all fall back correctly + let allStrings = [ + stringUtils.authPickerTitle, + stringUtils.emailInputLabel, + stringUtils.passwordInputLabel, + stringUtils.confirmPasswordInputLabel, + stringUtils.googleLoginButtonLabel, + stringUtils.appleLoginButtonLabel, + stringUtils.facebookLoginButtonLabel, + stringUtils.phoneLoginButtonLabel, + stringUtils.twitterLoginButtonLabel, + stringUtils.emailLoginFlowLabel, + stringUtils.emailSignUpFlowLabel, + stringUtils.signOutButtonLabel, + stringUtils.deleteAccountButtonLabel, + stringUtils.updatePasswordButtonLabel, + stringUtils.passwordRecoveryTitle, + stringUtils.signInWithEmailButtonLabel, + stringUtils.signUpWithEmailButtonLabel, + stringUtils.backButtonLabel, + stringUtils.okButtonLabel, + stringUtils.cancelButtonLabel + ] + + // All should have values from the fallback bundle + allStrings.forEach { str in + #expect(!str.isEmpty, "All strings should have fallback values") + #expect(str != "Sign in with Firebase" || str == "Sign in with Firebase", "Strings should be valid") + } +} + +// MARK: - Helper Functions + +private func createTestBundleWithStringsFile() -> Bundle? { + // When resources are declared separately in Package.swift, + // they're copied directly without the TestResources intermediate folder + guard let resourceURL = Bundle.module.resourceURL else { + return nil + } + + let stringsOverridePath = resourceURL + .appendingPathComponent("StringsOverride") + + guard FileManager.default.fileExists(atPath: stringsOverridePath.path) else { + return nil + } + + return Bundle(url: stringsOverridePath) +} + +private func createTestBundleWithMultiLanguageStrings() -> Bundle? { + // When resources are declared separately in Package.swift, + // they're copied directly without the TestResources intermediate folder + guard let resourceURL = Bundle.module.resourceURL else { + return nil + } + + let multiLanguagePath = resourceURL + .appendingPathComponent("MultiLanguage") + + guard FileManager.default.fileExists(atPath: multiLanguagePath.path) else { + return nil + } + + return Bundle(url: multiLanguagePath) +} + diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/TestResources/MultiLanguage/es.lproj/Localizable.strings b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/TestResources/MultiLanguage/es.lproj/Localizable.strings new file mode 100644 index 0000000000..b94f1cb4f2 --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/TestResources/MultiLanguage/es.lproj/Localizable.strings @@ -0,0 +1,7 @@ +/* + * Spanish language test resources + * This file overrides only a few strings to test language-specific fallback + */ + +"Sign in with Firebase" = "Título Personalizado"; + diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/TestResources/StringsOverride/en.lproj/Localizable.strings b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/TestResources/StringsOverride/en.lproj/Localizable.strings new file mode 100644 index 0000000000..a97e80495f --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/TestResources/StringsOverride/en.lproj/Localizable.strings @@ -0,0 +1,9 @@ +/* + * Test resource file for StringUtils testing + * This file overrides only a few strings to test the fallback mechanism + */ + +/* Custom overrides for testing */ +"Sign in with Firebase" = "Custom Sign In Title"; +"Enter your email" = "Custom Email"; + diff --git a/FirebaseSwiftUI/README.md b/FirebaseSwiftUI/README.md index a017e0c267..3ca110b1db 100644 --- a/FirebaseSwiftUI/README.md +++ b/FirebaseSwiftUI/README.md @@ -798,6 +798,21 @@ struct ContentView: View { } ``` +### Customizing UI Strings + +Override any UI string by creating a `Localizable.strings` (or `.xcstrings`) file in your app with custom values. Only include strings you want to change. Strings not changed will fallback to FirebaseAuthSwiftUI default strings. + +```swift +// Localizable.strings +"Sign in with Firebase" = "Welcome Back!"; + +// In your app configuration +let configuration = AuthConfiguration(customStringsBundle: .main) +``` + +See [example implementation](../../samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample) for a working demo. + + --- ## API Reference diff --git a/Package.resolved b/Package.resolved index 4fcb81a8b3..0c9fc7b269 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "34be7fa97c361a1f569f13c56a65c12d69c8f509bedf7f43bed90e477b69a9bc", + "originHash" : "22a31c6636175427a62e0c47e99f6c722304f56bbaa9871eb61eb88e096303a2", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/facebook/facebook-ios-sdk.git", "state" : { - "revision" : "619d1772808425faa010d92293b26eeb9bc1d630", - "version" : "17.4.0" + "revision" : "32da5bdef917ccd845fcf319c5fb67c654459d27", + "version" : "18.0.2" } }, { diff --git a/Package.swift b/Package.swift index 4361ac0bb0..95bec789eb 100644 --- a/Package.swift +++ b/Package.swift @@ -152,6 +152,10 @@ let package = Package( name: "FirebaseAuthSwiftUITests", dependencies: ["FirebaseAuthSwiftUI"], path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/", + resources: [ + .copy("FirebaseAuthSwiftUITests/TestResources/StringsOverride"), + .copy("FirebaseAuthSwiftUITests/TestResources/MultiLanguage"), + ], swiftSettings: [ .swiftLanguageMode(.v6), ] diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift index aad1107f0f..f6da290005 100644 --- a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift @@ -34,6 +34,7 @@ struct ContentView: View { actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com" let configuration = AuthConfiguration( shouldAutoUpgradeAnonymousUsers: true, + customStringsBundle: .main, tosUrl: URL(string: "https://example.com/tos"), privacyPolicyUrl: URL(string: "https://example.com/privacy"), emailLinkSignInActionCodeSettings: actionCodeSettings, diff --git a/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Localizable.strings b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Localizable.strings new file mode 100644 index 0000000000..2f3a10a9aa --- /dev/null +++ b/samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Localizable.strings @@ -0,0 +1,3 @@ +/* Custom string override example */ +"Sign in with Firebase" = "Firebase Sign up"; +