diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 27b27e27a..753c20bef 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 598189ea0..f76714afa 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -106,6 +106,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 b055224f7..cd7f15cec 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 e158be52d..279a0a80b 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() @@ -178,9 +180,15 @@ final class OneSignalViewModel: ObservableObject { func logout() { service.logout() externalUserId = nil + setLanguage("") 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 0e0bb5067..c2ab240fc 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 + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m index 4609ea4d9..2bbf5a9fe 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSInAppMessageController.m @@ -69,21 +69,27 @@ 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:@"zh"] + && ![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 25f2bc264..ff466300f 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 813316635..181d5fbd0 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 0b966f7a3..b22266f92 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSPropertiesModel.swift @@ -29,6 +29,64 @@ 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() + if let chineseLanguage = chineseLanguage(locale) { + return chineseLanguage + } + guard let languageCode = languageCode(locale) else { + return DEFAULT_LANGUAGE + } + return languageCode + } + + private func chineseLanguage(_ locale: Locale) -> String? { + guard languageCode(locale) == "zh" else { + return nil + } + + switch scriptCode(locale) { + case "Hans": + return "zh-Hans" + case "Hant": + return "zh-Hant" + default: + 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 class OSLocationPoint: NSObject, NSCoding { let lat: Float @@ -80,16 +138,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/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index c84a03b77..4852a2b99 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 /** @@ -125,7 +131,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 @@ -837,17 +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 } - 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 ? 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 bc0c7dd82..c11fb7481 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OneSignalUserTests.swift @@ -48,6 +48,42 @@ 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 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("") + + XCTAssertEqual(user.propertiesModel.language, deviceLanguage) + XCTAssertEqual(manager.language, deviceLanguage) + } + func testInternalOnesignalIdTracksCurrentUser() { let manager = OneSignalUserManagerImpl.sharedInstance OneSignalUserMocks.setUserManagerInternalUser(externalId: "user-a", onesignalId: "osid-a")