Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,43 @@ 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
}

public func localizedString(for key: String) -> String {
// 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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}

Original file line number Diff line number Diff line change
@@ -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";

Original file line number Diff line number Diff line change
@@ -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";

15 changes: 15 additions & 0 deletions FirebaseSwiftUI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/* Custom string override example */
"Sign in with Firebase" = "Firebase Sign up";

Loading