From cd0aa5f3e31047dba89fc8b555541b5bd763a4e3 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 17 Sep 2026 12:28:32 -0700 Subject: [PATCH 1/8] fix: [SDK-5202] match Chinese IAM language variants Co-authored-by: Cursor --- .../Controller/OSInAppMessageController.m | 21 ++++---- .../IAMIntegrationTests.swift | 31 +++++++++++ ...SignalInAppMessagesTests-Bridging-Header.h | 1 + .../Source/OSPropertiesModel.swift | 53 +++++++++++++++---- .../OneSignalUserTests.swift | 20 +++++++ 5 files changed, 107 insertions(+), 19 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m index 4609ea4d97..e3fb4f4e5f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m @@ -69,21 +69,24 @@ variant over lower platforms (ie. 'all') even if they have a matching language. */ - (NSString * _Nullable)variantId { - // we only want the first two characters, ie. "en-US" we want "en" - NSString *userLanguageCode = [OneSignalUserManagerImpl.sharedInstance.language substringToIndex:2]; + NSString *userLanguage = OneSignalUserManagerImpl.sharedInstance.language; + NSString *baseLanguage = [[userLanguage componentsSeparatedByString:@"-"] firstObject]; NSString *variantId; for (NSString *type in PREFERRED_VARIANT_ORDER) { - if (self.variants[type]) { - if (self.variants[type][userLanguageCode]) { - variantId = self.variants[type][userLanguageCode]; - break; + NSDictionary *languageVariants = self.variants[type]; + if (languageVariants) { + if (userLanguage) { + variantId = languageVariants[userLanguage]; + } + + if (!variantId && baseLanguage && ![baseLanguage isEqualToString:userLanguage]) { + variantId = languageVariants[baseLanguage]; } - if (!variantId && self.variants[type][@"default"]) { - variantId = self.variants[type][@"default"]; - break; + if (!variantId) { + variantId = languageVariants[@"default"]; } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 25f2bc264f..ff466300f9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -50,6 +50,37 @@ final class IAMIntegrationTests: XCTestCase { override func tearDownWithError() throws { } + func testLanguageVariantSelection() throws { + OneSignalIdentifiers.currentAppId = "test-app-id" + _ = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: testOneSignalId) + + var messageJson = IAMTestHelpers.testDefaultMessageJson() + messageJson["variants"] = [ + "ios": [ + "default": "default-variant", + "en": "english-variant", + "zh-Hans": "simplified-chinese-variant", + "zh-Hant": "traditional-chinese-variant" + ], + "all": [ + "zh-Hans": "lower-priority-variant" + ] + ] + let message = try XCTUnwrap(OSInAppMessageInternal.instance(withJson: messageJson)) + + let testCases = [ + ("zh-Hans", "simplified-chinese-variant"), + ("zh-Hant", "traditional-chinese-variant"), + ("en-US", "english-variant"), + ("kl", "default-variant") + ] + + for (language, expectedVariant) in testCases { + OneSignalUserManagerImpl.sharedInstance.setLanguage(language) + XCTAssertEqual(message.variantId(), expectedVariant, language) + } + } + /** Test IAMs should display even when IAMs are paused. */ diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h index 813316635e..181d5fbd0b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h @@ -3,6 +3,7 @@ // #import "OSInAppMessageInternal.h" +#import "OSInAppMessageController.h" #import "OSMessagingController.h" #import "OSInAppMessagingRequests.h" diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift index 0b966f7a3f..08667b8d4e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift @@ -29,6 +29,48 @@ import Foundation import OneSignalOSCore import OneSignalCore +final class OSLanguageProviderDevice { + private let localeProvider: () -> Locale + + init(localeProvider: @escaping () -> Locale = { + Locale(identifier: NSLocale.preferredLanguages.first ?? DEFAULT_LANGUAGE) + }) { + self.localeProvider = localeProvider + } + + var language: String { + let locale = localeProvider() + guard let languageCode = locale.languageCode else { + return DEFAULT_LANGUAGE + } + + switch languageCode { + case "iw": + return "he" + case "in": + return "id" + case "ji": + return "yi" + case "zh": + return chineseLanguage(locale) + default: + return languageCode + } + } + + private func chineseLanguage(_ locale: Locale) -> String { + switch locale.scriptCode { + case "Hans": + return "zh-Hans" + case "Hant": + return "zh-Hant" + default: + let isTraditionalRegion = locale.regionCode.map { ["HK", "MO", "TW"].contains($0) } ?? false + return isTraditionalRegion ? "zh-Hant" : "zh-Hans" + } + } +} + // Both lat and long must exist to be accepted by the server class OSLocationPoint: NSObject, NSCoding { let lat: Float @@ -80,16 +122,7 @@ class OSPropertiesModel: OSModel { // We seem to lose access to this init() in superclass after adding init?(coder: NSCoder) override init(changeNotifier: OSEventProducer) { super.init(changeNotifier: changeNotifier) - self.language = getPreferredLanguage() - } - - private func getPreferredLanguage() -> String { - let preferredLanguages = NSLocale.preferredLanguages - if !preferredLanguages.isEmpty { - return preferredLanguages[0] - } else { - return DEFAULT_LANGUAGE - } + self.language = OSLanguageProviderDevice().language } override func encode(with coder: NSCoder) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index bc0c7dd822..07d79da3e4 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -48,6 +48,26 @@ final class OneSignalUserTests: XCTestCase { override func tearDownWithError() throws { } + func testDeviceLanguageNormalization() { + let testCases = [ + ("zh-Hans-HK", "zh-Hans"), + ("zh-Hant-CN", "zh-Hant"), + ("zh-CN", "zh-Hans"), + ("zh-TW", "zh-Hant"), + ("zh-HK", "zh-Hant"), + ("zh-MO", "zh-Hant"), + ("zh", "zh-Hans"), + ("en-US", "en") + ] + + for (languageTag, expectedLanguage) in testCases { + let provider = OSLanguageProviderDevice { + Locale(identifier: languageTag) + } + XCTAssertEqual(provider.language, expectedLanguage, languageTag) + } + } + func testInternalOnesignalIdTracksCurrentUser() { let manager = OneSignalUserManagerImpl.sharedInstance OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") From dae269b96e9f701cfa861ce82993fdd775e730db Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 17 Sep 2026 12:38:43 -0700 Subject: [PATCH 2/8] fix(user): empty language resets to device language --- .../OneSignalUser/Source/OSPropertiesModel.swift | 6 ------ .../Source/OneSignalUserManagerImpl.swift | 9 ++------- .../OneSignalUserTests/OneSignalUserTests.swift | 11 +++++++++++ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift index 08667b8d4e..86c41436ba 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift @@ -45,12 +45,6 @@ final class OSLanguageProviderDevice { } switch languageCode { - case "iw": - return "he" - case "in": - return "id" - case "ji": - return "yi" case "zh": return chineseLanguage(locale) default: diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index c84a03b77b..27235f753e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -125,7 +125,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { } @objc public var language: String? { - return _user?.propertiesModel.language + return _user?.propertiesModel.language ?? OSLanguageProviderDevice().language } @objc public let pushSubscriptionImpl: OSPushSubscriptionImpl @@ -842,12 +842,7 @@ extension OneSignalUserManagerImpl: OSUser { return } - if language == "" { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.User.setLanguage cannot be called with an empty language code.") - return - } - - user.setLanguage(language) + user.setLanguage(language.isEmpty ? nil : language) } public func trackEvent(name: String, properties: [String: Any]?) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 07d79da3e4..9f46dc274b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -68,6 +68,17 @@ final class OneSignalUserTests: XCTestCase { } } + func testEmptyLanguageUsesDeviceLanguage() { + let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: "osid-a") + let manager = OneSignalUserManagerImpl.sharedInstance + + manager.setLanguage("de") + manager.setLanguage("") + + XCTAssertNil(user.propertiesModel.language) + XCTAssertEqual(manager.language, OSLanguageProviderDevice().language) + } + func testInternalOnesignalIdTracksCurrentUser() { let manager = OneSignalUserManagerImpl.sharedInstance OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a") From abc5e22011610b07815163a5f5dec167221657fb Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 17 Sep 2026 12:38:48 -0700 Subject: [PATCH 3/8] feat(demo): add language selector to user section --- .../demo/App/Services/OneSignalService.swift | 6 + .../App/Services/PreferencesService.swift | 6 + .../App/ViewModels/OneSignalViewModel.swift | 7 + .../demo/App/Views/Sections/UserSection.swift | 122 +++++++++++++++++- 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 598189ea0d..06fd6028dc 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -63,6 +63,7 @@ final class OneSignalService { OneSignal.InAppMessages.paused = prefs.getIamPaused() OneSignal.Location.isShared = prefs.getLocationShared() + setLanguage(prefs.getLanguage()) if let storedExternalId = prefs.getExternalUserId() { OneSignal.login(storedExternalId) @@ -106,6 +107,11 @@ final class OneSignalService { OneSignal.logout() } + func setLanguage(_ language: String) { + prefs.setLanguage(language) + OneSignal.User.setLanguage(language) + } + // MARK: - Aliases func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) } diff --git a/examples/demo/App/Services/PreferencesService.swift b/examples/demo/App/Services/PreferencesService.swift index b055224f74..cd7f15cec5 100644 --- a/examples/demo/App/Services/PreferencesService.swift +++ b/examples/demo/App/Services/PreferencesService.swift @@ -47,6 +47,7 @@ final class PreferencesService { static let iamPaused = "onesignal.demo.iamPaused" static let locationShared = "onesignal.demo.locationShared" static let externalUserId = "onesignal.demo.externalUserId" + static let language = "onesignal.demo.language" } // MARK: - Consent @@ -83,4 +84,9 @@ final class PreferencesService { defaults.removeObject(forKey: Key.externalUserId) } } + + // MARK: - Language + + func getLanguage() -> String { defaults.string(forKey: Key.language) ?? "" } + func setLanguage(_ value: String) { defaults.set(value, forKey: Key.language) } } diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index e158be52db..541ef6bc45 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -42,6 +42,7 @@ final class OneSignalViewModel: ObservableObject { // MARK: - Identity @Published var externalUserId: String? + @Published var language: String @Published var aliases: [KeyValueItem] = [] // MARK: - Push @@ -100,6 +101,7 @@ final class OneSignalViewModel: ObservableObject { self.consentRequired = service.consentRequired self.consentGiven = service.consentGiven self.externalUserId = service.externalId ?? prefs.getExternalUserId() + self.language = prefs.getLanguage() self.hasNotificationPermission = service.hasNotificationPermission refreshState() setupObservers() @@ -181,6 +183,11 @@ final class OneSignalViewModel: ObservableObject { clearUserData() } + func setLanguage(_ language: String) { + service.setLanguage(language) + self.language = language + } + private func clearUserData() { aliases.removeAll() emails.removeAll() diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 0e0bb50677..c2ab240fc1 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -27,6 +27,60 @@ import SwiftUI +private struct LanguageOption: Identifiable { + let name: String + let code: String + + var id: String { code } +} + +private let languageOptions = [ + LanguageOption(name: "Device Default", code: ""), + LanguageOption(name: "English", code: "en"), + LanguageOption(name: "Arabic", code: "ar"), + LanguageOption(name: "Azerbaijani", code: "az"), + LanguageOption(name: "Bosnian", code: "bs"), + LanguageOption(name: "Catalan", code: "ca"), + LanguageOption(name: "Chinese (Simplified)", code: "zh-Hans"), + LanguageOption(name: "Chinese (Traditional)", code: "zh-Hant"), + LanguageOption(name: "Croatian", code: "hr"), + LanguageOption(name: "Czech", code: "cs"), + LanguageOption(name: "Danish", code: "da"), + LanguageOption(name: "Dutch", code: "nl"), + LanguageOption(name: "Estonian", code: "et"), + LanguageOption(name: "Finnish", code: "fi"), + LanguageOption(name: "French", code: "fr"), + LanguageOption(name: "Georgian", code: "ka"), + LanguageOption(name: "Bulgarian", code: "bg"), + LanguageOption(name: "German", code: "de"), + LanguageOption(name: "Greek", code: "el"), + LanguageOption(name: "Hindi", code: "hi"), + LanguageOption(name: "Hebrew", code: "he"), + LanguageOption(name: "Hungarian", code: "hu"), + LanguageOption(name: "Indonesian", code: "id"), + LanguageOption(name: "Italian", code: "it"), + LanguageOption(name: "Japanese", code: "ja"), + LanguageOption(name: "Korean", code: "ko"), + LanguageOption(name: "Latvian", code: "lv"), + LanguageOption(name: "Lithuanian", code: "lt"), + LanguageOption(name: "Malay", code: "ms"), + LanguageOption(name: "Norwegian", code: "nb"), + LanguageOption(name: "Persian", code: "fa"), + LanguageOption(name: "Polish", code: "pl"), + LanguageOption(name: "Portuguese", code: "pt"), + LanguageOption(name: "Punjabi", code: "pa"), + LanguageOption(name: "Romanian", code: "ro"), + LanguageOption(name: "Russian", code: "ru"), + LanguageOption(name: "Serbian", code: "sr"), + LanguageOption(name: "Slovak", code: "sk"), + LanguageOption(name: "Spanish", code: "es"), + LanguageOption(name: "Swedish", code: "sv"), + LanguageOption(name: "Thai", code: "th"), + LanguageOption(name: "Turkish", code: "tr"), + LanguageOption(name: "Ukrainian", code: "uk"), + LanguageOption(name: "Vietnamese", code: "vi") +] + /// Login/logout + status display, mirroring the Capacitor UserSection struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel @@ -34,19 +88,30 @@ struct UserSection: View { var body: some View { SectionCard(title: "USER", sectionKey: "user") { - ValueCard(rows: [ - ValueCard.Row( + VStack(spacing: 0) { + InfoRow( label: "Status", value: viewModel.isLoggedIn ? "Logged In" : "Anonymous", valueAccessibilityID: "user_status_value" - ), - ValueCard.Row( + ) + .padding(.vertical, 4) + + divider + + InfoRow( label: "External ID", value: viewModel.externalUserId ?? "—", valueAccessibilityID: "user_external_id_value", - monospaced: true + isMonospaced: true ) - ]) + .padding(.vertical, 4) + + divider + + languageMenu + .padding(.vertical, 4) + } + .osCard() ActionButton( viewModel.loginButtonTitle, @@ -76,4 +141,49 @@ struct UserSection: View { ) } } + + private var divider: some View { + Rectangle() + .fill(OS.Color.divider) + .frame(height: OS.Layout.dividerHeight) + .padding(.vertical, 4) + } + + private var languageMenu: some View { + HStack(alignment: .center, spacing: 12) { + Text("Language") + .font(OS.Font.bodyMedium) + .foregroundColor(OS.Color.bodyText) + + Spacer(minLength: 0) + + Menu { + ForEach(languageOptions) { option in + Button { + viewModel.setLanguage(option.code) + } label: { + if option.code == viewModel.language { + Label(option.name, systemImage: "checkmark") + } else { + Text(option.name) + } + } + } + } label: { + HStack(spacing: 4) { + Text(selectedLanguageName) + .font(OS.Font.bodySmall) + .foregroundColor(OS.Color.bodyText) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(OS.Color.grey600) + } + } + .accessibilityIdentifier("user_language_dropdown") + } + } + + private var selectedLanguageName: String { + languageOptions.first { $0.code == viewModel.language }?.name ?? viewModel.language + } } From f0f1361c8083909dcd8986d0350890b3c096ff87 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 17 Sep 2026 16:46:11 -0700 Subject: [PATCH 4/8] fix: [SDK-5202] handle language persistence edge cases Co-authored-by: Cursor --- MIGRATION_GUIDE.md | 2 +- .../demo/App/Services/OneSignalService.swift | 12 +++++- .../App/ViewModels/OneSignalViewModel.swift | 1 + .../Controller/OSInAppMessageController.m | 38 +++++++++++++++---- .../IAMIntegrationTests.swift | 4 ++ .../Source/OSPropertiesModel.swift | 27 +++++++++++-- .../Source/OneSignalUserManagerImpl.swift | 17 ++++++++- .../OneSignalUserTests.swift | 11 ++++-- 8 files changed, 95 insertions(+), 17 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 27b27e27a5..753c20bef6 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -333,7 +333,7 @@ The User name space is accessible via `OneSignal.User` and provides access to us | **Swift** | **Objective-C** | **Description** | | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `OneSignal.User.setLanguage("en")` | `[OneSignal.User setLanguage:@"en"]` | *Set the 2-character language for this user.* | +| `OneSignal.User.setLanguage("en")` | `[OneSignal.User setLanguage:@"en"]` | *Set the language tag for this user. Pass an empty string to restore the device language.* | | `let pushSubscriptionProperty = OneSignal.User.pushSubscription.` | `id pushSubscriptionProperty = OneSignal.User.pushSubscription.` | *The push subscription associated to the current user. Please refer to the Push Subscription Namespace API below for additional details.* | | `let id: String? = OneSignal.User.onesignalId` | `NSString* id = OneSignal.User.onesignalId` | *Returns the nullable OneSignal ID for the current user.* | | `let id: String? = OneSignal.User.externalId` | `NSString* id = OneSignal.User.externalId` | *Returns the nullable external ID for the current user.* | diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 06fd6028dc..8bd64209b9 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -63,11 +63,11 @@ final class OneSignalService { OneSignal.InAppMessages.paused = prefs.getIamPaused() OneSignal.Location.isShared = prefs.getLocationShared() - setLanguage(prefs.getLanguage()) if let storedExternalId = prefs.getExternalUserId() { OneSignal.login(storedExternalId) } + restoreLanguageOverride() } // MARK: - Identity @@ -92,6 +92,9 @@ final class OneSignalService { set { prefs.setConsentGiven(newValue) OneSignal.setConsentGiven(newValue) + if newValue { + restoreLanguageOverride() + } } } @@ -100,6 +103,7 @@ final class OneSignalService { func login(externalId: String) { prefs.setExternalUserId(externalId) OneSignal.login(externalId) + restoreLanguageOverride() } func logout() { @@ -112,6 +116,12 @@ final class OneSignalService { OneSignal.User.setLanguage(language) } + private func restoreLanguageOverride() { + let language = prefs.getLanguage() + guard !language.isEmpty else { return } + OneSignal.User.setLanguage(language) + } + // MARK: - Aliases func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) } diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index 541ef6bc45..279a0a80b3 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -180,6 +180,7 @@ final class OneSignalViewModel: ObservableObject { func logout() { service.logout() externalUserId = nil + setLanguage("") clearUserData() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m index e3fb4f4e5f..19d41425bf 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m @@ -68,21 +68,45 @@ - (void)loadPreviewMessageHTMLContentWithUUID:(NSString * _Nonnull)previewUUID s variant over lower platforms (ie. 'all') even if they have a matching language. */ +- (NSArray *)languageVariantCandidatesForLanguage:(NSString * _Nullable)userLanguage { + if (!userLanguage) { + return @[]; + } + + NSMutableArray *candidates = [NSMutableArray arrayWithObject:userLanguage]; + NSDictionary *components = [NSLocale componentsFromLocaleIdentifier:userLanguage]; + NSString *languageCode = components[NSLocaleLanguageCode]; + NSString *normalizedLanguage = languageCode; + + if ([languageCode isEqualToString:@"zh"]) { + NSString *scriptCode = components[NSLocaleScriptCode]; + NSString *regionCode = components[NSLocaleCountryCode]; + BOOL isTraditional = [scriptCode isEqualToString:@"Hant"] + || (!scriptCode && [@[@"HK", @"MO", @"TW"] containsObject:regionCode]); + normalizedLanguage = isTraditional ? @"zh-Hant" : @"zh-Hans"; + } + + if (normalizedLanguage && ![candidates containsObject:normalizedLanguage]) { + [candidates addObject:normalizedLanguage]; + } + + return candidates; +} + - (NSString * _Nullable)variantId { NSString *userLanguage = OneSignalUserManagerImpl.sharedInstance.language; - NSString *baseLanguage = [[userLanguage componentsSeparatedByString:@"-"] firstObject]; + NSArray *languageCandidates = [self languageVariantCandidatesForLanguage:userLanguage]; NSString *variantId; for (NSString *type in PREFERRED_VARIANT_ORDER) { NSDictionary *languageVariants = self.variants[type]; if (languageVariants) { - if (userLanguage) { - variantId = languageVariants[userLanguage]; - } - - if (!variantId && baseLanguage && ![baseLanguage isEqualToString:userLanguage]) { - variantId = languageVariants[baseLanguage]; + for (NSString *language in languageCandidates) { + variantId = languageVariants[language]; + if (variantId) { + break; + } } if (!variantId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index ff466300f9..197c4bb72c 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -71,6 +71,10 @@ final class IAMIntegrationTests: XCTestCase { let testCases = [ ("zh-Hans", "simplified-chinese-variant"), ("zh-Hant", "traditional-chinese-variant"), + ("zh-Hans-CN", "simplified-chinese-variant"), + ("zh-Hant-TW", "traditional-chinese-variant"), + ("zh-CN", "simplified-chinese-variant"), + ("zh-TW", "traditional-chinese-variant"), ("en-US", "english-variant"), ("kl", "default-variant") ] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift index 86c41436ba..9f88c89e4e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift @@ -40,7 +40,7 @@ final class OSLanguageProviderDevice { var language: String { let locale = localeProvider() - guard let languageCode = locale.languageCode else { + guard let languageCode = languageCode(locale) else { return DEFAULT_LANGUAGE } @@ -53,16 +53,37 @@ final class OSLanguageProviderDevice { } private func chineseLanguage(_ locale: Locale) -> String { - switch locale.scriptCode { + switch scriptCode(locale) { case "Hans": return "zh-Hans" case "Hant": return "zh-Hant" default: - let isTraditionalRegion = locale.regionCode.map { ["HK", "MO", "TW"].contains($0) } ?? false + let isTraditionalRegion = regionCode(locale).map { ["HK", "MO", "TW"].contains($0) } ?? false return isTraditionalRegion ? "zh-Hant" : "zh-Hans" } } + + private func languageCode(_ locale: Locale) -> String? { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return locale.language.languageCode?.identifier + } + return locale.languageCode + } + + private func scriptCode(_ locale: Locale) -> String? { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return locale.language.script?.identifier + } + return locale.scriptCode + } + + private func regionCode(_ locale: Locale) -> String? { + if #available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) { + return locale.region?.identifier + } + return locale.regionCode + } } // Both lat and long must exist to be accepted by the server diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 27235f753e..4852a2b999 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -73,7 +73,13 @@ import OneSignalNotifications // SMS func addSms(_ number: String) func removeSms(_ number: String) - // Language + /** + Sets the language for this user. + + See [Supported Languages](https://documentation.onesignal.com/docs/en/multi-language-messaging#supported-languages). + + - Parameter language: A supported language code, or an empty string to use the device default. + */ func setLanguage(_ language: String) // Events /** @@ -837,12 +843,19 @@ extension OneSignalUserManagerImpl: OSUser { self.subscriptionModelStore.remove(number) } + /** + Sets the language for this user. + + See [Supported Languages](https://documentation.onesignal.com/docs/en/multi-language-messaging#supported-languages). + + - Parameter language: A supported language code, or an empty string to use the device default. + */ public func setLanguage(_ language: String) { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "setLanguage") else { return } - user.setLanguage(language.isEmpty ? nil : language) + user.setLanguage(language.isEmpty ? OSLanguageProviderDevice().language : language) } public func trackEvent(name: String, properties: [String: Any]?) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift index 9f46dc274b..c11fb74815 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -71,12 +71,17 @@ final class OneSignalUserTests: XCTestCase { func testEmptyLanguageUsesDeviceLanguage() { let user = OneSignalUserMocks.setUserManagerInternalUser(onesignalId: "osid-a") let manager = OneSignalUserManagerImpl.sharedInstance + let deviceLanguage = OSLanguageProviderDevice().language + let customLanguage = deviceLanguage == "de" ? "fr" : "de" + + manager.setLanguage(customLanguage) + XCTAssertEqual(user.propertiesModel.language, customLanguage) + XCTAssertEqual(manager.language, customLanguage) - manager.setLanguage("de") manager.setLanguage("") - XCTAssertNil(user.propertiesModel.language) - XCTAssertEqual(manager.language, OSLanguageProviderDevice().language) + XCTAssertEqual(user.propertiesModel.language, deviceLanguage) + XCTAssertEqual(manager.language, deviceLanguage) } func testInternalOnesignalIdTracksCurrentUser() { From ad18d3bd0cf28d9da55898fa30c11f7307aba8c2 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 17 Sep 2026 16:48:33 -0700 Subject: [PATCH 5/8] fix: [SDK-5202] keep consent handling unchanged Co-authored-by: Cursor --- examples/demo/App/Services/OneSignalService.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 8bd64209b9..97a0ddbc6c 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -92,9 +92,6 @@ final class OneSignalService { set { prefs.setConsentGiven(newValue) OneSignal.setConsentGiven(newValue) - if newValue { - restoreLanguageOverride() - } } } From 7553a2346f27e3f52d7479ee6e4a64569e143580 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 17 Sep 2026 17:05:47 -0700 Subject: [PATCH 6/8] fix: [SDK-5202] address PR feedback Co-authored-by: Cursor --- examples/demo/App/Services/OneSignalService.swift | 8 -------- .../Controller/OSInAppMessageController.m | 5 ++++- .../OneSignalInAppMessagesTests/IAMIntegrationTests.swift | 1 + 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 97a0ddbc6c..f76714afaf 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -67,7 +67,6 @@ final class OneSignalService { if let storedExternalId = prefs.getExternalUserId() { OneSignal.login(storedExternalId) } - restoreLanguageOverride() } // MARK: - Identity @@ -100,7 +99,6 @@ final class OneSignalService { func login(externalId: String) { prefs.setExternalUserId(externalId) OneSignal.login(externalId) - restoreLanguageOverride() } func logout() { @@ -113,12 +111,6 @@ final class OneSignalService { OneSignal.User.setLanguage(language) } - private func restoreLanguageOverride() { - let language = prefs.getLanguage() - guard !language.isEmpty else { return } - OneSignal.User.setLanguage(language) - } - // MARK: - Aliases func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m index 19d41425bf..ebe9c0d435 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m @@ -82,13 +82,16 @@ variant over lower platforms (ie. 'all') even if they have a NSString *scriptCode = components[NSLocaleScriptCode]; NSString *regionCode = components[NSLocaleCountryCode]; BOOL isTraditional = [scriptCode isEqualToString:@"Hant"] - || (!scriptCode && [@[@"HK", @"MO", @"TW"] containsObject:regionCode]); + || (!scriptCode && regionCode && [@[@"HK", @"MO", @"TW"] containsObject:regionCode]); normalizedLanguage = isTraditional ? @"zh-Hant" : @"zh-Hans"; } if (normalizedLanguage && ![candidates containsObject:normalizedLanguage]) { [candidates addObject:normalizedLanguage]; } + if (languageCode && ![candidates containsObject:languageCode]) { + [candidates addObject:languageCode]; + } return candidates; } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 197c4bb72c..d456329781 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -75,6 +75,7 @@ final class IAMIntegrationTests: XCTestCase { ("zh-Hant-TW", "traditional-chinese-variant"), ("zh-CN", "simplified-chinese-variant"), ("zh-TW", "traditional-chinese-variant"), + ("zh", "simplified-chinese-variant"), ("en-US", "english-variant"), ("kl", "default-variant") ] From e11c325ba96a1ac28a1b778612e24831247e39fd Mon Sep 17 00:00:00 2001 From: Fadi George Date: Thu, 17 Sep 2026 17:19:27 -0700 Subject: [PATCH 7/8] fix: [SDK-5202] remove generic Chinese fallback Co-authored-by: Cursor --- .../Controller/OSInAppMessageController.m | 3 --- 1 file changed, 3 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m index ebe9c0d435..7ea3d9fa2f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m @@ -89,9 +89,6 @@ variant over lower platforms (ie. 'all') even if they have a if (normalizedLanguage && ![candidates containsObject:normalizedLanguage]) { [candidates addObject:normalizedLanguage]; } - if (languageCode && ![candidates containsObject:languageCode]) { - [candidates addObject:languageCode]; - } return candidates; } From 61b5c0c69f9c7d8256dc442cb226733b4395426a Mon Sep 17 00:00:00 2001 From: Fadi George Date: Fri, 18 Sep 2026 09:56:18 -0700 Subject: [PATCH 8/8] refactor: [SDK-5202] centralize Chinese locale handling Co-authored-by: Cursor --- .../Controller/OSInAppMessageController.m | 41 +++++-------------- .../IAMIntegrationTests.swift | 5 --- .../Source/OSPropertiesModel.swift | 15 +++---- 3 files changed, 18 insertions(+), 43 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m index 7ea3d9fa2f..2bbf5a9fe6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m @@ -68,45 +68,24 @@ - (void)loadPreviewMessageHTMLContentWithUUID:(NSString * _Nonnull)previewUUID s variant over lower platforms (ie. 'all') even if they have a matching language. */ -- (NSArray *)languageVariantCandidatesForLanguage:(NSString * _Nullable)userLanguage { - if (!userLanguage) { - return @[]; - } - - NSMutableArray *candidates = [NSMutableArray arrayWithObject:userLanguage]; - NSDictionary *components = [NSLocale componentsFromLocaleIdentifier:userLanguage]; - NSString *languageCode = components[NSLocaleLanguageCode]; - NSString *normalizedLanguage = languageCode; - - if ([languageCode isEqualToString:@"zh"]) { - NSString *scriptCode = components[NSLocaleScriptCode]; - NSString *regionCode = components[NSLocaleCountryCode]; - BOOL isTraditional = [scriptCode isEqualToString:@"Hant"] - || (!scriptCode && regionCode && [@[@"HK", @"MO", @"TW"] containsObject:regionCode]); - normalizedLanguage = isTraditional ? @"zh-Hant" : @"zh-Hans"; - } - - if (normalizedLanguage && ![candidates containsObject:normalizedLanguage]) { - [candidates addObject:normalizedLanguage]; - } - - return candidates; -} - - (NSString * _Nullable)variantId { NSString *userLanguage = OneSignalUserManagerImpl.sharedInstance.language; - NSArray *languageCandidates = [self languageVariantCandidatesForLanguage:userLanguage]; + NSString *baseLanguage = [[userLanguage componentsSeparatedByString:@"-"] firstObject]; NSString *variantId; for (NSString *type in PREFERRED_VARIANT_ORDER) { NSDictionary *languageVariants = self.variants[type]; if (languageVariants) { - for (NSString *language in languageCandidates) { - variantId = languageVariants[language]; - if (variantId) { - break; - } + if (userLanguage) { + variantId = languageVariants[userLanguage]; + } + + if (!variantId + && baseLanguage + && ![baseLanguage isEqualToString:@"zh"] + && ![baseLanguage isEqualToString:userLanguage]) { + variantId = languageVariants[baseLanguage]; } if (!variantId) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index d456329781..ff466300f9 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -71,11 +71,6 @@ final class IAMIntegrationTests: XCTestCase { let testCases = [ ("zh-Hans", "simplified-chinese-variant"), ("zh-Hant", "traditional-chinese-variant"), - ("zh-Hans-CN", "simplified-chinese-variant"), - ("zh-Hant-TW", "traditional-chinese-variant"), - ("zh-CN", "simplified-chinese-variant"), - ("zh-TW", "traditional-chinese-variant"), - ("zh", "simplified-chinese-variant"), ("en-US", "english-variant"), ("kl", "default-variant") ] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift index 9f88c89e4e..b22266f92d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift @@ -40,19 +40,20 @@ final class OSLanguageProviderDevice { var language: String { let locale = localeProvider() + if let chineseLanguage = chineseLanguage(locale) { + return chineseLanguage + } guard let languageCode = languageCode(locale) else { return DEFAULT_LANGUAGE } + return languageCode + } - switch languageCode { - case "zh": - return chineseLanguage(locale) - default: - return languageCode + private func chineseLanguage(_ locale: Locale) -> String? { + guard languageCode(locale) == "zh" else { + return nil } - } - private func chineseLanguage(_ locale: Locale) -> String { switch scriptCode(locale) { case "Hans": return "zh-Hans"