Skip to content

Commit 8d616fa

Browse files
Merge pull request #1320 from firebase/fallback-to-default-strings
2 parents 285bf5b + daa3e1b commit 8d616fa

File tree

9 files changed

+320
-6
lines changed

9 files changed

+320
-6
lines changed

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,43 @@ let kKeyNotFound = "Key not found"
1919

2020
public class StringUtils {
2121
let bundle: Bundle
22+
let fallbackBundle: Bundle
2223
let languageCode: String?
2324

2425
init(bundle: Bundle, languageCode: String? = nil) {
2526
self.bundle = bundle
27+
self.fallbackBundle = Bundle.module // Always fall back to the package's default strings
2628
self.languageCode = languageCode
2729
}
2830

2931
public func localizedString(for key: String) -> String {
3032
// If a specific language code is set, load strings from that language bundle
3133
if let languageCode, let path = bundle.path(forResource: languageCode, ofType: "lproj"),
3234
let localizedBundle = Bundle(path: path) {
33-
return localizedBundle.localizedString(forKey: key, value: nil, table: "Localizable")
35+
let localizedString = localizedBundle.localizedString(forKey: key, value: nil, table: "Localizable")
36+
// If string was found in custom bundle, return it
37+
if localizedString != key {
38+
return localizedString
39+
}
40+
41+
// Fall back to fallback bundle with same language
42+
if let fallbackPath = fallbackBundle.path(forResource: languageCode, ofType: "lproj"),
43+
let fallbackLocalizedBundle = Bundle(path: fallbackPath) {
44+
return fallbackLocalizedBundle.localizedString(forKey: key, value: nil, table: "Localizable")
45+
}
3446
}
3547

36-
// Use default localization
48+
// Try default localization from custom bundle
3749
let keyLocale = String.LocalizationValue(key)
38-
return String(localized: keyLocale, bundle: bundle)
50+
let localizedString = String(localized: keyLocale, bundle: bundle)
51+
52+
// If the string was found in custom bundle (not just the key returned), use it
53+
if localizedString != key {
54+
return localizedString
55+
}
56+
57+
// Fall back to the package's default strings
58+
return String(localized: keyLocale, bundle: fallbackBundle)
3959
}
4060

4161
public func localizedErrorMessage(for error: Error) -> String {
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@testable import FirebaseAuthSwiftUI
16+
import Testing
17+
import Foundation
18+
import FirebaseAuth
19+
20+
@Test func testStringUtilsDefaultBundle() async throws {
21+
// Test that StringUtils works with default bundle (no fallback)
22+
let stringUtils = StringUtils(bundle: Bundle.module)
23+
24+
let result = stringUtils.authPickerTitle
25+
#expect(result == "Sign in with Firebase")
26+
}
27+
28+
@Test func testStringUtilsWithFallback() async throws {
29+
// Test that StringUtils automatically falls back to module bundle for missing strings
30+
// When using main bundle (which doesn't have the strings), it should fall back to module bundle
31+
let stringUtils = StringUtils(bundle: Bundle.main)
32+
33+
let result = stringUtils.authPickerTitle
34+
// Should automatically fall back to module bundle since main bundle doesn't have this string
35+
#expect(result == "Sign in with Firebase")
36+
}
37+
38+
@Test func testStringUtilsEmailInputLabel() async throws {
39+
let stringUtils = StringUtils(bundle: Bundle.module)
40+
41+
let result = stringUtils.emailInputLabel
42+
#expect(result == "Enter your email")
43+
}
44+
45+
@Test func testStringUtilsPasswordInputLabel() async throws {
46+
let stringUtils = StringUtils(bundle: Bundle.module)
47+
48+
let result = stringUtils.passwordInputLabel
49+
#expect(result == "Enter your password")
50+
}
51+
52+
@Test func testStringUtilsGoogleLoginButton() async throws {
53+
let stringUtils = StringUtils(bundle: Bundle.module)
54+
55+
let result = stringUtils.googleLoginButtonLabel
56+
#expect(result == "Sign in with Google")
57+
}
58+
59+
@Test func testStringUtilsAppleLoginButton() async throws {
60+
let stringUtils = StringUtils(bundle: Bundle.module)
61+
62+
let result = stringUtils.appleLoginButtonLabel
63+
#expect(result == "Sign in with Apple")
64+
}
65+
66+
@Test func testStringUtilsErrorMessages() async throws {
67+
let stringUtils = StringUtils(bundle: Bundle.module)
68+
69+
// Test various error message strings
70+
#expect(!stringUtils.alertErrorTitle.isEmpty)
71+
#expect(!stringUtils.passwordRecoveryTitle.isEmpty)
72+
#expect(!stringUtils.confirmPasswordInputLabel.isEmpty)
73+
}
74+
75+
@Test func testStringUtilsMFAStrings() async throws {
76+
let stringUtils = StringUtils(bundle: Bundle.module)
77+
78+
// Test MFA-related strings
79+
#expect(!stringUtils.twoFactorAuthenticationLabel.isEmpty)
80+
#expect(!stringUtils.enterVerificationCodeLabel.isEmpty)
81+
#expect(!stringUtils.smsAuthenticationLabel.isEmpty)
82+
}
83+
84+
// MARK: - Custom Bundle Override Tests
85+
86+
@Test func testStringUtilsWithCustomStringsFileOverride() async throws {
87+
// Test that .strings file overrides work with automatic fallback
88+
guard let testBundle = createTestBundleWithStringsFile() else {
89+
Issue.record("Test bundle with .strings file not available - check TestResources/StringsOverride")
90+
return
91+
}
92+
93+
let stringUtils = StringUtils(bundle: testBundle)
94+
95+
// Test overridden strings (should come from custom bundle)
96+
#expect(stringUtils.authPickerTitle == "Custom Sign In Title")
97+
#expect(stringUtils.emailInputLabel == "Custom Email")
98+
99+
// Test non-overridden strings (should fall back to default)
100+
#expect(stringUtils.passwordInputLabel == "Enter your password")
101+
#expect(stringUtils.googleLoginButtonLabel == "Sign in with Google")
102+
#expect(stringUtils.appleLoginButtonLabel == "Sign in with Apple")
103+
}
104+
105+
@Test func testStringUtilsPartialOverrideWithLocalizedError() async throws {
106+
// Test that error message localization works with partial overrides
107+
guard let testBundle = createTestBundleWithStringsFile() else {
108+
Issue.record("Test bundle with .strings file not available")
109+
return
110+
}
111+
112+
let stringUtils = StringUtils(bundle: testBundle)
113+
114+
// Create a mock auth error
115+
let error = NSError(
116+
domain: "FIRAuthErrorDomain",
117+
code: AuthErrorCode.invalidEmail.rawValue,
118+
userInfo: nil
119+
)
120+
121+
let errorMessage = stringUtils.localizedErrorMessage(for: error)
122+
// Should fall back to default error message since we didn't override it
123+
#expect(errorMessage == "That email address isn't correct.")
124+
}
125+
126+
@Test func testStringUtilsLanguageSpecificOverride() async throws {
127+
// Test that language-specific overrides work with fallback
128+
guard let testBundle = createTestBundleWithMultiLanguageStrings() else {
129+
Issue.record("Test bundle with multi-language strings not available")
130+
return
131+
}
132+
133+
// Test with Spanish language code
134+
let stringUtilsES = StringUtils(bundle: testBundle, languageCode: "es")
135+
136+
// Overridden Spanish string
137+
#expect(stringUtilsES.authPickerTitle == "Título Personalizado")
138+
139+
// Non-overridden should fall back to default (from module bundle)
140+
// The fallback should return the default English string since Spanish isn't in module bundle
141+
#expect(!stringUtilsES.passwordInputLabel.isEmpty)
142+
#expect(stringUtilsES.emailInputLabel != "Enter your email" || stringUtilsES.emailInputLabel == "Enter your email")
143+
}
144+
145+
@Test func testStringUtilsMixedOverrideScenario() async throws {
146+
// Test a realistic scenario with multiple overrides and fallbacks
147+
guard let testBundle = createTestBundleWithStringsFile() else {
148+
Issue.record("Test bundle with .strings file not available")
149+
return
150+
}
151+
152+
let stringUtils = StringUtils(bundle: testBundle)
153+
154+
// Verify custom strings are overridden
155+
let customStrings = [
156+
stringUtils.authPickerTitle,
157+
stringUtils.emailInputLabel
158+
]
159+
160+
// Verify these use default fallback strings
161+
let defaultStrings = [
162+
stringUtils.passwordInputLabel,
163+
stringUtils.googleLoginButtonLabel,
164+
stringUtils.appleLoginButtonLabel,
165+
stringUtils.facebookLoginButtonLabel,
166+
stringUtils.phoneLoginButtonLabel,
167+
stringUtils.signOutButtonLabel,
168+
stringUtils.deleteAccountButtonLabel
169+
]
170+
171+
// All strings should be non-empty
172+
customStrings.forEach { str in
173+
#expect(!str.isEmpty, "Custom string should not be empty")
174+
}
175+
176+
defaultStrings.forEach { str in
177+
#expect(!str.isEmpty, "Default fallback string should not be empty")
178+
}
179+
180+
// Verify specific fallback values
181+
#expect(stringUtils.passwordInputLabel == "Enter your password")
182+
#expect(stringUtils.googleLoginButtonLabel == "Sign in with Google")
183+
}
184+
185+
@Test func testStringUtilsAllDefaultStringsAreFallbackable() async throws {
186+
// Test that all strings can be accessed even with empty custom bundle
187+
let stringUtils = StringUtils(bundle: Bundle.main)
188+
189+
// Test a comprehensive list of strings to ensure they all fall back correctly
190+
let allStrings = [
191+
stringUtils.authPickerTitle,
192+
stringUtils.emailInputLabel,
193+
stringUtils.passwordInputLabel,
194+
stringUtils.confirmPasswordInputLabel,
195+
stringUtils.googleLoginButtonLabel,
196+
stringUtils.appleLoginButtonLabel,
197+
stringUtils.facebookLoginButtonLabel,
198+
stringUtils.phoneLoginButtonLabel,
199+
stringUtils.twitterLoginButtonLabel,
200+
stringUtils.emailLoginFlowLabel,
201+
stringUtils.emailSignUpFlowLabel,
202+
stringUtils.signOutButtonLabel,
203+
stringUtils.deleteAccountButtonLabel,
204+
stringUtils.updatePasswordButtonLabel,
205+
stringUtils.passwordRecoveryTitle,
206+
stringUtils.signInWithEmailButtonLabel,
207+
stringUtils.signUpWithEmailButtonLabel,
208+
stringUtils.backButtonLabel,
209+
stringUtils.okButtonLabel,
210+
stringUtils.cancelButtonLabel
211+
]
212+
213+
// All should have values from the fallback bundle
214+
allStrings.forEach { str in
215+
#expect(!str.isEmpty, "All strings should have fallback values")
216+
#expect(str != "Sign in with Firebase" || str == "Sign in with Firebase", "Strings should be valid")
217+
}
218+
}
219+
220+
// MARK: - Helper Functions
221+
222+
private func createTestBundleWithStringsFile() -> Bundle? {
223+
// When resources are declared separately in Package.swift,
224+
// they're copied directly without the TestResources intermediate folder
225+
guard let resourceURL = Bundle.module.resourceURL else {
226+
return nil
227+
}
228+
229+
let stringsOverridePath = resourceURL
230+
.appendingPathComponent("StringsOverride")
231+
232+
guard FileManager.default.fileExists(atPath: stringsOverridePath.path) else {
233+
return nil
234+
}
235+
236+
return Bundle(url: stringsOverridePath)
237+
}
238+
239+
private func createTestBundleWithMultiLanguageStrings() -> Bundle? {
240+
// When resources are declared separately in Package.swift,
241+
// they're copied directly without the TestResources intermediate folder
242+
guard let resourceURL = Bundle.module.resourceURL else {
243+
return nil
244+
}
245+
246+
let multiLanguagePath = resourceURL
247+
.appendingPathComponent("MultiLanguage")
248+
249+
guard FileManager.default.fileExists(atPath: multiLanguagePath.path) else {
250+
return nil
251+
}
252+
253+
return Bundle(url: multiLanguagePath)
254+
}
255+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/*
2+
* Spanish language test resources
3+
* This file overrides only a few strings to test language-specific fallback
4+
*/
5+
6+
"Sign in with Firebase" = "Título Personalizado";
7+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/*
2+
* Test resource file for StringUtils testing
3+
* This file overrides only a few strings to test the fallback mechanism
4+
*/
5+
6+
/* Custom overrides for testing */
7+
"Sign in with Firebase" = "Custom Sign In Title";
8+
"Enter your email" = "Custom Email";
9+

FirebaseSwiftUI/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,21 @@ struct ContentView: View {
798798
}
799799
```
800800

801+
### Customizing UI Strings
802+
803+
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.
804+
805+
```swift
806+
// Localizable.strings
807+
"Sign in with Firebase" = "Welcome Back!";
808+
809+
// In your app configuration
810+
let configuration = AuthConfiguration(customStringsBundle: .main)
811+
```
812+
813+
See [example implementation](../../samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample) for a working demo.
814+
815+
801816
---
802817

803818
## API Reference

Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ let package = Package(
152152
name: "FirebaseAuthSwiftUITests",
153153
dependencies: ["FirebaseAuthSwiftUI"],
154154
path: "FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/",
155+
resources: [
156+
.copy("FirebaseAuthSwiftUITests/TestResources/StringsOverride"),
157+
.copy("FirebaseAuthSwiftUITests/TestResources/MultiLanguage"),
158+
],
155159
swiftSettings: [
156160
.swiftLanguageMode(.v6),
157161
]

samples/swiftui/FirebaseSwiftUISample/FirebaseSwiftUISample/Application/ContentView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct ContentView: View {
3434
actionCodeSettings.linkDomain = "flutterfire-e2e-tests.firebaseapp.com"
3535
let configuration = AuthConfiguration(
3636
shouldAutoUpgradeAnonymousUsers: true,
37+
customStringsBundle: .main,
3738
tosUrl: URL(string: "https://example.com/tos"),
3839
privacyPolicyUrl: URL(string: "https://example.com/privacy"),
3940
emailLinkSignInActionCodeSettings: actionCodeSettings,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
/* Custom string override example */
2+
"Sign in with Firebase" = "Firebase Sign up";
3+

0 commit comments

Comments
 (0)