diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 054ceef4c..41a15de8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ concurrency: jobs: run-tests-core: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install yeetd @@ -25,7 +25,7 @@ jobs: run-tests-message-center: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install yeetd @@ -38,7 +38,7 @@ jobs: run-tests-preference-center: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install yeetd @@ -51,7 +51,7 @@ jobs: run-tests-feature-flags: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install yeetd @@ -64,7 +64,7 @@ jobs: run-tests-automation: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install yeetd @@ -77,7 +77,7 @@ jobs: run-tests-extensions: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install yeetd @@ -90,7 +90,7 @@ jobs: run-package-tests: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Install xcodegen @@ -100,7 +100,7 @@ jobs: build-samples: runs-on: macos-14-xlarge - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Build samples @@ -171,4 +171,4 @@ jobs: # steps: # - uses: actions/checkout@v4 # - name: Pod lint - # run: make pod-lint-visionos \ No newline at end of file + # run: make pod-lint-visionos diff --git a/Airship.podspec b/Airship.podspec index 7cfb17557..ae5cb8ec4 100644 --- a/Airship.podspec +++ b/Airship.podspec @@ -1,4 +1,4 @@ -AIRSHIP_VERSION="18.3.1" +AIRSHIP_VERSION="18.4.0" Pod::Spec.new do |s| s.version = AIRSHIP_VERSION diff --git a/Airship.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Airship.xcworkspace/xcshareddata/swiftpm/Package.resolved index 762c780a1..55f9b87bd 100644 --- a/Airship.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Airship.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "15f70542934037e26c0fb164e8d9c1cad34f07aec670e7536cfde8fc58e7d90f", "pins" : [ { "identity" : "yams", @@ -10,5 +11,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/Airship/Airship.xcodeproj/project.pbxproj b/Airship/Airship.xcodeproj/project.pbxproj index 305d94d38..25305e516 100644 --- a/Airship/Airship.xcodeproj/project.pbxproj +++ b/Airship/Airship.xcodeproj/project.pbxproj @@ -59,6 +59,9 @@ 320AD3A629E7FA2000D66106 /* PagerGestureMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 320AD3A529E7FA2000D66106 /* PagerGestureMap.swift */; }; 3215CA9D2739349800B7D97E /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3215CA9C2739349700B7D97E /* ModalView.swift */; }; 3215CA9E2739349800B7D97E /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3215CA9C2739349700B7D97E /* ModalView.swift */; }; + 322AAB1E2B5AB65700652DAC /* AddChannelPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DDC0562AF1055300D23EBE /* AddChannelPromptView.swift */; }; + 322AAB212B5ACB2800652DAC /* ChannelListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 322AAB202B5ACB2800652DAC /* ChannelListView.swift */; }; + 322AAB222B5FCB6B00652DAC /* ContactManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 322AAB1C2B5A869000652DAC /* ContactManagementView.swift */; }; 3231126A29D5E4F600CF0D86 /* AutomationResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231126929D5E4F600CF0D86 /* AutomationResources.swift */; }; 3231128229D5E67200CF0D86 /* FrequencyLimitStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231127B29D5E67200CF0D86 /* FrequencyLimitStore.swift */; }; 3231128329D5E67200CF0D86 /* FrequencyLimitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231127C29D5E67200CF0D86 /* FrequencyLimitManager.swift */; }; @@ -77,6 +80,8 @@ 32515871272B007C00DF8B44 /* MediaWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32515868272AFB2E00DF8B44 /* MediaWebView.swift */; }; 32515872272B007F00DF8B44 /* Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32515866272AFB2E00DF8B44 /* Media.swift */; }; 32515874272B038300DF8B44 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D6E87A2727F7060077C784 /* Image.swift */; }; + 3255C7A72B33317F0053F0C2 /* ContactChannelsAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32BBFB3F2B274C8600C6A998 /* ContactChannelsAPIClient.swift */; }; + 3255C7A82B33317F0053F0C2 /* ContactChannelsAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32BBFB3F2B274C8600C6A998 /* ContactChannelsAPIClient.swift */; }; 325D53DA295C7979003421B4 /* ActionRegistryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325D53D9295C7979003421B4 /* ActionRegistryTest.swift */; }; 325D53F629646E53003421B4 /* TestChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E48426A60CDF0038CFDD /* TestChannel.swift */; }; 325D53F729648150003421B4 /* TestDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1422683B8FA00A2CBD0 /* TestDate.swift */; }; @@ -105,6 +110,7 @@ 32B5BE4928F8B66500F2254B /* MessageCenterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE4828F8B66500F2254B /* MessageCenterViewController.swift */; }; 32B632882906CA17000D3E34 /* MessageCenterThemeLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B632862906CA17000D3E34 /* MessageCenterThemeLoader.swift */; }; 32B632892906CA17000D3E34 /* MessageCenterTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B632872906CA17000D3E34 /* MessageCenterTheme.swift */; }; + 32BBFB402B274C8600C6A998 /* ContactChannelsAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32BBFB3F2B274C8600C6A998 /* ContactChannelsAPIClient.swift */; }; 32C68D0529424449006BBB29 /* RemoteDataTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C68D0429424449006BBB29 /* RemoteDataTest.swift */; }; 32CF81E2275627F4003009D1 /* AirshipAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CF81E1275627F4003009D1 /* AirshipAsyncImage.swift */; }; 32CF81E3275627F4003009D1 /* AirshipAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32CF81E1275627F4003009D1 /* AirshipAsyncImage.swift */; }; @@ -151,6 +157,12 @@ 45A8ADF023134B38004AD8CA /* testMCColorsCatalog.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 45A8ADD123133E51004AD8CA /* testMCColorsCatalog.xcassets */; }; 54DE2901247B2AF059E46862 /* Pods_AirshipTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EBF83042659DAF0D42AD7A9E /* Pods_AirshipTests.framework */; }; 6018AF572B29C20A008E528B /* SearchEventTemplateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6018AF562B29C20A008E528B /* SearchEventTemplateTest.swift */; }; + 603269532BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603269522BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift */; }; + 603269552BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603269542BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift */; }; + 603269582BF7550E007F7F75 /* AdditionalAudienceCheckerResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 603269572BF7550E007F7F75 /* AdditionalAudienceCheckerResolverTest.swift */; }; + 603269592BF75976007F7F75 /* AirshipCacheTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7EACD02AF4192400DA286B /* AirshipCacheTest.swift */; }; + 6032695B2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6032695A2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift */; }; + 6032695C2BF75E39007F7F75 /* AirshipHTTPResponseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6032695A2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift */; }; 605073832B2CD38200209B51 /* ActiveTimerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605073822B2CD38200209B51 /* ActiveTimerTest.swift */; }; 605073842B2CD46D00209B51 /* TestAppStateTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E61267C03C700654DB2 /* TestAppStateTracker.swift */; }; 6050738A2B32F85100209B51 /* ThomasViewModelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 605073892B32F85100209B51 /* ThomasViewModelTest.swift */; }; @@ -362,6 +374,9 @@ 6E1D8AD826CC66BE0049DACB /* RemoteConfigCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1D8AD726CC66BE0049DACB /* RemoteConfigCache.swift */; }; 6E1D8AD926CC66BE0049DACB /* RemoteConfigCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1D8AD726CC66BE0049DACB /* RemoteConfigCache.swift */; }; 6E1D90022B2D1AB4004BA130 /* RetryingQueueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1D90012B2D1AB4004BA130 /* RetryingQueueTests.swift */; }; + 6E1EEE902BD81AF300B45A87 /* ContactChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1EEE8F2BD81AF300B45A87 /* ContactChannel.swift */; }; + 6E1EEE912BD81AF300B45A87 /* ContactChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1EEE8F2BD81AF300B45A87 /* ContactChannel.swift */; }; + 6E1EEE922BD81AF300B45A87 /* ContactChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1EEE8F2BD81AF300B45A87 /* ContactChannel.swift */; }; 6E1F6E842BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel in Resources */ = {isa = PBXBuildFile; fileRef = 6E1F6E832BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel */; }; 6E1F6E852BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel in Resources */ = {isa = PBXBuildFile; fileRef = 6E1F6E832BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel */; }; 6E1F6E862BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel in Resources */ = {isa = PBXBuildFile; fileRef = 6E1F6E832BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel */; }; @@ -379,6 +394,8 @@ 6E2486F22898341400657CE4 /* ConditionsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486F02898341400657CE4 /* ConditionsMonitor.swift */; }; 6E2486F728984D0D00657CE4 /* PreferenceCenterTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486F628984D0D00657CE4 /* PreferenceCenterTheme.swift */; }; 6E2486FD2899C06100657CE4 /* PreferenceCenterViewLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486FC2899C06100657CE4 /* PreferenceCenterViewLoader.swift */; }; + 6E2811682BE406A50040D928 /* FeatureFlagDeferredResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7EACD22AF4220E00DA286B /* FeatureFlagDeferredResolverTest.swift */; }; + 6E28116C2BE40E860040D928 /* FeatureFlagVariablesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E28116B2BE40E860040D928 /* FeatureFlagVariablesTest.swift */; }; 6E28BA912B9BCFD400A641B6 /* MessageCenterUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E28BA902B9BCFD400A641B6 /* MessageCenterUtils.swift */; }; 6E29474B2AD47E15009EC6DD /* AirshipPreferenceCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 847BFFF4267CD739007CD249 /* AirshipPreferenceCenter.framework */; }; 6E29474D2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E29474C2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift */; }; @@ -596,6 +613,14 @@ 6E510C262721DA86006D9126 /* ForegroundColorViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1C9C48271F77F2009EF9EF /* ForegroundColorViewModifier.swift */; }; 6E510C272721DA86006D9126 /* BackgroundColorViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1C9C4A271F7878009EF9EF /* BackgroundColorViewModifier.swift */; }; 6E510C282721DA88006D9126 /* ThomasViewModelExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E062D0A271657BB001A74A1 /* ThomasViewModelExtensions.swift */; }; + 6E524C732C126F5F002CA094 /* AirshipEventType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524C722C126F5F002CA094 /* AirshipEventType.swift */; }; + 6E524C742C126F5F002CA094 /* AirshipEventType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524C722C126F5F002CA094 /* AirshipEventType.swift */; }; + 6E524C752C126F5F002CA094 /* AirshipEventType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524C722C126F5F002CA094 /* AirshipEventType.swift */; }; + 6E524CC32C180A39002CA094 /* AirshipLogPrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524CC22C180A39002CA094 /* AirshipLogPrivacyLevel.swift */; }; + 6E524CC42C180A39002CA094 /* AirshipLogPrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524CC22C180A39002CA094 /* AirshipLogPrivacyLevel.swift */; }; + 6E524CC52C180A39002CA094 /* AirshipLogPrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524CC22C180A39002CA094 /* AirshipLogPrivacyLevel.swift */; }; + 6E524D022C1A2CAE002CA094 /* InAppMessageThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524D012C1A2CAE002CA094 /* InAppMessageThemeManager.swift */; }; + 6E524D042C1A454E002CA094 /* InAppMessageThemeShadow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524D032C1A454E002CA094 /* InAppMessageThemeShadow.swift */; }; 6E57CE3228DB8BDA00287601 /* LiveActivityUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E57CE3128DB8BDA00287601 /* LiveActivityUpdate.swift */; }; 6E57CE3328DB8BDA00287601 /* LiveActivityUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E57CE3128DB8BDA00287601 /* LiveActivityUpdate.swift */; }; 6E57CE3428DB8BDA00287601 /* LiveActivityUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E57CE3128DB8BDA00287601 /* LiveActivityUpdate.swift */; }; @@ -1262,42 +1287,61 @@ 990A09592B5C677C00244D90 /* InAppMessageWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990A09582B5C677C00244D90 /* InAppMessageWebView.swift */; }; 990A09942B5CA5B700244D90 /* InAppMessageExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990A09932B5CA5B700244D90 /* InAppMessageExtensions.swift */; }; 990A09AF2B5DBD0400244D90 /* InAppMessageViewUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990A09AE2B5DBD0400244D90 /* InAppMessageViewUtils.swift */; }; + 990EB3B12BF59A1500315EAC /* ContactChannelsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990EB3B02BF59A1500315EAC /* ContactChannelsProvider.swift */; }; + 990EB3B22BF59A6C00315EAC /* ContactChannelsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990EB3B02BF59A1500315EAC /* ContactChannelsProvider.swift */; }; + 990EB3B32BF59A7200315EAC /* ContactChannelsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 990EB3B02BF59A1500315EAC /* ContactChannelsProvider.swift */; }; + 99104DF32BA6689A0040C0FD /* PreferenceCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99104DF22BA6689A0040C0FD /* PreferenceCloseButton.swift */; }; 9919EE422B2263B300A46BFE /* CustomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9908E60D2B000DBA00DB3E2E /* CustomView.swift */; }; 9919EE432B2263B300A46BFE /* CustomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9908E60D2B000DBA00DB3E2E /* CustomView.swift */; }; 9919EE442B2263CF00A46BFE /* ArishipCustomViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9908E6112B0189F800DB3E2E /* ArishipCustomViewManager.swift */; }; 9919EE452B2263CF00A46BFE /* ArishipCustomViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9908E6112B0189F800DB3E2E /* ArishipCustomViewManager.swift */; }; - 993969B92BD8353F0011D73A /* ShadowTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 993969B82BD8353F0011D73A /* ShadowTheme.swift */; }; - 9971A8812C0FF58500092ED1 /* LogPrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9971A8802C0FF58500092ED1 /* LogPrivacyLevel.swift */; }; - 9971A8822C0FF58500092ED1 /* LogPrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9971A8802C0FF58500092ED1 /* LogPrivacyLevel.swift */; }; - 9971A8832C0FF58500092ED1 /* LogPrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9971A8802C0FF58500092ED1 /* LogPrivacyLevel.swift */; }; + 99303B062BD97F89002174CA /* ChannelListViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99303B052BD97F89002174CA /* ChannelListViewCell.swift */; }; + 993AFDFE2C1B2D9A00AA875B /* PreferenceCenterConfig+ContactManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 993AFDFD2C1B2D9A00AA875B /* PreferenceCenterConfig+ContactManagement.swift */; }; + 99560C1E2BAE2FFA00F28BDC /* ChannelTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C1D2BAE2FFA00F28BDC /* ChannelTextField.swift */; }; + 99560C222BAE3D5D00F28BDC /* LabeledButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C212BAE3D5D00F28BDC /* LabeledButton.swift */; }; + 99560C282BB3843600F28BDC /* PreferenceCenterUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C272BB3843600F28BDC /* PreferenceCenterUtils.swift */; }; + 99560C2B2BB384A700F28BDC /* BackgroundShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C2A2BB384A700F28BDC /* BackgroundShape.swift */; }; + 99560C2D2BB3855800F28BDC /* EmptySectionLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C2C2BB3855800F28BDC /* EmptySectionLabel.swift */; }; + 99560C2F2BB385F500F28BDC /* ChannelListViewHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C2E2BB385F500F28BDC /* ChannelListViewHostingController.swift */; }; + 99560C312BB3864E00F28BDC /* RemoveChannelPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C302BB3864E00F28BDC /* RemoveChannelPromptView.swift */; }; + 99560C352BB38A2900F28BDC /* ResultPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C342BB38A2900F28BDC /* ResultPromptView.swift */; }; + 99560C372BB38A5F00F28BDC /* ErrorLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C362BB38A5F00F28BDC /* ErrorLabel.swift */; }; + 9971A8852C125C0200092ED1 /* ContactChannelsProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9971A8842C125C0200092ED1 /* ContactChannelsProviderTest.swift */; }; 998572BF2B3CF95D0091E9C9 /* DefaultAssetDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 998572BE2B3CF95D0091E9C9 /* DefaultAssetDownloader.swift */; }; 998572C12B3CF97B0091E9C9 /* DefaultAssetFileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 998572C02B3CF97B0091E9C9 /* DefaultAssetFileManager.swift */; }; 999DC85E2B5B721D0048C6AF /* HTMLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 999DC85D2B5B721D0048C6AF /* HTMLView.swift */; }; + 99C3CC762BCF23DF00B5BED5 /* SMSValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C3CC752BCF23DF00B5BED5 /* SMSValidator.swift */; }; + 99C3CC792BCF3E5B00B5BED5 /* SMSValidatorAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C3CC772BCF3DF700B5BED5 /* SMSValidatorAPIClientTest.swift */; }; + 99C3CC7A2BCF3FA200B5BED5 /* SMSValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C3CC752BCF23DF00B5BED5 /* SMSValidator.swift */; }; + 99C3CC7B2BCF3FA300B5BED5 /* SMSValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C3CC752BCF23DF00B5BED5 /* SMSValidator.swift */; }; + 99C3CC7E2BCF40B200B5BED5 /* SMSValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C3CC7C2BCF401B00B5BED5 /* SMSValidatorTest.swift */; }; + 99CC0D952BC87868001D93D0 /* AddChannelPromptViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99CC0D942BC87868001D93D0 /* AddChannelPromptViewModel.swift */; }; 99CF46182B3217C300B6FD9B /* AirshipCachedAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99CF46172B3217C300B6FD9B /* AirshipCachedAssets.swift */; }; 99CF461A2B3217DE00B6FD9B /* AssetCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99CF46192B3217DE00B6FD9B /* AssetCacheManager.swift */; }; 99D1B3282B44F08900447840 /* AirshipSceneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D1B3272B44F08900447840 /* AirshipSceneManager.swift */; }; - 99E0BD0D2B4DD4AB00465B37 /* FullScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E0BD0C2B4DD4AB00465B37 /* FullScreenView.swift */; }; + 99E0BD0D2B4DD4AB00465B37 /* FullscreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E0BD0C2B4DD4AB00465B37 /* FullscreenView.swift */; }; 99E0BD0F2B4DD71A00465B37 /* InAppMessageHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E0BD0E2B4DD71A00465B37 /* InAppMessageHostingController.swift */; }; 99E6EF6A2B8E36BA0006326A /* InAppMessageValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E6EF692B8E36BA0006326A /* InAppMessageValidation.swift */; }; 99E6EF6D2B8E3C250006326A /* InAppMessageContentValidationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E6EF6B2B8E3AF60006326A /* InAppMessageContentValidationTest.swift */; }; 99E6F0702B97EA8F0006326A /* ValidTestMessageCenterTheme.plist in Resources */ = {isa = PBXBuildFile; fileRef = 99E6F06F2B97EA8F0006326A /* ValidTestMessageCenterTheme.plist */; }; 99E8D7972B4F17260099B6F3 /* CloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7962B4F17260099B6F3 /* CloseButton.swift */; }; 99E8D7992B4F19BA0099B6F3 /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7982B4F19BA0099B6F3 /* TextView.swift */; }; - 99E8D79B2B4F2FCE0099B6F3 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D79A2B4F2FCE0099B6F3 /* Theme.swift */; }; + 99E8D79B2B4F2FCE0099B6F3 /* InAppMessageTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D79A2B4F2FCE0099B6F3 /* InAppMessageTheme.swift */; }; 99E8D7BB2B50A7C20099B6F3 /* InAppMessageViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7BA2B50A7C20099B6F3 /* InAppMessageViewDelegate.swift */; }; 99E8D7BD2B50AA060099B6F3 /* InAppMessageEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7BC2B50AA060099B6F3 /* InAppMessageEnvironment.swift */; }; 99E8D7BF2B50C2C10099B6F3 /* ButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7BE2B50C2C10099B6F3 /* ButtonGroup.swift */; }; 99E8D7C12B50E5F40099B6F3 /* InAppMessageRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7C02B50E5F40099B6F3 /* InAppMessageRootView.swift */; }; 99E8D7C52B5192D40099B6F3 /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7C42B5192D40099B6F3 /* MediaView.swift */; }; - 99E8D7C92B54A5CB0099B6F3 /* ModalTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7C82B54A5CB0099B6F3 /* ModalTheme.swift */; }; - 99E8D7CB2B54A6340099B6F3 /* FullScreenTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7CA2B54A6340099B6F3 /* FullScreenTheme.swift */; }; - 99E8D7CE2B54A66E0099B6F3 /* BannerTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7CD2B54A66E0099B6F3 /* BannerTheme.swift */; }; - 99E8D7D02B54A68F0099B6F3 /* HTMLTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7CF2B54A68F0099B6F3 /* HTMLTheme.swift */; }; - 99E8D7D52B55B0300099B6F3 /* EdgeInsets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7D42B55B0300099B6F3 /* EdgeInsets.swift */; }; - 99E8D7D82B55B0440099B6F3 /* ButtonTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7D72B55B0440099B6F3 /* ButtonTheme.swift */; }; - 99E8D7DA2B55B05D0099B6F3 /* TextTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7D92B55B05D0099B6F3 /* TextTheme.swift */; }; - 99E8D7DC2B55C4C20099B6F3 /* MediaTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7DB2B55C4C20099B6F3 /* MediaTheme.swift */; }; + 99E8D7C92B54A5CB0099B6F3 /* InAppMessageThemeModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7C82B54A5CB0099B6F3 /* InAppMessageThemeModal.swift */; }; + 99E8D7CB2B54A6340099B6F3 /* InAppMessageThemeFullscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7CA2B54A6340099B6F3 /* InAppMessageThemeFullscreen.swift */; }; + 99E8D7CE2B54A66E0099B6F3 /* InAppMessageThemeBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7CD2B54A66E0099B6F3 /* InAppMessageThemeBanner.swift */; }; + 99E8D7D02B54A68F0099B6F3 /* InAppMessageThemeHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7CF2B54A68F0099B6F3 /* InAppMessageThemeHTML.swift */; }; + 99E8D7D52B55B0300099B6F3 /* InAppMessageThemeAdditionalPadding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7D42B55B0300099B6F3 /* InAppMessageThemeAdditionalPadding.swift */; }; + 99E8D7D82B55B0440099B6F3 /* InAppMessageThemeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7D72B55B0440099B6F3 /* InAppMessageThemeButton.swift */; }; + 99E8D7DA2B55B05D0099B6F3 /* InAppMessageThemeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7D92B55B05D0099B6F3 /* InAppMessageThemeText.swift */; }; + 99E8D7DC2B55C4C20099B6F3 /* InAppMessageThemeMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7DB2B55C4C20099B6F3 /* InAppMessageThemeMedia.swift */; }; 99E8D7DE2B55C73B0099B6F3 /* ThemeExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7DD2B55C73B0099B6F3 /* ThemeExtensions.swift */; }; + 99F4FE5B2BC36A6700754F0F /* PreferenceCenterViewStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F4FE5A2BC36A6700754F0F /* PreferenceCenterViewStyle.swift */; }; 99F662B02B5DDC2900696098 /* BeveledLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F662AF2B5DDC2900696098 /* BeveledLoadingView.swift */; }; 99F662B22B60425E00696098 /* InAppMessageModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F662B12B60425E00696098 /* InAppMessageModalView.swift */; }; 99F662D22B63047300696098 /* InAppMessageBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F662D12B63047300696098 /* InAppMessageBannerView.swift */; }; @@ -1899,6 +1943,8 @@ 0F55D47F540C142B0F469570 /* Pods-AirshipTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AirshipTests.debug.xcconfig"; path = "../Pods/Target Support Files/Pods-AirshipTests/Pods-AirshipTests.debug.xcconfig"; sourceTree = ""; }; 320AD3A529E7FA2000D66106 /* PagerGestureMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerGestureMap.swift; sourceTree = ""; }; 3215CA9C2739349700B7D97E /* ModalView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; + 322AAB1C2B5A869000652DAC /* ContactManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactManagementView.swift; sourceTree = ""; }; + 322AAB202B5ACB2800652DAC /* ChannelListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListView.swift; sourceTree = ""; }; 3231126929D5E4F600CF0D86 /* AutomationResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationResources.swift; sourceTree = ""; }; 3231127B29D5E67200CF0D86 /* FrequencyLimitStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrequencyLimitStore.swift; sourceTree = ""; }; 3231127C29D5E67200CF0D86 /* FrequencyLimitManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrequencyLimitManager.swift; sourceTree = ""; }; @@ -1933,9 +1979,11 @@ 32B5BE4828F8B66500F2254B /* MessageCenterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterViewController.swift; sourceTree = ""; }; 32B632862906CA17000D3E34 /* MessageCenterThemeLoader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterThemeLoader.swift; sourceTree = ""; }; 32B632872906CA17000D3E34 /* MessageCenterTheme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterTheme.swift; sourceTree = ""; }; + 32BBFB3F2B274C8600C6A998 /* ContactChannelsAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactChannelsAPIClient.swift; sourceTree = ""; }; 32C68D0429424449006BBB29 /* RemoteDataTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataTest.swift; sourceTree = ""; }; 32CF81E1275627F4003009D1 /* AirshipAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAsyncImage.swift; sourceTree = ""; }; 32D6E87A2727F7060077C784 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; + 32DDC0562AF1055300D23EBE /* AddChannelPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelPromptView.swift; sourceTree = ""; }; 32E339E22A334A2000CD3BE5 /* AddCustomEventActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddCustomEventActionTest.swift; sourceTree = ""; }; 32F293D4295AFD94004A7D9C /* ActionArgumentsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionArgumentsTest.swift; sourceTree = ""; }; 32F615A628F708980015696D /* MessageCenterListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterListTests.swift; sourceTree = ""; }; @@ -1976,6 +2024,10 @@ 494DD9571B0EB677009C134E /* AirshipCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 494DD95B1B0EB677009C134E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6018AF562B29C20A008E528B /* SearchEventTemplateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEventTemplateTest.swift; sourceTree = ""; }; + 603269522BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalAudienceCheckerApiClient.swift; sourceTree = ""; }; + 603269542BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalAudienceCheckerResolver.swift; sourceTree = ""; }; + 603269572BF7550E007F7F75 /* AdditionalAudienceCheckerResolverTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdditionalAudienceCheckerResolverTest.swift; sourceTree = ""; }; + 6032695A2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipHTTPResponseTest.swift; sourceTree = ""; }; 605073822B2CD38200209B51 /* ActiveTimerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveTimerTest.swift; sourceTree = ""; }; 605073892B32F85100209B51 /* ThomasViewModelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasViewModelTest.swift; sourceTree = ""; }; 6050738F2B347B6400209B51 /* ThomasPresentationModelCodingTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPresentationModelCodingTest.swift; sourceTree = ""; }; @@ -2142,6 +2194,7 @@ 6E1D8AB226CC5D490049DACB /* RemoteConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteConfig.swift; sourceTree = ""; }; 6E1D8AD726CC66BE0049DACB /* RemoteConfigCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteConfigCache.swift; sourceTree = ""; }; 6E1D90012B2D1AB4004BA130 /* RetryingQueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryingQueueTests.swift; sourceTree = ""; }; + 6E1EEE8F2BD81AF300B45A87 /* ContactChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactChannel.swift; sourceTree = ""; }; 6E1F6E832BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = UARemoteDataMappingV2toV4.xcmappingmodel; sourceTree = ""; }; 6E1F6E872BE683E600CFC7A7 /* UARemoteDataMappingV1toV4.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = UARemoteDataMappingV1toV4.xcmappingmodel; sourceTree = ""; }; 6E1F6E8B2BE684D700CFC7A7 /* UAInboxDataMappingV1toV3.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = UAInboxDataMappingV1toV3.xcmappingmodel; sourceTree = ""; }; @@ -2153,6 +2206,7 @@ 6E2486F02898341400657CE4 /* ConditionsMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConditionsMonitor.swift; sourceTree = ""; }; 6E2486F628984D0D00657CE4 /* PreferenceCenterTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterTheme.swift; sourceTree = ""; }; 6E2486FC2899C06100657CE4 /* PreferenceCenterViewLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterViewLoader.swift; sourceTree = ""; }; + 6E28116B2BE40E860040D928 /* FeatureFlagVariablesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagVariablesTest.swift; sourceTree = ""; }; 6E28BA902B9BCFD400A641B6 /* MessageCenterUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterUtils.swift; sourceTree = ""; }; 6E29474C2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistrationStatus.swift; sourceTree = ""; }; 6E2947502AD5DB5A009EC6DD /* LiveActivityRegistrationStatusUpdates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistrationStatusUpdates.swift; sourceTree = ""; }; @@ -2302,6 +2356,10 @@ 6E4E2E2729CEB222002E7682 /* ContactManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactManagerProtocol.swift; sourceTree = ""; }; 6E4E5B3926E7F91600198175 /* AirshipLocalizationUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipLocalizationUtils.swift; sourceTree = ""; }; 6E4E5B3A26E7F91600198175 /* Attributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Attributes.swift; sourceTree = ""; }; + 6E524C722C126F5F002CA094 /* AirshipEventType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipEventType.swift; sourceTree = ""; }; + 6E524CC22C180A39002CA094 /* AirshipLogPrivacyLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipLogPrivacyLevel.swift; sourceTree = ""; }; + 6E524D012C1A2CAE002CA094 /* InAppMessageThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeManager.swift; sourceTree = ""; }; + 6E524D032C1A454E002CA094 /* InAppMessageThemeShadow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeShadow.swift; sourceTree = ""; }; 6E57CE3128DB8BDA00287601 /* LiveActivityUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityUpdate.swift; sourceTree = ""; }; 6E57CE3628DBBD9A00287601 /* LiveActivityRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistry.swift; sourceTree = ""; }; 6E590E6D29A94CA90036DFAB /* AppStateTrackerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTrackerTest.swift; sourceTree = ""; }; @@ -2705,37 +2763,54 @@ 990A09582B5C677C00244D90 /* InAppMessageWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageWebView.swift; sourceTree = ""; }; 990A09932B5CA5B700244D90 /* InAppMessageExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageExtensions.swift; sourceTree = ""; }; 990A09AE2B5DBD0400244D90 /* InAppMessageViewUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageViewUtils.swift; sourceTree = ""; }; - 993969B82BD8353F0011D73A /* ShadowTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowTheme.swift; sourceTree = ""; }; - 9971A8802C0FF58500092ED1 /* LogPrivacyLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogPrivacyLevel.swift; sourceTree = ""; }; + 990EB3B02BF59A1500315EAC /* ContactChannelsProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactChannelsProvider.swift; sourceTree = ""; }; + 99104DF22BA6689A0040C0FD /* PreferenceCloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCloseButton.swift; sourceTree = ""; }; + 99303B052BD97F89002174CA /* ChannelListViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListViewCell.swift; sourceTree = ""; }; + 993AFDFD2C1B2D9A00AA875B /* PreferenceCenterConfig+ContactManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PreferenceCenterConfig+ContactManagement.swift"; sourceTree = ""; }; + 99560C1D2BAE2FFA00F28BDC /* ChannelTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTextField.swift; sourceTree = ""; }; + 99560C212BAE3D5D00F28BDC /* LabeledButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabeledButton.swift; sourceTree = ""; }; + 99560C272BB3843600F28BDC /* PreferenceCenterUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterUtils.swift; sourceTree = ""; }; + 99560C2A2BB384A700F28BDC /* BackgroundShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundShape.swift; sourceTree = ""; }; + 99560C2C2BB3855800F28BDC /* EmptySectionLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptySectionLabel.swift; sourceTree = ""; }; + 99560C2E2BB385F500F28BDC /* ChannelListViewHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListViewHostingController.swift; sourceTree = ""; }; + 99560C302BB3864E00F28BDC /* RemoveChannelPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveChannelPromptView.swift; sourceTree = ""; }; + 99560C342BB38A2900F28BDC /* ResultPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultPromptView.swift; sourceTree = ""; }; + 99560C362BB38A5F00F28BDC /* ErrorLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLabel.swift; sourceTree = ""; }; + 9971A8842C125C0200092ED1 /* ContactChannelsProviderTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContactChannelsProviderTest.swift; sourceTree = ""; }; 998572BE2B3CF95D0091E9C9 /* DefaultAssetDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAssetDownloader.swift; sourceTree = ""; }; 998572C02B3CF97B0091E9C9 /* DefaultAssetFileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAssetFileManager.swift; sourceTree = ""; }; 999DC85D2B5B721D0048C6AF /* HTMLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLView.swift; sourceTree = ""; }; + 99C3CC752BCF23DF00B5BED5 /* SMSValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSValidator.swift; sourceTree = ""; }; + 99C3CC772BCF3DF700B5BED5 /* SMSValidatorAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSValidatorAPIClientTest.swift; sourceTree = ""; }; + 99C3CC7C2BCF401B00B5BED5 /* SMSValidatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSValidatorTest.swift; sourceTree = ""; }; + 99CC0D942BC87868001D93D0 /* AddChannelPromptViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelPromptViewModel.swift; sourceTree = ""; }; 99CF46172B3217C300B6FD9B /* AirshipCachedAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipCachedAssets.swift; sourceTree = ""; }; 99CF46192B3217DE00B6FD9B /* AssetCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCacheManager.swift; sourceTree = ""; }; 99D1B3272B44F08900447840 /* AirshipSceneManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSceneManager.swift; sourceTree = ""; }; - 99E0BD0C2B4DD4AB00465B37 /* FullScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenView.swift; sourceTree = ""; }; + 99E0BD0C2B4DD4AB00465B37 /* FullscreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullscreenView.swift; sourceTree = ""; }; 99E0BD0E2B4DD71A00465B37 /* InAppMessageHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageHostingController.swift; sourceTree = ""; }; 99E6EF692B8E36BA0006326A /* InAppMessageValidation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageValidation.swift; sourceTree = ""; }; 99E6EF6B2B8E3AF60006326A /* InAppMessageContentValidationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageContentValidationTest.swift; sourceTree = ""; }; 99E6F06F2B97EA8F0006326A /* ValidTestMessageCenterTheme.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = ValidTestMessageCenterTheme.plist; sourceTree = ""; }; 99E8D7962B4F17260099B6F3 /* CloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseButton.swift; sourceTree = ""; }; 99E8D7982B4F19BA0099B6F3 /* TextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextView.swift; sourceTree = ""; }; - 99E8D79A2B4F2FCE0099B6F3 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 99E8D79A2B4F2FCE0099B6F3 /* InAppMessageTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageTheme.swift; sourceTree = ""; }; 99E8D79C2B4F9E830099B6F3 /* InAppMessageThemeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeTest.swift; sourceTree = ""; }; 99E8D7BA2B50A7C20099B6F3 /* InAppMessageViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageViewDelegate.swift; sourceTree = ""; }; 99E8D7BC2B50AA060099B6F3 /* InAppMessageEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageEnvironment.swift; sourceTree = ""; }; 99E8D7BE2B50C2C10099B6F3 /* ButtonGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonGroup.swift; sourceTree = ""; }; 99E8D7C02B50E5F40099B6F3 /* InAppMessageRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageRootView.swift; sourceTree = ""; }; 99E8D7C42B5192D40099B6F3 /* MediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = ""; }; - 99E8D7C82B54A5CB0099B6F3 /* ModalTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalTheme.swift; sourceTree = ""; }; - 99E8D7CA2B54A6340099B6F3 /* FullScreenTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenTheme.swift; sourceTree = ""; }; - 99E8D7CD2B54A66E0099B6F3 /* BannerTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerTheme.swift; sourceTree = ""; }; - 99E8D7CF2B54A68F0099B6F3 /* HTMLTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLTheme.swift; sourceTree = ""; }; - 99E8D7D42B55B0300099B6F3 /* EdgeInsets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EdgeInsets.swift; sourceTree = ""; }; - 99E8D7D72B55B0440099B6F3 /* ButtonTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonTheme.swift; sourceTree = ""; }; - 99E8D7D92B55B05D0099B6F3 /* TextTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextTheme.swift; sourceTree = ""; }; - 99E8D7DB2B55C4C20099B6F3 /* MediaTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaTheme.swift; sourceTree = ""; }; + 99E8D7C82B54A5CB0099B6F3 /* InAppMessageThemeModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeModal.swift; sourceTree = ""; }; + 99E8D7CA2B54A6340099B6F3 /* InAppMessageThemeFullscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeFullscreen.swift; sourceTree = ""; }; + 99E8D7CD2B54A66E0099B6F3 /* InAppMessageThemeBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeBanner.swift; sourceTree = ""; }; + 99E8D7CF2B54A68F0099B6F3 /* InAppMessageThemeHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeHTML.swift; sourceTree = ""; }; + 99E8D7D42B55B0300099B6F3 /* InAppMessageThemeAdditionalPadding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeAdditionalPadding.swift; sourceTree = ""; }; + 99E8D7D72B55B0440099B6F3 /* InAppMessageThemeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeButton.swift; sourceTree = ""; }; + 99E8D7D92B55B05D0099B6F3 /* InAppMessageThemeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeText.swift; sourceTree = ""; }; + 99E8D7DB2B55C4C20099B6F3 /* InAppMessageThemeMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageThemeMedia.swift; sourceTree = ""; }; 99E8D7DD2B55C73B0099B6F3 /* ThemeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeExtensions.swift; sourceTree = ""; }; + 99F4FE5A2BC36A6700754F0F /* PreferenceCenterViewStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterViewStyle.swift; sourceTree = ""; }; 99F662AF2B5DDC2900696098 /* BeveledLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeveledLoadingView.swift; sourceTree = ""; }; 99F662B12B60425E00696098 /* InAppMessageModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageModalView.swift; sourceTree = ""; }; 99F662D12B63047300696098 /* InAppMessageBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageBannerView.swift; sourceTree = ""; }; @@ -2977,6 +3052,22 @@ name = "Recovered References"; sourceTree = ""; }; + 322AAB1F2B5ACA3400652DAC /* Contact management */ = { + isa = PBXGroup; + children = ( + 99CC0D942BC87868001D93D0 /* AddChannelPromptViewModel.swift */, + 32DDC0562AF1055300D23EBE /* AddChannelPromptView.swift */, + 99560C302BB3864E00F28BDC /* RemoveChannelPromptView.swift */, + 99560C342BB38A2900F28BDC /* ResultPromptView.swift */, + 322AAB1C2B5A869000652DAC /* ContactManagementView.swift */, + 322AAB202B5ACB2800652DAC /* ChannelListView.swift */, + 99303B052BD97F89002174CA /* ChannelListViewCell.swift */, + 99560C2E2BB385F500F28BDC /* ChannelListViewHostingController.swift */, + 99560C292BB3848A00F28BDC /* Component Views */, + ); + path = "Contact management"; + sourceTree = ""; + }; 3231127A29D5E67200CF0D86 /* Limits */ = { isa = PBXGroup; children = ( @@ -3219,6 +3310,23 @@ name = Products; sourceTree = ""; }; + 603269512BF4B12B007F7F75 /* AudienceCheck */ = { + isa = PBXGroup; + children = ( + 603269522BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift */, + 603269542BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift */, + ); + path = AudienceCheck; + sourceTree = ""; + }; + 603269562BF754FE007F7F75 /* AudienceCheck */ = { + isa = PBXGroup; + children = ( + 603269572BF7550E007F7F75 /* AdditionalAudienceCheckerResolverTest.swift */, + ); + path = AudienceCheck; + sourceTree = ""; + }; 6058771B2AC73C550021628E /* MeteredUsage */ = { isa = PBXGroup; children = ( @@ -3323,6 +3431,7 @@ 6E0B8740294A9C500064B7BD /* Source */ = { isa = PBXGroup; children = ( + 603269512BF4B12B007F7F75 /* AudienceCheck */, 6E1B7B112B714FED00695561 /* Actions */, 6E986EF82B44D41E00FBE6A0 /* InAppAutomation.swift */, 6E986F002B44E86900FBE6A0 /* RemoteData */, @@ -3356,6 +3465,7 @@ 6E0F4BEA2B326F6B00673CA4 /* Automation */ = { isa = PBXGroup; children = ( + 603269562BF754FE007F7F75 /* AudienceCheck */, 6EC0CA692B4B696500333A87 /* Engine */, 6E6B2DBD2B33B768008BF788 /* AutomationScheduleTest.swift */, 6E60EF6529DF4BB5003F7A8D /* ApplicationMetricsTest.swift */, @@ -3599,6 +3709,8 @@ 6E2486FA2899BB0E00657CE4 /* view */ = { isa = PBXGroup; children = ( + 99560C272BB3843600F28BDC /* PreferenceCenterUtils.swift */, + 322AAB1F2B5ACA3400652DAC /* Contact management */, 6E2486F02898341400657CE4 /* ConditionsMonitor.swift */, 6E2486EB2894901E00657CE4 /* ConditionsViewModifier.swift */, 6E9B488E2891B57300C905B1 /* CommonSectionView.swift */, @@ -3608,6 +3720,7 @@ 6E9B48922891B6A700C905B1 /* ChannelSubscriptionView.swift */, 6E9B48942891B6B400C905B1 /* ContactSubscriptionView.swift */, 6E9B488A2891962000C905B1 /* PreferenceCenterView.swift */, + 99F4FE5A2BC36A6700754F0F /* PreferenceCenterViewStyle.swift */, 6E2486FC2899C06100657CE4 /* PreferenceCenterViewLoader.swift */, 6E2486DE28945D3900657CE4 /* PreferenceCenterState.swift */, 6E3B231228A32EC30005D46E /* PreferenceCenterViewExtensions.swift */, @@ -3627,9 +3740,9 @@ 6E2E3CA02B3271BC00B8515B /* View */ = { isa = PBXGroup; children = ( - 99E0BD0C2B4DD4AB00465B37 /* FullScreenView.swift */, - 999DC85D2B5B721D0048C6AF /* HTMLView.swift */, + 99E0BD0C2B4DD4AB00465B37 /* FullscreenView.swift */, 99F662B12B60425E00696098 /* InAppMessageModalView.swift */, + 999DC85D2B5B721D0048C6AF /* HTMLView.swift */, 99F662D12B63047300696098 /* InAppMessageBannerView.swift */, 99E8D7962B4F17260099B6F3 /* CloseButton.swift */, 990A09932B5CA5B700244D90 /* InAppMessageExtensions.swift */, @@ -3818,6 +3931,7 @@ 6EB11C862697ACBF00DC698F /* ContactOperation.swift */, 6EC7E48C26A738C70038CFDD /* ContactConflictEvent.swift */, 6E698E3A267BEDC300654DB2 /* AirshipContact.swift */, + 99C3CC752BCF23DF00B5BED5 /* SMSValidator.swift */, E99605A027A071EA00365AE4 /* EmailRegistrationOptions.swift */, E99605A327A075B800365AE4 /* SMSRegistrationOptions.swift */, E99605A627A075C600365AE4 /* OpenRegistrationOptions.swift */, @@ -3825,7 +3939,10 @@ 6E94760E29BA8FA30025F364 /* ContactManager.swift */, 6E4E2E2729CEB222002E7682 /* ContactManagerProtocol.swift */, 6E6363E129DCD0CF009C358A /* ContactSubscriptionListClient.swift */, + 32BBFB3F2B274C8600C6A998 /* ContactChannelsAPIClient.swift */, 6E60EF6929DF542B003F7A8D /* AnonContactData.swift */, + 6E1EEE8F2BD81AF300B45A87 /* ContactChannel.swift */, + 990EB3B02BF59A1500315EAC /* ContactChannelsProvider.swift */, ); name = Contacts; sourceTree = ""; @@ -4023,6 +4140,7 @@ 6E4325E82B7AEB1F00A9B000 /* AirshipEvent.swift */, 6E96ECF1293EB7900053CC91 /* AirshipEventData.swift */, 6E4326002B7C327C00A9B000 /* AirshipEvents.swift */, + 6E524C722C126F5F002CA094 /* AirshipEventType.swift */, 6E4325F02B7AEC9700A9B000 /* Region */, 6E4325EC2B7AEC2E00A9B000 /* Custom Events */, ); @@ -4162,6 +4280,7 @@ 841E7D11268617C800EA0317 /* PreferenceCenterResponse.swift */, 6E1892D4268E3D8500417887 /* PreferenceCenterDecoder.swift */, 6E6C3F9927A47DB4007F55C7 /* PreferenceCenterConfig.swift */, + 993AFDFD2C1B2D9A00AA875B /* PreferenceCenterConfig+ContactManagement.swift */, ); path = data; sourceTree = ""; @@ -4591,8 +4710,8 @@ 6EF1933828380086005F192A /* Logger */ = { isa = PBXGroup; children = ( + 6E524CC22C180A39002CA094 /* AirshipLogPrivacyLevel.swift */, 6E87BDFE26E283840005D20D /* LogLevel.swift */, - 9971A8802C0FF58500092ED1 /* LogPrivacyLevel.swift */, 6E698DEA26790AC300654DB2 /* AirshipLogger.swift */, 6EF1933B2838062B005F192A /* AirshipLogHandler.swift */, 6EF1933D28380644005F192A /* DefaultLogHandler.swift */, @@ -4678,30 +4797,36 @@ path = Assets; sourceTree = ""; }; - 99E8D7CC2B54A6470099B6F3 /* Theme */ = { + 99560C292BB3848A00F28BDC /* Component Views */ = { isa = PBXGroup; children = ( - 99E8D79A2B4F2FCE0099B6F3 /* Theme.swift */, - 99E8D7DD2B55C73B0099B6F3 /* ThemeExtensions.swift */, - 99E8D7CD2B54A66E0099B6F3 /* BannerTheme.swift */, - 99E8D7C82B54A5CB0099B6F3 /* ModalTheme.swift */, - 99E8D7CA2B54A6340099B6F3 /* FullScreenTheme.swift */, - 99E8D7CF2B54A68F0099B6F3 /* HTMLTheme.swift */, - 99E8D7D32B55AF8D0099B6F3 /* Components */, + 99560C2C2BB3855800F28BDC /* EmptySectionLabel.swift */, + 99560C362BB38A5F00F28BDC /* ErrorLabel.swift */, + 99560C1D2BAE2FFA00F28BDC /* ChannelTextField.swift */, + 99104DF22BA6689A0040C0FD /* PreferenceCloseButton.swift */, + 99560C212BAE3D5D00F28BDC /* LabeledButton.swift */, + 99560C2A2BB384A700F28BDC /* BackgroundShape.swift */, ); - path = Theme; + path = "Component Views"; sourceTree = ""; }; - 99E8D7D32B55AF8D0099B6F3 /* Components */ = { + 99E8D7CC2B54A6470099B6F3 /* Theme */ = { isa = PBXGroup; children = ( - 99E8D7D42B55B0300099B6F3 /* EdgeInsets.swift */, - 99E8D7D72B55B0440099B6F3 /* ButtonTheme.swift */, - 993969B82BD8353F0011D73A /* ShadowTheme.swift */, - 99E8D7DB2B55C4C20099B6F3 /* MediaTheme.swift */, - 99E8D7D92B55B05D0099B6F3 /* TextTheme.swift */, + 99E8D7D42B55B0300099B6F3 /* InAppMessageThemeAdditionalPadding.swift */, + 6E524D012C1A2CAE002CA094 /* InAppMessageThemeManager.swift */, + 99E8D7DD2B55C73B0099B6F3 /* ThemeExtensions.swift */, + 99E8D79A2B4F2FCE0099B6F3 /* InAppMessageTheme.swift */, + 6E524D032C1A454E002CA094 /* InAppMessageThemeShadow.swift */, + 99E8D7D72B55B0440099B6F3 /* InAppMessageThemeButton.swift */, + 99E8D7DB2B55C4C20099B6F3 /* InAppMessageThemeMedia.swift */, + 99E8D7D92B55B05D0099B6F3 /* InAppMessageThemeText.swift */, + 99E8D7CD2B54A66E0099B6F3 /* InAppMessageThemeBanner.swift */, + 99E8D7C82B54A5CB0099B6F3 /* InAppMessageThemeModal.swift */, + 99E8D7CA2B54A6340099B6F3 /* InAppMessageThemeFullscreen.swift */, + 99E8D7CF2B54A68F0099B6F3 /* InAppMessageThemeHTML.swift */, ); - path = Components; + path = Theme; sourceTree = ""; }; A61517AD26A97419008A41C4 /* Subscription Lists */ = { @@ -4727,6 +4852,7 @@ 6E8BDEFF2A679CD100F816D9 /* FeatureFlagRemoteDataAccessTest.swift */, 6E938DBB2AC39A0500F691D9 /* FeatureFlagAnalyticsTest.swift */, 6E7EACD22AF4220E00DA286B /* FeatureFlagDeferredResolverTest.swift */, + 6E28116B2BE40E860040D928 /* FeatureFlagVariablesTest.swift */, ); path = Tests; sourceTree = ""; @@ -4759,11 +4885,14 @@ A6CDD8CE269491850040A673 /* Contacts */ = { isa = PBXGroup; children = ( + 9971A8842C125C0200092ED1 /* ContactChannelsProviderTest.swift */, A6CDD8CF269491BE0040A673 /* ContactAPIClientTest.swift */, 6EC7E46D269E2A4C0038CFDD /* AttributeEditorTest.swift */, 6EC7E46F269E33290038CFDD /* TagGroupsEditorTest.swift */, 6EC7E471269E51030038CFDD /* AttributeUpdateTest.swift */, + 99C3CC772BCF3DF700B5BED5 /* SMSValidatorAPIClientTest.swift */, 6EC7E473269E52600038CFDD /* ContactOperationTest.swift */, + 99C3CC7C2BCF401B00B5BED5 /* SMSValidatorTest.swift */, 6EC7E47526A5EE910038CFDD /* AudienceUtilsTest.swift */, 6EC7E47726A604080038CFDD /* AirshipContactTest.swift */, 6E6363E529DCE9A2009C358A /* ContactSubscriptionListAPIClientTest.swift */, @@ -4940,6 +5069,7 @@ 6ED2F52A2B7FC5C8000AFC80 /* VersionMatcherTest.swift */, 6ED2F5342B7FFCD7000AFC80 /* AirshipDateFormatterTest.swift */, 6EB8394D2BC8B1F4006611C4 /* AirshipAsyncChannelTest.swift */, + 6032695A2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift */, ); name = Utils; sourceTree = ""; @@ -5944,6 +6074,7 @@ buildActionMask = 2147483647; files = ( 6EB1B3F326EAA4D6000421B9 /* ChannelAPIClient.swift in Sources */, + 990EB3B12BF59A1500315EAC /* ContactChannelsProvider.swift in Sources */, 6E7112A12880DACB004942E4 /* EnableBehaviorModifiers.swift in Sources */, 6E12539129A81ACE0009EE58 /* AirshipCoreDataPredicate.swift in Sources */, 6E6C3F7F27A20C3C007F55C7 /* ChannelScope.swift in Sources */, @@ -6049,6 +6180,8 @@ 6EFD6D8B272A53FB005B26F1 /* PagerController.swift in Sources */, 6E664BD326C4917000A2C8E5 /* RemoveTagsAction.swift in Sources */, 6EF66D8D276461DA00ABCB76 /* UrlInfo.swift in Sources */, + 32BBFB402B274C8600C6A998 /* ContactChannelsAPIClient.swift in Sources */, + 6E1EEE902BD81AF300B45A87 /* ContactChannel.swift in Sources */, 6E6BD2422AE995DA00B9DFC9 /* DeferredResolver.swift in Sources */, 6ECDDE6C29B7EEE9009D79DB /* AuthToken.swift in Sources */, 6EAD7CE526B216DB00B88EA7 /* DeepLinkAction.swift in Sources */, @@ -6096,6 +6229,7 @@ 6E698E09267A7DD900654DB2 /* RemoteDataAPIClient.swift in Sources */, A61517C426B009D6008A41C4 /* SubscriptionListAPIClient.swift in Sources */, 8401769426C5671100373AF7 /* JSONMatcher.swift in Sources */, + 6E524CC32C180A39002CA094 /* AirshipLogPrivacyLevel.swift in Sources */, 9908E60E2B000DBA00DB3E2E /* CustomView.swift in Sources */, 6E739D6626B9BDC100BC6F6D /* ChannelBulkUpdateAPIClient.swift in Sources */, 6EF1933E28380644005F192A /* DefaultLogHandler.swift in Sources */, @@ -6116,7 +6250,6 @@ 6EC7559F2A4E5AB200851ABB /* DeviceAudienceChecker.swift in Sources */, 6E15B6F426CD85C40099C92D /* RuntimeConfig.swift in Sources */, 6ED8079A273DA56000D1F455 /* ThomasViewController.swift in Sources */, - 9971A8812C0FF58500092ED1 /* LogPrivacyLevel.swift in Sources */, 6E15B6DB26CC749F0099C92D /* RemoteConfigManager.swift in Sources */, A6D6D48F2A0253AA0072A5CA /* ActionArguments.swift in Sources */, 6E6BD24E2AEAFEC500B9DFC9 /* AirshipStateOverrides.swift in Sources */, @@ -6140,6 +6273,7 @@ 6EB5158128A47BD700870C5A /* SubscriptionListEdit.swift in Sources */, 6E91E43D28EF423400B6F25E /* WorkConditionsMonitor.swift in Sources */, 6E698E03267A799500654DB2 /* AirshipErrors.swift in Sources */, + 99C3CC762BCF23DF00B5BED5 /* SMSValidator.swift in Sources */, 6E2D6AEE26B083DB00B7C226 /* ChannelAudienceManager.swift in Sources */, 6E698E3F267BEDC300654DB2 /* AttributesEditor.swift in Sources */, 6EB11C892697AF5600DC698F /* TagGroupUpdate.swift in Sources */, @@ -6164,6 +6298,7 @@ 3CA84AB826DE257200A59685 /* Analytics.swift in Sources */, 6E94760F29BA8FA30025F364 /* ContactManager.swift in Sources */, 6E49D7B428401D2E00C7BB9D /* PermissionDelegate.swift in Sources */, + 6E524C732C126F5F002CA094 /* AirshipEventType.swift in Sources */, 6E71129B2880DACB004942E4 /* ViewState.swift in Sources */, 8401769626C5715E00373AF7 /* VersionMatcher.swift in Sources */, 6E91E44028EF423400B6F25E /* Worker.swift in Sources */, @@ -6273,17 +6408,17 @@ 998572BF2B3CF95D0091E9C9 /* DefaultAssetDownloader.swift in Sources */, 6E1B7B132B714FFC00695561 /* LandingPageAction.swift in Sources */, 6068E03B2B2CBCF200349E82 /* ActiveTimer.swift in Sources */, - 99E8D7CE2B54A66E0099B6F3 /* BannerTheme.swift in Sources */, + 99E8D7CE2B54A66E0099B6F3 /* InAppMessageThemeBanner.swift in Sources */, 3231128329D5E67200CF0D86 /* FrequencyLimitManager.swift in Sources */, 6E1528202B4DC59C00DF1377 /* InAppMessageSceneDelegate.swift in Sources */, - 99E8D7D52B55B0300099B6F3 /* EdgeInsets.swift in Sources */, - 99E8D79B2B4F2FCE0099B6F3 /* Theme.swift in Sources */, + 99E8D7D52B55B0300099B6F3 /* InAppMessageThemeAdditionalPadding.swift in Sources */, + 99E8D79B2B4F2FCE0099B6F3 /* InAppMessageTheme.swift in Sources */, 99E6EF6A2B8E36BA0006326A /* InAppMessageValidation.swift in Sources */, 99E8D7DE2B55C73B0099B6F3 /* ThemeExtensions.swift in Sources */, 6EDF1D982B2A25C800E23BC4 /* InAppMessageMediaInfo.swift in Sources */, - 99E8D7DA2B55B05D0099B6F3 /* TextTheme.swift in Sources */, + 99E8D7DA2B55B05D0099B6F3 /* InAppMessageThemeText.swift in Sources */, 6EDF1DA62B2A300100E23BC4 /* InAppMessage.swift in Sources */, - 99E8D7D02B54A68F0099B6F3 /* HTMLTheme.swift in Sources */, + 99E8D7D02B54A68F0099B6F3 /* InAppMessageThemeHTML.swift in Sources */, 6EDF1D962B2A25B400E23BC4 /* InAppMessageButtonInfo.swift in Sources */, 6EE6AAC92B58A947002FEA75 /* InAppEventRecorder.swift in Sources */, 6EDF1DB82B2BB2B800E23BC4 /* RetryingQueue.swift in Sources */, @@ -6302,7 +6437,6 @@ 3231128429D5E67200CF0D86 /* Occurrence.swift in Sources */, 6E1CBE2D2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodeld in Sources */, 99CF46182B3217C300B6FD9B /* AirshipCachedAssets.swift in Sources */, - 993969B92BD8353F0011D73A /* ShadowTheme.swift in Sources */, 999DC85E2B5B721D0048C6AF /* HTMLView.swift in Sources */, 99CF461A2B3217DE00B6FD9B /* AssetCacheManager.swift in Sources */, 6E1A9BF72B606CF200A6489B /* AutomationDelayProcessor.swift in Sources */, @@ -6329,7 +6463,8 @@ 6EC0CA532B48A2C300333A87 /* AutomationAudience.swift in Sources */, 6E1A9BC92B5EE34600A6489B /* AutomationEventFeed.swift in Sources */, 99E8D7C12B50E5F40099B6F3 /* InAppMessageRootView.swift in Sources */, - 99E8D7CB2B54A6340099B6F3 /* FullScreenTheme.swift in Sources */, + 99E8D7CB2B54A6340099B6F3 /* InAppMessageThemeFullscreen.swift in Sources */, + 603269552BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift in Sources */, 6EE6AACA2B58A947002FEA75 /* InAppPermissionResultEvent.swift in Sources */, 6EE6AAD22B58A947002FEA75 /* InAppFormResultEvent.swift in Sources */, 6EDF1D9E2B2A2A5900E23BC4 /* InAppMessageDisplayContent.swift in Sources */, @@ -6338,6 +6473,7 @@ 6EE6AA2C2B51DB1E002FEA75 /* AutomationSourceInfoStore.swift in Sources */, 99E8D7992B4F19BA0099B6F3 /* TextView.swift in Sources */, 6EE6AAFF2B58AB66002FEA75 /* LegacyInAppAnalytics.swift in Sources */, + 6E524D022C1A2CAE002CA094 /* InAppMessageThemeManager.swift in Sources */, 6EE6AB132B59DD37002FEA75 /* ThomasPagerTracker.swift in Sources */, 6EE6AACB2B58A947002FEA75 /* InAppPagerCompletedEvent.swift in Sources */, 6EE6AACF2B58A947002FEA75 /* InAppFormDisplayEvent.swift in Sources */, @@ -6359,10 +6495,12 @@ 6E15281D2B4DC43100DF1377 /* ActionAutomationPreparer.swift in Sources */, 6EE6AAC52B58A947002FEA75 /* InAppEventMessageID.swift in Sources */, 6E986EFB2B44D48C00FBE6A0 /* InAppMessaging.swift in Sources */, - 99E8D7DC2B55C4C20099B6F3 /* MediaTheme.swift in Sources */, + 99E8D7DC2B55C4C20099B6F3 /* InAppMessageThemeMedia.swift in Sources */, 99E8D7C52B5192D40099B6F3 /* MediaView.swift in Sources */, + 6E524D042C1A454E002CA094 /* InAppMessageThemeShadow.swift in Sources */, + 603269532BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift in Sources */, 6E4AEEBC2B6D6380008AEAC1 /* TriggerData.swift in Sources */, - 99E8D7D82B55B0440099B6F3 /* ButtonTheme.swift in Sources */, + 99E8D7D82B55B0440099B6F3 /* InAppMessageThemeButton.swift in Sources */, 6E0F4BE52B32645600673CA4 /* AutomationTrigger.swift in Sources */, 6E1A9BAB2B5AE38A00A6489B /* InAppMessageDisplayListener.swift in Sources */, 6EE6AAC82B58A947002FEA75 /* InAppGestureEvent.swift in Sources */, @@ -6374,7 +6512,7 @@ 6E1A9BD52B5EE97000A6489B /* TriggeringInfo.swift in Sources */, 6E68028B2B850DDE00F4591F /* ApplicationMetrics.swift in Sources */, 6E15282C2B4DE81E00DF1377 /* AutomationSDKModule.swift in Sources */, - 99E0BD0D2B4DD4AB00465B37 /* FullScreenView.swift in Sources */, + 99E0BD0D2B4DD4AB00465B37 /* FullscreenView.swift in Sources */, 6E6802902B8671E700F4591F /* InAppAutomationComponent.swift in Sources */, 6E1528262B4DC64B00DF1377 /* DisplayAdapterFactory.swift in Sources */, 6EE6AAC42B58A947002FEA75 /* InAppResolutionEvent.swift in Sources */, @@ -6396,7 +6534,7 @@ 6E1528312B4DED8900DF1377 /* InAppMessageAnalyticsFactory.swift in Sources */, 6E1528242B4DC60200DF1377 /* DisplayCoordinatorManager.swift in Sources */, 99E8D7972B4F17260099B6F3 /* CloseButton.swift in Sources */, - 99E8D7C92B54A5CB0099B6F3 /* ModalTheme.swift in Sources */, + 99E8D7C92B54A5CB0099B6F3 /* InAppMessageThemeModal.swift in Sources */, 6E4AEE652B6B44EA008AEAC1 /* AutomationStore.swift in Sources */, 60F8E7602B8FAF5400460EDF /* ScheduleAction.swift in Sources */, 60F8E75C2B8F3D4B00460EDF /* CancelSchedulesAction.swift in Sources */, @@ -6430,6 +6568,7 @@ 6EC0CA6D2B4B879800333A87 /* AutomationPreparerTest.swift in Sources */, 6E4AEE2C2B6B302D008AEAC1 /* ActionAutomationPreparerTest.swift in Sources */, 6E1B7B162B715FFE00695561 /* LandingPageActionTest.swift in Sources */, + 603269582BF7550E007F7F75 /* AdditionalAudienceCheckerResolverTest.swift in Sources */, 60FCA30E2B5535F4005C9232 /* TestAnalytics.swift in Sources */, 6E68028C2B85149900F4591F /* ApplicationMetricsTest.swift in Sources */, 6EE6AAD52B58A977002FEA75 /* TestInAppEvent.swift in Sources */, @@ -6449,6 +6588,7 @@ 6E1A9BB22B5B172F00A6489B /* TestActionRunner.swift in Sources */, 60FCA30D2B5534DB005C9232 /* TestAirshipInstance.swift in Sources */, 6EE6AAF32B58A9D5002FEA75 /* InAppPagerSummaryEventTest.swift in Sources */, + 603269592BF75976007F7F75 /* AirshipCacheTest.swift in Sources */, 60FCA30F2B55363E005C9232 /* PushTest.swift in Sources */, 6E1A9BB52B5B1A0900A6489B /* ThomasPagerTrackerTest.swift in Sources */, 6EE6AB092B59C236002FEA75 /* CustomDisplayAdapterWrapperTest.swift in Sources */, @@ -6491,6 +6631,7 @@ 6EC0CA702B4B895A00333A87 /* TestDeferredResolver.swift in Sources */, 6EE6AAEA2B58A9D5002FEA75 /* InAppPagerCompletedEventTest.swift in Sources */, 325D53FB2964885B003421B4 /* AirshipBaseTest.swift in Sources */, + 6032695C2BF75E39007F7F75 /* AirshipHTTPResponseTest.swift in Sources */, 6E1528422B4F156200DF1377 /* TestDisplayCoordinator.swift in Sources */, 6EE6AAE82B58A9D5002FEA75 /* InAppDisplayEventTest.swift in Sources */, 6EE6AAF52B58A9D5002FEA75 /* InAppFormDisplayEventTest.swift in Sources */, @@ -6538,11 +6679,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 99560C2D2BB3855800F28BDC /* EmptySectionLabel.swift in Sources */, + 99560C352BB38A2900F28BDC /* ResultPromptView.swift in Sources */, 6E2486F22898341400657CE4 /* ConditionsMonitor.swift in Sources */, + 99303B062BD97F89002174CA /* ChannelListViewCell.swift in Sources */, + 99560C372BB38A5F00F28BDC /* ErrorLabel.swift in Sources */, + 99560C282BB3843600F28BDC /* PreferenceCenterUtils.swift in Sources */, + 322AAB212B5ACB2800652DAC /* ChannelListView.swift in Sources */, 6E3B231328A32EC30005D46E /* PreferenceCenterViewExtensions.swift in Sources */, 6E9B48912891B68C00C905B1 /* PreferenceCenterAlertView.swift in Sources */, + 99560C2B2BB384A700F28BDC /* BackgroundShape.swift in Sources */, + 99560C312BB3864E00F28BDC /* RemoveChannelPromptView.swift in Sources */, 6E9B488B2891962000C905B1 /* PreferenceCenterView.swift in Sources */, 6E2486F728984D0D00657CE4 /* PreferenceCenterTheme.swift in Sources */, + 99104DF32BA6689A0040C0FD /* PreferenceCloseButton.swift in Sources */, 6E9B48932891B6A700C905B1 /* ChannelSubscriptionView.swift in Sources */, 6E9B48952891B6B400C905B1 /* ContactSubscriptionView.swift in Sources */, 6EB5157128A4608C00870C5A /* PreferenceCenterViewControllerFactory.swift in Sources */, @@ -6550,13 +6700,21 @@ 6E2486DF28945D3900657CE4 /* PreferenceCenterState.swift in Sources */, 6EB5156E28A42B5800870C5A /* PreferenceCenterResources.swift in Sources */, 841E7D12268617C800EA0317 /* PreferenceCenterResponse.swift in Sources */, + 99560C2F2BB385F500F28BDC /* ChannelListViewHostingController.swift in Sources */, 6E3B230F28A318CD0005D46E /* PreferenceCenterThemeLoader.swift in Sources */, 6E1892D5268E3D8500417887 /* PreferenceCenterDecoder.swift in Sources */, 6E2486EC2894901E00657CE4 /* ConditionsViewModifier.swift in Sources */, 6E6C3F9A27A47DB4007F55C7 /* PreferenceCenterConfig.swift in Sources */, 6E9B488D2891B43F00C905B1 /* LabeledSectionBreakView.swift in Sources */, + 99560C222BAE3D5D00F28BDC /* LabeledButton.swift in Sources */, 847B0013267CE558007CD249 /* PreferenceCenterSDKModule.swift in Sources */, 6E9B48972891B6BF00C905B1 /* ContactSubscriptionGroupView.swift in Sources */, + 993AFDFE2C1B2D9A00AA875B /* PreferenceCenterConfig+ContactManagement.swift in Sources */, + 99CC0D952BC87868001D93D0 /* AddChannelPromptViewModel.swift in Sources */, + 322AAB1E2B5AB65700652DAC /* AddChannelPromptView.swift in Sources */, + 99F4FE5B2BC36A6700754F0F /* PreferenceCenterViewStyle.swift in Sources */, + 322AAB222B5FCB6B00652DAC /* ContactManagementView.swift in Sources */, + 99560C1E2BAE2FFA00F28BDC /* ChannelTextField.swift in Sources */, 6E6802922B86732200F4591F /* PreferenceCenterComponent.swift in Sources */, 6E2486FD2899C06100657CE4 /* PreferenceCenterViewLoader.swift in Sources */, 84483A68267CF0C000D0DA7D /* PreferenceCenter.swift in Sources */, @@ -6597,7 +6755,9 @@ buildActionMask = 2147483647; files = ( A61F3A752A5DA58500EE94CC /* FeatureFlagManagerTest.swift in Sources */, + 6E2811682BE406A50040D928 /* FeatureFlagDeferredResolverTest.swift in Sources */, 6E8BDF012A679E5000F816D9 /* FeatureFlagRemoteDataAccessTest.swift in Sources */, + 6E28116C2BE40E860040D928 /* FeatureFlagVariablesTest.swift in Sources */, 6E4326092B7C396F00A9B000 /* TestAnalytics.swift in Sources */, 6E938DBC2AC39A0500F691D9 /* FeatureFlagAnalyticsTest.swift in Sources */, 6E8BDEFE2A67938200F816D9 /* FeatureFlagInfoTest.swift in Sources */, @@ -6635,6 +6795,7 @@ 6E6BD26F2AF1AC5700B9DFC9 /* AirshipCache.swift in Sources */, A68C749A285CAFB300EEA2F4 /* PromptPermissionAction.swift in Sources */, A68C7499285CAFAB00EEA2F4 /* DefaultLogHandler.swift in Sources */, + 6E524C752C126F5F002CA094 /* AirshipEventType.swift in Sources */, A68C7498285CAF8C00EEA2F4 /* CachedList.swift in Sources */, A68C7497285CAF8600EEA2F4 /* AirshipLogHandler.swift in Sources */, 6EC755AB2A4F8FBC00851ABB /* AudienceHashSelector.swift in Sources */, @@ -6698,6 +6859,7 @@ A6722A9E281A9EDA0033F54D /* TagGroupUpdate.swift in Sources */, 6E4325F42B7B1EDA00A9B000 /* SessionEventFactory.swift in Sources */, A6722A9F281A9EDA0033F54D /* TagGroupsEditor.swift in Sources */, + 99C3CC7B2BCF3FA300B5BED5 /* SMSValidator.swift in Sources */, A6722AA0281A9EDA0033F54D /* TagGroupMutations.swift in Sources */, A6722AA1281A9EDA0033F54D /* AirshipLocalizationUtils.swift in Sources */, A6722AA2281A9EDA0033F54D /* AirshipUtils.swift in Sources */, @@ -6718,9 +6880,11 @@ A6722AB4281A9EDB0033F54D /* Image.swift in Sources */, 6EDE5F512BA248FF00E33D04 /* TouchViewModifier.swift in Sources */, A6722AB5281A9EDB0033F54D /* CachedValue.swift in Sources */, + 6E524CC52C180A39002CA094 /* AirshipLogPrivacyLevel.swift in Sources */, A6722AB6281A9EDB0033F54D /* AirshipDateFormatter.swift in Sources */, A6722A41281A9EB80033F54D /* AccountEventTemplate.swift in Sources */, 6EE49C122A0C142F00AB1CF4 /* RemoteDataInfo.swift in Sources */, + 3255C7A82B33317F0053F0C2 /* ContactChannelsAPIClient.swift in Sources */, 6EE49C1F2A0D9D8000AB1CF4 /* RemoteDataProvider.swift in Sources */, A6722A46281A9EB80033F54D /* AssociatedIdentifiers.swift in Sources */, A6722A48281A9EB80033F54D /* CircularRegion.swift in Sources */, @@ -6834,6 +6998,7 @@ A6722A16281A9E820033F54D /* ActivityViewController.swift in Sources */, 6ECDDE7B29B804FB009D79DB /* ChannelAuthTokenAPIClient.swift in Sources */, 6EF1401D2A2671ED009A125D /* AirshipDeviceID.swift in Sources */, + 6E1EEE922BD81AF300B45A87 /* ContactChannel.swift in Sources */, 6EE49C0A2A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift in Sources */, A6722A17281A9E830033F54D /* AddCustomEventAction.swift in Sources */, 6E299FD928D13E54001305A7 /* AirshipRequest.swift in Sources */, @@ -6851,6 +7016,7 @@ A6722A1F281A9E830033F54D /* EnableFeatureAction.swift in Sources */, A6722A21281A9E830033F54D /* FetchDeviceInfoAction.swift in Sources */, A67229C5281A9E630033F54D /* AirshipImageLoader.swift in Sources */, + 990EB3B32BF59A7200315EAC /* ContactChannelsProvider.swift in Sources */, 6E1CBD832BA3A30300519D9C /* AirshipEmbeddedInfo.swift in Sources */, 6E2F5A8C2A66088100CABD3D /* AudienceDeviceInfoProvider.swift in Sources */, A67229C6281A9E630033F54D /* AirshipImageProvider.swift in Sources */, @@ -6914,7 +7080,6 @@ A67229F4281A9E640033F54D /* ViewConstraints.swift in Sources */, A67229F5281A9E640033F54D /* ScrollLayout.swift in Sources */, A6D6D4A32A02BB190072A5CA /* AirshipAction.swift in Sources */, - 9971A8832C0FF58500092ED1 /* LogPrivacyLevel.swift in Sources */, 6EB3FCF12ABCFA680018594E /* RemoteDataProtocol.swift in Sources */, A67229F6281A9E640033F54D /* ViewFactory.swift in Sources */, 6E4325EB2B7AEB1F00A9B000 /* AirshipEvent.swift in Sources */, @@ -6993,6 +7158,7 @@ 60E09FDB2B2780DB005A16EA /* JsonMatcherTest.swift in Sources */, 6EC755AF2A4FCD8800851ABB /* AudienceHashSelectorTest.swift in Sources */, 3299EF26294B222F00251E70 /* RemoteDataStoreTest.swift in Sources */, + 99C3CC792BCF3E5B00B5BED5 /* SMSValidatorAPIClientTest.swift in Sources */, A629F7DA295B514C00671647 /* PasteboardActionTest.swift in Sources */, 32E339E32A334A2000CD3BE5 /* AddCustomEventActionTest.swift in Sources */, 6EFAFB8429561F23008AD187 /* ModifyAttributesActionTest.swift in Sources */, @@ -7006,6 +7172,7 @@ 6EC0CA6F2B4B893500333A87 /* TestDeferredResolver.swift in Sources */, 6E65244E2A4FD69F0019F353 /* DeviceAudienceSelectorTest.swift in Sources */, 6E6363E629DCE9A2009C358A /* ContactSubscriptionListAPIClientTest.swift in Sources */, + 6032695B2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift in Sources */, 6E15B6FA26CDCA6A0099C92D /* RuntimeConfigTest.swift in Sources */, 6E4325C32B7A9D9A00A9B000 /* AirshipPrivacyManagerTest.swift in Sources */, 6E87BE1626E29BC90005D20D /* TestAirshipInstance.swift in Sources */, @@ -7070,6 +7237,7 @@ 607951222A1CD1A50086578F /* ExperimentManagerTest.swift in Sources */, 6E96ED1A2941A0EC0053CC91 /* EventTestUtils.swift in Sources */, 60A5CC082B28DC500017EDB2 /* NotificationCategoriesTest.swift in Sources */, + 99C3CC7E2BCF40B200B5BED5 /* SMSValidatorTest.swift in Sources */, 6E739D7F26BAFCB800BC6F6D /* TestChannelBulkUpdateAPIClient.swift in Sources */, 6E6C3F8D27A26992007F55C7 /* CachedValueTest.swift in Sources */, 6E4007162A153ABE0013C2DE /* ContactRemoteDataProviderTest.swift in Sources */, @@ -7087,6 +7255,7 @@ 6EFAFB78295525C3008AD187 /* ChannelAPIClientTest.swift in Sources */, 6E4326072B7C364300A9B000 /* AssociatedIdentifiersTest.swift in Sources */, 6ED2F52B2B7FC5C8000AFC80 /* VersionMatcherTest.swift in Sources */, + 9971A8852C125C0200092ED1 /* ContactChannelsProviderTest.swift in Sources */, 6E65244C2A4FD4270019F353 /* DeviceTagSelectorTest.swift in Sources */, 3299EF222949EC3E00251E70 /* AirshipBaseTest.swift in Sources */, 6E7EACD12AF4192400DA286B /* AirshipCacheTest.swift in Sources */, @@ -7101,6 +7270,7 @@ buildActionMask = 2147483647; files = ( 6E7112A22880DACB004942E4 /* EnableBehaviorModifiers.swift in Sources */, + 990EB3B22BF59A6C00315EAC /* ContactChannelsProvider.swift in Sources */, 6E12539229A81ACE0009EE58 /* AirshipCoreDataPredicate.swift in Sources */, C0EE8CE526C1B3410006F003 /* ChannelCapture.swift in Sources */, 6E4E2E2929CEB222002E7682 /* ContactManagerProtocol.swift in Sources */, @@ -7207,9 +7377,11 @@ A6F6726426E1163A008C69C3 /* AirshipJSONUtils.swift in Sources */, 6E87BD8E26D815780005D20D /* AppIntegration.swift in Sources */, 6EFD6D8C272A53FB005B26F1 /* PagerController.swift in Sources */, + 6E1EEE912BD81AF300B45A87 /* ContactChannel.swift in Sources */, 6E6BD2432AE995DA00B9DFC9 /* DeferredResolver.swift in Sources */, 6ECDDE6D29B7EEE9009D79DB /* AuthToken.swift in Sources */, 6E739D6F26B9F58700BC6F6D /* TagGroupMutations.swift in Sources */, + 3255C7A72B33317F0053F0C2 /* ContactChannelsAPIClient.swift in Sources */, 6E299FDC28D14208001305A7 /* AirshipResponse.swift in Sources */, 6ED2F5322B7FF819000AFC80 /* AirshipViewUtils.swift in Sources */, 6E698E60267BF63B00654DB2 /* ApplicationState.swift in Sources */, @@ -7253,6 +7425,7 @@ 6E92EC91284954B10038802D /* ButtonState.swift in Sources */, 6E692B0729E0DA5200D96CCC /* JavaScriptCommand.swift in Sources */, 6E152BCB2743235800788402 /* Icons.swift in Sources */, + 6E524CC42C180A39002CA094 /* AirshipLogPrivacyLevel.swift in Sources */, 6E510C1C2721DA6A006D9126 /* ViewFactory.swift in Sources */, 6EE49C1E2A0D9D8000AB1CF4 /* RemoteDataProvider.swift in Sources */, 6EF1934028380648005F192A /* AirshipLogHandler.swift in Sources */, @@ -7273,7 +7446,6 @@ 6E698E0A267A7DD900654DB2 /* RemoteDataAPIClient.swift in Sources */, 6E9D529926C1A502004EA16B /* ActionRunner.swift in Sources */, A61517C526B009D6008A41C4 /* SubscriptionListAPIClient.swift in Sources */, - 9971A8822C0FF58500092ED1 /* LogPrivacyLevel.swift in Sources */, 6ED6ECA826AE05B700973364 /* EmptyAction.swift in Sources */, 6E510C1F2721DA79006D9126 /* LabelButton.swift in Sources */, 6E6BD24F2AEAFEC500B9DFC9 /* AirshipStateOverrides.swift in Sources */, @@ -7298,6 +7470,7 @@ 6E88739B2763D8B200AC248A /* AirshipImageProvider.swift in Sources */, 6E952930268BBD7D00398B54 /* RetailEventTemplate.swift in Sources */, 6E887CD7272C602B00E83363 /* CheckboxController.swift in Sources */, + 99C3CC7A2BCF3FA200B5BED5 /* SMSValidator.swift in Sources */, 6E91E43E28EF423400B6F25E /* WorkConditionsMonitor.swift in Sources */, 6E1892B2268CE8FE00417887 /* AirshipLock.swift in Sources */, 6E510C252721DA86006D9126 /* MarginViewModifier.swift in Sources */, @@ -7321,6 +7494,7 @@ 6E4086912B8D125A00435E2C /* AirshipViewSizeReader.swift in Sources */, 6E91E45328EF423400B6F25E /* AirshipWorkManager.swift in Sources */, 6E4325EA2B7AEB1F00A9B000 /* AirshipEvent.swift in Sources */, + 6E524C742C126F5F002CA094 /* AirshipEventType.swift in Sources */, 6E49D7B928401D2E00C7BB9D /* Atomic.swift in Sources */, A6D6D4A22A02BB180072A5CA /* AirshipAction.swift in Sources */, 3CA84AB926DE257200A59685 /* Analytics.swift in Sources */, diff --git a/Airship/AirshipAutomation/Source/ActionAutomation/ActionAutomationExecutor.swift b/Airship/AirshipAutomation/Source/ActionAutomation/ActionAutomationExecutor.swift index 4cb5cc9a1..71fbe2fba 100644 --- a/Airship/AirshipAutomation/Source/ActionAutomation/ActionAutomationExecutor.swift +++ b/Airship/AirshipAutomation/Source/ActionAutomation/ActionAutomationExecutor.swift @@ -22,6 +22,10 @@ struct ActionAutomationExecutor: AutomationExecutorDelegate { } func execute(data: AirshipJSON, preparedScheduleInfo: PreparedScheduleInfo) async -> ScheduleExecuteResult { + guard preparedScheduleInfo.additionalAudienceCheckResult else { + return .finished + } + await actionRunner.runActions(data, situation: .automation, metadata: [:]) return .finished } diff --git a/Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerApiClient.swift b/Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerApiClient.swift new file mode 100644 index 000000000..2d2fc579a --- /dev/null +++ b/Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerApiClient.swift @@ -0,0 +1,104 @@ +/* Copyright Airship and Contributors */ + +import Foundation +#if canImport(AirshipCore) +import AirshipCore +#endif + +protocol AdditionalAudienceCheckerAPIClientProtocol: Sendable { + func resolve( + info: AdditionalAudienceCheckResult.Request + ) async throws -> AirshipHTTPResponse +} + +struct AdditionalAudienceCheckResult: Codable, Sendable, Equatable { + let isMatched: Bool + let cacheTTL: TimeInterval + + enum CodingKeys: String, CodingKey { + case isMatched = "allowed" + case cacheTTL = "cache_seconds" + } + + struct Request: Sendable { + let url: URL + let channelID: String + let contactID: String + let namedUserID: String? + let context: AirshipJSON? + } +} + +final class AdditionalAudienceCheckerAPIClient: AdditionalAudienceCheckerAPIClientProtocol { + private let config: RuntimeConfig + private let session: AirshipRequestSession + private let encoder: JSONEncoder + + init(config: RuntimeConfig, session: AirshipRequestSession, encoder: JSONEncoder = JSONEncoder()) { + self.config = config + self.session = session + self.encoder = encoder + } + + convenience init(config: RuntimeConfig) { + self.init( + config: config, + session: config.requestSession + ) + } + + func resolve( + info: AdditionalAudienceCheckResult.Request + ) async throws -> AirshipHTTPResponse { + + let body = RequestBody( + channelID: info.channelID, + contactID: info.contactID, + namedUserID: info.namedUserID, + context: info.context + ) + + let request = AirshipRequest( + url: info.url, + headers: [ + "Content-Type": "application/json", + "Accept": "application/vnd.urbanairship+json; version=3;", + "X-UA-Contact-ID": info.contactID, + "X-UA-Device-Family": "ios", + ], + method: "POST", + auth: .contactAuthToken(identifier: info.contactID), + body: try encoder.encode(body) + ) + + AirshipLogger.trace("Performing additional audience check with request \(request), body: \(body)") + + return try await session.performHTTPRequest(request) { data, response in + AirshipLogger.debug("Additional audience check response finished with response: \(response)") + + guard (200..<300).contains(response.statusCode) else { + return nil + } + + guard let data = data else { + throw AirshipErrors.error("Invalid response body \(String(describing: data))") + } + + return try JSONDecoder().decode(AdditionalAudienceCheckResult.self, from: data) + } + } + + fileprivate struct RequestBody: Encodable { + let channelID: String + let contactID: String + let namedUserID: String? + let context: AirshipJSON? + + enum CodingKeys: String, CodingKey { + case channelID = "channel_id" + case contactID = "contact_id" + case namedUserID = "named_user_id" + case context + } + } +} diff --git a/Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerResolver.swift b/Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerResolver.swift new file mode 100644 index 000000000..f622f716f --- /dev/null +++ b/Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerResolver.swift @@ -0,0 +1,139 @@ +/* Copyright Airship and Contributors */ + +import Foundation +#if canImport(AirshipCore) +import AirshipCore +#endif + +protocol AdditionalAudienceCheckerResolverProtocol: AnyActor { + func resolve( + deviceInfoProvider: AudienceDeviceInfoProvider, + additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? + ) async throws -> Bool +} + +actor AdditionalAudienceCheckerResolver: AdditionalAudienceCheckerResolverProtocol { + private let cache: AirshipCache + private let apiClient: AdditionalAudienceCheckerAPIClientProtocol + + private let date: AirshipDateProtocol + private var inProgress: Task? + private let configProvider: () -> RemoteConfig.AdditionalAudienceCheckConfig? + + private var additionalAudienceConfig: RemoteConfig.AdditionalAudienceCheckConfig? { + get { + configProvider() + } + } + + init( + config: RuntimeConfig, + cache: AirshipCache, + date: AirshipDateProtocol = AirshipDate.shared + ) { + self.cache = cache + self.apiClient = AdditionalAudienceCheckerAPIClient(config: config) + self.date = date + self.configProvider = { + config.remoteConfig.iaaConfig?.additionalAudienceConfig + } + } + + /// Testing + init( + cache: AirshipCache, + apiClient: AdditionalAudienceCheckerAPIClientProtocol, + date: AirshipDateProtocol, + configProvider: @escaping () -> RemoteConfig.AdditionalAudienceCheckConfig? + ) { + self.cache = cache + self.apiClient = apiClient + self.date = date + self.configProvider = configProvider + } + + func resolve( + deviceInfoProvider: AudienceDeviceInfoProvider, + additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? + ) async throws -> Bool { + + guard + let config = additionalAudienceConfig, + config.isEnabled + else { + return true + } + + guard + let urlString = additionalAudienceCheckOverrides?.url ?? config.url, + let url = URL(string: urlString) + else { + AirshipLogger.warn("Failed to parse additional audience check url " + + String(describing: additionalAudienceCheckOverrides) + ", " + + String(describing: config) + ")") + throw AirshipErrors.error("Missing additional audience check url") + } + + guard additionalAudienceCheckOverrides?.bypass != true else { + AirshipLogger.trace("Additional audience check is bypassed") + return true + } + let context = additionalAudienceCheckOverrides?.context ?? config.context + + _ = try? await inProgress?.value + let task = Task { + return try await doResolve( + url: url, + context: context, + deviceInfoProvider: deviceInfoProvider + ) + } + + inProgress = task + return try await task.value + } + + private func doResolve( + url: URL, + context: AirshipJSON?, + deviceInfoProvider: AudienceDeviceInfoProvider + ) async throws -> Bool { + + let channelID = try await deviceInfoProvider.channelID + let contactInfo = await deviceInfoProvider.stableContactInfo + + let cacheKey = try cacheKey( + url: url.absoluteString, + context: context ?? .null, + contactID: contactInfo.contactID, + channelID: channelID + ) + + if let cached: AdditionalAudienceCheckResult = await cache.getCachedValue(key: cacheKey) { + return cached.isMatched + } + + let request = AdditionalAudienceCheckResult.Request( + url: url, + channelID: channelID, + contactID: contactInfo.contactID, + namedUserID: contactInfo.namedUserID, + context: context + ) + + let response = try await apiClient.resolve(info: request) + + if response.isSuccess, let result = response.result { + await cache.setCachedValue(result, key: cacheKey, ttl: result.cacheTTL) + return result.isMatched + } else if response.isServerError { + throw AirshipErrors.error("Failed to perform additional check due to server error \(response)") + } else { + return false + } + } + + private func cacheKey(url: String, context: AirshipJSON, contactID: String, channelID: String) throws -> String { + return String([url, try context.toString(), contactID, channelID].joined(separator: ":")) + } +} diff --git a/Airship/AirshipAutomation/Source/Automation/AutomationAudience.swift b/Airship/AirshipAutomation/Source/Automation/AutomationAudience.swift index 9549f3bca..82cd03bca 100644 --- a/Airship/AirshipAutomation/Source/Automation/AutomationAudience.swift +++ b/Airship/AirshipAutomation/Source/Automation/AutomationAudience.swift @@ -50,4 +50,14 @@ public struct AutomationAudience: Codable, Sendable, Equatable { } } +struct AdditionalAudienceCheckOverrides: Codable, Sendable, Equatable { + let bypass: Bool? + let context: AirshipJSON? + let url: String? + + enum CodingKeys: String, CodingKey { + case bypass, context, url + } +} + diff --git a/Airship/AirshipAutomation/Source/Automation/AutomationSchedule.swift b/Airship/AirshipAutomation/Source/Automation/AutomationSchedule.swift index b51b26541..09721dde4 100644 --- a/Airship/AirshipAutomation/Source/Automation/AutomationSchedule.swift +++ b/Airship/AirshipAutomation/Source/Automation/AutomationSchedule.swift @@ -66,6 +66,7 @@ public struct AutomationSchedule: Sendable, Codable, Equatable { public var editGracePeriodDays: UInt? /// internal + let additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? var metadata: AirshipJSON? var frequencyConstraintIDs: [String]? var messageType: String? @@ -103,6 +104,7 @@ public struct AutomationSchedule: Sendable, Codable, Equatable { case message case minSDKVersion = "min_sdk_version" case queue + case additionalAudienceCheckOverrides = "additional_audience_check_overrides" } enum ScheduleType: String, Codable { @@ -164,6 +166,7 @@ public struct AutomationSchedule: Sendable, Codable, Equatable { self.reportingContext = nil self.productID = nil self.queue = nil + self.additionalAudienceCheckOverrides = nil } init( @@ -189,7 +192,8 @@ public struct AutomationSchedule: Sendable, Codable, Equatable { frequencyConstraintIDs: [String]? = nil, messageType: String? = nil, minSDKVersion: String? = nil, - queue: String? = nil + queue: String? = nil, + additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? = nil ) { self.identifier = identifier self.triggers = triggers @@ -213,6 +217,7 @@ public struct AutomationSchedule: Sendable, Codable, Equatable { self.created = created self.minSDKVersion = minSDKVersion self.queue = queue + self.additionalAudienceCheckOverrides = additionalAudienceCheckOverrides } public init(from decoder: Decoder) throws { @@ -238,6 +243,7 @@ public struct AutomationSchedule: Sendable, Codable, Equatable { self.messageType = try container.decodeIfPresent(String.self, forKey: .messageType) self.minSDKVersion = try container.decodeIfPresent(String.self, forKey: .minSDKVersion) self.queue = try container.decodeIfPresent(String.self, forKey: .queue) + self.additionalAudienceCheckOverrides = try container.decodeIfPresent(AdditionalAudienceCheckOverrides.self, forKey: .additionalAudienceCheckOverrides) let scheduleType = try container.decode(ScheduleType.self, forKey: .scheduleType) switch(scheduleType) { @@ -292,6 +298,7 @@ public struct AutomationSchedule: Sendable, Codable, Equatable { try container.encodeIfPresent(self.messageType, forKey: .messageType) try container.encodeIfPresent(self.minSDKVersion, forKey: .minSDKVersion) try container.encodeIfPresent(self.queue, forKey: .queue) + try container.encodeIfPresent(self.additionalAudienceCheckOverrides, forKey: .additionalAudienceCheckOverrides) switch(self.data) { case .actions(let actions): diff --git a/Airship/AirshipAutomation/Source/Automation/AutomationTrigger.swift b/Airship/AirshipAutomation/Source/Automation/AutomationTrigger.swift index d89103fd8..d867af1a5 100644 --- a/Airship/AirshipAutomation/Source/Automation/AutomationTrigger.swift +++ b/Airship/AirshipAutomation/Source/Automation/AutomationTrigger.swift @@ -7,7 +7,7 @@ import AirshipCore #endif /// Automation trigger types -public enum EventAutomationTriggerType: String, Sendable, Codable, Equatable { +public enum EventAutomationTriggerType: String, Sendable, Codable, Equatable, CaseIterable { /// Foreground case foreground @@ -40,6 +40,42 @@ public enum EventAutomationTriggerType: String, Sendable, Codable, Equatable { /// Active session case activeSession = "active_session" + + /// IAX display + case inAppDisplay = "in_app_display" + + /// IAX resolution + case inAppResolution = "in_app_resolution" + + /// IAX button tap + case inAppButtonTap = "in_app_button_tap" + + /// IAX permission result + case inAppPermissionResult = "in_app_permission_result" + + /// IAX form display + case inAppFormDisplay = "in_app_form_display" + + /// IAX form result + case inAppFormResult = "in_app_form_result" + + /// IAX gesture + case inAppGesture = "in_app_gesture" + + /// IAX pager completed + case inAppPagerCompleted = "in_app_pager_completed" + + /// IAX pager summary + case inAppPagerSummary = "in_app_pager_summary" + + /// IAX page swipe + case inAppPageSwipe = "in_app_page_swipe" + + /// IAX page view + case inAppPageView = "in_app_page_view" + + /// IAX page action + case inAppPageAction = "in_app_page_action" } public enum CompoundAutomationTriggerType: String, Sendable, Codable, Equatable { diff --git a/Airship/AirshipAutomation/Source/Automation/Engine/AutomationEventFeed.swift b/Airship/AirshipAutomation/Source/Automation/Engine/AutomationEventFeed.swift index 8b3c36034..48137e34c 100644 --- a/Airship/AirshipAutomation/Source/Automation/Engine/AutomationEventFeed.swift +++ b/Airship/AirshipAutomation/Source/Automation/Engine/AutomationEventFeed.swift @@ -16,30 +16,13 @@ struct TriggerableState: Equatable, Codable { } enum AutomationEvent: Sendable, Equatable { - case foreground - case background - case screenView(name: String?) - case appInit case stateChanged(state: TriggerableState) - case regionEnter(data: AirshipJSON) - case regionExit(data: AirshipJSON) - case customEvent(data: AirshipJSON, value: Double?) - case featureFlagInterracted(data: AirshipJSON) + case event(type: EventAutomationTriggerType, data: AirshipJSON? = nil, value: Double = 1.0) - func reportPayload() -> AirshipJSON? { + var eventData: AirshipJSON? { switch self { - case .foreground, .background, .appInit, .stateChanged: - return nil - case .screenView(let name): - return try? AirshipJSON.wrap(name) - case .regionEnter(let data): - return data - case .regionExit(let data): - return data - case .customEvent(let data, _): - return data - case .featureFlagInterracted(let data): - return data + case .event(_, let data, _): return data + default: return nil } } } @@ -60,11 +43,6 @@ final class AutomationEventFeed: AutomationEventFeedProtocol { private var appSessionState = TriggerableState() private var regions: Set = Set() - fileprivate enum AnalyticEventFilter: String { - case featureFlagInteraction = "feature_flag_interaction" - case customEvent = "enhanced_custom_event" - } - let feed: Stream init( @@ -86,7 +64,7 @@ final class AutomationEventFeed: AutomationEventFeedProtocol { if !isFirstAttach { isFirstAttach = true - self.continuation.yield(.appInit) + self.continuation.yield(.event(type: .appInit)) if applicationMetrics.isAppVersionUpdated, @@ -125,47 +103,28 @@ final class AutomationEventFeed: AutomationEventFeedProtocol { guard !Task.isCancelled else { return } if (state == .active) { - await onEvent(.foreground) + await onEvent(.event(type: .foreground)) } if (state == .background) { - await onEvent(.background) + await onEvent(.event(type: .background)) } } } group.addTask { - for await event in analyticsFeed.updates { + for await event in await analyticsFeed.updates { guard !Task.isCancelled else { return } - if let event = await Self.parseEvent(event: event) { - await onEvent(event) + guard let converted = event.toAutomationEvent() else { return } + + for item in converted { + await onEvent(item) } } } } } } - - - private class func parseEvent(event: AirshipAnalyticsFeed.Event) -> AutomationEvent? { - switch(event) { - case .customEvent(body: let body, value: let value): - return .customEvent(data: body, value: value) - case .regionEnter(body: let body): - return .regionEnter(data: body) - case .regionExit(body: let body): - return .regionExit(data: body) - case .featureFlagInteraction(body: let body): - return .featureFlagInterracted(data: body) - case .screenChange(screen: let screen): - return .screenView(name: screen) -#if canImport(AirshipCore) - @unknown default: - return nil -#endif - } - - } private func setAppSessionID(_ id: String?) { guard self.appSessionState.appSessionID != id else { return } @@ -177,12 +136,69 @@ final class AutomationEventFeed: AutomationEventFeedProtocol { self.continuation.yield(event) switch event { - case .foreground: - self.setAppSessionID(UUID().uuidString) - case .background: - self.setAppSessionID(nil) + case .event(let type, _, _): + switch type { + case .foreground: + self.setAppSessionID(UUID().uuidString) + case .background: + self.setAppSessionID(nil) + default: break + } default: break } } +} +private extension AirshipAnalyticsFeed.Event { + + func toAutomationEvent() -> [AutomationEvent]? { + switch self { + case .screen(let screen): + return [.event(type: .screen, data: try? AirshipJSON.wrap(screen))] + case .analytics(let eventType, let body, let value): + switch eventType { + case .regionEnter: + return [.event(type: .regionEnter, data: body)] + case .regionExit: + return [.event(type: .regionExit, data: body)] + case .customEvent: + return [ + .event(type: .customEventCount, data: body), + .event(type: .customEventValue, data: body, value: value ?? 1.0) + ] + case .featureFlagInteraction: + return [.event(type: .featureFlagInteraction, data: body)] + case .inAppDisplay: + return [.event(type: .inAppDisplay, data: body)] + case .inAppResolution: + return [.event(type: .inAppResolution, data: body)] + case .inAppButtonTap: + return [.event(type: .inAppButtonTap, data: body)] + case .inAppPermissionResult: + return [.event(type: .inAppPermissionResult, data: body)] + case .inAppFormDisplay: + return [.event(type: .inAppFormDisplay, data: body)] + case .inAppFormResult: + return [.event(type: .inAppFormResult, data: body)] + case .inAppGesture: + return [.event(type: .inAppGesture, data: body)] + case .inAppPagerCompleted: + return [.event(type: .inAppPagerCompleted, data: body)] + case .inAppPagerSummary: + return [.event(type: .inAppPagerSummary, data: body)] + case .inAppPageSwipe: + return [.event(type: .inAppPageSwipe, data: body)] + case .inAppPageView: + return [.event(type: .inAppPageView, data: body)] + case .inAppPageAction: + return [.event(type: .inAppPageAction, data: body)] + default: + return nil + } +#if canImport(AirshipCore) + @unknown default: + return nil +#endif + } + } } diff --git a/Airship/AirshipAutomation/Source/Automation/Engine/AutomationPreparer.swift b/Airship/AirshipAutomation/Source/Automation/Engine/AutomationPreparer.swift index 02c6557c7..ffe0aa90a 100644 --- a/Airship/AirshipAutomation/Source/Automation/Engine/AutomationPreparer.swift +++ b/Airship/AirshipAutomation/Source/Automation/Engine/AutomationPreparer.swift @@ -37,7 +37,9 @@ struct AutomationPreparer: AutomationPreparerProtocol { private let audienceChecker: DeviceAudienceChecker private let experiments: ExperimentDataProvider private let remoteDataAccess: AutomationRemoteDataAccessProtocol - private let queues: Queues = Queues() + private let queues: Queues + private let config: RuntimeConfig + private let additionalAudienceResolver: AdditionalAudienceCheckerResolverProtocol private static let deferredResultKey: String = "AirshipAutomation#deferredResult" private static let defaultMessageType: String = "transactional" @@ -52,9 +54,11 @@ struct AutomationPreparer: AutomationPreparerProtocol { audienceChecker: DeviceAudienceChecker = DefaultDeviceAudienceChecker(), experiments: ExperimentDataProvider, remoteDataAccess: AutomationRemoteDataAccessProtocol, + config: RuntimeConfig, deviceInfoProviderFactory: @escaping @Sendable (String?) -> AudienceDeviceInfoProvider = { contactID in CachingAudienceDeviceInfoProvider(contactID: contactID) - } + }, + additionalAudienceResolver: AdditionalAudienceCheckerResolverProtocol ) { self.actionPreparer = actionPreparer self.messagePreparer = messagePreparer @@ -64,6 +68,9 @@ struct AutomationPreparer: AutomationPreparerProtocol { self.experiments = experiments self.remoteDataAccess = remoteDataAccess self.deviceInfoProviderFactory = deviceInfoProviderFactory + self.config = config + self.queues = Queues(config: config) + self.additionalAudienceResolver = additionalAudienceResolver } func cancelled(schedule: AutomationSchedule) async { @@ -82,6 +89,7 @@ struct AutomationPreparer: AutomationPreparerProtocol { AirshipLogger.trace("Preparing \(schedule.identifier)") let queue = await self.queues.queue(name: schedule.queue) + return await queue.run(name: "schedule: \(schedule.identifier)") { retryState in guard await !self.remoteDataAccess.requiresUpdate(schedule: schedule) else { @@ -124,6 +132,7 @@ struct AutomationPreparer: AutomationPreparerProtocol { ) if (!match) { + AirshipLogger.trace("Local audience miss \(schedule.identifier)") return .success( result: schedule.missedAudiencePrepareResult, ignoreReturnOrder: true @@ -143,52 +152,66 @@ struct AutomationPreparer: AutomationPreparerProtocol { nil } - let scheduleInfo = PreparedScheduleInfo( - scheduleID: schedule.identifier, - productID: schedule.productID, - campaigns: schedule.campaigns, - contactID: await deviceInfoProvider.stableContactID, - experimentResult: experimentResult, - reportingContext: schedule.reportingContext, - triggerSessionID: triggerSessionID - ) - AirshipLogger.trace("Preparing data \(schedule.identifier)") return try await self.prepareData( data: schedule.data, - triggerContext: triggerContext, - deviceInfoProvider: deviceInfoProvider, - scheduleInfo: scheduleInfo, - frequencyChecker: frequencyChecker, schedule: schedule, - retryState: retryState + retryState: retryState, + deferredRequest: { url in + DeferredRequest( + url: url, + channelID: try await deviceInfoProvider.channelID, + triggerContext: triggerContext, + locale: deviceInfoProvider.locale, + notificationOptIn: await deviceInfoProvider.isUserOptedInPushNotifications + ) + }, + prepareScheduleInfo: { + let result = try await additionalAudienceResolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: schedule.additionalAudienceCheckOverrides + ) + + return PreparedScheduleInfo( + scheduleID: schedule.identifier, + productID: schedule.productID, + campaigns: schedule.campaigns, + contactID: await deviceInfoProvider.stableContactInfo.contactID, + experimentResult: experimentResult, + reportingContext: schedule.reportingContext, + triggerSessionID: triggerSessionID, + additionalAudienceCheckResult: result + ) + }, + prepareSchedule: { [frequencyChecker] scheduleInfo, data in + return PreparedSchedule( + info: scheduleInfo, + data: data, + frequencyChecker: frequencyChecker + ) + } ) } } private func prepareData( data: AutomationSchedule.ScheduleData, - triggerContext: AirshipTriggerContext?, - deviceInfoProvider: AudienceDeviceInfoProvider, - scheduleInfo: PreparedScheduleInfo, - frequencyChecker: FrequencyCheckerProtocol?, schedule: AutomationSchedule, - retryState: RetryingQueue.State + retryState: RetryingQueue.State, + deferredRequest: @escaping @Sendable (URL) async throws -> DeferredRequest, + prepareScheduleInfo: @escaping @Sendable () async throws -> PreparedScheduleInfo, + prepareSchedule: @escaping @Sendable (PreparedScheduleInfo, PreparedScheduleData) -> PreparedSchedule ) async throws -> RetryingQueue.Result { switch (data) { case .actions(let data): + let preparedInfo = try await prepareScheduleInfo() let result = try await self.actionPreparer.prepare( data: data, - preparedScheduleInfo: scheduleInfo - ) - - let preparedSchedule = PreparedSchedule( - info: scheduleInfo, - data: .actions(result), - frequencyChecker: frequencyChecker + preparedScheduleInfo: preparedInfo ) + let preparedSchedule = prepareSchedule(preparedInfo, .actions(result)) return .success(result: .prepared(preparedSchedule)) case .inAppMessage(let data): @@ -197,64 +220,46 @@ struct AutomationPreparer: AutomationPreparerProtocol { return .success(result: .skip) } + let preparedInfo = try await prepareScheduleInfo() let result = try await self.messagePreparer.prepare( data: data, - preparedScheduleInfo: scheduleInfo + preparedScheduleInfo: preparedInfo ) - let preparedSchedule = PreparedSchedule( - info: scheduleInfo, - data: .inAppMessage(result), - frequencyChecker: frequencyChecker - ) + let preparedSchedule = prepareSchedule(preparedInfo, .inAppMessage(result)) return .success(result: .prepared(preparedSchedule)) case .deferred(let deferred): return try await self.prepareDeferred( deferred: deferred, - triggerContext: triggerContext, - deviceInfoProvider: deviceInfoProvider, schedule: schedule, - frequencyChecker: frequencyChecker, - retryState: retryState + retryState: retryState, + deferredRequest: deferredRequest ) { data in try await self.prepareData( data: data, - triggerContext: triggerContext, - deviceInfoProvider: deviceInfoProvider, - scheduleInfo: scheduleInfo, - frequencyChecker: frequencyChecker, schedule: schedule, - retryState: retryState + retryState: retryState, + deferredRequest: deferredRequest, + prepareScheduleInfo: prepareScheduleInfo, + prepareSchedule: prepareSchedule ) } } } + private func prepareDeferred( deferred: DeferredAutomationData, - triggerContext: AirshipTriggerContext?, - deviceInfoProvider: AudienceDeviceInfoProvider, schedule: AutomationSchedule, - frequencyChecker: FrequencyCheckerProtocol?, retryState: RetryingQueue.State, + deferredRequest: @escaping @Sendable (URL) async throws -> DeferredRequest, onResult: @escaping @Sendable (AutomationSchedule.ScheduleData) async throws -> RetryingQueue.Result ) async throws -> RetryingQueue.Result { AirshipLogger.trace("Resolving deferred \(schedule.identifier)") - guard let channelID = deviceInfoProvider.channelID else { - AirshipLogger.info("Unable to resolve deferred until channel is created") - return .retry - } - - let request = DeferredRequest( - url: deferred.url, - channelID: channelID, - triggerContext: triggerContext, - locale: deviceInfoProvider.locale, - notificationOptIn: await deviceInfoProvider.isUserOptedInPushNotifications - ) + let request = try await deferredRequest(deferred.url) if let cached: AutomationSchedule.ScheduleData = await retryState.value(key: Self.deferredResultKey) { AirshipLogger.trace("Deferred resolved from cache \(schedule.identifier)") @@ -334,8 +339,16 @@ fileprivate extension AutomationSchedule { fileprivate actor Queues { var queues: [String: RetryingQueue] = [:] - let defaultQueue: RetryingQueue = RetryingQueue() + lazy var defaultQueue: RetryingQueue = { + return RetryingQueue(config: config.remoteConfig.iaaConfig?.retryingQueue) + }() + private let config: RuntimeConfig + @MainActor + init(config: RuntimeConfig) { + self.config = config + } + func queue(name: String?) -> RetryingQueue { guard let name = name, !name.isEmpty else { return defaultQueue @@ -345,7 +358,7 @@ fileprivate actor Queues { return queue } - let queue: RetryingQueue = RetryingQueue() + let queue: RetryingQueue = RetryingQueue(config: config.remoteConfig.iaaConfig?.retryingQueue) queues[name] = queue return queue } diff --git a/Airship/AirshipAutomation/Source/Automation/Engine/PreparedSchedule.swift b/Airship/AirshipAutomation/Source/Automation/Engine/PreparedSchedule.swift index 9b0edbfcc..e067e80ee 100644 --- a/Airship/AirshipAutomation/Source/Automation/Engine/PreparedSchedule.swift +++ b/Airship/AirshipAutomation/Source/Automation/Engine/PreparedSchedule.swift @@ -23,6 +23,7 @@ struct PreparedScheduleInfo: Codable, Equatable { var experimentResult: ExperimentResult? var reportingContext: AirshipJSON? var triggerSessionID: String + var additionalAudienceCheckResult: Bool init( scheduleID: String, @@ -31,7 +32,8 @@ struct PreparedScheduleInfo: Codable, Equatable { contactID: String? = nil, experimentResult: ExperimentResult? = nil, reportingContext: AirshipJSON? = nil, - triggerSessionID: String + triggerSessionID: String, + additionalAudienceCheckResult: Bool = true ) { self.scheduleID = scheduleID self.productID = productID @@ -40,6 +42,7 @@ struct PreparedScheduleInfo: Codable, Equatable { self.experimentResult = experimentResult self.reportingContext = reportingContext self.triggerSessionID = triggerSessionID + self.additionalAudienceCheckResult = additionalAudienceCheckResult } init(from decoder: any Decoder) throws { @@ -51,6 +54,7 @@ struct PreparedScheduleInfo: Codable, Equatable { self.experimentResult = try container.decodeIfPresent(ExperimentResult.self, forKey: .experimentResult) self.reportingContext = try container.decodeIfPresent(AirshipJSON.self, forKey: .reportingContext) self.triggerSessionID = try container.decodeIfPresent(String.self, forKey: .triggerSessionID) ?? UUID().uuidString + self.additionalAudienceCheckResult = try container.decodeIfPresent(Bool.self, forKey: .additionalAudienceCheckResult) ?? true } } diff --git a/Airship/AirshipAutomation/Source/Automation/Engine/TriggerProcessor/PreparedTrigger.swift b/Airship/AirshipAutomation/Source/Automation/Engine/TriggerProcessor/PreparedTrigger.swift index 130868259..5097e11c4 100644 --- a/Airship/AirshipAutomation/Source/Automation/Engine/TriggerProcessor/PreparedTrigger.swift +++ b/Airship/AirshipAutomation/Source/Automation/Engine/TriggerProcessor/PreparedTrigger.swift @@ -67,7 +67,7 @@ final class PreparedTrigger { return EventProcessResult( triggerData: triggerData, - triggerResult: match?.isTriggered == true ? generateTriggerResult(for: event) : nil, + triggerResult: match?.isTriggered == true ? generateTriggerResult(eventData: event.eventData ?? .null) : nil, priority: self.priority ) } @@ -99,7 +99,7 @@ final class PreparedTrigger { self.isActive = false } - private func generateTriggerResult(for event: AutomationEvent) -> TriggerResult { + private func generateTriggerResult(eventData: AirshipJSON) -> TriggerResult { return TriggerResult( scheduleID: self.scheduleID, triggerExecutionType: self.executionType, @@ -107,7 +107,7 @@ final class PreparedTrigger { context: AirshipTriggerContext( type: trigger.type, goal: trigger.goal, - event: event.reportPayload() ?? AirshipJSON.null), + event: eventData), date: self.date.now ) ) @@ -141,29 +141,13 @@ extension EventAutomationTrigger { switch event { case .stateChanged(let state): return stateTriggerMatch(state: state, data: &data) - case .foreground: - guard self.type == .foreground else { return nil } - return evaluateResults(data: &data, increment: 1) - case .background: - guard self.type == .background else { return nil } - return evaluateResults(data: &data, increment: 1) - case .appInit: - guard self.type == .appInit else { return nil } - return evaluateResults(data: &data, increment: 1) - case .screenView(let name): - guard self.type == .screen, isPredicateMatching(value: name) else { return nil } - return evaluateResults(data: &data, increment: 1) - case .regionEnter(let eventData): - guard self.type == .regionEnter, isPredicateMatching(value: eventData) else { return nil } - return evaluateResults(data: &data, increment: 1) - case .regionExit(let eventData): - guard self.type == .regionExit, isPredicateMatching(value: eventData) else { return nil } - return evaluateResults(data: &data, increment: 1) - case .customEvent(let eventData, let value): - return customEvenTriggerMatch(eventData: eventData, value: value, data: &data) - case .featureFlagInterracted(let eventData): - guard self.type == .featureFlagInteraction, isPredicateMatching(value: eventData) else { return nil } - return evaluateResults(data: &data, increment: 1) + case .event(let type, let eventData, let value): + guard + self.type == type, + isPredicateMatching(value: eventData) + else { return nil } + + return evaluateResults(data: &data, increment: value) } } diff --git a/Airship/AirshipAutomation/Source/AutomationSDKModule.swift b/Airship/AirshipAutomation/Source/AutomationSDKModule.swift index 0deae397b..a51e4f1e0 100644 --- a/Airship/AirshipAutomation/Source/AutomationSDKModule.swift +++ b/Airship/AirshipAutomation/Source/AutomationSDKModule.swift @@ -27,6 +27,7 @@ public class AutomationSDKModule: NSObject, AirshipSDKModule { let messageSceneManager = InAppMessageSceneManager(sceneManger: sceneManager) let airshipAnalytics = dependencies[SDKDependencyKeys.analytics] as! InternalAnalyticsProtocol let meteredUsage = dependencies[SDKDependencyKeys.meteredUsage] as! AirshipMeteredUsageProtocol + let cache = dependencies[SDKDependencyKeys.cache] as! AirshipCache /// Utils let remoteDataAccess = AutomationRemoteDataAccess(remoteData: remoteData) @@ -58,7 +59,12 @@ public class AutomationSDKModule: NSObject, AirshipSDKModule { deferredResolver: deferredResolver, frequencyLimits: frequencyLimits, experiments: experiments, - remoteDataAccess: remoteDataAccess + remoteDataAccess: remoteDataAccess, + config: config, + additionalAudienceResolver: AdditionalAudienceCheckerResolver( + config: config, + cache: cache + ) ) diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppButtonTapEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppButtonTapEvent.swift index 89f92e752..7fefeae75 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppButtonTapEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppButtonTapEvent.swift @@ -7,7 +7,7 @@ import AirshipCore #endif struct InAppButtonTapEvent: InAppEvent { - let name: String = "in_app_button_tap" + let name = EventType.inAppButtonTap let data: (Sendable&Encodable)? init(identifier: String, reportingMetadata: AirshipJSON?) { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppDisplayEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppDisplayEvent.swift index a6c0e2bce..d954d1f09 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppDisplayEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppDisplayEvent.swift @@ -1,8 +1,11 @@ /* Copyright Airship and Contributors */ import Foundation +#if canImport(AirshipCore) +import AirshipCore +#endif struct InAppDisplayEvent: InAppEvent { - let name: String = "in_app_display" + let name = EventType.inAppDisplay let data: (Sendable&Encodable)? = nil } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppEvent.swift index 09fda88ea..3e62d1853 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppEvent.swift @@ -1,10 +1,13 @@ /* Copyright Airship and Contributors */ import Foundation +#if canImport(AirshipCore) +import AirshipCore +#endif /// NOTE: For internal use only. :nodoc: protocol InAppEvent: Sendable { - var name: String { get } + var name: EventType { get } var data: (any Sendable&Encodable)? { get } } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormDisplayEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormDisplayEvent.swift index 3dc6a7510..9f6db9275 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormDisplayEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormDisplayEvent.swift @@ -7,7 +7,7 @@ import AirshipCore #endif struct InAppFormDisplayEvent: InAppEvent { - let name: String = "in_app_form_display" + let name = EventType.inAppFormDisplay let data: (Sendable&Encodable)? init(formInfo: ThomasFormInfo) { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormResultEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormResultEvent.swift index 9e567e94e..706b22625 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormResultEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppFormResultEvent.swift @@ -7,7 +7,7 @@ import AirshipCore #endif struct InAppFormResultEvent: InAppEvent { - let name: String = "in_app_form_result" + let name = EventType.inAppFormResult let data: (Sendable&Encodable)? init(forms: AirshipJSON) { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppGestureEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppGestureEvent.swift index 42fd4e34f..9f4000018 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppGestureEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppGestureEvent.swift @@ -7,7 +7,7 @@ import AirshipCore #endif struct InAppGestureEvent: InAppEvent { - let name: String = "in_app_gesture" + let name = EventType.inAppGesture let data: (Sendable&Encodable)? diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageActionEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageActionEvent.swift index 0af0505c4..176ef7d1e 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageActionEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageActionEvent.swift @@ -7,7 +7,7 @@ import AirshipCore #endif struct InAppPageActionEvent: InAppEvent { - let name: String = "in_app_page_action" + let name = EventType.inAppPageAction let data: (Sendable&Encodable)? diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageSwipeEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageSwipeEvent.swift index bdf294c8e..c37cf490d 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageSwipeEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageSwipeEvent.swift @@ -9,7 +9,7 @@ import AirshipCore struct InAppPageSwipeEvent: InAppEvent { - let name: String = "in_app_page_swipe" + let name = EventType.inAppPageSwipe let data: (Sendable&Encodable)? init(from: ThomasPagerInfo, to: ThomasPagerInfo) { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageViewEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageViewEvent.swift index f60500a96..b5a862f99 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageViewEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPageViewEvent.swift @@ -7,7 +7,7 @@ import AirshipCore #endif struct InAppPageViewEvent: InAppEvent { - let name: String = "in_app_page_view" + let name = EventType.inAppPageView let data: (Sendable&Encodable)? init(pagerInfo: ThomasPagerInfo, viewCount: Int) { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerCompletedEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerCompletedEvent.swift index 34a25b0e1..9ed35ec8a 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerCompletedEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerCompletedEvent.swift @@ -8,7 +8,7 @@ import AirshipCore #endif struct InAppPagerCompletedEvent: InAppEvent { - let name: String = "in_app_pager_completed" + let name = EventType.inAppPagerCompleted let data: (Sendable&Encodable)? init(pagerInfo: ThomasPagerInfo) { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerSummaryEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerSummaryEvent.swift index bef84e2af..6df648438 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerSummaryEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPagerSummaryEvent.swift @@ -31,7 +31,7 @@ struct PageViewSummary: Encodable, Sendable, Equatable { } struct InAppPagerSummaryEvent: InAppEvent { - let name: String = "in_app_pager_summary" + let name = EventType.inAppPagerSummary let data: (Sendable&Encodable)? init(pagerInfo: ThomasPagerInfo, viewedPages: [PageViewSummary]) { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPermissionResultEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPermissionResultEvent.swift index a50266869..20cb5a0a5 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPermissionResultEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppPermissionResultEvent.swift @@ -7,7 +7,7 @@ import AirshipCore #endif struct InAppPermissionResultEvent: InAppEvent { - let name: String = "in_app_permission_result" + let name = EventType.inAppPermissionResult let data: (Sendable&Encodable)? init( diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppResolutionEvent.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppResolutionEvent.swift index e4a299c3b..0af0e4262 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppResolutionEvent.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/Events/InAppResolutionEvent.swift @@ -8,7 +8,7 @@ import AirshipCore struct InAppResolutionEvent: InAppEvent { - let name: String = "in_app_resolution" + let name = EventType.inAppResolution let data: (Sendable&Encodable)? private init(data: Sendable&Encodable) { @@ -83,6 +83,15 @@ struct InAppResolutionEvent: InAppEvent { ) } + static func audienceExcluded() -> InAppResolutionEvent { + return InAppResolutionEvent( + data: ResolutionData( + resolutionType: .audienceCheckExcluded, + displayTime: 0.0 + ) + ) + } + private struct DeviceInfo: Encodable, Sendable { var channel: String? var contact: String? @@ -102,6 +111,7 @@ struct InAppResolutionEvent: InAppEvent { case timedOut case interrupted case control + case audienceCheckExcluded } let resolutionType: ResolutionType @@ -147,6 +157,8 @@ struct InAppResolutionEvent: InAppEvent { try resolution.encode("interrupted", forKey: .resolutionType) case .control: try resolution.encode("control", forKey: .resolutionType) + case .audienceCheckExcluded: + try resolution.encode("audience_check_excluded", forKey: .resolutionType) } } } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/InAppMessageAnalytics.swift b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/InAppMessageAnalytics.swift index dd3b1c621..b3aa89c11 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Analytics/InAppMessageAnalytics.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Analytics/InAppMessageAnalytics.swift @@ -37,9 +37,6 @@ final class LoggingInAppMessageAnalytics: InAppMessageAnalyticsProtocol { } final class InAppMessageAnalytics: InAppMessageAnalyticsProtocol { - - - private let preparedScheduleInfo: PreparedScheduleInfo private let messageID: InAppEventMessageID private let source: InAppEventSource diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/AirshipLayoutDisplayAdapter.swift b/Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/AirshipLayoutDisplayAdapter.swift index 2b266812b..6bbe31728 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/AirshipLayoutDisplayAdapter.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/AirshipLayoutDisplayAdapter.swift @@ -14,6 +14,12 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter { private let actionRunner: InternalInAppActionRunner? private let networkChecker: AirshipNetworkCheckerProtocol + @MainActor + var themeManager: InAppAutomationThemeManager { + return InAppAutomation.shared.inAppMessaging.themeManager + } + + init( message: InAppMessage, assets: AirshipCachedAssetsProtocol, @@ -170,9 +176,10 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter { continuation.resume(returning: result) } + let theme = self.themeManager.makeBannerTheme(message: self.message) + let environment = InAppMessageEnvironment( delegate: listener, - theme: Theme.banner(BannerTheme()), extensions: makeInAppExtensions() ) @@ -184,6 +191,7 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter { environment: environment, displayContent: banner, bannerConstraints: bannerConstraints, + theme: theme, onDismiss: dismissViewController ) @@ -204,6 +212,7 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter { } } + @MainActor private func displayModal( _ modal: InAppMessageDisplayContent.Modal, @@ -220,14 +229,15 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter { continuation.resume(returning: result) } + let theme = self.themeManager.makeModalTheme(message: self.message) + let environment = InAppMessageEnvironment( delegate: listener, - theme: Theme.modal(ModalTheme()), extensions: makeInAppExtensions() ) let rootView = InAppMessageRootView(inAppMessageEnvironment: environment) { orientation in - InAppMessageModalView(displayContent: modal) + InAppMessageModalView(displayContent: modal, theme: theme) } let viewController = InAppMessageHostingController(rootView: rootView) @@ -254,14 +264,15 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter { continuation.resume(returning: result) } + let theme = self.themeManager.makeFullscreenTheme(message: self.message) + let environment = InAppMessageEnvironment( delegate: listener, - theme: Theme.fullScreen(FullScreenTheme()), extensions: makeInAppExtensions() ) let rootView = InAppMessageRootView(inAppMessageEnvironment: environment) { orientation in - FullScreenView(displayContent: fullscreen) + FullscreenView(displayContent: fullscreen, theme: theme) } let viewController = InAppMessageHostingController(rootView: rootView) @@ -288,14 +299,15 @@ final class AirshipLayoutDisplayAdapter: DisplayAdapter { continuation.resume(returning: result) } + let theme = self.themeManager.makeHTMLTheme(message: self.message) + let environment = InAppMessageEnvironment( delegate: listener, - theme: Theme.html(HTMLTheme()), extensions: makeInAppExtensions() ) let rootView = InAppMessageRootView(inAppMessageEnvironment: environment) { orientation in - HTMLView(displayContent: html) + HTMLView(displayContent: html, theme: theme) } let viewController = InAppMessageHostingController(rootView: rootView) @@ -358,22 +370,3 @@ fileprivate class AssetCacheImageProvider : AirshipImageProvider { return imageData } } - -private extension UIWindow { - func addRootController( - _ viewController: T? - ) { - viewController?.modalPresentationStyle = UIModalPresentationStyle.automatic - viewController?.view.isUserInteractionEnabled = true - - if let viewController = viewController, - let rootController = self.rootViewController - { - rootController.addChild(viewController) - viewController.didMove(toParent: rootController) - rootController.view.addSubview(viewController.view) - } - - self.isUserInteractionEnabled = true - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationExecutor.swift b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationExecutor.swift index 1a6cb4f60..d7482c01b 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationExecutor.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationExecutor.swift @@ -88,6 +88,15 @@ final class InAppMessageAutomationExecutor: AutomationExecutorDelegate { data: PreparedInAppMessageData, preparedScheduleInfo: PreparedScheduleInfo ) async throws -> ScheduleExecuteResult { + guard preparedScheduleInfo.additionalAudienceCheckResult else { + AirshipLogger.info("Schedule \(preparedScheduleInfo.scheduleID) missed additional audience check") + data.analytics.recordEvent( + InAppResolutionEvent.audienceExcluded(), + layoutContext: nil + ) + return .finished + } + let scene = try self.sceneManager.scene(forMessage: data.message) // Display @@ -101,6 +110,7 @@ final class InAppMessageAutomationExecutor: AutomationExecutorDelegate { let experimentResult = preparedScheduleInfo.experimentResult if let experimentResult = experimentResult, experimentResult.isMatch { + AirshipLogger.info("Schedule \(preparedScheduleInfo.scheduleID) part of experiment") data.analytics.recordEvent( InAppResolutionEvent.control(experimentResult: experimentResult), layoutContext: nil diff --git a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationPreparer.swift b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationPreparer.swift index a365a3b66..7ba38f8ce 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationPreparer.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationPreparer.swift @@ -55,7 +55,8 @@ final class InAppMessageAutomationPreparer: AutomationPreparerDelegate { ) async throws -> PreparedInAppMessageData { let assets = try await self.prepareAssets( message: data, - scheduleID: preparedScheduleInfo.scheduleID + scheduleID: preparedScheduleInfo.scheduleID, + skip: preparedScheduleInfo.additionalAudienceCheckResult == false || preparedScheduleInfo.experimentResult?.isMatch == true ) let displayCoordinator = self.displayCoordinatorManager.displayCoordinator(message: data) @@ -85,15 +86,19 @@ final class InAppMessageAutomationPreparer: AutomationPreparerDelegate { await self.assetManager.clearCache(identifier: scheduleID) } - private func prepareAssets(message: InAppMessage, scheduleID: String) async throws -> AirshipCachedAssetsProtocol { + private func prepareAssets(message: InAppMessage, scheduleID: String, skip: Bool) async throws -> AirshipCachedAssetsProtocol { // - prepare assets - let imageURLs: [String] = message.urlInfos - .compactMap { info in - guard case .image(let url, let prefetch) = info, prefetch else { - return nil + let imageURLs: [String] = if skip { + [] + } else { + message.urlInfos + .compactMap { info in + guard case .image(let url, let prefetch) = info, prefetch else { + return nil + } + return url } - return url - } + } AirshipLogger.trace("Preparing assets \(scheduleID): \(imageURLs)") diff --git a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageEnvironment.swift b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageEnvironment.swift index 9b11380a1..604228349 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageEnvironment.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessageEnvironment.swift @@ -11,7 +11,6 @@ import AirshipCore class InAppMessageEnvironment: ObservableObject { private let delegate: InAppMessageViewDelegate - var theme: Theme @Published var imageLoader: AirshipImageLoader? let nativeBridgeExtension: NativeBridgeExtensionDelegate? @@ -22,11 +21,9 @@ class InAppMessageEnvironment: ObservableObject { @MainActor init( delegate: InAppMessageViewDelegate, - theme: Theme, extensions: InAppMessageExtensions? = nil ) { self.delegate = delegate - self.theme = theme self.imageLoader = if let provider = extensions?.imageProvider { AirshipImageLoader(imageProvider: provider) diff --git a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessaging.swift b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessaging.swift index 7df229837..3d958a404 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/InAppMessaging.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/InAppMessaging.swift @@ -8,6 +8,11 @@ import AirshipCore /// In-app messaging public protocol InAppMessagingProtocol: AnyObject, Sendable { + + /// Theme manager + @MainActor + var themeManager: InAppAutomationThemeManager { get } + /// Display interval @MainActor var displayInterval: TimeInterval { get set } @@ -55,6 +60,9 @@ final class InAppMessaging: InAppMessagingProtocol { let executor: InAppMessageAutomationExecutor let preparer: InAppMessageAutomationPreparer + @MainActor + let themeManager: InAppAutomationThemeManager = InAppAutomationThemeManager() + @MainActor var displayInterval: TimeInterval { get { diff --git a/Airship/AirshipAutomation/Source/InAppMessage/Legacy/LegacyInAppAnalytics.swift b/Airship/AirshipAutomation/Source/InAppMessage/Legacy/LegacyInAppAnalytics.swift index 2e974d33d..d3fb8e7ae 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/Legacy/LegacyInAppAnalytics.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/Legacy/LegacyInAppAnalytics.swift @@ -41,7 +41,7 @@ struct LegacyInAppAnalytics : LegacyInAppAnalyticsProtocol { } struct LegacyResolutionEvent : InAppEvent { - let name: String = "in_app_resolution" + let name = EventType.inAppResolution let data: (Encodable & Sendable)? diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/ButtonGroup.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/ButtonGroup.swift index de9adfeac..653893619 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/ButtonGroup.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/ButtonGroup.swift @@ -2,6 +2,10 @@ import SwiftUI +#if canImport(AirshipCore) +import AirshipCore +#endif + private let defaultButtonMargin: CGFloat = 15 private let defaultFooterMargin: CGFloat = 0 private let buttonDefaultBorderWidth: CGFloat = 2 @@ -15,66 +19,49 @@ struct ViewHeightKey: PreferenceKey { } struct ButtonGroup: View { - @EnvironmentObject var environment: InAppMessageEnvironment - let layout:InAppMessageButtonLayoutType - let buttons:[InAppMessageButtonInfo] - /// Prevent cycling onPreferenceChange to set rest of the buttons' minHeight to the largest button's height @State private var buttonMinHeight: CGFloat = 33 @State private var lastButtonHeight: CGFloat? + @EnvironmentObject var environment: InAppMessageEnvironment - var stackedButtonSpacing: CGFloat { - switch environment.theme { - case .banner(let theme): - return theme.buttonTheme.stackedButtonSpacing - case .modal(let theme): - return theme.buttonTheme.stackedButtonSpacing - case .fullScreen(let theme): - return theme.buttonTheme.stackedButtonSpacing - case .html(_): - return 0 /// HTML views do not currently support stacked buttons - } - } + let layout: InAppMessageButtonLayoutType + let buttons: [InAppMessageButtonInfo] + let theme: InAppMessageTheme.Button - var separateButtonSpacing: CGFloat { - switch environment.theme { - case .banner(let theme): - return theme.buttonTheme.separatedButtonSpacing - case .modal(let theme): - return theme.buttonTheme.separatedButtonSpacing - case .fullScreen(let theme): - return theme.buttonTheme.separatedButtonSpacing - case .html(_): - return 0 /// HTML views do not currently support separate buttons - } - } private func makeButtonView(buttonInfo: InAppMessageButtonInfo, roundedEdge: RoundedEdge = .all) -> some View { - return ButtonView(buttonInfo: buttonInfo, roundedEdge: roundedEdge, relativeMinHeight: $buttonMinHeight) - .frame(minHeight:buttonMinHeight) - .environmentObject(environment) - .background( - GeometryReader { - Color.tappableClear.preference(key: ViewHeightKey.self, - value: $0.frame(in: .global).size.height) } - .onPreferenceChange(ViewHeightKey.self) { value in - DispatchQueue.main.async { - let buttonHeight = round(value) - /// Prevent cycling by storing the last button height - if self.lastButtonHeight ?? 0 != buttonHeight { - /// Minium button height is the height of the largest button in the group - self.buttonMinHeight = max(buttonMinHeight, buttonHeight) - self.lastButtonHeight = buttonHeight - } - } + return ButtonView( + buttonInfo: buttonInfo, + roundedEdge: roundedEdge, + relativeMinHeight: $buttonMinHeight, + minHeight: theme.height + ) + .frame(minHeight:buttonMinHeight) + .environmentObject(environment) + .background( + GeometryReader { + Color.airshipTappableClear.preference( + key: ViewHeightKey.self, + value: $0.frame(in: .global).size.height + ) + }.onPreferenceChange(ViewHeightKey.self) { value in + DispatchQueue.main.async { + let buttonHeight = round(value) + /// Prevent cycling by storing the last button height + if self.lastButtonHeight ?? 0 != buttonHeight { + /// Minium button height is the height of the largest button in the group + self.buttonMinHeight = max(buttonMinHeight, buttonHeight) + self.lastButtonHeight = buttonHeight } - ) + } + } + ) } var body: some View { switch layout { case .stacked: - VStack(spacing: stackedButtonSpacing) { + VStack(spacing: theme.stackedSpacing) { ForEach(buttons, id: \.identifier) { button in makeButtonView(buttonInfo: button) } @@ -102,7 +89,7 @@ struct ButtonGroup: View { } }.fixedSize(horizontal: false, vertical: true) /// Hug children in horizontal axis and veritcal axis case .separate: - HStack(spacing: separateButtonSpacing) { + HStack(spacing: theme.separatedSpacing) { ForEach(buttons, id: \.identifier) { button in makeButtonView(buttonInfo: button) } @@ -113,56 +100,49 @@ struct ButtonGroup: View { struct ButtonView: View { @EnvironmentObject var environment: InAppMessageEnvironment - - let buttonInfo: InAppMessageButtonInfo - let roundedEdge: RoundedEdge - @ScaledMetric var scaledPadding: CGFloat = 12 - @State private var isPressed = false private let pressedOpacity: Double = 0.7 + let buttonInfo: InAppMessageButtonInfo + let roundedEdge: RoundedEdge + let minHeight: CGFloat + /// Min height of the button that can be dynamically set to size to the largest button in the group /// This is so buttons normalize in height to match the button with the largest font size @Binding private var relativeMinHeight:CGFloat - internal init(buttonInfo: InAppMessageButtonInfo, - roundedEdge:RoundedEdge = .all, - relativeMinHeight: Binding? = nil) { + internal init( + buttonInfo: InAppMessageButtonInfo, + roundedEdge:RoundedEdge = .all, + relativeMinHeight: Binding? = nil, + minHeight: CGFloat = 33 + ) { self.buttonInfo = buttonInfo self.roundedEdge = roundedEdge - _relativeMinHeight = relativeMinHeight ?? Binding.constant(CGFloat(0)) + self.minHeight = minHeight } - private var buttonHeight: CGFloat { - switch environment.theme { - case .banner(let theme): - return theme.buttonTheme.buttonHeight - case .fullScreen(let theme): - return theme.buttonTheme.buttonHeight - case .html(_): - return 0 /// HTML views do not currently support button views - case .modal(let theme): - return theme.buttonTheme.buttonHeight - } - } @ViewBuilder var buttonLabel: some View { - TextView(textInfo: buttonInfo.label, - textTheme: TextTheme(letterSpacing: 0, - lineSpacing: 0, - additionalPadding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))) + TextView( + textInfo: buttonInfo.label, + textTheme: InAppMessageTheme.Text( + letterSpacing: 0, + lineSpacing: 0, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ) + ) .opacity(isPressed ? pressedOpacity : 1.0) - } var body: some View { Button(action:onTap) { buttonLabel .padding(scaledPadding) - .frame(maxWidth: .infinity, minHeight: max(relativeMinHeight, buttonHeight)) + .frame(maxWidth: .infinity, minHeight: max(relativeMinHeight, minHeight)) .background(buttonInfo.backgroundColor?.color) .roundEdge(radius: buttonInfo.borderRadius ?? 0, edge: roundedEdge, diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/FullScreenView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/FullscreenView.swift similarity index 61% rename from Airship/AirshipAutomation/Source/InAppMessage/View/FullScreenView.swift rename to Airship/AirshipAutomation/Source/InAppMessage/View/FullscreenView.swift index 0428f988e..85a2d4d18 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/FullScreenView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/FullscreenView.swift @@ -3,34 +3,20 @@ import SwiftUI import Combine -struct FullScreenView: View, Sendable { +#if canImport(AirshipCore) +import AirshipCore +#endif + +struct FullscreenView: View, Sendable { @EnvironmentObject var environment: InAppMessageEnvironment let displayContent: InAppMessageDisplayContent.Fullscreen + let theme: InAppMessageTheme.Fullscreen - private var padding: EdgeInsets { - environment.theme.fullScreenTheme.additionalPadding - } - - private var headerTheme: TextTheme { - environment.theme.fullScreenTheme.headerTheme - } - - private var bodyTheme: TextTheme { - environment.theme.fullScreenTheme.bodyTheme - } - - private var mediaTheme: MediaTheme { - environment.theme.fullScreenTheme.mediaTheme - } - - private var dismissIconResource: String { - environment.theme.fullScreenTheme.dismissIconResource - } @ViewBuilder private var headerView: some View { if let heading = displayContent.heading { - TextView(textInfo: heading, textTheme:headerTheme) + TextView(textInfo: heading, textTheme: self.theme.header) .applyAlignment(placement: displayContent.heading?.alignment ?? .left) } } @@ -38,7 +24,7 @@ struct FullScreenView: View, Sendable { @ViewBuilder private var bodyView: some View { if let body = displayContent.body { - TextView(textInfo: body, textTheme:bodyTheme) + TextView(textInfo: body, textTheme: self.theme.body) .applyAlignment(placement: displayContent.body?.alignment ?? .left) } } @@ -46,17 +32,18 @@ struct FullScreenView: View, Sendable { @ViewBuilder private var mediaView: some View { if let media = displayContent.media { - MediaView(mediaInfo: media, mediaTheme: mediaTheme, imageLoader: environment.imageLoader) - .padding(.horizontal, -mediaTheme.additionalPadding.leading) + MediaView(mediaInfo: media, mediaTheme: self.theme.media, imageLoader: environment.imageLoader) } } @ViewBuilder private var buttonsView: some View { if let buttons = displayContent.buttons, !buttons.isEmpty { - ButtonGroup(layout: displayContent.buttonLayoutType ?? .stacked, - buttons: buttons) - .environmentObject(environment) + ButtonGroup( + layout: displayContent.buttonLayoutType ?? .stacked, + buttons: buttons, + theme: theme.buttons + ) } } @@ -64,8 +51,6 @@ struct FullScreenView: View, Sendable { private var footerButton: some View { if let footer = displayContent.footer { ButtonView(buttonInfo: footer) - .frame(height:Theme.defaultFooterHeight) - .environmentObject(environment) } } @@ -81,19 +66,20 @@ struct FullScreenView: View, Sendable { headerView bodyView mediaView - case .mediaHeaderBody, .none: /// None should never be hit - mediaView.padding(.top, -padding.top) /// Remove top padding when media is on top + case .mediaHeaderBody, .none: + mediaView.padding(.top, -theme.padding.top) /// Remove top padding when media is on top headerView bodyView } buttonsView footerButton - }.padding(padding) - .background(Color.tappableClear) + } + .padding(theme.padding) + .background(Color.airshipTappableClear) } .addCloseButton( dismissButtonColor: displayContent.dismissButtonColor?.color ?? Color.white, - dismissIconResource: dismissIconResource, + dismissIconResource: theme.dismissIconResource, onUserDismissed: { environment.onUserDismissed() } @@ -113,5 +99,5 @@ struct FullScreenView: View, Sendable { let displayContent = InAppMessageDisplayContent.Fullscreen(heading: headingText, body:bodyText, buttons: [], template: .headerMediaBody) - return FullScreenView(displayContent: displayContent) + return FullscreenView(displayContent: displayContent, theme: InAppMessageTheme.Fullscreen.defaultTheme) } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/HTMLView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/HTMLView.swift index 2b46271e4..b93b6ca9e 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/HTMLView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/HTMLView.swift @@ -7,10 +7,14 @@ import AirshipCore #endif struct HTMLView: View { - @EnvironmentObject var environment: InAppMessageEnvironment - let displayContent: InAppMessageDisplayContent.HTML - @Environment(\.orientation) var orientation +#if !os(tvOS) && !os(watchOS) + @Environment(\.verticalSizeClass) private var verticalSizeClass + @Environment(\.horizontalSizeClass) private var horizontalSizeClass +#endif + + let displayContent: InAppMessageDisplayContent.HTML + let theme: InAppMessageTheme.HTML #if os(iOS) private var orientationChangePublisher = NotificationCenter.default @@ -18,54 +22,72 @@ struct HTMLView: View { .makeConnectable() .autoconnect() #endif - - init(displayContent: InAppMessageDisplayContent.HTML) { - self.displayContent = displayContent - } - private var additionalPadding: EdgeInsets { - environment.theme.htmlTheme.additionalPadding - } + @EnvironmentObject private var environment: InAppMessageEnvironment + @Environment(\.orientation) private var orientation - private var dismissIconResource: String { - environment.theme.htmlTheme.dismissIconResource - } - private var hideDismissIcon: Bool { - environment.theme.htmlTheme.hideDismissIcon + init(displayContent: InAppMessageDisplayContent.HTML, theme: InAppMessageTheme.HTML) { + self.displayContent = displayContent + self.theme = theme } var body: some View { - let isModal = displayContent.width != nil || displayContent.height != nil + let allowAspectLock = displayContent.width != nil && displayContent.height != nil && displayContent.aspectLock == true InAppMessageWebView(displayContent: displayContent, accessibilityLabel: "In-app web view") - .applyIf(!hideDismissIcon){ + .applyIf(!theme.hideDismissIcon){ $0.addCloseButton( dismissButtonColor: displayContent.dismissButtonColor?.color ?? Color.white, - dismissIconResource: dismissIconResource, - circleColor: .tappableClear, /// Probably should just do this everywhere and remove circleColor entirely + dismissIconResource: theme.dismissIconResource, + circleColor: .airshipTappableClear, /// Probably should just do this everywhere and remove circleColor entirely onUserDismissed: { environment.onUserDismissed() } ) - }.applyIf(isModal) { + }.applyIf(isModal && allowAspectLock) { + $0.cornerRadius(displayContent.borderRadius ?? 0) + .aspectResize( + width: displayContent.width, + height: displayContent.height + ) + .parentClampingResize(maxWidth: theme.maxWidth, maxHeight: theme.maxHeight) + .padding(theme.padding) + .addBackground(color: .airshipShadowColor) + }.applyIf(isModal && !allowAspectLock) { $0.cornerRadius(displayContent.borderRadius ?? 0) - .aspectResize(width:displayContent.width, height:displayContent.height) - .padding(additionalPadding) - .addBackground(color: .shadowColor) + .parentClampingResize( + maxWidth: min(theme.maxWidth, (displayContent.width ?? .infinity)), + maxHeight: min(theme.maxHeight, (displayContent.height ?? .infinity)) + ) + .padding(theme.padding) + .addBackground(color: .airshipShadowColor) }.applyIf(!isModal) { - $0.padding(additionalPadding) - .padding(-24) /// Undo default padding when in fullscreen - .addBackground(color: displayContent.backgroundColor?.color ?? Color.clear) + $0.addBackground(color: displayContent.backgroundColor?.color ?? Color.clear) } .onAppear { self.environment.onAppear() } } + + var isModal: Bool { + guard displayContent.allowFullscreen == true else { + return true + } + + #if os(tvOS) + return true + #elseif os(watchOS) + return false + #else + return verticalSizeClass == .regular && horizontalSizeClass == .regular + #endif + } + + } #Preview { let displayContent = InAppMessageDisplayContent.HTML(url: "www.airship.com") - return HTMLView(displayContent: displayContent) + return HTMLView(displayContent: displayContent, theme: InAppMessageTheme.HTML.defaultTheme) } - diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageBannerView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageBannerView.swift index 7c36a0f66..ca6d67595 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageBannerView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageBannerView.swift @@ -21,24 +21,13 @@ struct InAppMessageBannerView: View { @State var messageBodyOpacity: CGFloat = 1 @State var swipeOffset: CGFloat = 0 + var theme: InAppMessageTheme.Banner + var onDismiss: () -> Void - private var padding: EdgeInsets { - environment.theme.bannerTheme.additionalPadding - } - - private var tapOpacity: CGFloat { - environment.theme.bannerTheme.tapOpacity - } - - private var shadowTheme: ShadowTheme { - environment.theme.bannerTheme.shadowTheme - } private let displayContent: InAppMessageDisplayContent.Banner - private var messageMaxWidth: CGFloat = 480 - private var mediaMaxWidth: CGFloat = 120 private var mediaMinHeight: CGFloat = 88 @@ -46,22 +35,10 @@ struct InAppMessageBannerView: View { private let animationInOutDuration = 0.2 - private var headerTheme: TextTheme { - environment.theme.bannerTheme.headerTheme - } - - private var bodyTheme: TextTheme { - environment.theme.bannerTheme.bodyTheme - } - - private var mediaTheme: MediaTheme { - environment.theme.bannerTheme.mediaTheme - } - @ViewBuilder private var headerView: some View { if let heading = displayContent.heading { - TextView(textInfo: heading, textTheme: headerTheme) + TextView(textInfo: heading, textTheme: self.theme.header) .fixedSize(horizontal: false, vertical: true) } } @@ -69,7 +46,7 @@ struct InAppMessageBannerView: View { @ViewBuilder private var bodyView: some View { if let body = displayContent.body { - TextView(textInfo: body, textTheme:bodyTheme) + TextView(textInfo: body, textTheme: self.theme.body) .fixedSize(horizontal: false, vertical: true) } } @@ -77,11 +54,13 @@ struct InAppMessageBannerView: View { @ViewBuilder private var mediaView: some View { if let media = displayContent.media { - MediaView(mediaInfo: media, mediaTheme: mediaTheme, imageLoader: environment.imageLoader) - .padding(.horizontal, -mediaTheme.additionalPadding.leading) - .frame(maxWidth: mediaMaxWidth, - minHeight: mediaMinHeight, - maxHeight: mediaMaxHeight) + MediaView(mediaInfo: media, mediaTheme: self.theme.media, imageLoader: environment.imageLoader) + .padding(.horizontal, -theme.media.padding.leading) + .frame( + maxWidth: mediaMaxWidth, + minHeight: mediaMinHeight, + maxHeight: mediaMaxHeight + ) .fixedSize(horizontal: false, vertical: true) } } @@ -89,9 +68,11 @@ struct InAppMessageBannerView: View { @ViewBuilder private var buttonsView: some View { if let buttons = displayContent.buttons, !buttons.isEmpty { - ButtonGroup(layout: displayContent.buttonLayoutType ?? .stacked, - buttons: buttons) - .environmentObject(environment) + ButtonGroup( + layout: displayContent.buttonLayoutType ?? .stacked, + buttons: buttons, + theme: self.theme.buttons + ) } } @@ -105,11 +86,13 @@ struct InAppMessageBannerView: View { init(environment:InAppMessageEnvironment, displayContent: InAppMessageDisplayContent.Banner, bannerConstraints: InAppMessageBannerConstraints, + theme: InAppMessageTheme.Banner, onDismiss: @escaping () -> Void ) { self.displayContent = displayContent self.environment = environment self.bannerConstraints = bannerConstraints + self.theme = theme self.onDismiss = onDismiss } @@ -158,10 +141,13 @@ struct InAppMessageBannerView: View { } buttonsView - }.padding([.top, .horizontal], itemSpacing) - .addNub(placement: displayContent.placement, - nub: AnyView(nub), - itemSpacing: itemSpacing) + } + .padding([.top, .horizontal], itemSpacing) + .addNub( + placement: displayContent.placement ?? .top, + nub: AnyView(nub), + itemSpacing: itemSpacing + ) } private func setShowing(state:Bool, completion: (() -> Void)? = nil) { @@ -184,12 +170,17 @@ struct InAppMessageBannerView: View { private var banner: some View { messageBody .showing(isShowing: isShowing) - .frame(maxWidth: messageMaxWidth) + .frame(maxWidth: theme.maxWidth) .background( (displayContent.backgroundColor?.color ?? Color.white) .cornerRadius(displayContent.borderRadius ?? 0) .edgesIgnoringSafeArea(displayContent.placement == .top ? .top : .bottom) - .shadow(color: shadowTheme.color, radius: shadowTheme.radius, x: shadowTheme.xOffset, y: shadowTheme.yOffset) + .shadow( + color: theme.shadow.color, + radius: theme.shadow.radius, + x: theme.shadow.xOffset, + y: theme.shadow.yOffset + ) ) .background( GeometryReader(content: { contentMetrics -> Color in @@ -200,23 +191,25 @@ struct InAppMessageBannerView: View { self.lastSize = size } } - return .tappableClear + return Color.airshipTappableClear }) ) - .padding(padding) + .padding(theme.padding) .applyTransitioningPlacement(placement: displayContent.placement ?? .top) - .addTapAndSwipeDismiss(placement: displayContent.placement ?? .top, - isPressed: $isPressed, - tapAction: bannerOnTapAction, - swipeOffset: $swipeOffset, - onDismiss: environment.onUserDismissed) + .addTapAndSwipeDismiss( + placement: displayContent.placement ?? .top, + isPressed: $isPressed, + tapAction: bannerOnTapAction, + swipeOffset: $swipeOffset, + onDismiss: environment.onUserDismissed + ) .onAppear { setShowing(state: true) } .airshipOnChangeOf(environment.isDismissed) { _ in - setShowing(state:false, completion: { + setShowing(state: false) { onDismiss() - }) + } } .onAppear { self.environment.onAppear() @@ -226,10 +219,10 @@ struct InAppMessageBannerView: View { var body: some View { InAppMessageRootView(inAppMessageEnvironment: environment) { orientation in #if os(visionOS) - banner.frame(width: min(1280, messageMaxWidth)) + banner.frame(width: min(1280, theme.maxWidth)) #else - banner.frame(width: min(UIScreen.main.bounds.size.width, messageMaxWidth)) + banner.frame(width: min(UIScreen.main.bounds.size.width, theme.maxWidth)) #endif - }.opacity(isPressed && displayContent.actions != nil ? tapOpacity : 1) + }.opacity(isPressed && displayContent.actions != nil ? theme.tapOpacity : 1) } } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageModalView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageModalView.swift index 43040a8fa..6ae5b0285 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageModalView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageModalView.swift @@ -8,46 +8,23 @@ import AirshipCore struct InAppMessageModalView: View { @EnvironmentObject var environment: InAppMessageEnvironment - let displayContent: InAppMessageDisplayContent.Modal - @Environment(\.orientation) var orientation +#if !os(tvOS) && !os(watchOS) + @Environment(\.verticalSizeClass) private var verticalSizeClass + @Environment(\.horizontalSizeClass) private var horizontalSizeClass +#endif + + let displayContent: InAppMessageDisplayContent.Modal + let theme: InAppMessageTheme.Modal + @State private var scrollViewContentSize: CGSize = .zero - - private var padding: EdgeInsets { - environment.theme.modalTheme.additionalPadding - } - - private var headerTheme: TextTheme { - environment.theme.modalTheme.headerTheme - } - - private var bodyTheme: TextTheme { - environment.theme.modalTheme.bodyTheme - } - - private var mediaTheme: MediaTheme { - environment.theme.modalTheme.mediaTheme - } - - private var dismissIconResource: String { - environment.theme.modalTheme.dismissIconResource - } - - private var maxHeight: CGFloat { - CGFloat(environment.theme.modalTheme.maxHeight) - } - - private var maxWidth: CGFloat { - CGFloat(environment.theme.modalTheme.maxWidth) - } - @ViewBuilder private var headerView: some View { if let heading = displayContent.heading { - TextView(textInfo: heading, textTheme:headerTheme) + TextView(textInfo: heading, textTheme: theme.header) .applyAlignment(placement: displayContent.heading?.alignment ?? .left) } } @@ -55,7 +32,7 @@ struct InAppMessageModalView: View { @ViewBuilder private var bodyView: some View { if let body = displayContent.body { - TextView(textInfo: body, textTheme:bodyTheme) + TextView(textInfo: body, textTheme: theme.body) .applyAlignment(placement: displayContent.body?.alignment ?? .left) } } @@ -63,18 +40,18 @@ struct InAppMessageModalView: View { @ViewBuilder private var mediaView: some View { if let media = displayContent.media { - MediaView(mediaInfo: media, mediaTheme: mediaTheme, imageLoader: environment.imageLoader) - .applyMediaTheme(mediaTheme) - .padding(.horizontal, -mediaTheme.additionalPadding.leading).padding(mediaTheme.additionalPadding) + MediaView(mediaInfo: media, mediaTheme: theme.media, imageLoader: environment.imageLoader) } } @ViewBuilder private var buttonsView: some View { if let buttons = displayContent.buttons, !buttons.isEmpty { - ButtonGroup(layout: displayContent.buttonLayoutType ?? .stacked, - buttons: buttons) - .environmentObject(environment) + ButtonGroup( + layout: displayContent.buttonLayoutType ?? .stacked, + buttons: buttons, + theme: theme.buttons + ) } } @@ -82,8 +59,6 @@ struct InAppMessageModalView: View { private var footerButton: some View { if let footer = displayContent.footer { ButtonView(buttonInfo: footer) - .frame(height:Theme.defaultFooterHeight) - .environmentObject(environment) } } @@ -94,13 +69,14 @@ struct InAppMessageModalView: View { .autoconnect() #endif - init(displayContent: InAppMessageDisplayContent.Modal) { + init(displayContent: InAppMessageDisplayContent.Modal, theme: InAppMessageTheme.Modal) { self.displayContent = displayContent + self.theme = theme } @ViewBuilder private var content: some View { - VStack(spacing:0) { + VStack(spacing:24) { ScrollView { VStack(spacing:24) { switch displayContent.template { @@ -112,19 +88,21 @@ struct InAppMessageModalView: View { headerView bodyView mediaView - case .mediaHeaderBody, .none: /// None should never be hit - mediaView.padding(.top, -padding.top) /// Remove top padding when media is on top + case .mediaHeaderBody, .none: + mediaView.padding(.top, -theme.padding.top) /// Remove top padding when media is on top headerView bodyView } } - .padding(padding) + .padding(.leading, theme.padding.leading) + .padding(.trailing, theme.padding.trailing) + .padding(.top, theme.padding.top) .background( GeometryReader { geo -> Color in DispatchQueue.main.async { if scrollViewContentSize != geo.size { if case .mediaHeaderBody = displayContent.template { - scrollViewContentSize = CGSize(width: geo.size.width, height: geo.size.height - padding.top) + scrollViewContentSize = CGSize(width: geo.size.width, height: geo.size.height - theme.padding.top) } else { scrollViewContentSize = geo.size } @@ -134,13 +112,16 @@ struct InAppMessageModalView: View { } ) } - .frame(maxHeight: scrollViewContentSize.height) - + .applyIf(isModal) { + $0.frame(maxHeight: scrollViewContentSize.height) + } VStack(spacing:24) { buttonsView footerButton } - .padding(padding) + .padding(.leading, theme.padding.leading) + .padding(.trailing, theme.padding.trailing) + .padding(.bottom, theme.padding.bottom) } } @@ -148,17 +129,36 @@ struct InAppMessageModalView: View { content .addCloseButton( dismissButtonColor: displayContent.dismissButtonColor?.color ?? Color.white, - dismissIconResource: dismissIconResource, - circleColor: .tappableClear, /// Probably should just do this everywhere and remove circleColor entirely + dismissIconResource: theme.dismissIconResource, + circleColor: .airshipTappableClear, /// Probably should just do this everywhere and remove circleColor entirely onUserDismissed: { environment.onUserDismissed() } ) .background(displayContent.backgroundColor?.color ?? Color.black) - .cornerRadius(displayContent.borderRadius ?? 0) - .parentClampingResize(maxWidth: maxWidth, maxHeight: maxHeight) - .padding(padding) - .addBackground(color: .shadowColor) + .applyIf(isModal) { + $0.cornerRadius(displayContent.borderRadius ?? 0) + .parentClampingResize(maxWidth: theme.maxWidth, maxHeight: theme.maxHeight) + .padding(theme.padding) + .addBackground(color: .airshipShadowColor) + } + .applyIf(!isModal) { + $0.frame(maxWidth: .infinity, maxHeight: .infinity) + } .onAppear { self.environment.onAppear() } } + + var isModal: Bool { + guard displayContent.allowFullscreenDisplay == true else { + return true + } + + #if os(tvOS) + return true + #elseif os(watchOS) + return false + #else + return verticalSizeClass == .regular && horizontalSizeClass == .regular + #endif + } } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageViewUtils.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageViewUtils.swift index 11b92443f..89bbc58b8 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageViewUtils.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageViewUtils.swift @@ -3,11 +3,6 @@ import Foundation import SwiftUI -extension Color { - static var tappableClear: Color { Color.white.opacity(0.001) } - static var shadowColor: Color { Color.black.opacity(0.33) } -} - extension View { @ViewBuilder func showing(isShowing: Bool) -> some View { @@ -19,12 +14,16 @@ extension View { } @ViewBuilder - func addNub(placement: InAppMessageDisplayContent.Banner.Placement?, nub: AnyView, itemSpacing: CGFloat) -> some View { + func addNub( + placement: InAppMessageDisplayContent.Banner.Placement, + nub: AnyView, itemSpacing: CGFloat + ) -> some View { VStack(spacing: 0) { - if placement == .top { + switch(placement) { + case .top: self nub.padding(.vertical, itemSpacing / 2) - } else { + case .bottom: nub.padding(.vertical, itemSpacing / 2) self } @@ -40,11 +39,13 @@ extension View { } @ViewBuilder - func addTapAndSwipeDismiss(placement: InAppMessageDisplayContent.Banner.Placement, - isPressed: Binding, - tapAction: (() -> ())? = nil, - swipeOffset: Binding, - onDismiss: @escaping () -> Void) -> some View { + func addTapAndSwipeDismiss( + placement: InAppMessageDisplayContent.Banner.Placement, + isPressed: Binding, + tapAction: (() -> ())? = nil, + swipeOffset: Binding, + onDismiss: @escaping () -> Void + ) -> some View { self.offset(x: 0, y: swipeOffset.wrappedValue) .gesture( DragGesture(minimumDistance: 0) @@ -80,7 +81,8 @@ extension View { swipeOffset.wrappedValue = 0 } } - }) + } + ) } @ViewBuilder @@ -125,14 +127,15 @@ extension View { private func applyTransition( placement: InAppMessageDisplayContent.Banner.Placement ) -> some View { - if (placement == .top) { + switch(placement) { + case .top: self.transition( .asymmetric( insertion: .move(edge: .top), removal: .move(edge: .top).combined(with: .opacity) ) ) - } else { + case .bottom: self.transition( .asymmetric( insertion: .move(edge: .bottom), @@ -182,25 +185,29 @@ struct CenteredGeometryReader: View { var body: some View { GeometryReader { geo in let size = geo.size - content(size) - .position(x: size.width / 2, y: size.height / 2) + content(size).position( + x: size.width / 2, + y: size.height / 2 + ) } } } /// Attempt to resize to specified size and clamp any size axis that exceeds parent size axis to said axis. struct AspectResize: ViewModifier { - var width:Double? - var height:Double? + var width: Double? + var height: Double? func body(content: Content) -> some View { CenteredGeometryReader { size in let parentWidth = size.width let parentHeight = size.height - content - .aspectRatio(CGSize(width: width ?? parentWidth, height: height ?? parentHeight), contentMode: .fit) - .frame(maxWidth: parentWidth, maxHeight: parentHeight) + content.aspectRatio( + CGSize(width: width ?? parentWidth, height: height ?? parentHeight), + contentMode: .fit + ) + .frame(maxWidth: parentWidth, maxHeight: parentHeight) } } } @@ -215,8 +222,10 @@ struct ParentClampingResize: ViewModifier { let parentWidth = parentSize.width let parentHeight = parentSize.height - content - .frame(maxWidth: min(parentWidth, maxWidth), maxHeight: min(parentHeight, maxHeight)) + content.frame( + maxWidth: min(parentWidth, maxWidth), + maxHeight: min(parentHeight, maxHeight) + ) } } } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift index b5a3ba093..a1be374b4 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift @@ -9,7 +9,7 @@ import AirshipCore struct MediaView: View { var mediaInfo: InAppMessageMediaInfo - var mediaTheme: MediaTheme + var mediaTheme: InAppMessageTheme.Media var imageLoader: AirshipImageLoader? /// Ideally this would be an associated value on the media info enum var body: some View { @@ -36,13 +36,13 @@ struct MediaView: View { ProgressView() } ) - .padding(mediaTheme.additionalPadding) + .padding(mediaTheme.padding) } } private var webView: some View { InAppMessageMediaWebView(mediaInfo: mediaInfo) - .padding(mediaTheme.additionalPadding) + .padding(mediaTheme.padding) } } diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/TextView.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/TextView.swift index 6650e3ba9..bffb6e916 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/TextView.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/TextView.swift @@ -4,7 +4,7 @@ import SwiftUI struct TextView: View { let textInfo: InAppMessageTextInfo - let textTheme: TextTheme + let textTheme: InAppMessageTheme.Text var body: some View { Text(textInfo.text) diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/BannerTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/BannerTheme.swift deleted file mode 100644 index 85951a218..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/BannerTheme.swift +++ /dev/null @@ -1,129 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - - - -struct BannerTheme: Equatable { - var additionalPadding: EdgeInsets - var maxWidth: Int - var tapOpacity: CGFloat - var shadowTheme: ShadowTheme - var headerTheme: TextTheme - var bodyTheme: TextTheme - var mediaTheme: MediaTheme - var buttonTheme: ButtonTheme - - /// Used for testing - init(plistName: String, - bundle: Bundle? = Bundle.main) { - let overrides = BannerThemeOverride(plistName: plistName, bundle: bundle) - self.init(themeOverride: overrides) - } - - init(additionalPadding: EdgeInsets, - maxWidth: Int, - tapOpacity: CGFloat, - shadowTheme: ShadowTheme, - headerTheme: TextTheme, - bodyTheme: TextTheme, - mediaTheme: MediaTheme, - buttonTheme: ButtonTheme) { - self.additionalPadding = additionalPadding - self.maxWidth = maxWidth - self.tapOpacity = tapOpacity - self.shadowTheme = shadowTheme - self.headerTheme = headerTheme - self.bodyTheme = bodyTheme - self.mediaTheme = mediaTheme - self.buttonTheme = buttonTheme - } - - init(themeOverride:BannerThemeOverride? = BannerThemeOverride(plistName: defaultPlistName, bundle: Bundle.main), - defaultValues:BannerTheme = Self.defaultValues) { - self.additionalPadding = themeOverride?.additionalPadding - .map { EdgeInsets(themeOverride: $0, defaults: defaultValues.additionalPadding) } - ?? defaultValues.additionalPadding - - self.maxWidth = themeOverride?.maxWidth - ?? defaultValues.maxWidth - - self.tapOpacity = themeOverride?.tapOpacity - ?? defaultValues.tapOpacity - - self.shadowTheme = themeOverride?.shadowTheme - .map { ShadowTheme(themeOverride: $0, defaults: defaultValues.shadowTheme) } - ?? defaultValues.shadowTheme - - self.headerTheme = themeOverride?.headerTheme - .map { TextTheme(themeOverride: $0, defaults: defaultValues.headerTheme) } - ?? defaultValues.headerTheme - - self.bodyTheme = themeOverride?.bodyTheme - .map { TextTheme(themeOverride: $0, defaults: defaultValues.bodyTheme) } - ?? defaultValues.bodyTheme - - self.mediaTheme = themeOverride?.mediaTheme - .map { MediaTheme(themeOverride: $0, defaults: defaultValues.mediaTheme) } - ?? defaultValues.mediaTheme - - self.buttonTheme = themeOverride?.buttonTheme - .map { ButtonTheme(themeOverride: $0, defaults: defaultValues.buttonTheme) } - ?? defaultValues.buttonTheme - } -} - -struct BannerThemeOverride: Decodable, PlistLoadable { - var additionalPadding: EdgeInsetsThemeOverride? - var maxWidth: Int? - var tapOpacity: CGFloat? - var shadowTheme: ShadowThemeOverride? - var headerTheme: TextThemeOverride? - var bodyTheme: TextThemeOverride? - var mediaTheme: MediaThemeOverride? - var buttonTheme: ButtonThemeOverride? - - init(additionalPadding: EdgeInsetsThemeOverride? = nil, - maxWidth: Int? = nil, - tapOpacity: CGFloat? = nil, - shadowTheme: ShadowThemeOverride? = nil, - headerTheme: TextThemeOverride? = nil, - bodyTheme: TextThemeOverride? = nil, - mediaTheme: MediaThemeOverride? = nil, - buttonTheme: ButtonThemeOverride? = nil) { - self.additionalPadding = additionalPadding - self.maxWidth = maxWidth - self.headerTheme = headerTheme - self.bodyTheme = bodyTheme - self.mediaTheme = mediaTheme - self.buttonTheme = buttonTheme - } - - init?() { - self.init(plistName: BannerTheme.defaultPlistName) - } - - enum CodingKeys: String, CodingKey { - case additionalPadding = "additionalPadding" - case maxWidth = "maxWidth" - case tapOpacity = "tapOpacity" - case shadowTheme = "shadowStyle" - case headerTheme = "headerStyle" - case bodyTheme = "bodyStyle" - case mediaTheme = "mediaStyle" - case buttonTheme = "buttonStyle" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - additionalPadding = try container.decodeIfPresent(EdgeInsetsThemeOverride.self, forKey: .additionalPadding) - headerTheme = try container.decodeIfPresent(TextThemeOverride.self, forKey: .headerTheme) - bodyTheme = try container.decodeIfPresent(TextThemeOverride.self, forKey: .bodyTheme) - mediaTheme = try container.decodeIfPresent(MediaThemeOverride.self, forKey: .mediaTheme) - buttonTheme = try container.decodeIfPresent(ButtonThemeOverride.self, forKey: .buttonTheme) - maxWidth = try container.decodeIfPresent(Int.self, forKey: .maxWidth) - tapOpacity = try container.decodeIfPresent(CGFloat.self, forKey: .tapOpacity) - shadowTheme = try container.decodeIfPresent(ShadowThemeOverride.self, forKey: .shadowTheme) - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/ButtonTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/ButtonTheme.swift deleted file mode 100644 index 1a2c35086..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/ButtonTheme.swift +++ /dev/null @@ -1,39 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -struct ButtonTheme: Equatable { - var buttonHeight: CGFloat - var stackedButtonSpacing: CGFloat - var separatedButtonSpacing: CGFloat - var additionalPadding: EdgeInsets - - init(themeOverride:ButtonThemeOverride, defaults:ButtonTheme) { - self.buttonHeight = themeOverride.buttonHeight.flatMap(CGFloat.init) ?? defaults.buttonHeight - self.stackedButtonSpacing = themeOverride.stackedButtonSpacing.flatMap(CGFloat.init) ?? defaults.stackedButtonSpacing - self.separatedButtonSpacing = themeOverride.separatedButtonSpacing.flatMap(CGFloat.init) ?? defaults.separatedButtonSpacing - self.additionalPadding = themeOverride.additionalPadding.map { EdgeInsets(themeOverride: $0, defaults: defaults.additionalPadding) } ?? defaults.additionalPadding - } - - init(buttonHeight: CGFloat, stackedButtonSpacing: CGFloat, separatedButtonSpacing: CGFloat, additionalPadding: EdgeInsets) { - self.buttonHeight = buttonHeight - self.stackedButtonSpacing = stackedButtonSpacing - self.separatedButtonSpacing = separatedButtonSpacing - self.additionalPadding = additionalPadding - } -} - -struct ButtonThemeOverride: Decodable { - var buttonHeight: Int? - var stackedButtonSpacing: Int? - var separatedButtonSpacing: Int? - var additionalPadding: EdgeInsetsThemeOverride? - - init(buttonHeight: Int, stackedButtonSpacing: Int, separatedButtonSpacing: Int, additionalPadding: EdgeInsetsThemeOverride) { - self.buttonHeight = buttonHeight - self.stackedButtonSpacing = stackedButtonSpacing - self.separatedButtonSpacing = separatedButtonSpacing - self.additionalPadding = additionalPadding - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/EdgeInsets.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/EdgeInsets.swift deleted file mode 100644 index 82f605c00..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/EdgeInsets.swift +++ /dev/null @@ -1,38 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -extension EdgeInsets { - init(themeOverride:EdgeInsetsThemeOverride, defaults:EdgeInsets) { - var t: CGFloat = defaults.top - var l: CGFloat = defaults.leading - var tr: CGFloat = defaults.trailing - var b: CGFloat = defaults.bottom - - if let overrideTop = themeOverride.top { - t = CGFloat(overrideTop) - } - - if let overrideLeading = themeOverride.leading { - l = CGFloat(overrideLeading) - } - - if let overrideTrailing = themeOverride.trailing { - tr = CGFloat(overrideTrailing) - } - - if let overrideBottom = themeOverride.bottom { - b = CGFloat(overrideBottom) - } - - self = EdgeInsets(top: t, leading: l, bottom: b, trailing: tr) - } -} - -struct EdgeInsetsThemeOverride: Decodable { - var top: Int? - var leading: Int? - var trailing: Int? - var bottom: Int? -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/MediaTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/MediaTheme.swift deleted file mode 100644 index 10657f0b4..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/MediaTheme.swift +++ /dev/null @@ -1,28 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -struct MediaTheme: Equatable { - var additionalPadding: EdgeInsets - - init(themeOverride: MediaThemeOverride, defaults: MediaTheme) { - additionalPadding = themeOverride.additionalPadding.map { EdgeInsets(themeOverride: $0, defaults: defaults.additionalPadding) } ?? defaults.additionalPadding - } - - init(additionalPadding: EdgeInsets) { - self.additionalPadding = additionalPadding - } -} - -struct MediaThemeOverride: Decodable { - var additionalPadding: EdgeInsetsThemeOverride? - - enum CodingKeys: String, CodingKey { - case additionalPadding - } - - init(additionalPadding: EdgeInsetsThemeOverride) { - self.additionalPadding = additionalPadding - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/ShadowTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/ShadowTheme.swift deleted file mode 100644 index 4c2166a96..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/ShadowTheme.swift +++ /dev/null @@ -1,51 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -/// For color utils -#if canImport(AirshipCore) -import AirshipCore -#endif - -struct ShadowTheme: Equatable { - var radius: CGFloat - var xOffset: CGFloat - var yOffset: CGFloat - var color: Color - - init(radius: CGFloat, xOffset: CGFloat, yOffset: CGFloat, color: Color) { - self.radius = radius - self.xOffset = xOffset - self.yOffset = yOffset - self.color = color - } - - init(themeOverride: ShadowThemeOverride, defaults: ShadowTheme) { - self.radius = themeOverride.radius.flatMap(CGFloat.init) ?? defaults.radius - self.xOffset = themeOverride.xOffset.flatMap(CGFloat.init) ?? defaults.xOffset - self.yOffset = themeOverride.yOffset.flatMap(CGFloat.init) ?? defaults.yOffset - self.color = themeOverride.color.flatMap { $0.airshipToColor() } ?? defaults.color - } -} - -struct ShadowThemeOverride: Decodable { - var radius: Int? - var xOffset: Int? - var yOffset: Int? - var color: String? - - init(radius: Int? = nil, xOffset: Int? = nil, yOffset: Int? = nil, color: String? = nil) { - self.radius = radius - self.xOffset = xOffset - self.yOffset = yOffset - self.color = color - } - - enum CodingKeys: String, CodingKey { - case radius = "radius" - case xOffset = "xOffset" - case yOffset = "yOffset" - case color = "colorHex" - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/TextTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/TextTheme.swift deleted file mode 100644 index 67ddb7719..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Components/TextTheme.swift +++ /dev/null @@ -1,34 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -struct TextTheme: Equatable { - var letterSpacing: CGFloat - var lineSpacing: CGFloat - var additionalPadding: EdgeInsets - - init(letterSpacing: CGFloat, lineSpacing: CGFloat, additionalPadding: EdgeInsets) { - self.letterSpacing = letterSpacing - self.lineSpacing = lineSpacing - self.additionalPadding = additionalPadding - } - - init(themeOverride:TextThemeOverride, defaults:TextTheme) { - self.letterSpacing = themeOverride.letterSpacing.flatMap(CGFloat.init) ?? defaults.letterSpacing - self.lineSpacing = themeOverride.lineSpacing.flatMap(CGFloat.init) ?? defaults.lineSpacing - self.additionalPadding = themeOverride.additionalPadding.flatMap { EdgeInsets(themeOverride: $0, defaults: defaults.additionalPadding) } ?? defaults.additionalPadding - } -} - -struct TextThemeOverride: Decodable { - var letterSpacing: Int? - var lineSpacing: Int? - var additionalPadding: EdgeInsetsThemeOverride? - - init(letterSpacing: Int? = nil, lineSpacing: Int? = nil, additionalPadding: EdgeInsetsThemeOverride? = nil) { - self.letterSpacing = letterSpacing - self.lineSpacing = lineSpacing - self.additionalPadding = additionalPadding - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/FullScreenTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/FullScreenTheme.swift deleted file mode 100644 index 56d30cbd8..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/FullScreenTheme.swift +++ /dev/null @@ -1,82 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -struct FullScreenTheme: Equatable { - var additionalPadding: EdgeInsets - var headerTheme: TextTheme - var bodyTheme: TextTheme - var mediaTheme: MediaTheme - var buttonTheme: ButtonTheme - var dismissIconResource: String - - /// Used for testing - init(plistName: String, - bundle: Bundle? = Bundle.main) { - let overrides = FullScreenThemeOverride(plistName: plistName, bundle: bundle) - self.init(themeOverride: overrides) - } - - init(additionalPadding: EdgeInsets, - headerTheme: TextTheme, - bodyTheme: TextTheme, - mediaTheme: MediaTheme, - buttonTheme: ButtonTheme, - dismissIconResource: String) { - self.additionalPadding = additionalPadding - self.headerTheme = headerTheme - self.bodyTheme = bodyTheme - self.mediaTheme = mediaTheme - self.buttonTheme = buttonTheme - self.dismissIconResource = dismissIconResource - } - - init(themeOverride:FullScreenThemeOverride? = FullScreenThemeOverride(plistName: Self.defaultPlistName, bundle: Bundle.main), defaultValues:FullScreenTheme = Self.defaultValues) { - self.additionalPadding = themeOverride?.additionalPadding - .map { EdgeInsets(themeOverride: $0, defaults: defaultValues.additionalPadding) } - ?? defaultValues.additionalPadding - - self.headerTheme = themeOverride?.headerTheme - .map { TextTheme(themeOverride: $0, defaults: defaultValues.headerTheme) } - ?? defaultValues.headerTheme - - self.bodyTheme = themeOverride?.bodyTheme - .map { TextTheme(themeOverride: $0, defaults: defaultValues.bodyTheme) } - ?? defaultValues.bodyTheme - - self.mediaTheme = themeOverride?.mediaTheme - .map { MediaTheme(themeOverride: $0, defaults: defaultValues.mediaTheme) } - ?? defaultValues.mediaTheme - - self.buttonTheme = themeOverride?.buttonTheme - .map { ButtonTheme(themeOverride: $0, defaults: Self.defaultValues.buttonTheme) } - ?? defaultValues.buttonTheme - - self.dismissIconResource = themeOverride?.dismissIconResource - ?? defaultValues.dismissIconResource - - } -} - -struct FullScreenThemeOverride: Decodable, PlistLoadable { - var additionalPadding: EdgeInsetsThemeOverride? - var headerTheme: TextThemeOverride? - var bodyTheme: TextThemeOverride? - var mediaTheme: MediaThemeOverride? - var buttonTheme: ButtonThemeOverride? - var dismissIconResource: String? - - enum CodingKeys: String, CodingKey { - case additionalPadding = "additionalPadding" - case headerTheme = "headerStyle" - case bodyTheme = "bodyStyle" - case mediaTheme = "mediaStyle" - case buttonTheme = "buttonStyle" - case dismissIconResource = "dismissIconResource" - } - - init?() { - self.init(plistName: FullScreenTheme.defaultPlistName) - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/HTMLTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/HTMLTheme.swift deleted file mode 100644 index 3ccff385c..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/HTMLTheme.swift +++ /dev/null @@ -1,63 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -struct HTMLTheme: Equatable { - var hideDismissIcon: Bool - var additionalPadding: EdgeInsets - var dismissIconResource: String - - var maxWidth: Int - var maxHeight: Int - - /// Used for testing - init(plistName: String, - bundle: Bundle? = Bundle.main) { - let overrides = HTMLThemeOverride(plistName: plistName, bundle: bundle) - self.init(themeOverride: overrides) - } - - init(hideDismissIcon: Bool, - additionalPadding: EdgeInsets, - dismissIconResource: String, - maxWidth: Int, - maxHeight: Int) { - self.hideDismissIcon = hideDismissIcon - self.additionalPadding = additionalPadding - self.dismissIconResource = dismissIconResource - self.maxWidth = maxWidth - self.maxHeight = maxHeight - } - - init(themeOverride:HTMLThemeOverride? = HTMLThemeOverride(plistName: Self.defaultPlistName, bundle: Bundle.main), - defaultValues:HTMLTheme = Self.defaultValues) { - self.hideDismissIcon = themeOverride?.hideDismissIcon - ?? defaultValues.hideDismissIcon - - self.dismissIconResource = themeOverride?.dismissIconResource - ?? defaultValues.dismissIconResource - - self.additionalPadding = themeOverride?.additionalPadding - .map { EdgeInsets(themeOverride: $0, defaults: defaultValues.additionalPadding) } - ?? defaultValues.additionalPadding - - self.maxWidth = themeOverride?.maxWidth - ?? defaultValues.maxWidth - - self.maxHeight = themeOverride?.maxHeight - ?? defaultValues.maxHeight - } -} - -struct HTMLThemeOverride: Decodable, PlistLoadable { - var hideDismissIcon: Bool? - var additionalPadding: EdgeInsetsThemeOverride? - var dismissIconResource: String? - var maxWidth: Int? - var maxHeight: Int? - - init?() { - self.init(plistName: HTMLTheme.defaultPlistName) - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageTheme.swift new file mode 100644 index 000000000..c3da7da02 --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageTheme.swift @@ -0,0 +1,44 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +#if canImport(AirshipCore) +import AirshipCore +#endif + +/// In-app message themes +public struct InAppMessageTheme { + static func decode( + _ type: T.Type, + plistName: String, + bundle: Bundle? = Bundle.main + ) throws -> T where T : Decodable { + guard + let bundle, + let url = bundle.url(forResource: plistName, withExtension: "plist"), + let data = try? Data(contentsOf: url) + else { + throw AirshipErrors.error("Unable to locate theme override \(plistName) from \(String(describing: bundle))") + } + + return try PropertyListDecoder().decode(type, from: data) + } + + static func decodeIfExists( + _ type: T.Type, + plistName: String, + bundle: Bundle? = Bundle.main + ) throws -> T? where T : Decodable { + guard + let bundle, + let url = bundle.url(forResource: plistName, withExtension: "plist"), + let data = try? Data(contentsOf: url) + else { + return nil + } + + return try PropertyListDecoder().decode(type, from: data) + } +} + diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeAdditionalPadding.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeAdditionalPadding.swift new file mode 100644 index 000000000..487a80317 --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeAdditionalPadding.swift @@ -0,0 +1,25 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + + +extension InAppMessageTheme { + struct AdditionalPadding: Decodable { + var top: CGFloat? + var leading: CGFloat? + var trailing: CGFloat? + var bottom: CGFloat? + } +} + + +extension EdgeInsets { + mutating func add(_ additionalPadding: InAppMessageTheme.AdditionalPadding?) { + guard let additionalPadding else { return } + self.top = self.top + (additionalPadding.top ?? 0) + self.leading = self.leading + (additionalPadding.leading ?? 0) + self.trailing = self.trailing + (additionalPadding.trailing ?? 0) + self.bottom = self.bottom + (additionalPadding.bottom ?? 0) + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeBanner.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeBanner.swift new file mode 100644 index 000000000..81f9cdbbe --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeBanner.swift @@ -0,0 +1,143 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +#if canImport(AirshipCore) +import AirshipCore +#endif + +public extension InAppMessageTheme { + + + /// Banner in-app message theme + struct Banner: Equatable { + + /// Max width + public var maxWidth: CGFloat + + /// Padding + public var padding: EdgeInsets + + /// Tap opacity when the banner is tappable + public var tapOpacity: CGFloat + + /// Shadow theme + public var shadow: InAppMessageTheme.Shadow + + /// Header theme + public var header: InAppMessageTheme.Text + + /// Body theme + public var body: InAppMessageTheme.Text + + // Media theme + public var media: InAppMessageTheme.Media + + /// Button theme + public var buttons: InAppMessageTheme.Button + + /// Default plist file for overrides + public static let defaultPlistName: String = "UAInAppMessageBannerStyle" + + /// Applies a style from a plist to the theme. + /// - Parameters: + /// - plistName: The name of the plist + /// - bundle: The plist bundle. + public mutating func applyPlist(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decode( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + + mutating func applyPlistIfExists(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decodeIfExists( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides = overrides else { return } + self.padding.add(overrides.additionalPadding) + self.maxWidth = overrides.maxWidth ?? self.maxWidth + self.tapOpacity = overrides.tapOpacity ?? tapOpacity + self.shadow.applyOverrides(overrides.shadowTheme) + self.header.applyOverrides(overrides.headerTheme) + self.body.applyOverrides(overrides.bodyTheme) + self.media.applyOverrides(overrides.mediaTheme) + self.buttons.applyOverrides(overrides.buttonTheme) + } + + struct Overrides: Decodable { + var additionalPadding: InAppMessageTheme.AdditionalPadding? + var maxWidth: CGFloat? + var tapOpacity: CGFloat? + var shadowTheme: InAppMessageTheme.Shadow.Overrides? + var headerTheme: InAppMessageTheme.Text.Overrides? + var bodyTheme: InAppMessageTheme.Text.Overrides? + var mediaTheme: InAppMessageTheme.Media.Overrides? + var buttonTheme: InAppMessageTheme.Button.Overrides? + + enum CodingKeys: String, CodingKey { + case additionalPadding = "additionalPadding" + case maxWidth = "maxWidth" + case tapOpacity = "tapOpacity" + case shadowTheme = "shadowStyle" + case headerTheme = "headerStyle" + case bodyTheme = "bodyStyle" + case mediaTheme = "mediaStyle" + case buttonTheme = "buttonStyle" + } + } + + static let defaultTheme: InAppMessageTheme.Banner = { + // Default + var theme = InAppMessageTheme.Banner( + maxWidth: 480, + padding: EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 24), + tapOpacity: 0.7, + shadow: InAppMessageTheme.Shadow( + radius: 5, + xOffset: 0, + yOffset: 0, + color: Color.black.opacity(0.33) + ), + header: InAppMessageTheme.Text( + letterSpacing: 0, + lineSpacing: 0, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + body: InAppMessageTheme.Text( + letterSpacing: 0, + lineSpacing: 0, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + media: InAppMessageTheme.Media( + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + buttons: InAppMessageTheme.Button( + height: 33, + stackedSpacing: 24, + separatedSpacing: 16, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ) + ) + + /// Overrides + do { + try theme.applyPlistIfExists(plistName: "UAInAppMessageBannerStyle") + } catch { + AirshipLogger.error("Unable to apply theme overrides \(error)") + } + return theme + }() + } + +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeButton.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeButton.swift new file mode 100644 index 000000000..c7a9d4561 --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeButton.swift @@ -0,0 +1,57 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + + +public extension InAppMessageTheme { + + /// Button in-app message theme + struct Button: Equatable { + /// Button height + public var height: Double + + /// Button spacing when stackeds + public var stackedSpacing: Double + + /// Button spacing when seperated + public var separatedSpacing: Double + + /// Padding + public var padding: EdgeInsets + + public init( + height: Double, + stackedSpacing: Double, + separatedSpacing: Double, + padding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ) { + self.height = height + self.stackedSpacing = stackedSpacing + self.separatedSpacing = separatedSpacing + self.padding = padding + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides else { return } + self.height = overrides.buttonHeight ?? self.height + self.stackedSpacing = overrides.stackedButtonSpacing ?? self.stackedSpacing + self.separatedSpacing = overrides.separatedButtonSpacing ?? self.separatedSpacing + self.padding.add(overrides.additionalPadding) + } + + struct Overrides: Decodable { + var buttonHeight: Double? + var stackedButtonSpacing: Double? + var separatedButtonSpacing: Double? + var additionalPadding: InAppMessageTheme.AdditionalPadding? + + enum CodingKeys: String, CodingKey { + case buttonHeight + case stackedButtonSpacing + case separatedButtonSpacing + case additionalPadding + } + } + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeFullscreen.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeFullscreen.swift new file mode 100644 index 000000000..c2ad26feb --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeFullscreen.swift @@ -0,0 +1,118 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +#if canImport(AirshipCore) +import AirshipCore +#endif + +public extension InAppMessageTheme { + + /// Fullscreen in-app message theme + struct Fullscreen: Equatable { + + /// Padding + public var padding: EdgeInsets + + /// Header theme + public var header: InAppMessageTheme.Text + + /// Body theme + public var body: InAppMessageTheme.Text + + // Media theme + public var media: InAppMessageTheme.Media + + /// Button theme + public var buttons: InAppMessageTheme.Button + + /// Dismiss icon resource name + public var dismissIconResource: String + + /// Applies a style from a plist to the theme. + /// - Parameters: + /// - plistName: The name of the plist + /// - bundle: The plist bundle. + public mutating func applyPlist(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decode( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + mutating func applyPlistIfExists(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decodeIfExists( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides = overrides else { return } + self.padding.add(overrides.additionalPadding) + self.header.applyOverrides(overrides.headerTheme) + self.body.applyOverrides(overrides.bodyTheme) + self.media.applyOverrides(overrides.mediaTheme) + self.buttons.applyOverrides(overrides.buttonTheme) + self.dismissIconResource = overrides.dismissIconResource ?? self.dismissIconResource + } + + struct Overrides: Decodable { + var additionalPadding: InAppMessageTheme.AdditionalPadding? + var headerTheme: InAppMessageTheme.Text.Overrides? + var bodyTheme: InAppMessageTheme.Text.Overrides? + var mediaTheme: InAppMessageTheme.Media.Overrides? + var buttonTheme: InAppMessageTheme.Button.Overrides? + var dismissIconResource: String? + + enum CodingKeys: String, CodingKey { + case additionalPadding = "additionalPadding" + case headerTheme = "headerStyle" + case bodyTheme = "bodyStyle" + case mediaTheme = "mediaStyle" + case buttonTheme = "buttonStyle" + case dismissIconResource = "dismissIconResource" + } + } + + static let defaultTheme: InAppMessageTheme.Fullscreen = { + // Default + var theme = InAppMessageTheme.Fullscreen( + padding: EdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24), + header: InAppMessageTheme.Text( + letterSpacing: 0, + lineSpacing: 0, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + body: InAppMessageTheme.Text( + letterSpacing: 0, + lineSpacing: 0, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + media: InAppMessageTheme.Media( + padding: EdgeInsets(top: 0, leading: -24, bottom: 0, trailing: -24) + ), + buttons: InAppMessageTheme.Button( + height: 33, + stackedSpacing: 24, + separatedSpacing: 16, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + dismissIconResource: "xmark" + ) + + /// Overrides + do { + try theme.applyPlistIfExists(plistName: "UAInAppMessageFullScreenStyle") + } catch { + AirshipLogger.error("Unable to apply theme overrides \(error)") + } + return theme + }() + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeHTML.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeHTML.swift new file mode 100644 index 000000000..723df54b2 --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeHTML.swift @@ -0,0 +1,88 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + + +#if canImport(AirshipCore) +import AirshipCore +#endif + + +public extension InAppMessageTheme { + /// Html message theme + struct HTML: Equatable { + + /// Max width in points + public var maxWidth: CGFloat + + /// Max height in pointts + public var maxHeight: CGFloat + + /// If the dismiss icon should be hidden or not. Defaults to `false` + public var hideDismissIcon: Bool = false + + /// Additional padding + public var padding: EdgeInsets + + /// Dismiss icon resource name + public var dismissIconResource: String + + /// Applies a style from a plist to the theme. + /// - Parameters: + /// - plistName: The name of the plist + /// - bundle: The plist bundle. + public mutating func applyPlist(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decode( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + mutating func applyPlistIfExists(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decodeIfExists( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides = overrides else { return } + self.hideDismissIcon = overrides.hideDismissIcon ?? self.hideDismissIcon + self.padding.add(overrides.additionalPadding) + self.dismissIconResource = overrides.dismissIconResource ?? self.dismissIconResource + self.maxWidth = overrides.maxWidth ?? self.maxWidth + self.maxHeight = overrides.maxHeight ?? self.maxHeight + } + + struct Overrides: Decodable { + var hideDismissIcon: Bool? + var additionalPadding: InAppMessageTheme.AdditionalPadding? + var dismissIconResource: String? + var maxWidth: CGFloat? + var maxHeight: CGFloat? + } + + static let defaultTheme: InAppMessageTheme.HTML = { + // Default + var theme = InAppMessageTheme.HTML( + maxWidth: 420, + maxHeight: 720, + padding: EdgeInsets(top: 48, leading: 24, bottom: 48, trailing: 24), + dismissIconResource: "xmark" + ) + + /// Overrides + do { + try theme.applyPlistIfExists(plistName: "UAInAppMessageHTMLStyle") + } catch { + AirshipLogger.error("Unable to apply theme overrides \(error)") + } + return theme + }() + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeManager.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeManager.swift new file mode 100644 index 000000000..354ad872e --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeManager.swift @@ -0,0 +1,51 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + + +#if canImport(AirshipCore) +import AirshipCore +#endif + +/// Theme manager for in-app messages. +@MainActor +public final class InAppAutomationThemeManager { + + /// Sets the html theme extender block + public var htmlThemeExtender: (@MainActor (InAppMessage, inout InAppMessageTheme.HTML) -> Void)? + + /// Sets the modal theme extender block + public var modalThemeExtender: (@MainActor (InAppMessage, inout InAppMessageTheme.Modal) -> Void)? + + /// Sets the fullscreen theme extender block + public var fullscreenThemeExtender: (@MainActor (InAppMessage, inout InAppMessageTheme.Fullscreen) -> Void)? + + /// Sets the banner theme extender block + public var bannerThemeExtender: (@MainActor (InAppMessage, inout InAppMessageTheme.Banner) -> Void)? + + + func makeHTMLTheme(message: InAppMessage) -> InAppMessageTheme.HTML { + var theme = InAppMessageTheme.HTML.defaultTheme + htmlThemeExtender?(message, &theme) + return theme + } + + func makeModalTheme(message: InAppMessage) -> InAppMessageTheme.Modal { + var theme = InAppMessageTheme.Modal.defaultTheme + modalThemeExtender?(message, &theme) + return theme + } + + func makeFullscreenTheme(message: InAppMessage) -> InAppMessageTheme.Fullscreen { + var theme = InAppMessageTheme.Fullscreen.defaultTheme + fullscreenThemeExtender?(message, &theme) + return theme + } + + func makeBannerTheme(message: InAppMessage) -> InAppMessageTheme.Banner { + var theme = InAppMessageTheme.Banner.defaultTheme + bannerThemeExtender?(message, &theme) + return theme + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeMedia.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeMedia.swift new file mode 100644 index 000000000..e6b112846 --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeMedia.swift @@ -0,0 +1,32 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + + +public extension InAppMessageTheme { + + /// Media in-app message theme + struct Media: Equatable { + + /// Padding + public var padding: EdgeInsets + + public init(padding: EdgeInsets) { + self.padding = padding + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides else { return } + self.padding.add(overrides.additionalPadding) + } + + struct Overrides: Decodable { + var additionalPadding: AdditionalPadding? + + enum CodingKeys: String, CodingKey { + case additionalPadding + } + } + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeModal.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeModal.swift new file mode 100644 index 000000000..a6cf56a9d --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeModal.swift @@ -0,0 +1,133 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +#if canImport(AirshipCore) +import AirshipCore +#endif + +public extension InAppMessageTheme { + + /// Modal in-app message theme + struct Modal: Equatable { + + /// Max width + public var maxWidth: CGFloat + + /// Max height + public var maxHeight: CGFloat + + /// Padding + public var padding: EdgeInsets + + /// Header theme + public var header: InAppMessageTheme.Text + + /// Body theme + public var body: InAppMessageTheme.Text + + // Media theme + public var media: InAppMessageTheme.Media + + /// Button theme + public var buttons: InAppMessageTheme.Button + + /// Dismiss icon resource name + public var dismissIconResource: String + + + /// Applies a style from a plist to the theme. + /// - Parameters: + /// - plistName: The name of the plist + /// - bundle: The plist bundle. + public mutating func applyPlist(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decode( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + mutating func applyPlistIfExists(plistName: String, bundle: Bundle? = Bundle.main) throws { + let overrides = try InAppMessageTheme.decodeIfExists( + Overrides.self, + plistName: plistName, + bundle: bundle + ) + self.applyOverrides(overrides) + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides = overrides else { return } + self.maxWidth = overrides.maxWidth ?? self.maxWidth + self.maxHeight = overrides.maxHeight ?? self.maxHeight + self.padding.add(overrides.additionalPadding) + self.header.applyOverrides(overrides.headerTheme) + self.body.applyOverrides(overrides.bodyTheme) + self.media.applyOverrides(overrides.mediaTheme) + self.buttons.applyOverrides(overrides.buttonTheme) + self.dismissIconResource = overrides.dismissIconResource ?? self.dismissIconResource + } + + struct Overrides: Decodable { + var additionalPadding: InAppMessageTheme.AdditionalPadding? + var headerTheme: InAppMessageTheme.Text.Overrides? + var bodyTheme: InAppMessageTheme.Text.Overrides? + var mediaTheme: InAppMessageTheme.Media.Overrides? + var buttonTheme: InAppMessageTheme.Button.Overrides? + var dismissIconResource: String? + var maxWidth: CGFloat? + var maxHeight: CGFloat? + + enum CodingKeys: String, CodingKey { + case additionalPadding = "additionalPadding" + case headerTheme = "headerStyle" + case bodyTheme = "bodyStyle" + case mediaTheme = "mediaStyle" + case buttonTheme = "buttonStyle" + case dismissIconResource = "dismissIconResource" + case maxWidth = "maxWidth" + case maxHeight = "maxHeight" + } + } + + static let defaultTheme: InAppMessageTheme.Modal = { + // Default + var theme = InAppMessageTheme.Modal( + maxWidth: 420, + maxHeight: 720, + padding: EdgeInsets(top: 48, leading: 24, bottom: 48, trailing: 24), + header: InAppMessageTheme.Text( + letterSpacing: 0, + lineSpacing: 0, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + body: InAppMessageTheme.Text( + letterSpacing: 0, + lineSpacing: 0, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + media: InAppMessageTheme.Media( + padding: EdgeInsets(top: 0, leading: -24, bottom: 0, trailing: -24) + ), + buttons: InAppMessageTheme.Button( + height: 33, + stackedSpacing: 24, + separatedSpacing: 16, + padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + ), + dismissIconResource: "xmark" + ) + + /// Overrides + do { + try theme.applyPlistIfExists(plistName: "UAInAppMessageModalStyle") + } catch { + AirshipLogger.error("Unable to apply theme overrides \(error)") + } + return theme + }() + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeShadow.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeShadow.swift new file mode 100644 index 000000000..ecf50207e --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeShadow.swift @@ -0,0 +1,65 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +/// For color utils +#if canImport(AirshipCore) +import AirshipCore +#endif + + +public extension InAppMessageTheme { + + /// Shadow in-app message theme + struct Shadow: Equatable { + + /// Shadow radius + public var radius: CGFloat + + /// X offset + public var xOffset: CGFloat + + /// Y offset + public var yOffset: CGFloat + + /// Shadow color + public var color: Color + + public init(radius: CGFloat, xOffset: CGFloat, yOffset: CGFloat, color: Color) { + self.radius = radius + self.xOffset = xOffset + self.yOffset = yOffset + self.color = color + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides else { return } + self.radius = overrides.radius ?? self.radius + self.xOffset = overrides.xOffset ?? self.xOffset + self.yOffset = overrides.yOffset ?? self.yOffset + self.color = overrides.color.flatMap { $0.airshipToColor() } ?? self.color + } + + struct Overrides: Decodable { + var radius: CGFloat? + var xOffset: CGFloat? + var yOffset: CGFloat? + var color: String? + + init(radius: CGFloat? = nil, xOffset: CGFloat? = nil, yOffset: CGFloat? = nil, color: String? = nil) { + self.radius = radius + self.xOffset = xOffset + self.yOffset = yOffset + self.color = color + } + + enum CodingKeys: String, CodingKey { + case radius = "radius" + case xOffset = "xOffset" + case yOffset = "yOffset" + case color = "colorHex" + } + } + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeText.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeText.swift new file mode 100644 index 000000000..5e554989f --- /dev/null +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeText.swift @@ -0,0 +1,39 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +public extension InAppMessageTheme { + + /// Text in-app message theme + struct Text: Equatable { + + /// Letter spacing + public var letterSpacing: CGFloat + + /// Line spacing + public var lineSpacing: CGFloat + + /// Text view padding + public var padding: EdgeInsets + + public init(letterSpacing: Double, lineSpacing: Double, padding: EdgeInsets) { + self.letterSpacing = letterSpacing + self.lineSpacing = lineSpacing + self.padding = padding + } + + mutating func applyOverrides(_ overrides: Overrides?) { + guard let overrides else { return } + self.letterSpacing = overrides.letterSpacing ?? self.letterSpacing + self.lineSpacing = overrides.lineSpacing ?? self.lineSpacing + self.padding.add(overrides.additionalPadding) + } + + struct Overrides: Decodable { + var letterSpacing: CGFloat? + var lineSpacing: CGFloat? + var additionalPadding: InAppMessageTheme.AdditionalPadding? + } + } +} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ModalTheme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ModalTheme.swift deleted file mode 100644 index a8114b370..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ModalTheme.swift +++ /dev/null @@ -1,108 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -struct ModalTheme: Equatable { - var additionalPadding: EdgeInsets - var headerTheme: TextTheme - var bodyTheme: TextTheme - var mediaTheme: MediaTheme - var buttonTheme: ButtonTheme - var dismissIconResource: String - var maxWidth: Int - var maxHeight: Int - - /// Used for testing - init(plistName: String, - bundle: Bundle? = Bundle.main) { - let overrides = ModalThemeOverride(plistName: plistName, bundle: bundle) - self.init(themeOverride: overrides) - } - - init(additionalPadding: EdgeInsets, - headerTheme: TextTheme, - bodyTheme: TextTheme, - mediaTheme: MediaTheme, - buttonTheme: ButtonTheme, - dismissIconResource: String, - maxWidth: Int, - maxHeight: Int) { - self.additionalPadding = additionalPadding - self.headerTheme = headerTheme - self.bodyTheme = bodyTheme - self.mediaTheme = mediaTheme - self.buttonTheme = buttonTheme - self.dismissIconResource = dismissIconResource - self.maxWidth = maxWidth - self.maxHeight = maxHeight - } - - init(themeOverride:ModalThemeOverride? = ModalThemeOverride(plistName: Self.defaultPlistName, bundle: Bundle.main), - defaultValues:ModalTheme = Self.defaultValues) { - self.additionalPadding = themeOverride?.additionalPadding.map { EdgeInsets(themeOverride: $0, defaults: defaultValues.additionalPadding) } ?? Self.defaultValues.additionalPadding - - self.headerTheme = themeOverride?.headerTheme - .map { TextTheme(themeOverride: $0, defaults: defaultValues.headerTheme) } - ?? defaultValues.headerTheme - - self.bodyTheme = themeOverride?.bodyTheme - .map { TextTheme(themeOverride: $0, defaults: defaultValues.bodyTheme) } - ?? defaultValues.bodyTheme - - self.mediaTheme = themeOverride?.mediaTheme - .map { MediaTheme(themeOverride: $0, defaults: defaultValues.mediaTheme) } - ?? defaultValues.mediaTheme - - self.buttonTheme = themeOverride?.buttonTheme - .map { ButtonTheme(themeOverride: $0, defaults: defaultValues.buttonTheme) } - ?? defaultValues.buttonTheme - - self.dismissIconResource = themeOverride?.dismissIconResource - ?? defaultValues.dismissIconResource - - self.maxWidth = themeOverride?.maxWidth - ?? defaultValues.maxWidth - - self.maxHeight = themeOverride?.maxHeight - ?? defaultValues.maxHeight - } -} - -struct ModalThemeOverride: Decodable, PlistLoadable { - var additionalPadding: EdgeInsetsThemeOverride? - var headerTheme: TextThemeOverride? - var bodyTheme: TextThemeOverride? - var mediaTheme: MediaThemeOverride? - var buttonTheme: ButtonThemeOverride? - var dismissIconResource: String? - var maxWidth: Int? - var maxHeight: Int? - - init?() { - self.init(plistName: ModalTheme.defaultPlistName) - } - - enum CodingKeys: String, CodingKey { - case additionalPadding = "additionalPadding" - case headerTheme = "headerStyle" - case bodyTheme = "bodyStyle" - case mediaTheme = "mediaStyle" - case buttonTheme = "buttonStyle" - case dismissIconResource = "dismissIconResource" - case maxWidth = "maxWidth" - case maxHeight = "maxHeight" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - headerTheme = try container.decodeIfPresent(TextThemeOverride.self, forKey: .headerTheme) - bodyTheme = try container.decodeIfPresent(TextThemeOverride.self, forKey: .bodyTheme) - mediaTheme = try container.decodeIfPresent(MediaThemeOverride.self, forKey: .mediaTheme) - buttonTheme = try container.decodeIfPresent(ButtonThemeOverride.self, forKey: .buttonTheme) - dismissIconResource = try container.decodeIfPresent(String.self, forKey: .dismissIconResource) - maxWidth = try container.decodeIfPresent(Int.self, forKey: .maxWidth) - maxHeight = try container.decodeIfPresent(Int.self, forKey: .maxHeight) - additionalPadding = try container.decodeIfPresent(EdgeInsetsThemeOverride.self, forKey: .additionalPadding) - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Theme.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Theme.swift deleted file mode 100644 index 568bf240d..000000000 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/Theme.swift +++ /dev/null @@ -1,147 +0,0 @@ -/* Copyright Airship and Contributors */ - -import Foundation -import SwiftUI - -enum Theme { - case banner(BannerTheme) - case modal(ModalTheme) - case fullScreen(FullScreenTheme) - case html(HTMLTheme) - - var bannerTheme: BannerTheme { - if case .banner(let theme) = self { - return theme - } - return BannerTheme() - } - - var modalTheme: ModalTheme { - if case .modal(let theme) = self { - return theme - } - return ModalTheme() - } - - var fullScreenTheme: FullScreenTheme { - if case .fullScreen(let theme) = self { - return theme - } - return FullScreenTheme() - } - - var htmlTheme: HTMLTheme { - if case .html(let theme) = self { - return theme - } - - return HTMLTheme() - } - - static let defaultButtonHeight: CGFloat = 33 - static let defaultFooterHeight: CGFloat = 33 - - static let defaultStackedButtonSpacing: CGFloat = 24 - static let defaultSeparatedButtonSpacing: CGFloat = 16 -} - -extension BannerTheme: ThemeDefaultable { - static let defaultPlistName: String = "UAInAppMessageBannerStyle" - - static var defaultValues: BannerTheme { - let defaultPadding = EdgeInsets(top: 0, leading: 24, bottom: 0, trailing: 24) - let defaultHeaderPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - let defaultBodyPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - let defaultMediaPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - let defaultButtonPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - return BannerTheme( - additionalPadding: defaultPadding, - maxWidth: 0, - tapOpacity: 0.7, - shadowTheme: ShadowTheme(radius: 5, xOffset: 0, yOffset: 0, color: Color.black.opacity(0.33)), - headerTheme: TextTheme(letterSpacing: 0, - lineSpacing: 0, - additionalPadding: defaultHeaderPadding), - bodyTheme: TextTheme(letterSpacing: 0, - lineSpacing: 0, - additionalPadding: defaultBodyPadding), - mediaTheme: MediaTheme(additionalPadding: defaultMediaPadding), - buttonTheme: ButtonTheme(buttonHeight: Theme.defaultButtonHeight, - stackedButtonSpacing: Theme.defaultStackedButtonSpacing, - separatedButtonSpacing: Theme.defaultSeparatedButtonSpacing, - additionalPadding: defaultButtonPadding) - ) - } -} - -extension ModalTheme: ThemeDefaultable { - static let defaultPlistName: String = "UAInAppMessageModalStyle" - - static var defaultValues: ModalTheme { - let defaultPadding = EdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24) - let defaultHeaderPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - let defaultBodyPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - let defaultMediaPadding = EdgeInsets(top: 0, leading: -24, bottom: 0, trailing: -24) - let defaultButtonPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - - return ModalTheme( - additionalPadding: defaultPadding, - headerTheme: TextTheme(letterSpacing: 0, - lineSpacing: 0, - additionalPadding: defaultHeaderPadding), - bodyTheme: TextTheme(letterSpacing: 0, - lineSpacing: 0, - additionalPadding: defaultBodyPadding), - mediaTheme: MediaTheme(additionalPadding: defaultMediaPadding), - buttonTheme: ButtonTheme(buttonHeight: Theme.defaultButtonHeight, - stackedButtonSpacing: Theme.defaultStackedButtonSpacing, - separatedButtonSpacing: Theme.defaultSeparatedButtonSpacing, - additionalPadding: defaultButtonPadding), - dismissIconResource: "xmark", - maxWidth: 480, - maxHeight: 900 - ) - } -} - -extension FullScreenTheme: ThemeDefaultable { - static let defaultPlistName: String = "UAInAppMessageFullScreenStyle" - - static var defaultValues: FullScreenTheme { - let defaultPadding = EdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24) - let defaultHeaderPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - let defaultBodyPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - let defaultMediaPadding = EdgeInsets(top: 0, leading: -24, bottom: 0, trailing: -24) - let defaultButtonPadding = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) - - return FullScreenTheme( - additionalPadding: defaultPadding, - headerTheme: TextTheme(letterSpacing: 0, - lineSpacing: 0, - additionalPadding: defaultHeaderPadding), - bodyTheme: TextTheme(letterSpacing: 0, - lineSpacing: 0, - additionalPadding: defaultBodyPadding), - mediaTheme: MediaTheme(additionalPadding: defaultMediaPadding), - buttonTheme: ButtonTheme(buttonHeight: Theme.defaultButtonHeight, - stackedButtonSpacing: Theme.defaultStackedButtonSpacing, - separatedButtonSpacing: Theme.defaultSeparatedButtonSpacing, - additionalPadding: defaultButtonPadding), - dismissIconResource: "xmark" - ) - } -} - -extension HTMLTheme: ThemeDefaultable { - static let defaultPlistName: String = "UAInAppMessageHTMLStyle" - - static var defaultValues: HTMLTheme { - let defaultPadding = EdgeInsets(top: 24, leading: 24, bottom: 24, trailing: 24) - - return HTMLTheme(hideDismissIcon: false, - additionalPadding: defaultPadding, - dismissIconResource: "xmark", - maxWidth: Int.max, - maxHeight: Int.max) /// No limit on default size - } -} diff --git a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ThemeExtensions.swift b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ThemeExtensions.swift index 605400d98..3437823c2 100644 --- a/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ThemeExtensions.swift +++ b/Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ThemeExtensions.swift @@ -4,56 +4,17 @@ import Foundation import SwiftUI -protocol ThemeDefaultable { - static var defaultPlistName: String { get } - static var defaultValues: Self { get } -} - -protocol PlistLoadable { - init?(plistName: String, bundle: Bundle?) -} - -extension PlistLoadable where Self: Decodable { - init?(plistName: String, bundle: Bundle? = Bundle.main) { - guard let url = bundle?.url(forResource: plistName, withExtension: "plist"), - let data = try? Data(contentsOf: url) else { - return nil - } - - let decoder = PropertyListDecoder() - guard let decoded = try? decoder.decode(Self.self, from: data) else { - return nil - } - - self = decoded - } -} - -extension ButtonView { - @ViewBuilder - func applyButtonTheme(_ buttonTheme: ButtonTheme) -> some View { - self.padding(buttonTheme.additionalPadding) - } -} - -extension MediaView { - @ViewBuilder - func applyMediaTheme(_ textTheme: MediaTheme) -> some View { - self.padding(textTheme.additionalPadding) - } -} - extension View { @ViewBuilder - func applyTextTheme(_ textTheme: TextTheme) -> some View { + func applyTextTheme(_ textTheme: InAppMessageTheme.Text) -> some View { if #available(iOS 16.0, *) { self - .padding(textTheme.additionalPadding) + .padding(textTheme.padding) .lineSpacing(textTheme.lineSpacing) .kerning(textTheme.letterSpacing) } else { self - .padding(textTheme.additionalPadding) + .padding(textTheme.padding) .lineSpacing(textTheme.lineSpacing) /// TODO add a pre-16.0 version of kerning/letter spacing and manually test } diff --git a/Airship/AirshipAutomation/Source/RemoteData/AutomationRemoteDataAccess.swift b/Airship/AirshipAutomation/Source/RemoteData/AutomationRemoteDataAccess.swift index 0b9ec27f6..c63d8effd 100644 --- a/Airship/AirshipAutomation/Source/RemoteData/AutomationRemoteDataAccess.swift +++ b/Airship/AirshipAutomation/Source/RemoteData/AutomationRemoteDataAccess.swift @@ -203,7 +203,7 @@ struct InAppRemoteData: Sendable { switch(parsed) { case .succeed(let result): return result case .failed(let error): - AirshipLogger.warn("Failed to parse schedule \(error)") + AirshipLogger.error("Failed to parse schedule \(error)") return nil } } diff --git a/Airship/AirshipAutomation/Source/Utils/RetryingQueue.swift b/Airship/AirshipAutomation/Source/Utils/RetryingQueue.swift index 07a1fb631..6746c111f 100644 --- a/Airship/AirshipAutomation/Source/Utils/RetryingQueue.swift +++ b/Airship/AirshipAutomation/Source/Utils/RetryingQueue.swift @@ -89,22 +89,33 @@ actor RetryingQueue { } /// Max number of operations to run simultaneously - private let maxConcurrentOperations: UInt + private var maxConcurrentOperations: UInt /// Max number of pending results before blocking new operations from starting - private let maxPendingResults: UInt + private var maxPendingResults: UInt /// Initial backOff interval - private let initialBackOff: TimeInterval + private var initialBackOff: TimeInterval // Max backOff - private let maxBackOff: TimeInterval + private var maxBackOff: TimeInterval private var operationState: [UInt: OperationState] = [:] private var nextID: UInt = 1 private let taskSleeper: AirshipTaskSleeper + init( + config: RemoteConfig.RetryingQueueConfig? = nil, + taskSleeper: AirshipTaskSleeper = .shared + ) { + self.maxConcurrentOperations = config?.maxConcurrentOperations ?? 3 + self.maxPendingResults = config?.maxPendingResults ?? 2 + self.initialBackOff = config?.initialBackoff ?? 15 + self.maxBackOff = config?.maxBackOff ?? 60 + self.taskSleeper = taskSleeper + } + init( maxConcurrentOperations: UInt = 3, maxPendingResults: UInt = 2, @@ -112,7 +123,7 @@ actor RetryingQueue { maxBackOff: TimeInterval = 60, taskSleeper: AirshipTaskSleeper = .shared ) { - self.maxConcurrentOperations = max(1, maxConcurrentOperations) + self.maxConcurrentOperations = max(1,maxConcurrentOperations) self.maxPendingResults = max(1, maxPendingResults) self.initialBackOff = max(1, initialBackOff) self.maxBackOff = max(initialBackOff, maxBackOff) diff --git a/Airship/AirshipAutomation/Tests/Actions/CancelSchedulesActionTest.swift b/Airship/AirshipAutomation/Tests/Actions/CancelSchedulesActionTest.swift index 44c47d64b..5bc41bb3c 100644 --- a/Airship/AirshipAutomation/Tests/Actions/CancelSchedulesActionTest.swift +++ b/Airship/AirshipAutomation/Tests/Actions/CancelSchedulesActionTest.swift @@ -185,6 +185,9 @@ final class CancelSchedulesActionTest: XCTestCase { } final class TestInAppMessaging: InAppMessagingProtocol, @unchecked Sendable { + @MainActor + var themeManager: InAppAutomationThemeManager = InAppAutomationThemeManager() + var displayInterval: TimeInterval = 0.0 var displayDelegate: InAppMessageDisplayDelegate? diff --git a/Airship/AirshipAutomation/Tests/Automation/AudienceCheck/AdditionalAudienceCheckerResolverTest.swift b/Airship/AirshipAutomation/Tests/Automation/AudienceCheck/AdditionalAudienceCheckerResolverTest.swift new file mode 100644 index 000000000..56e151fe0 --- /dev/null +++ b/Airship/AirshipAutomation/Tests/Automation/AudienceCheck/AdditionalAudienceCheckerResolverTest.swift @@ -0,0 +1,290 @@ +/* Copyright Airship and Contributors */ + +import XCTest + +@testable +import AirshipAutomation +import AirshipCore + +class AdditionalAudienceCheckerResolverTest: XCTestCase { + + private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) + private let date = UATestDate(dateOverride: Date()) + private let apiClient = TestAudienceApiClient() + private var cache: AirshipCache! + + private var resolver: AdditionalAudienceCheckerResolver! + private var deviceInfoProvider: TestDeviceInfoProvider = TestDeviceInfoProvider() + + private let defaultAudienceConfig = RemoteConfig.AdditionalAudienceCheckConfig( + isEnabled: true, + context: .string("remote config context"), + url: "https://test.config") + + override func setUp() async throws { + cache = TestAirshipCoreDataCache.makeCache(date: date) + } + + func testHappyPath() async throws { + makeResolver(config: defaultAudienceConfig) + + deviceInfoProvider.stableContactInfo = StableContactInfo(contactID: "existing-contact-id", namedUserID: "some user id") + deviceInfoProvider.channelID = "channel-id" + + apiClient.onResponse = { request in + XCTAssertEqual("channel-id", request.channelID) + XCTAssertEqual("existing-contact-id", request.contactID) + XCTAssertEqual("some user id", request.namedUserID) + XCTAssertEqual(AirshipJSON.string("default context"), request.context) + XCTAssertEqual("https://test.config", request.url.absoluteString) + + return AirshipHTTPResponse.make( + result: AdditionalAudienceCheckResult(isMatched: true, cacheTTL: 10), + statusCode: 200, + headers: [:]) + } + + let cacheKey = "https://test.config:\"default context\":existing-contact-id:channel-id" + + var cached: AdditionalAudienceCheckResult? = await cache.getCachedValue(key: cacheKey) + XCTAssertNil(cached) + + let result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil + ) + ) + + cached = await cache.getCachedValue(key: cacheKey) + XCTAssertEqual(true, cached?.isMatched) + XCTAssertEqual(10, cached?.cacheTTL) + XCTAssert(result) + } + + func testResolverReturnsTrueOnNoConfigOrDisabled() async throws { + makeResolver(config: nil) + + var result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil + ) + ) + + XCTAssert(result) + + makeResolver(config: .init(isEnabled: false, context: .null, url: "test")) + result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil + ) + ) + + XCTAssert(result) + } + + func testResolverThrowsOnNoUrlProvided() async throws { + date.offset = 0 + makeResolver(config: defaultAudienceConfig) + apiClient.onResponse = { _ in + return AirshipHTTPResponse.make( + result: AdditionalAudienceCheckResult(isMatched: true, cacheTTL: 1), + statusCode: 200, + headers: [:]) + } + + var result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil)) + + XCTAssert(result) + + date.offset = 2 + makeResolver(config: .init(isEnabled: true, context: .null, url: nil)) + result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: "https://test.url")) + + XCTAssert(result) + + date.offset += 2 + do { + result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil)) + XCTFail() + } catch { + + } + } + + func testOverridesBypass() async throws { + makeResolver(config: defaultAudienceConfig) + apiClient.onResponse = { _ in + AirshipHTTPResponse.make(result: nil, statusCode: 400, headers: [:]) + } + + let result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: true, + context: .null, + url: nil)) + + XCTAssert(result) + } + + func testContextDefaultsToConfig() async throws { + makeResolver(config: defaultAudienceConfig) + + apiClient.onResponse = { request in + XCTAssertEqual(AirshipJSON.string("remote config context"), request.context) + + return AirshipHTTPResponse.make( + result: AdditionalAudienceCheckResult(isMatched: true, cacheTTL: 10), + statusCode: 200, + headers: [:]) + } + + let result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: true, + context: nil, + url: nil + ) + ) + + XCTAssert(result) + } + + func testReturnsCachedIfAvailable() async throws { + makeResolver(config: defaultAudienceConfig) + + deviceInfoProvider.stableContactInfo = StableContactInfo(contactID: "existing-contact-id", namedUserID: "some user id") + deviceInfoProvider.channelID = "channel-id" + + apiClient.onResponse = { request in + return AirshipHTTPResponse.make( + result: nil, + statusCode: 400, + headers: [:]) + } + + let cacheKey = "https://test.config:\"default context\":existing-contact-id:channel-id" + + await cache.setCachedValue(AdditionalAudienceCheckResult(isMatched: true, cacheTTL: 10), key: cacheKey, ttl: 10) + + let result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil + ) + ) + + XCTAssert(result) + } + + func testIsNotCachedOnError() async throws { + makeResolver(config: defaultAudienceConfig) + + deviceInfoProvider.stableContactInfo = StableContactInfo(contactID: "existing-contact-id", namedUserID: "some user id") + deviceInfoProvider.channelID = "channel-id" + + apiClient.onResponse = { request in + return AirshipHTTPResponse.make( + result: nil, + statusCode: 400, + headers: [:]) + } + + let cacheKey = "https://test.config:\"default context\":existing-contact-id:channel-id" + + var cached: AdditionalAudienceCheckResult? = await cache.getCachedValue(key: cacheKey) + XCTAssertNil(cached) + + let result = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil + ) + ) + + XCTAssertFalse(result) + + cached = await cache.getCachedValue(key: cacheKey) + XCTAssertNil(cached) + } + + func testThrowsOnServerError() async throws { + makeResolver(config: defaultAudienceConfig) + + apiClient.onResponse = { request in + return AirshipHTTPResponse.make( + result: nil, + statusCode: 500, + headers: [:]) + } + + do { + _ = try await resolver.resolve( + deviceInfoProvider: deviceInfoProvider, + additionalAudienceCheckOverrides: .init( + bypass: false, + context: .string("default context"), + url: nil + ) + ) + XCTFail() + } catch {} + } + + private func makeResolver( + config: RemoteConfig.AdditionalAudienceCheckConfig? + ) { + resolver = AdditionalAudienceCheckerResolver( + cache: cache, + apiClient: apiClient, + date: date, + configProvider: { config } + ) + } + +} + + +final class TestAudienceApiClient: AdditionalAudienceCheckerAPIClientProtocol, @unchecked Sendable { + + var onResponse: ((AdditionalAudienceCheckResult.Request) -> AirshipHTTPResponse)? = nil + + func resolve(info: AdditionalAudienceCheckResult.Request) async throws -> AirshipHTTPResponse { + guard let handler = onResponse else { + return AirshipHTTPResponse.make(result: nil, statusCode: 200, headers: [:]) + } + + return handler(info) + } +} + + diff --git a/Airship/AirshipAutomation/Tests/Automation/AutomationScheduleTest.swift b/Airship/AirshipAutomation/Tests/Automation/AutomationScheduleTest.swift index 346951726..f3f36ce2e 100644 --- a/Airship/AirshipAutomation/Tests/Automation/AutomationScheduleTest.swift +++ b/Airship/AirshipAutomation/Tests/Automation/AutomationScheduleTest.swift @@ -37,11 +37,15 @@ class AutomationScheduleTests: XCTestCase { "frequency_constraint_ids": ["constraint1", "constraint2"], "message_type": "test_type", "last_updated": "2023-12-20T12:30:00Z", - "created": "2023-12-20T12:00:00Z" + "created": "2023-12-20T12:00:00Z", + "additional_audience_check_overrides": { + "bypass": true, + "context": "json-context", + "url": "https://result.url" + } } """ - let expectedSchedule = AutomationSchedule( identifier: "test_schedule", data: .actions(try AirshipJSON.wrap(["foo": "bar"])), @@ -60,7 +64,8 @@ class AutomationScheduleTests: XCTestCase { editGracePeriodDays: 7, metadata: .object([:]), frequencyConstraintIDs: ["constraint1", "constraint2"], - messageType: "test_type" + messageType: "test_type", + additionalAudienceCheckOverrides: .init(bypass: true, context: .string("json-context"), url: "https://result.url") ) try verify(json: jsonString, expected: expectedSchedule) diff --git a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEngineTest.swift b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEngineTest.swift index 531dbf2d9..0f2e34458 100644 --- a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEngineTest.swift +++ b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEngineTest.swift @@ -28,6 +28,7 @@ final class AutomationEngineTest: XCTestCase { private var messageExecutor: InAppMessageAutomationExecutor! private var delayProcessor: AutomationDelayProcessor! private var metrics: ApplicationMetrics! + private var runtimeConfig: RuntimeConfig? override func setUp() async throws { self.privacyManager = await AirshipPrivacyManager( @@ -39,6 +40,14 @@ final class AutomationEngineTest: XCTestCase { defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) + + let config = AirshipConfig() + config.requireInitialRemoteConfigEnabled = false + self.runtimeConfig = RuntimeConfig( + config: config, + dataStore: PreferenceDataStore(appKey: UUID().uuidString) + ) + self.automationStore = AutomationStore(appKey: UUID().uuidString, inMemory: true) self.preparer = await AutomationPreparer( actionPreparer: actionPreparer, @@ -47,7 +56,9 @@ final class AutomationEngineTest: XCTestCase { frequencyLimits: frequencyLimits, audienceChecker: audienceChecker, experiments: experiments, - remoteDataAccess: remoteDataAccess + remoteDataAccess: remoteDataAccess, + config: self.runtimeConfig!, + additionalAudienceResolver: TestAdditionalAudienceResolver() ) let actionExecutor = ActionAutomationExecutor() @@ -136,3 +147,31 @@ final class AutomationEngineTest: XCTestCase { XCTAssertNil(schedule) } } + +actor TestAdditionalAudienceResolver: AdditionalAudienceCheckerResolverProtocol { + struct ResolveRequest { + let channelID: String + let contactID: String? + let overrides: AdditionalAudienceCheckOverrides? + } + + var recordedReqeusts: [ResolveRequest] = [] + public func setResult(_ result: Bool) { + returnResult = result + } + private var returnResult = true + + func resolve( + deviceInfoProvider: AudienceDeviceInfoProvider, + additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? + ) async throws -> Bool { + recordedReqeusts.append( + ResolveRequest( + channelID: try await deviceInfoProvider.channelID, + contactID: await deviceInfoProvider.stableContactInfo.contactID, + overrides: additionalAudienceCheckOverrides + ) + ) + return returnResult + } +} diff --git a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEventFeedTest.swift b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEventFeedTest.swift index 464b67a56..89e936a9a 100644 --- a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEventFeedTest.swift +++ b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEventFeedTest.swift @@ -4,7 +4,7 @@ import XCTest @testable import AirshipAutomation -import AirshipCore +@testable import AirshipCore final class AutomationEventFeedTest: XCTestCase, @unchecked Sendable { private let date = UATestDate(offset: 0, dateOverride: Date()) @@ -33,7 +33,7 @@ final class AutomationEventFeedTest: XCTestCase, @unchecked Sendable { let state = TriggerableState(versionUpdated: "test") - XCTAssertEqual([AutomationEvent.appInit, AutomationEvent.stateChanged(state: state)], events) + XCTAssertEqual([AutomationEvent.event(type: .appInit), AutomationEvent.stateChanged(state: state)], events) } func testSubsequentAttachEmitsNoEvents() async throws { @@ -57,41 +57,102 @@ final class AutomationEventFeedTest: XCTestCase, @unchecked Sendable { stateTracker.currentState = .active var events = await takeNext(count: 2) - XCTAssertEqual(AutomationEvent.foreground, events.first) + XCTAssertEqual(AutomationEvent.event(type: .foreground), events.first) verifyStateChange(event: events.last!, foreground: true, versionUpdated: "test") stateTracker.currentState = .background events = await takeNext(count: 2) - XCTAssertEqual(AutomationEvent.background, events.first) + XCTAssertEqual(AutomationEvent.event(type: .background), events.first) verifyStateChange(event: events.last!, foreground: false, versionUpdated: "test") let trackScreenName = "test-screen" - analyticsFeed.notifyEvent(.screenChange(screen: trackScreenName)) + await analyticsFeed.notifyEvent(.screen(screen: trackScreenName)) var event = await takeNext().first - XCTAssertEqual(AutomationEvent.screenView(name: trackScreenName), event) + XCTAssertEqual(AutomationEvent.event(type: .screen, data: .string(trackScreenName)), event) - analyticsFeed.notifyEvent(.regionEnter(body: .string("some region data"))) + await analyticsFeed.notifyEvent(.analytics(eventType: .regionEnter, body: .string("some region data"))) event = await takeNext().first - XCTAssertEqual(AutomationEvent.regionEnter(data: .string("some region data")), event) + XCTAssertEqual(AutomationEvent.event(type: .regionEnter, data: .string("some region data")), event) - analyticsFeed.notifyEvent(.regionExit(body: .string("some region data"))) + await analyticsFeed.notifyEvent(.analytics(eventType: .regionExit, body: .string("some region data"))) event = await takeNext().first - XCTAssertEqual(AutomationEvent.regionExit(data: .string("some region data")), event) + XCTAssertEqual(AutomationEvent.event(type: .regionExit, data: .string("some region data")), event) - analyticsFeed.notifyEvent(.customEvent(body: .string("some data"), value: 100.0)) + await analyticsFeed.notifyEvent(.analytics(eventType: .customEvent, body: .string("some data"), value: 100)) event = await takeNext().first - XCTAssertEqual(AutomationEvent.customEvent(data: .string("some data"), value: 100.0), event) + XCTAssertEqual(AutomationEvent.event(type: .customEventCount, data: .string("some data"), value: 1), event) + event = await takeNext().first + XCTAssertEqual(AutomationEvent.event(type: .customEventValue, data: .string("some data"), value: 100), event) + - analyticsFeed.notifyEvent(.featureFlagInteraction(body: .string("some data"))) + await analyticsFeed.notifyEvent(.analytics(eventType: .featureFlagInteraction, body: .string("some data"))) + event = await takeNext().first + XCTAssertEqual(AutomationEvent.event(type: .featureFlagInteraction, data: .string("some data")), event) + } + + func testAnalyticFeedEvents() async throws { + await subject.attach() + await takeNext(count: 3) + + let eventMap: [EventType: [EventAutomationTriggerType]] = [ + .customEvent: [.customEventCount, .customEventValue], + .regionExit: [.regionExit], + .regionEnter: [.regionEnter], + .featureFlagInteraction: [.featureFlagInteraction], + .inAppDisplay: [.inAppDisplay], + .inAppResolution: [.inAppResolution], + .inAppButtonTap: [.inAppButtonTap], + .inAppPermissionResult: [.inAppPermissionResult], + .inAppFormDisplay: [.inAppFormDisplay], + .inAppFormResult: [.inAppFormResult], + .inAppGesture: [.inAppGesture], + .inAppPagerCompleted: [.inAppPagerCompleted], + .inAppPagerSummary: [.inAppPagerSummary], + .inAppPageSwipe: [.inAppPageSwipe], + .inAppPageView: [.inAppPageView], + .inAppPageAction: [.inAppPageAction] + ] + + for eventType in EventType.allCases { + guard let expected = eventMap[eventType] else { continue } + + let data = AirshipJSON.string(UUID().uuidString) + await analyticsFeed.notifyEvent(.analytics(eventType: eventType, body: data)) + + for expectedTriggerType in expected { + let event = await takeNext().first + XCTAssertEqual(AutomationEvent.event(type: expectedTriggerType, data: data, value: 1.0), event) + } + } + } + + func testScreenEvent() async throws { + await subject.attach() + await takeNext(count: 3) + + await analyticsFeed.notifyEvent(.screen(screen: "foo")) + let event = await takeNext().first + XCTAssertEqual(AutomationEvent.event(type: .screen, data: .string("foo"), value: 1.0), event) + } + + func testCustomEventValues() async throws { + await subject.attach() + await takeNext(count: 3) + + await analyticsFeed.notifyEvent(.analytics(eventType: .customEvent, body: .null, value: 10)) + + var event = await takeNext().first + XCTAssertEqual(AutomationEvent.event(type: .customEventCount, data: .null, value: 1.0), event) + event = await takeNext().first - XCTAssertEqual(AutomationEvent.featureFlagInterracted(data: .string("some data")), event) + XCTAssertEqual(AutomationEvent.event(type: .customEventValue, data: .null, value: 10.0), event) } func testNoEventsIfNotAttached() async throws { var events = await takeNext() XCTAssert(events.isEmpty) - self.analyticsFeed.notifyEvent(.screenChange(screen: "foo")) + await self.analyticsFeed.notifyEvent(.screen(screen: "foo")) events = await takeNext() XCTAssert(events.isEmpty) } @@ -103,7 +164,7 @@ final class AutomationEventFeedTest: XCTestCase, @unchecked Sendable { await subject.detach() - self.analyticsFeed.notifyEvent(.screenChange(screen: "foo")) + await self.analyticsFeed.notifyEvent(.screen(screen: "foo")) events = await takeNext() XCTAssert(events.isEmpty) } diff --git a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationPreparerTest.swift b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationPreparerTest.swift index 11abfb74d..9ea876bbd 100644 --- a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationPreparerTest.swift +++ b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationPreparerTest.swift @@ -16,10 +16,13 @@ final class AutomationPreparerTest: XCTestCase { private let frequencyLimits: TestFrequencyLimitManager = TestFrequencyLimitManager() private let audienceChecker: TestAudienceChecker = TestAudienceChecker() private var preparer: AutomationPreparer! + private let deviceInfoProvider = TestDeviceInfoProvider() + private let audienceAdditionalResolver = TestAdditionalAudienceResolver() private let triggerContext = AirshipTriggerContext(type: "some type", goal: 10, event: .null) private var preparedMessageData: PreparedInAppMessageData! + private var runtimeConfig: RuntimeConfig? @MainActor override func setUp() async throws { @@ -34,6 +37,13 @@ final class AutomationPreparerTest: XCTestCase { actionRunner: TestInAppActionRunner() ) + let config = AirshipConfig() + config.requireInitialRemoteConfigEnabled = false + self.runtimeConfig = RuntimeConfig( + config: config, + dataStore: PreferenceDataStore(appKey: UUID().uuidString) + ) + self.preparer = AutomationPreparer( actionPreparer: actionPreparer, messagePreparer: messagePreparer, @@ -41,10 +51,15 @@ final class AutomationPreparerTest: XCTestCase { frequencyLimits: frequencyLimits, audienceChecker: audienceChecker, experiments: experiments, - remoteDataAccess: remoteDataAccess - ) { contactID in - TestDeviceInfoProvider(contactID: contactID) - } + remoteDataAccess: remoteDataAccess, + config: self.runtimeConfig!, + deviceInfoProviderFactory: { [provider = self.deviceInfoProvider] contactID in + provider.stableContactInfo = StableContactInfo(contactID: contactID ?? UUID().uuidString) + return provider + }, + additionalAudienceResolver: audienceAdditionalResolver + ) + } func testRequiresUpdate() async throws { @@ -217,6 +232,7 @@ final class AutomationPreparerTest: XCTestCase { XCTAssertTrue(prepareResult.isPenalize) } + func testAudienceMismatchCancel() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, @@ -281,7 +297,7 @@ final class AutomationPreparerTest: XCTestCase { } self.audienceChecker.onEvaluate = { _, _, provider in - let contactID = await provider.stableContactID + let contactID = await provider.stableContactInfo.contactID XCTAssertEqual("contact ID", contactID) return false } @@ -363,6 +379,56 @@ final class AutomationPreparerTest: XCTestCase { XCTAssertNotNil(prepared.frequencyChecker) } + func testAdditionalAudienceMiss() async throws { + let automationSchedule = AutomationSchedule( + identifier: UUID().uuidString, + triggers: [], + data: .inAppMessage( + InAppMessage(name: "name", displayContent: .custom(.null)) + ), + audience: AutomationAudience( + audienceSelector: DeviceAudienceSelector(), + missBehavior: .skip + ) + ) + + self.remoteDataAccess.contactIDBlock = { _ in + return nil + } + + self.remoteDataAccess.requiresUpdateBlock = { _ in + return false + } + self.remoteDataAccess.bestEffortRefreshBlock = { _ in + return true + } + + self.audienceChecker.onEvaluate = { audience, created, provider in + return true + } + + await self.audienceAdditionalResolver.setResult(false) + + let preparedData = self.preparedMessageData! + + self.messagePreparer.prepareBlock = { message, info in + XCTAssertFalse(info.additionalAudienceCheckResult) + return preparedData + } + + let prepareResult = await self.preparer.prepare( + schedule: automationSchedule, + triggerContext: triggerContext, + triggerSessionID: UUID().uuidString + ) + + guard case .prepared(let prepared) = prepareResult else { + XCTFail() + return + } + XCTAssertFalse(prepared.info.additionalAudienceCheckResult) + } + func testPrepareInvalidMessage() async throws { let invalidBanner = InAppMessageDisplayContent.Banner( heading: nil, @@ -814,7 +880,7 @@ final class AutomationPreparerTest: XCTestCase { ) self.experiments.onEvaluate = { info, provider in - let contactID = await provider.stableContactID + let contactID = await provider.stableContactInfo.contactID XCTAssertEqual( info, MessageInfo( @@ -887,7 +953,7 @@ final class AutomationPreparerTest: XCTestCase { ) self.experiments.onEvaluate = { info, provider in - let contactID = await provider.stableContactID + let contactID = await provider.stableContactInfo.contactID XCTAssertEqual( info, MessageInfo( @@ -1101,7 +1167,7 @@ extension SchedulePrepareResult { } -fileprivate final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unchecked Sendable { +final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unchecked Sendable { var sdkVersion: String = "1.0.0" @@ -1109,7 +1175,9 @@ fileprivate final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unc var tags: Set = Set() - var channelID: String? = UUID().uuidString + var isChannelCreated: Bool = true + + var channelID: String = UUID().uuidString var locale: Locale = Locale.current @@ -1123,10 +1191,11 @@ fileprivate final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unc var installDate: Date = Date() - var stableContactID: String + var stableContactInfo: StableContactInfo - init(contactID: String?) { - self.stableContactID = contactID ?? UUID().uuidString + init(contactID: String = UUID().uuidString) { + self.stableContactInfo = StableContactInfo(contactID: contactID) } + } diff --git a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationTriggerProcessorTest.swift b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationTriggerProcessorTest.swift index 0c6e4a47c..8fb48a414 100644 --- a/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationTriggerProcessorTest.swift +++ b/Airship/AirshipAutomation/Tests/Automation/Engine/AutomationTriggerProcessorTest.swift @@ -71,7 +71,7 @@ final class AutomationTriggerProcessorTest: XCTestCase, @unchecked Sendable { try await restoreSchedules() - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) XCTAssertEqual( TriggerData( @@ -86,7 +86,7 @@ final class AutomationTriggerProcessorTest: XCTestCase, @unchecked Sendable { await self.processor.cancel(scheduleIDs: ["schedule-id"]) XCTAssert(self.store.stored.isEmpty) - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) let result = await takeNext() XCTAssert(result.isEmpty) @@ -97,7 +97,7 @@ final class AutomationTriggerProcessorTest: XCTestCase, @unchecked Sendable { let schedule = defaultSchedule(trigger: trigger, group: "test-group") try await self.processor.restoreSchedules([schedule]) - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) XCTAssertEqual( TriggerData( @@ -112,7 +112,7 @@ final class AutomationTriggerProcessorTest: XCTestCase, @unchecked Sendable { await self.processor.cancel(group: "test-group") XCTAssert(self.store.stored.isEmpty) - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) let result = await takeNext() XCTAssert(result.isEmpty) @@ -123,7 +123,7 @@ final class AutomationTriggerProcessorTest: XCTestCase, @unchecked Sendable { try await restoreSchedules(trigger: trigger) - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) XCTAssertEqual( TriggerData( @@ -145,19 +145,19 @@ final class AutomationTriggerProcessorTest: XCTestCase, @unchecked Sendable { try await restoreSchedules(trigger: trigger) - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) var result = await takeNext() XCTAssertNotNil(result) - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) result = await takeNext() XCTAssertNotNil(result) self.processor.setPaused(true) - await self.processor.processEvent(.appInit) + await self.processor.processEvent(.event(type: .appInit)) result = await takeNext() XCTAssert(result.isEmpty) diff --git a/Airship/AirshipAutomation/Tests/Automation/Engine/PreparedTriggerTest.swift b/Airship/AirshipAutomation/Tests/Automation/Engine/PreparedTriggerTest.swift index 05f58c66f..3d74051b1 100644 --- a/Airship/AirshipAutomation/Tests/Automation/Engine/PreparedTriggerTest.swift +++ b/Airship/AirshipAutomation/Tests/Automation/Engine/PreparedTriggerTest.swift @@ -64,11 +64,11 @@ final class PreparedTriggerTest: XCTestCase { XCTAssertEqual(0, instance.triggerData.count) - var result = instance.process(event: .appInit) + var result = instance.process(event: .event(type: .appInit)) XCTAssertEqual(1, result?.triggerData.count) XCTAssertNil(result?.triggerResult) - result = instance.process(event: .appInit) + result = instance.process(event: .event(type: .appInit)) XCTAssertEqual(0, result?.triggerData.count) let report = try XCTUnwrap(result?.triggerResult) @@ -83,7 +83,7 @@ final class PreparedTriggerTest: XCTestCase { let instance = makeTrigger(trigger: .event(trigger)) - XCTAssertNil(instance.process(event: .appInit)) + XCTAssertNil(instance.process(event: .event(type: .appInit))) instance.activate() instance.update( @@ -93,7 +93,7 @@ final class PreparedTriggerTest: XCTestCase { priority: 0 ) - XCTAssertNil(instance.process(event: .appInit)) + XCTAssertNil(instance.process(event: .event(type: .appInit))) instance.update( trigger: .event(trigger), @@ -102,7 +102,7 @@ final class PreparedTriggerTest: XCTestCase { priority: 0 ) - XCTAssertNotNil(instance.process(event: .appInit)) + XCTAssertNotNil(instance.process(event: .event(type: .appInit))) } func testProcessEventDoesNothingForInvalidEventType() { @@ -110,8 +110,8 @@ final class PreparedTriggerTest: XCTestCase { let instance = makeTrigger(trigger: .event(trigger)) instance.activate() - XCTAssertNil(instance.process(event: .foreground)) - XCTAssertNotNil(instance.process(event: .background)) + XCTAssertNil(instance.process(event: .event(type: .foreground))) + XCTAssertNotNil(instance.process(event: .event(type: .background))) } func testEventProcessingTypes() { @@ -123,25 +123,16 @@ final class PreparedTriggerTest: XCTestCase { return result?.triggerData } - XCTAssertEqual(1, check(.foreground, .foreground)?.count) - XCTAssertEqual(1, check(.background, .background)?.count) - XCTAssertEqual(1, check(.appInit, .appInit)?.count) - XCTAssertEqual(1, check(.screen, .screenView(name: nil))?.count) - XCTAssertEqual(1, check(.regionEnter, .regionEnter(data: .string("regionid")))?.count) - XCTAssertEqual(1, check(.regionExit, .regionExit(data: .string("regionid")))?.count) - XCTAssertEqual(1, check(.featureFlagInteraction, .featureFlagInterracted(data: .null))?.count) - XCTAssertEqual(2, check(.customEventValue, .customEvent(data: .null, value: 2))?.count) - XCTAssertEqual(1, check(.customEventCount, .customEvent(data: .null, value: 2))?.count) - - XCTAssertNil(check(.version, .stateChanged(state: TriggerableState()))) - XCTAssertEqual(1, check(.version, .stateChanged(state: TriggerableState(versionUpdated: "1.2.3")))?.count) + for eventType in EventAutomationTriggerType.allCases { + let event = AutomationEvent.event(type: eventType, data: .null) + XCTAssertEqual(1, check(eventType, event)?.count) + } - XCTAssertNil(check(.activeSession, .stateChanged(state: TriggerableState()))) - XCTAssertEqual(1, check(.activeSession, .stateChanged(state: TriggerableState(appSessionID: "session-id")))?.count) + XCTAssertEqual(2, check(.customEventValue, .event(type: .customEventValue, data: .null, value: 2))?.count) + XCTAssertEqual(2, check(.customEventCount, .event(type: .customEventCount, data: .null, value: 2))?.count) let instance = makeTrigger() instance.activate() - let state = TriggerableState(appSessionID: "session-id", versionUpdated: "123") let _ = instance.process(event: .stateChanged(state: state)) @@ -164,10 +155,10 @@ final class PreparedTriggerTest: XCTestCase { instance.activate() - var state = instance.process(event: .background) + var state = instance.process(event: .event(type: .background)) XCTAssertNil(state?.triggerResult) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) @@ -177,7 +168,7 @@ final class PreparedTriggerTest: XCTestCase { var appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) @@ -187,10 +178,10 @@ final class PreparedTriggerTest: XCTestCase { appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNotNil(state?.triggerResult) } @@ -211,10 +202,10 @@ final class PreparedTriggerTest: XCTestCase { instance.activate() - var state = instance.process(event: .background) + var state = instance.process(event: .event(type: .background)) XCTAssertNil(state?.triggerResult) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) @@ -224,7 +215,7 @@ final class PreparedTriggerTest: XCTestCase { var appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) @@ -234,8 +225,8 @@ final class PreparedTriggerTest: XCTestCase { appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) - _ = instance.process(event: .appInit) - state = instance.process(event: .foreground) + _ = instance.process(event: .event(type: .appInit)) + state = instance.process(event: .event(type: .foreground)) XCTAssertNotNil(state?.triggerResult) } @@ -254,32 +245,32 @@ final class PreparedTriggerTest: XCTestCase { let instance = makeTrigger(trigger: trigger) instance.activate() - var state = instance.process(event: .foreground) + var state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertEqual(0, state?.triggerData.count) XCTAssertNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertEqual(1, state?.triggerData.count) XCTAssertNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertEqual(1, state?.triggerData.count) XCTAssertNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertEqual(0, state?.triggerData.count) XCTAssertNotNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) @@ -302,37 +293,37 @@ final class PreparedTriggerTest: XCTestCase { let instance = makeTrigger(trigger: trigger) instance.activate() - var state = instance.process(event: .foreground) + var state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertEqual(0, state?.triggerData.count) XCTAssertNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertEqual(1, state?.triggerData.count) XCTAssertNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertEqual(1, state?.triggerData.count) XCTAssertNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) @@ -352,45 +343,45 @@ final class PreparedTriggerTest: XCTestCase { let instance = makeTrigger(trigger: trigger) instance.activate() - var state = instance.process(event: .foreground) + var state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData.count) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) @@ -413,7 +404,7 @@ final class PreparedTriggerTest: XCTestCase { var state = instance.process(event: .stateChanged(state: TriggerableState(appSessionID: "test"))) XCTAssertNil(state?.triggerResult) - state = instance.process(event: .customEvent(data: .null, value: 1)) + state = instance.process(event: .event(type: .customEventValue, data: .null, value: 1)) XCTAssertNotNil(state?.triggerResult) } @@ -430,57 +421,57 @@ final class PreparedTriggerTest: XCTestCase { let instance = makeTrigger(trigger: trigger) instance.activate() - var state = instance.process(event: .foreground) + var state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 1) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .foreground) + state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) @@ -519,19 +510,19 @@ final class PreparedTriggerTest: XCTestCase { let instance = makeTrigger(trigger: trigger) instance.activate() - var state = instance.process(event: .foreground) + var state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) - state = instance.process(event: .screenView(name: nil)) + state = instance.process(event: .event(type: .screen)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) - state = instance.process(event: .appInit) + state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) - state = instance.process(event: .background) + state = instance.process(event: .event(type: .background)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppButtonTapEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppButtonTapEventTest.swift index 1535ae9a6..eba56d13d 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppButtonTapEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppButtonTapEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppButtonTapEventTest: XCTestCase { @@ -21,7 +20,7 @@ final class InAppButtonTapEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_button_tap") + XCTAssertEqual(event.name.reportingName, "in_app_button_tap") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppDisplayEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppDisplayEventTest.swift index 9271f7612..6c0beeaa5 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppDisplayEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppDisplayEventTest.swift @@ -2,14 +2,13 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppDisplayEventTest: XCTestCase { func testEvent() throws { let event = InAppDisplayEvent() - XCTAssertEqual(event.name, "in_app_display") + XCTAssertEqual(event.name.reportingName, "in_app_display") XCTAssertNil(event.data) } } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormDisplayEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormDisplayEventTest.swift index 799e8c7f3..2f3dad95f 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormDisplayEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormDisplayEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppFormDisplayEventTest: XCTestCase { @@ -23,7 +22,7 @@ final class InAppFormDisplayEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_form_display") + XCTAssertEqual(event.name.reportingName, "in_app_form_display") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormResultEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormResultEventTest.swift index a9030a160..523216f9a 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormResultEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppFormResultEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppFormResultEventTest: XCTestCase { @@ -19,7 +18,7 @@ final class InAppFormResultEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_form_result") + XCTAssertEqual(event.name.reportingName, "in_app_form_result") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppGestureEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppGestureEventTest.swift index 851036733..9ea19ba1b 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppGestureEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppGestureEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppGestureTapEventTest: XCTestCase { @@ -21,7 +20,7 @@ final class InAppGestureTapEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_gesture") + XCTAssertEqual(event.name.reportingName, "in_app_gesture") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppMessageAnalyticsTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppMessageAnalyticsTest.swift index 75bed7969..1ae12dfad 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppMessageAnalyticsTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppMessageAnalyticsTest.swift @@ -157,7 +157,7 @@ class InAppMessageAnalyticsTest: XCTestCase { let data = self.eventRecorder.eventData.first! XCTAssertEqual(data.context, expectedContext) XCTAssertEqual(data.renderedLocale, AirshipJSON.string("rendered locale")) - XCTAssertEqual(data.event.name, "test_event") + XCTAssertEqual(data.event.name, EventType.customEvent) } @MainActor diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageActionEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageActionEventTest.swift index bf7df53bb..814b56477 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageActionEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageActionEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppPageActionEventTest: XCTestCase { @@ -21,7 +20,7 @@ final class InAppPageActionEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_page_action") + XCTAssertEqual(event.name.reportingName, "in_app_page_action") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageSwipeEventAction.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageSwipeEventAction.swift index 5def22f0c..e3b89f2c9 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageSwipeEventAction.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageSwipeEventAction.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppPageSwipeEventAction: XCTestCase { @@ -36,7 +35,7 @@ final class InAppPageSwipeEventAction: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_page_swipe") + XCTAssertEqual(event.name.reportingName, "in_app_page_swipe") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageViewEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageViewEventTest.swift index 086747321..40df66c8b 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageViewEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPageViewEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppPageViewEventTest: XCTestCase { @@ -31,7 +30,7 @@ final class InAppPageViewEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_page_view") + XCTAssertEqual(event.name.reportingName, "in_app_page_view") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerCompletedEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerCompletedEventTest.swift index 8ffeadf52..d414f536d 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerCompletedEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerCompletedEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppPagerCompletedEventTest: XCTestCase { @@ -28,7 +27,7 @@ final class InAppPagerCompletedEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_pager_completed") + XCTAssertEqual(event.name.reportingName, "in_app_pager_completed") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerSummaryEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerSummaryEventTest.swift index 30995ca54..c32927116 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerSummaryEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPagerSummaryEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppPagerSummaryEventTest: XCTestCase { @@ -60,7 +59,7 @@ final class InAppPagerSummaryEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_pager_summary") + XCTAssertEqual(event.name.reportingName, "in_app_pager_summary") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPermissionResultEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPermissionResultEventTest.swift index e6ccddac7..d8a3a1bdb 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPermissionResultEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppPermissionResultEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppPermissionResultEventTest: XCTestCase { @@ -24,7 +23,7 @@ final class InAppPermissionResultEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_permission_result") + XCTAssertEqual(event.name.reportingName, "in_app_permission_result") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppResolutionEventTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppResolutionEventTest.swift index 6e364999c..22c04a371 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppResolutionEventTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/Events/InAppResolutionEventTest.swift @@ -2,9 +2,8 @@ import XCTest -@testable -import AirshipAutomation -import AirshipCore +@testable import AirshipAutomation +@testable import AirshipCore final class InAppResolutionEventTest: XCTestCase { @@ -26,7 +25,7 @@ final class InAppResolutionEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_resolution") + XCTAssertEqual(event.name.reportingName, "in_app_resolution") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } @@ -42,7 +41,7 @@ final class InAppResolutionEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_resolution") + XCTAssertEqual(event.name.reportingName, "in_app_resolution") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } @@ -58,7 +57,7 @@ final class InAppResolutionEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_resolution") + XCTAssertEqual(event.name.reportingName, "in_app_resolution") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } @@ -74,7 +73,7 @@ final class InAppResolutionEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_resolution") + XCTAssertEqual(event.name.reportingName, "in_app_resolution") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } @@ -101,7 +100,24 @@ final class InAppResolutionEventTest: XCTestCase { } """ - XCTAssertEqual(event.name, "in_app_resolution") + XCTAssertEqual(event.name.reportingName, "in_app_resolution") + XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) + } + + func testAudienceExcluded() throws { + + let event = InAppResolutionEvent.audienceExcluded() + + let expectedJSON = """ + { + "resolution": { + "display_time":"0.00", + "type":"audience_check_excluded" + } + } + """ + + XCTAssertEqual(event.name.reportingName, "in_app_resolution") XCTAssertEqual(try event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } } diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/InAppEventRecorderTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/InAppEventRecorderTest.swift index c01ad7ae6..abe9c9cdd 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/InAppEventRecorderTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/Analytics/InAppEventRecorderTest.swift @@ -36,7 +36,7 @@ class InAppEventRecorderTest: XCTestCase { func testEventData() async throws { let inAppEvent = TestInAppEvent( - name: "some-name", + name: .appInit, data: TestData(field: "something", anotherField: "something something") ) @@ -86,7 +86,7 @@ class InAppEventRecorderTest: XCTestCase { func testConversionIDs() async throws { let inAppEvent = TestInAppEvent( - name: "some-name", + name: .featureFlagInteraction, data: TestData(field: "something", anotherField: "something something") ) @@ -142,7 +142,7 @@ class InAppEventRecorderTest: XCTestCase { func testEventDataError() async throws { let inAppEvent = TestInAppEvent( - name: "some-name", + name: .appForeground, data: ErrorData(field: "something", anotherField: "something something") ) diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageAutomationExecutorTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageAutomationExecutorTest.swift index 28c76d800..c8cfeca2b 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageAutomationExecutorTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageAutomationExecutorTest.swift @@ -121,9 +121,6 @@ final class InAppMessageAutomationExecutorTest: XCTestCase { data: .inAppMessage(preparedData.message) ) - - - _ = await self.executor.interrupted(schedule: schedule, preparedScheduleInfo: preparedInfo) let cleared = await self.assetManager.cleared XCTAssertEqual([self.preparedInfo.scheduleID], cleared) @@ -233,6 +230,25 @@ final class InAppMessageAutomationExecutorTest: XCTestCase { XCTAssertTrue(self.actionRunner.actionPayloads.isEmpty) } + @MainActor + func testAdditionalAudienceCheckMiss() async throws { + self.displayAdapter.onDisplay = { incomingScene, incomingAnalytics in + throw AirshipErrors.error("Failed") + } + var preparedInfo = preparedInfo + preparedInfo.additionalAudienceCheckResult = false + + let result = try await self.executor.execute( + data: preparedData, + preparedScheduleInfo: preparedInfo + ) + + XCTAssertEqual(analytics.events.first!.0.name, InAppResolutionEvent.audienceExcluded().name) + XCTAssertFalse(self.displayAdapter.displayed) + XCTAssertEqual(result, .finished) + XCTAssertTrue(self.actionRunner.actionPayloads.isEmpty) + } + @MainActor func testExecuteNoScene() async throws { self.sceneManager.onScene = { _ in diff --git a/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageThemeTest.swift b/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageThemeTest.swift index 01600173a..71fa5ffc6 100644 --- a/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageThemeTest.swift +++ b/Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageThemeTest.swift @@ -2,11 +2,9 @@ import XCTest -@testable import AirshipAutomation - -#if canImport(AirshipCore) +@testable +import AirshipAutomation import AirshipCore -#endif final class InAppMessageThemeTest: XCTestCase { @@ -16,156 +14,174 @@ final class InAppMessageThemeTest: XCTestCase { testBundle = Bundle(for: type(of: self)) } - func testBannerParsing() { - let bannerTheme = BannerTheme(plistName: "Valid-UAInAppMessageBannerStyle", bundle: testBundle) - - XCTAssertEqual(1, bannerTheme.additionalPadding.top) - XCTAssertEqual(2, bannerTheme.additionalPadding.bottom) - XCTAssertEqual(3, bannerTheme.additionalPadding.leading) - XCTAssertEqual(4, bannerTheme.additionalPadding.trailing) - XCTAssertEqual(5, bannerTheme.headerTheme.letterSpacing) - XCTAssertEqual(6, bannerTheme.headerTheme.lineSpacing) - XCTAssertEqual(7, bannerTheme.headerTheme.additionalPadding.top) - XCTAssertEqual(8, bannerTheme.headerTheme.additionalPadding.bottom) - XCTAssertEqual(9, bannerTheme.headerTheme.additionalPadding.leading) - XCTAssertEqual(10, bannerTheme.headerTheme.additionalPadding.trailing) - XCTAssertEqual(11, bannerTheme.bodyTheme.letterSpacing) - XCTAssertEqual(12, bannerTheme.bodyTheme.lineSpacing) - XCTAssertEqual(13, bannerTheme.bodyTheme.additionalPadding.top) - XCTAssertEqual(14, bannerTheme.bodyTheme.additionalPadding.bottom) - XCTAssertEqual(15, bannerTheme.bodyTheme.additionalPadding.leading) - XCTAssertEqual(16, bannerTheme.bodyTheme.additionalPadding.trailing) - XCTAssertEqual(17, bannerTheme.mediaTheme.additionalPadding.top) - XCTAssertEqual(18, bannerTheme.mediaTheme.additionalPadding.bottom) - XCTAssertEqual(19, bannerTheme.mediaTheme.additionalPadding.leading) - XCTAssertEqual(20, bannerTheme.mediaTheme.additionalPadding.trailing) - XCTAssertEqual(21, bannerTheme.buttonTheme.buttonHeight) - XCTAssertEqual(22, bannerTheme.buttonTheme.additionalPadding.top) - XCTAssertEqual(23, bannerTheme.buttonTheme.additionalPadding.bottom) - XCTAssertEqual(24, bannerTheme.buttonTheme.additionalPadding.leading) - XCTAssertEqual(25, bannerTheme.buttonTheme.additionalPadding.trailing) + func testBannerParsing() throws { + var bannerTheme = InAppMessageTheme.Banner.defaultTheme + try bannerTheme.applyPlist(plistName: "Valid-UAInAppMessageBannerStyle", bundle: testBundle) + + // default is 24 horizontal padding + XCTAssertEqual(1, bannerTheme.padding.top) + XCTAssertEqual(2, bannerTheme.padding.bottom) + XCTAssertEqual(27, bannerTheme.padding.leading) + XCTAssertEqual(28, bannerTheme.padding.trailing) + + + XCTAssertEqual(5, bannerTheme.header.letterSpacing) + XCTAssertEqual(6, bannerTheme.header.lineSpacing) + XCTAssertEqual(7, bannerTheme.header.padding.top) + XCTAssertEqual(8, bannerTheme.header.padding.bottom) + XCTAssertEqual(9, bannerTheme.header.padding.leading) + XCTAssertEqual(10, bannerTheme.header.padding.trailing) + XCTAssertEqual(11, bannerTheme.body.letterSpacing) + XCTAssertEqual(12, bannerTheme.body.lineSpacing) + XCTAssertEqual(13, bannerTheme.body.padding.top) + XCTAssertEqual(14, bannerTheme.body.padding.bottom) + XCTAssertEqual(15, bannerTheme.body.padding.leading) + XCTAssertEqual(16, bannerTheme.body.padding.trailing) + XCTAssertEqual(17, bannerTheme.media.padding.top) + XCTAssertEqual(18, bannerTheme.media.padding.bottom) + XCTAssertEqual(19, bannerTheme.media.padding.leading) + XCTAssertEqual(20, bannerTheme.media.padding.trailing) + XCTAssertEqual(21, bannerTheme.buttons.height) + XCTAssertEqual(22, bannerTheme.buttons.padding.top) + XCTAssertEqual(23, bannerTheme.buttons.padding.bottom) + XCTAssertEqual(24, bannerTheme.buttons.padding.leading) + XCTAssertEqual(25, bannerTheme.buttons.padding.trailing) XCTAssertEqual(26, bannerTheme.maxWidth) XCTAssertEqual(27, bannerTheme.tapOpacity) - XCTAssertEqual(28, bannerTheme.shadowTheme.radius) - XCTAssertEqual(29, bannerTheme.shadowTheme.xOffset) - XCTAssertEqual(30, bannerTheme.shadowTheme.yOffset) - XCTAssertEqual("003100".airshipToColor() , bannerTheme.shadowTheme.color) + XCTAssertEqual(28, bannerTheme.shadow.radius) + XCTAssertEqual(29, bannerTheme.shadow.xOffset) + XCTAssertEqual(30, bannerTheme.shadow.yOffset) + XCTAssertEqual("003100".airshipToColor() , bannerTheme.shadow.color) } - func testModalParsing() { - let modalTheme = ModalTheme(plistName: "Valid-UAInAppMessageModalStyle", bundle: testBundle) - - XCTAssertEqual(1, modalTheme.additionalPadding.top) - XCTAssertEqual(2, modalTheme.additionalPadding.bottom) - XCTAssertEqual(3, modalTheme.additionalPadding.leading) - XCTAssertEqual(4, modalTheme.additionalPadding.trailing) - XCTAssertEqual(5, modalTheme.headerTheme.letterSpacing) - XCTAssertEqual(6, modalTheme.headerTheme.lineSpacing) - XCTAssertEqual(7, modalTheme.headerTheme.additionalPadding.top) - XCTAssertEqual(8, modalTheme.headerTheme.additionalPadding.bottom) - XCTAssertEqual(9, modalTheme.headerTheme.additionalPadding.leading) - XCTAssertEqual(10, modalTheme.headerTheme.additionalPadding.trailing) - XCTAssertEqual(11, modalTheme.bodyTheme.letterSpacing) - XCTAssertEqual(12, modalTheme.bodyTheme.lineSpacing) - XCTAssertEqual(13, modalTheme.bodyTheme.additionalPadding.top) - XCTAssertEqual(14, modalTheme.bodyTheme.additionalPadding.bottom) - XCTAssertEqual(15, modalTheme.bodyTheme.additionalPadding.leading) - XCTAssertEqual(16, modalTheme.bodyTheme.additionalPadding.trailing) - XCTAssertEqual(17, modalTheme.mediaTheme.additionalPadding.top) - XCTAssertEqual(18, modalTheme.mediaTheme.additionalPadding.bottom) - XCTAssertEqual(19, modalTheme.mediaTheme.additionalPadding.leading) - XCTAssertEqual(20, modalTheme.mediaTheme.additionalPadding.trailing) - XCTAssertEqual(21, modalTheme.buttonTheme.buttonHeight) - XCTAssertEqual(22, modalTheme.buttonTheme.stackedButtonSpacing) - XCTAssertEqual(23, modalTheme.buttonTheme.separatedButtonSpacing) - XCTAssertEqual(24, modalTheme.buttonTheme.additionalPadding.top) - XCTAssertEqual(25, modalTheme.buttonTheme.additionalPadding.bottom) - XCTAssertEqual(26, modalTheme.buttonTheme.additionalPadding.leading) - XCTAssertEqual(27, modalTheme.buttonTheme.additionalPadding.trailing) + func testModalParsing() throws { + var modalTheme = InAppMessageTheme.Modal.defaultTheme + try modalTheme.applyPlist(plistName: "Valid-UAInAppMessageModalStyle", bundle: testBundle) + + + // default is 24 horizontal, 48 vertical + XCTAssertEqual(49, modalTheme.padding.top) + XCTAssertEqual(50, modalTheme.padding.bottom) + XCTAssertEqual(27, modalTheme.padding.leading) + XCTAssertEqual(28, modalTheme.padding.trailing) + + XCTAssertEqual(5, modalTheme.header.letterSpacing) + XCTAssertEqual(6, modalTheme.header.lineSpacing) + XCTAssertEqual(7, modalTheme.header.padding.top) + XCTAssertEqual(8, modalTheme.header.padding.bottom) + XCTAssertEqual(9, modalTheme.header.padding.leading) + XCTAssertEqual(10, modalTheme.header.padding.trailing) + XCTAssertEqual(11, modalTheme.body.letterSpacing) + XCTAssertEqual(12, modalTheme.body.lineSpacing) + XCTAssertEqual(13, modalTheme.body.padding.top) + XCTAssertEqual(14, modalTheme.body.padding.bottom) + XCTAssertEqual(15, modalTheme.body.padding.leading) + XCTAssertEqual(16, modalTheme.body.padding.trailing) + + /// Default is -24 horizontal padding + XCTAssertEqual(17, modalTheme.media.padding.top) + XCTAssertEqual(18, modalTheme.media.padding.bottom) + XCTAssertEqual(-5, modalTheme.media.padding.leading) + XCTAssertEqual(-4, modalTheme.media.padding.trailing) + + XCTAssertEqual(21, modalTheme.buttons.height) + XCTAssertEqual(22, modalTheme.buttons.stackedSpacing) + XCTAssertEqual(23, modalTheme.buttons.separatedSpacing) + XCTAssertEqual(24, modalTheme.buttons.padding.top) + XCTAssertEqual(25, modalTheme.buttons.padding.bottom) + XCTAssertEqual(26, modalTheme.buttons.padding.leading) + XCTAssertEqual(27, modalTheme.buttons.padding.trailing) XCTAssertEqual(28, modalTheme.maxWidth) XCTAssertEqual(29, modalTheme.maxHeight) XCTAssertEqual("testDismissIconResourceName", modalTheme.dismissIconResource) - } - func testFullScreenParsing() { - let fullScreenTheme = FullScreenTheme(plistName: "Valid-UAInAppMessageFullScreenStyle", bundle: testBundle) - - XCTAssertEqual(1, fullScreenTheme.additionalPadding.top) - XCTAssertEqual(2, fullScreenTheme.additionalPadding.bottom) - XCTAssertEqual(3, fullScreenTheme.additionalPadding.leading) - XCTAssertEqual(4, fullScreenTheme.additionalPadding.trailing) - XCTAssertEqual(5, fullScreenTheme.headerTheme.letterSpacing) - XCTAssertEqual(6, fullScreenTheme.headerTheme.lineSpacing) - XCTAssertEqual(7, fullScreenTheme.headerTheme.additionalPadding.top) - XCTAssertEqual(8, fullScreenTheme.headerTheme.additionalPadding.bottom) - XCTAssertEqual(9, fullScreenTheme.headerTheme.additionalPadding.leading) - XCTAssertEqual(10, fullScreenTheme.headerTheme.additionalPadding.trailing) - XCTAssertEqual(11, fullScreenTheme.bodyTheme.letterSpacing) - XCTAssertEqual(12, fullScreenTheme.bodyTheme.lineSpacing) - XCTAssertEqual(13, fullScreenTheme.bodyTheme.additionalPadding.top) - XCTAssertEqual(14, fullScreenTheme.bodyTheme.additionalPadding.bottom) - XCTAssertEqual(15, fullScreenTheme.bodyTheme.additionalPadding.leading) - XCTAssertEqual(16, fullScreenTheme.bodyTheme.additionalPadding.trailing) - XCTAssertEqual(17, fullScreenTheme.mediaTheme.additionalPadding.top) - XCTAssertEqual(18, fullScreenTheme.mediaTheme.additionalPadding.bottom) - XCTAssertEqual(19, fullScreenTheme.mediaTheme.additionalPadding.leading) - XCTAssertEqual(20, fullScreenTheme.mediaTheme.additionalPadding.trailing) - XCTAssertEqual(21, fullScreenTheme.buttonTheme.buttonHeight) - XCTAssertEqual(22, fullScreenTheme.buttonTheme.stackedButtonSpacing) - XCTAssertEqual(23, fullScreenTheme.buttonTheme.separatedButtonSpacing) - XCTAssertEqual(24, fullScreenTheme.buttonTheme.additionalPadding.top) - XCTAssertEqual(25, fullScreenTheme.buttonTheme.additionalPadding.bottom) - XCTAssertEqual(26, fullScreenTheme.buttonTheme.additionalPadding.leading) - XCTAssertEqual(27, fullScreenTheme.buttonTheme.additionalPadding.trailing) - XCTAssertEqual("testDismissIconResourceName", fullScreenTheme.dismissIconResource) + func testFullScreenParsing() throws { + var fullscreenTheme = InAppMessageTheme.Fullscreen.defaultTheme + try fullscreenTheme.applyPlist(plistName: "Valid-UAInAppMessageFullScreenStyle", bundle: testBundle) + + // default is 24 on all sides + XCTAssertEqual(25, fullscreenTheme.padding.top) + XCTAssertEqual(26, fullscreenTheme.padding.bottom) + XCTAssertEqual(27, fullscreenTheme.padding.leading) + XCTAssertEqual(28, fullscreenTheme.padding.trailing) + + XCTAssertEqual(5, fullscreenTheme.header.letterSpacing) + XCTAssertEqual(6, fullscreenTheme.header.lineSpacing) + XCTAssertEqual(7, fullscreenTheme.header.padding.top) + XCTAssertEqual(8, fullscreenTheme.header.padding.bottom) + XCTAssertEqual(9, fullscreenTheme.header.padding.leading) + XCTAssertEqual(10, fullscreenTheme.header.padding.trailing) + XCTAssertEqual(11, fullscreenTheme.body.letterSpacing) + XCTAssertEqual(12, fullscreenTheme.body.lineSpacing) + XCTAssertEqual(13, fullscreenTheme.body.padding.top) + XCTAssertEqual(14, fullscreenTheme.body.padding.bottom) + XCTAssertEqual(15, fullscreenTheme.body.padding.leading) + XCTAssertEqual(16, fullscreenTheme.body.padding.trailing) + + /// Default is -24 horizontal padding + XCTAssertEqual(17, fullscreenTheme.media.padding.top) + XCTAssertEqual(18, fullscreenTheme.media.padding.bottom) + XCTAssertEqual(-5, fullscreenTheme.media.padding.leading) + XCTAssertEqual(-4, fullscreenTheme.media.padding.trailing) + + XCTAssertEqual(21, fullscreenTheme.buttons.height) + XCTAssertEqual(22, fullscreenTheme.buttons.stackedSpacing) + XCTAssertEqual(23, fullscreenTheme.buttons.separatedSpacing) + XCTAssertEqual(24, fullscreenTheme.buttons.padding.top) + XCTAssertEqual(25, fullscreenTheme.buttons.padding.bottom) + XCTAssertEqual(26, fullscreenTheme.buttons.padding.leading) + XCTAssertEqual(27, fullscreenTheme.buttons.padding.trailing) + XCTAssertEqual("testDismissIconResourceName", fullscreenTheme.dismissIconResource) } - func testHTMLParsing() { - let htmlTheme = HTMLTheme(plistName: "Valid-UAInAppMessageHTMLStyle", bundle: testBundle) + func testHTMLParsing() throws { + var htmlTheme = InAppMessageTheme.HTML.defaultTheme + try htmlTheme.applyPlist(plistName: "Valid-UAInAppMessageHTMLStyle", bundle: testBundle) + XCTAssertTrue(htmlTheme.hideDismissIcon == true) - XCTAssertEqual(1, htmlTheme.additionalPadding.top) - XCTAssertEqual(2, htmlTheme.additionalPadding.bottom) - XCTAssertEqual(3, htmlTheme.additionalPadding.leading) - XCTAssertEqual(4, htmlTheme.additionalPadding.trailing) + + // default is 24 horizontal, 48 vertical + XCTAssertEqual(49, htmlTheme.padding.top) + XCTAssertEqual(50, htmlTheme.padding.bottom) + XCTAssertEqual(27, htmlTheme.padding.leading) + XCTAssertEqual(28, htmlTheme.padding.trailing) + XCTAssertEqual("testDismissIconResourceName", htmlTheme.dismissIconResource) XCTAssertEqual(28, htmlTheme.maxWidth) XCTAssertEqual(29, htmlTheme.maxHeight) } - /// Test parsing an invalid style plist fails gracefully - func testInvalidParsing() { - XCTAssertNoThrow(BannerTheme(plistName: "Invalid-UAInAppMessageBannerStyle", bundle: testBundle)) - XCTAssertNoThrow(ModalTheme(plistName: "Invalid-UAInAppMessageModalStyle", bundle: testBundle)) - XCTAssertNoThrow(FullScreenTheme(plistName: "Invalid-UAInAppMessageFullScreenStyle", bundle: testBundle)) - XCTAssertNoThrow(HTMLTheme(plistName: "Invalid-UAInAppMessageHTMLStyle", bundle: testBundle)) - } /// Test when plist parsing fails the theme is equivalent to its default values func testBannerDefaults() { - let theme = BannerTheme(plistName: "Non-existent plist name", bundle: testBundle) + var theme = InAppMessageTheme.Banner.defaultTheme + try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) - XCTAssertEqual(theme, BannerTheme.defaultValues) + XCTAssertEqual(theme, InAppMessageTheme.Banner.defaultTheme) } /// Test when plist parsing fails the theme is equivalent to its default values func testModalDefaults() { - let theme = ModalTheme(plistName: "Non-existent plist name", bundle: testBundle) + var theme = InAppMessageTheme.Modal.defaultTheme + try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) - XCTAssertEqual(theme, ModalTheme.defaultValues) + XCTAssertEqual(theme, InAppMessageTheme.Modal.defaultTheme) } /// Test when plist parsing fails the theme is equivalent to its default values func testFullscreenDefaults() { - let theme = FullScreenTheme(plistName: "Non-existent plist name", bundle: testBundle) + var theme = InAppMessageTheme.Fullscreen.defaultTheme + try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) - XCTAssertEqual(theme, FullScreenTheme.defaultValues) + XCTAssertEqual(theme, InAppMessageTheme.Fullscreen.defaultTheme) } /// Test when plist parsing fails the theme is equivalent to its default values func testHTMLDefaults() { - let theme = HTMLTheme(plistName: "Non-existent plist name", bundle: testBundle) + var theme = InAppMessageTheme.HTML.defaultTheme + try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) - XCTAssertEqual(theme, HTMLTheme.defaultValues) + XCTAssertEqual(theme, InAppMessageTheme.HTML.defaultTheme) } } diff --git a/Airship/AirshipAutomation/Tests/Legacy/LegacyInAppAnalyticsTest.swift b/Airship/AirshipAutomation/Tests/Legacy/LegacyInAppAnalyticsTest.swift index 41c4bada1..cc4fecb58 100644 --- a/Airship/AirshipAutomation/Tests/Legacy/LegacyInAppAnalyticsTest.swift +++ b/Airship/AirshipAutomation/Tests/Legacy/LegacyInAppAnalyticsTest.swift @@ -27,7 +27,7 @@ final class LegacyInAppAnalyticsTest: XCTestCase { } """ - XCTAssertEqual(eventData.event.name, "in_app_resolution") + XCTAssertEqual(eventData.event.name.reportingName, "in_app_resolution") XCTAssertEqual(try eventData.event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } @@ -47,7 +47,7 @@ final class LegacyInAppAnalyticsTest: XCTestCase { } """ - XCTAssertEqual(eventData.event.name, "in_app_resolution") + XCTAssertEqual(eventData.event.name.reportingName, "in_app_resolution") XCTAssertEqual(try eventData.event.bodyJSON, try! AirshipJSON.from(json: expectedJSON)) } } diff --git a/Airship/AirshipAutomation/Tests/Test Utils/TestInAppEvent.swift b/Airship/AirshipAutomation/Tests/Test Utils/TestInAppEvent.swift index 3774e2963..2a1c14760 100644 --- a/Airship/AirshipAutomation/Tests/Test Utils/TestInAppEvent.swift +++ b/Airship/AirshipAutomation/Tests/Test Utils/TestInAppEvent.swift @@ -2,13 +2,14 @@ import Foundation +@testable import AirshipCore @testable import AirshipAutomation struct TestInAppEvent: InAppEvent { - var name: String + var name: EventType var data: (any Encodable & Sendable)? - init(name: String = "test_event", data: (Encodable & Sendable)? = nil) { + init(name: EventType = .customEvent, data: (Encodable & Sendable)? = nil) { self.name = name self.data = data } diff --git a/Airship/AirshipAutomation/Tests/Utils/RetryingQueueTests.swift b/Airship/AirshipAutomation/Tests/Utils/RetryingQueueTests.swift index 3ddfc8abc..0e18ea71e 100644 --- a/Airship/AirshipAutomation/Tests/Utils/RetryingQueueTests.swift +++ b/Airship/AirshipAutomation/Tests/Utils/RetryingQueueTests.swift @@ -11,7 +11,7 @@ final class RetryingQueueTests: XCTestCase { private let taskSleeper: TestTaskSleeper = TestTaskSleeper() func testState() async throws { - let queue = RetryingQueue(taskSleeper: taskSleeper) + let queue = RetryingQueue() let result = await queue.run(name: "testState") { state in let runCount: Int = await state.value(key: "runCount") ?? 1 diff --git a/Airship/AirshipConfig.xcconfig b/Airship/AirshipConfig.xcconfig index 35ed76285..4a0991804 100644 --- a/Airship/AirshipConfig.xcconfig +++ b/Airship/AirshipConfig.xcconfig @@ -1,6 +1,6 @@ //* Copyright Airship and Contributors */ -CURRENT_PROJECT_VERSION = 18.3.1 +CURRENT_PROJECT_VERSION = 18.4.0 // Uncomment to include the preview build warning // OTHER_CFLAGS = $(inherited) -DUA_PREVIEW=1 diff --git a/Airship/AirshipCore/Source/Airship.swift b/Airship/AirshipCore/Source/Airship.swift index 7f5706826..00574615d 100644 --- a/Airship/AirshipCore/Source/Airship.swift +++ b/Airship/AirshipCore/Source/Airship.swift @@ -303,7 +303,6 @@ public class Airship: NSObject { UALegacyLoggingBridge.logger = { logLevel, function, line, message in AirshipLogger.log( logLevel: AirshipLogLevel(rawValue: logLevel) ?? .none, - logPrivacyLevel: AirshipLogPrivacyLevel(rawValue: logPrivacyLevel.rawValue) ?? .private, message: message(), fileID: "", line: line, @@ -384,18 +383,11 @@ public class Airship: NSObject { } } - /// Airship log privacy. - /// All logs have privacy settings that default to `.private`, - /// and in both developer mode and production. Values set before `takeOff` will be overridden by + /// Airship default log privacy. + /// Set log privacy level for default logger. All logs have privacy settings that default to `.private` + /// in both developer mode and production. Values set before `takeOff` will be overridden by /// the value from the AirshipConfig. - public static var logPrivacyLevel: AirshipLogPrivacyLevel { - get { - return AirshipLogger.logPrivacyLevel - } - set { - AirshipLogger.logPrivacyLevel = newValue - } - } + public static var logPrivacyLevel: AirshipLogPrivacyLevel = .private /// - NOTE: For internal use only. :nodoc: public class func component(ofType componentType: E.Type) -> E? { diff --git a/Airship/AirshipCore/Source/AirshipAnalyticsFeed.swift b/Airship/AirshipCore/Source/AirshipAnalyticsFeed.swift index 6d3b4d88d..978664d8b 100644 --- a/Airship/AirshipCore/Source/AirshipAnalyticsFeed.swift +++ b/Airship/AirshipCore/Source/AirshipAnalyticsFeed.swift @@ -7,29 +7,20 @@ import Combine /// For internal use only. :nodoc: public final class AirshipAnalyticsFeed: Sendable { public enum Event: Equatable, Sendable { - case customEvent(body: AirshipJSON, value: Double) - case regionEnter(body: AirshipJSON) - case regionExit(body: AirshipJSON) - case featureFlagInteraction(body: AirshipJSON) - case screenChange(screen: String?) + case screen(screen: String?) + case analytics(eventType: EventType, body: AirshipJSON, value: Double? = 1) } - private let subject = PassthroughSubject() + private let channel = AirshipAsyncChannel() public var updates: AsyncStream { - return AsyncStream { continuation in - let cancellable: AnyCancellable = subject.sink { value in - continuation.yield(value) - } - - continuation.onTermination = { _ in - cancellable.cancel() - } + get async { + return await channel.makeStream() } } - public func notifyEvent(_ event: Event) { - self.subject.send(event) + func notifyEvent(_ event: Event) async { + await channel.send(event) } diff --git a/Airship/AirshipCore/Source/AirshipContact.swift b/Airship/AirshipCore/Source/AirshipContact.swift index 695ea2637..fe1a73c6e 100644 --- a/Airship/AirshipCore/Source/AirshipContact.swift +++ b/Airship/AirshipCore/Source/AirshipContact.swift @@ -1,5 +1,6 @@ /* Copyright Airship and Contributors */ +@preconcurrency import Combine import Foundation @@ -8,11 +9,51 @@ import Foundation /// within Airship. Contacts may be named and have channels associated with it. @objc(UAContact) public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked Sendable { + public var contactChannelUpdates: AsyncStream { + get { + return self.contactChannelsProvider.contactChannels( + stableContactIDUpdates: self.stableContactIDUpdates + ) + } + } + + public var contactChannelPublisher: AnyPublisher { + get { + let updates = self.contactChannelUpdates + let subject = CurrentValueSubject(nil) + + Task { [weak subject] in + for await update in updates { + subject?.send(update) + } + } + + return subject.compactMap { $0 }.eraseToAnyPublisher() + } + } + + private var stableContactIDUpdates: AsyncStream { + AsyncStream { [contactIDUpdates] continuation in + let cancellable: AnyCancellable = contactIDUpdates + .filter { $0.isStable } + .map { $0.contactID } + .removeDuplicates() + .sink { value in + continuation.yield(value) + } + + continuation.onTermination = { _ in + cancellable.cancel() + } + } + } + private static let resolveDateKey = "Contact.resolveDate" static let legacyPendingTagGroupsKey = "com.urbanairship.tag_groups.pending_channel_tag_groups_mutations" static let legacyPendingAttributesKey = "com.urbanairship.named_user_attributes.registrar_persistent_queue_key" static let legacyNamedUserKey = "UANamedUserID" + // Interval for how often we emit a resolve operation on foreground static let defaultForegroundResolveInterval: TimeInterval = 3600.0 // 1 hour @@ -28,15 +69,28 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked private let config: RuntimeConfig private let privacyManager: AirshipPrivacyManager private let subscriptionListAPIClient: ContactSubscriptionListAPIClientProtocol + private let contactChannelsProvider: ContactChannelsProviderProtocol private let date: AirshipDateProtocol private let audienceOverridesProvider: AudienceOverridesProvider private let contactManager: ContactManagerProtocol - private let cachedSubscriptionLists: CachedValue<(String, [String: [ChannelScope]])> // (ContactID, [ListID, [ChannelScope]]) + private var smsValidator: SMSValidatorProtocol + private let cachedSubscriptionLists: CachedValue<(String, [String: [ChannelScope]])> private var setupTask: Task? = nil private var subscriptions: Set = Set() private let fetchSubscriptionListQueue: AirshipSerialQueue = AirshipSerialQueue() private let serialQueue: AirshipAsyncSerialQueue + /// Publishes all edits made to the subscription lists through the SDK + public var smsValidatorDelegate: SMSValidatorDelegate? { + set { + self.smsValidator.delegate = newValue + } + + get { + self.smsValidator.delegate + } + } + private var lastResolveDate: Date { get { let date = self.dataStore.object(forKey: AirshipContact.resolveDateKey) as? Date @@ -53,7 +107,7 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked public var subscriptionListEdits: AnyPublisher { subscriptionListEditsSubject.eraseToAnyPublisher() } - + private let conflictEventSubject = PassthroughSubject() public var conflictEventPublisher: AnyPublisher { conflictEventSubject.eraseToAnyPublisher() @@ -106,10 +160,12 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked channel: InternalAirshipChannelProtocol, privacyManager: AirshipPrivacyManager, subscriptionListAPIClient: ContactSubscriptionListAPIClientProtocol, + contactChannelsProvider: ContactChannelsProviderProtocol, date: AirshipDateProtocol = AirshipDate.shared, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, audienceOverridesProvider: AudienceOverridesProvider, contactManager: ContactManagerProtocol, + smsValidator: SMSValidatorProtocol, serialQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue(priority: .high) ) { @@ -117,9 +173,11 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked self.config = config self.privacyManager = privacyManager self.subscriptionListAPIClient = subscriptionListAPIClient + self.contactChannelsProvider = contactChannelsProvider self.audienceOverridesProvider = audienceOverridesProvider self.date = date self.contactManager = contactManager + self.smsValidator = smsValidator self.serialQueue = serialQueue self.cachedSubscriptionLists = CachedValue(date: date) @@ -130,6 +188,14 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked await self.migrateNamedUser() await audienceOverridesProvider.setPendingContactOverridesProvider { contactID in + + // Audience overrides will take any pending operations and updated operations. Since + // pending operations are added through the serialQueue to ensure order, some might still + // be in the queue. To avoid ignoring any of those, wait for current operations on the queue + // to finish + await self.serialQueue.waitForCurrentOperations() + + return await contactManager.pendingAudienceOverrides(contactID: contactID) } @@ -142,7 +208,8 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked contactID: update.contactID, tags: update.tags, attributes: update.attributes, - subscriptionLists: update.subscriptionLists + subscriptionLists: update.subscriptionLists, + channels: update.contactChannels ) } @@ -263,14 +330,20 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked config: config, channel: channel, privacyManager: privacyManager, - subscriptionListAPIClient: ContactSubscriptionListAPIClient(config: config), + subscriptionListAPIClient: ContactSubscriptionListAPIClient(config: config), + contactChannelsProvider: ContactChannelsProvider( + audienceOverrides: audienceOverridesProvider, + apiClient: ContactChannelsAPIClient(config: config), + privacyManager: privacyManager + ), audienceOverridesProvider: audienceOverridesProvider, contactManager: ContactManager( dataStore: dataStore, channel: channel, localeManager: localeManager, apiClient: ContactAPIClient(config: config) - ) + ), + smsValidator: SMSValidator(apiClient: SMSValidatorAPIClient(config: config)) ) } @@ -304,7 +377,6 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked self.addOperation(.reset) } - /// Can be called after the app performs a remote named user association for the channel instead /// of using `identify` or `reset` through the SDK. When called, the SDK will refresh the contact /// data. Applications should only call this method when the user login has changed. @@ -337,6 +409,14 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked } self.addOperation(.update(tagUpdates: updates)) + + self.notifyOverridesChanged() + } + } + + private func notifyOverridesChanged() { + Task { [weak audienceOverridesProvider] in + await audienceOverridesProvider?.notifyPendingChanged() } } @@ -371,6 +451,7 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked self.addOperation( .update(attributeUpdates: updates) ) + self.notifyOverridesChanged() } } @@ -401,6 +482,7 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked } self.addOperation(.registerEmail(address: address, options: options)) + self.notifyOverridesChanged() } /** @@ -418,6 +500,30 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked } self.addOperation(.registerSMS(msisdn: msisdn, options: options)) + self.notifyOverridesChanged() + } + + /** + * Validates MSISDN + * - Parameters: + * - msisdn: The mobile phone number to validate. + * - sender: The identifier given to the sender of the SMS message. + * - Returns: Async boolean indicating validity of msisdn + */ + public func validateSMS( + _ msisdn: String, + sender: String + ) async throws -> Bool { + guard self.privacyManager.isEnabled(.contacts) else { + AirshipLogger.warn( + "Contacts disabled. Enable to validate SMS." + ) + throw AirshipErrors.error( + "Validation of SMS requires contacts to be enabled." + ) + } + + return try await self.smsValidator.validateSMS(msisdn:msisdn, sender: sender) } /// Associates an open channel to the contact. @@ -442,9 +548,12 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked * - Parameters: * - channelID: The channel ID. * - type: The channel type. + * - options: The SMS/email channel options */ - @objc - public func associateChannel(_ channelID: String, type: ChannelType) { + public func associateChannel( + _ channelID: String, + type: ChannelType + ) { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.warn( "Contacts disabled. Enable to associate channel." @@ -452,8 +561,50 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked return } + self.addOperation( + .associateChannel( + channelID: channelID, + channelType: type + ) + ) + } + + /** + * Resends an opt-in message + * - Parameters: + * - channelID: The channel ID. + * - type: The channel type. + * - options: The SMS/email channel options + */ + public func resend(_ channel: ContactChannel) { + guard self.privacyManager.isEnabled(.contacts) else { + AirshipLogger.warn( + "Contacts disabled. Enable to re-send double opt in to channel." + ) + return + } + + self.addOperation(.resend(channel: channel)) + } + + /** + * Disassociates a channel + * - Parameters: + * - channel: The channel to disassociate. + */ + public func disassociateChannel(_ channel: ContactChannel) { + guard self.privacyManager.isEnabled(.contacts) else { + AirshipLogger.warn( + "Contacts disabled. Enable to disassociate channel." + ) + return + } + + self.addOperation(.disassociateChannel(channel: channel)) - self.addOperation(.associateChannel(channelID: channelID, channelType: type)) + Task { [weak audienceOverridesProvider] in + await audienceOverridesProvider?.notifyPendingChanged() + } } /// Begins a subscription list editing session @@ -530,6 +681,13 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked update.isStable }.contactID } + + public func getStableContactInfo() async -> StableContactInfo { + let info = await waitForContactIDInfo(filter: { $0.isStable }) + return StableContactInfo( + contactID: info.contactID, + namedUserID: info.namedUserID) + } private func getStableVerifiedContactID() async -> String { let now = self.date.now @@ -559,11 +717,6 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked let contactID = await getStableContactID() var subscriptions = try await self.resolveSubscriptionLists(contactID) - // Audience overrides will take any pending operations and updated operations. Since - // pending operations are added through the serialQueue to ensure order, some might still - // be in the queue. To avoid ignoring any of those, wait for current operations on the queue - // to finish - await self.serialQueue.waitForCurrentOperations() let overrides = await self.audienceOverridesProvider.contactOverrides(contactID: contactID) subscriptions = AudienceUtils.applySubscriptionListsUpdates( @@ -577,6 +730,7 @@ public final class AirshipContact: NSObject, AirshipContactProtocol, @unchecked private func resolveSubscriptionLists( _ contactID: String ) async throws -> [String: [ChannelScope]] { + return try await self.fetchSubscriptionListQueue.run { if let cached = self.cachedSubscriptionLists.value, cached.0 == contactID { diff --git a/Airship/AirshipCore/Source/AirshipEmbeddedObserver.swift b/Airship/AirshipCore/Source/AirshipEmbeddedObserver.swift index 2ef4d19c9..c79e993f7 100644 --- a/Airship/AirshipCore/Source/AirshipEmbeddedObserver.swift +++ b/Airship/AirshipCore/Source/AirshipEmbeddedObserver.swift @@ -3,7 +3,7 @@ import SwiftUI import Combine -/// NOTE: For internal use only. :nodoc: +/// Observable model for Airship embedded views @MainActor public final class AirshipEmbeddedObserver : ObservableObject { /// An array of embedded infos diff --git a/Airship/AirshipCore/Source/AirshipEmbeddedView.swift b/Airship/AirshipCore/Source/AirshipEmbeddedView.swift index b43917dd6..6b47b1e61 100644 --- a/Airship/AirshipCore/Source/AirshipEmbeddedView.swift +++ b/Airship/AirshipCore/Source/AirshipEmbeddedView.swift @@ -3,7 +3,7 @@ import SwiftUI import Combine -/// NOTE: For internal use only. :nodoc: +/// Airship embedded view - a scene that can be embedded in an app and managed remotely public struct AirshipEmbeddedView: View { @Environment(\.airshipEmbeddedViewStyle) @@ -127,13 +127,13 @@ public struct AirshipEmbeddedContentView : View { } } -/// NOTE: For internal use only. :nodoc: +/// Style configuration for customizing an Airship embedded view public struct AirshipEmbeddedViewStyleConfiguration { public let views: [AirshipEmbeddedContentView] public let placeHolder: AnyView } -/// NOTE: For internal use only. :nodoc: +/// Protocol for customizing an Airship embedded view with a style public protocol AirshipEmbeddedViewStyle { associatedtype Body: View typealias Configuration = AirshipEmbeddedViewStyleConfiguration @@ -147,7 +147,7 @@ extension AirshipEmbeddedViewStyle where Self == DefaultAirshipEmbeddedViewStyle } } -/// NOTE: For internal use only. :nodoc: +/// Default style for embedded views public struct DefaultAirshipEmbeddedViewStyle: AirshipEmbeddedViewStyle { @ViewBuilder public func makeBody(configuration: Configuration) -> some View { @@ -188,6 +188,7 @@ extension EnvironmentValues { extension View { + /// Setter for applying a style to an Airship embedded view public func setAirshipEmbeddedStyle( _ style: S ) -> some View where S: AirshipEmbeddedViewStyle { diff --git a/Airship/AirshipCore/Source/AirshipEvent.swift b/Airship/AirshipCore/Source/AirshipEvent.swift index 22429b06f..65f189f37 100644 --- a/Airship/AirshipCore/Source/AirshipEvent.swift +++ b/Airship/AirshipCore/Source/AirshipEvent.swift @@ -12,12 +12,12 @@ public enum AirshipEventPriority: Sendable { /// - Note: For Internal use only :nodoc: public struct AirshipEvent: Sendable { public var priority: AirshipEventPriority - public var eventType: String + public var eventType: EventType public var eventData: AirshipJSON public init( priority: AirshipEventPriority = .normal, - eventType: String, + eventType: EventType, eventData: AirshipJSON ) { self.priority = priority diff --git a/Airship/AirshipCore/Source/AirshipEventData.swift b/Airship/AirshipCore/Source/AirshipEventData.swift index 12eadc2c5..356c9a2e5 100644 --- a/Airship/AirshipCore/Source/AirshipEventData.swift +++ b/Airship/AirshipCore/Source/AirshipEventData.swift @@ -18,6 +18,6 @@ public struct AirshipEventData: Sendable, Equatable { public let sessionID: String /// The event type - public let type: String + public let type: EventType } diff --git a/Airship/AirshipCore/Source/AirshipEventType.swift b/Airship/AirshipCore/Source/AirshipEventType.swift new file mode 100644 index 000000000..83eeb797a --- /dev/null +++ b/Airship/AirshipCore/Source/AirshipEventType.swift @@ -0,0 +1,88 @@ +/* Copyright Airship and Contributors */ + +import Foundation + +/** + * Airship event types + */ +public enum EventType: CaseIterable, Sendable, Equatable, Hashable { + case appInit + case appForeground + case appBackground + case screenTracking + case associateIdentifiers + case installAttribution + case interactiveNotificationAction + case pushReceived + case deviceRegistration + case regionEnter + case regionExit + case customEvent + case featureFlagInteraction + case inAppDisplay + case inAppResolution + case inAppButtonTap + case inAppPermissionResult + case inAppFormDisplay + case inAppFormResult + case inAppGesture + case inAppPagerCompleted + case inAppPagerSummary + case inAppPageSwipe + case inAppPageView + case inAppPageAction + + /// NOTE: For internal use only. :nodoc: + public var reportingName: String { + switch self { + case .appInit: + return "app_init" + case .appForeground: + return "app_foreground" + case .appBackground: + return "app_background" + case .screenTracking: + return "screen_tracking" + case .associateIdentifiers: + return "associate_identifiers" + case .installAttribution: + return "install_attribution" + case .interactiveNotificationAction: + return "interactive_notification_action" + case .pushReceived: + return "push_received" + case .deviceRegistration: + return "device_registration" + case .regionEnter, .regionExit: + return "region_event" + case .customEvent: + return "enhanced_custom_event" + case .featureFlagInteraction: + return "feature_flag_interaction" + case .inAppDisplay: + return "in_app_display" + case .inAppResolution: + return "in_app_resolution" + case .inAppButtonTap: + return "in_app_button_tap" + case .inAppPermissionResult: + return "in_app_permission_result" + case .inAppFormDisplay: + return "in_app_form_display" + case .inAppFormResult: + return "in_app_form_result" + case .inAppGesture: + return "in_app_gesture" + case .inAppPagerCompleted: + return "in_app_pager_completed" + case .inAppPagerSummary: + return "in_app_pager_summary" + case .inAppPageSwipe: + return "in_app_page_swipe" + case .inAppPageView: + return "in_app_page_view" + case .inAppPageAction: + return "in_app_page_action" + } + } +} diff --git a/Airship/AirshipCore/Source/AirshipEvents.swift b/Airship/AirshipCore/Source/AirshipEvents.swift index 901ebe9d4..25b7b42b5 100644 --- a/Airship/AirshipCore/Source/AirshipEvents.swift +++ b/Airship/AirshipCore/Source/AirshipEvents.swift @@ -9,7 +9,7 @@ struct AirshipEvents { ) -> AirshipEvent { return AirshipEvent( priority: .normal, - eventType: "device_registration", + eventType: .deviceRegistration, eventData: AirshipJSON.makeObject { object in object.set(string: channelID, key: "channel_id") object.set(string: deviceToken, key: "device_token") @@ -25,7 +25,7 @@ struct AirshipEvents { return AirshipEvent( priority: .normal, - eventType: "push_received", + eventType: .pushReceived, eventData: AirshipJSON.makeObject { object in object.set(string: metadata, key: "metadata") object.set(string: pushID ?? "MISSING_SEND_ID", key: "push_id") @@ -44,7 +44,7 @@ struct AirshipEvents { return AirshipEvent( priority: .high, - eventType: "interactive_notification_action", + eventType: .interactiveNotificationAction, eventData: AirshipJSON.makeObject { object in object.set(string: category, key: "button_group") object.set(string: action.identifier, key: "button_id") @@ -76,7 +76,7 @@ struct AirshipEvents { return AirshipEvent( priority: .normal, - eventType: "screen_tracking", + eventType: .screenTracking, eventData: AirshipJSON.makeObject { object in object.set(string: screen, key: "screen") object.set(string: previousScreen, key: "previous_screen") @@ -113,7 +113,7 @@ struct AirshipEvents { return AirshipEvent( priority: .normal, - eventType: "associate_identifiers", + eventType: .associateIdentifiers, eventData: try AirshipJSON.wrap(identifiers) ) } @@ -124,7 +124,7 @@ struct AirshipEvents { ) -> AirshipEvent { return AirshipEvent( priority: .normal, - eventType: "install_attribution", + eventType: .installAttribution, eventData: AirshipJSON.makeObject { object in object.set( string: appPurchaseDate?.timeIntervalSince1970.toString(), @@ -192,11 +192,11 @@ fileprivate extension SessionEvent { } } - var eventType: String { + var eventType: EventType { switch self.type { - case .foregroundInit, .backgroundInit: return "app_init" - case .background: return "app_background" - case .foreground: return "app_foreground" + case .foregroundInit, .backgroundInit: return .appInit + case .background: return .appBackground + case .foreground: return .appForeground } } diff --git a/Airship/AirshipCore/Source/AirshipLogHandler.swift b/Airship/AirshipCore/Source/AirshipLogHandler.swift index 7e33258a3..6b3b433e9 100644 --- a/Airship/AirshipCore/Source/AirshipLogHandler.swift +++ b/Airship/AirshipCore/Source/AirshipLogHandler.swift @@ -10,7 +10,6 @@ public protocol AirshipLogHandler { /// Called to log a message. /// - Parameters: /// - logLevel: The Airship log level. - /// - logPrivacyLevel: The Airship log privacy level. /// - message: The log message. /// - fileID: The file ID. /// - line: The line number. @@ -18,7 +17,6 @@ public protocol AirshipLogHandler { @objc func log( logLevel: AirshipLogLevel, - logPrivacyLevel: AirshipLogPrivacyLevel, message: String, fileID: String, line: UInt, diff --git a/Airship/AirshipCore/Source/LogPrivacyLevel.swift b/Airship/AirshipCore/Source/AirshipLogPrivacyLevel.swift similarity index 100% rename from Airship/AirshipCore/Source/LogPrivacyLevel.swift rename to Airship/AirshipCore/Source/AirshipLogPrivacyLevel.swift diff --git a/Airship/AirshipCore/Source/AirshipLogger.swift b/Airship/AirshipCore/Source/AirshipLogger.swift index 1b63bab55..caa2d947a 100644 --- a/Airship/AirshipCore/Source/AirshipLogger.swift +++ b/Airship/AirshipCore/Source/AirshipLogger.swift @@ -10,7 +10,6 @@ import os public class AirshipLogger { static var logLevel: AirshipLogLevel = .error - static var logPrivacyLevel: AirshipLogPrivacyLevel = .private static var logHandler: AirshipLogHandler = DefaultLogHandler() @@ -23,7 +22,6 @@ public class AirshipLogger { log( logLevel: AirshipLogLevel.verbose, - logPrivacyLevel: AirshipLogger.logPrivacyLevel, message: message(), fileID: fileID, line: line, @@ -40,7 +38,6 @@ public class AirshipLogger { log( logLevel: AirshipLogLevel.debug, - logPrivacyLevel: AirshipLogger.logPrivacyLevel, message: message(), fileID: fileID, line: line, @@ -56,7 +53,6 @@ public class AirshipLogger { ) { log( logLevel: AirshipLogLevel.info, - logPrivacyLevel: AirshipLogger.logPrivacyLevel, message: message(), fileID: fileID, line: line, @@ -72,7 +68,6 @@ public class AirshipLogger { ) { log( logLevel: AirshipLogLevel.info, - logPrivacyLevel: AirshipLogger.logPrivacyLevel, message: message, fileID: fileID, line: line, @@ -89,7 +84,6 @@ public class AirshipLogger { ) { log( logLevel: AirshipLogLevel.warn, - logPrivacyLevel: AirshipLogger.logPrivacyLevel, message: message(), fileID: fileID, line: line, @@ -103,10 +97,8 @@ public class AirshipLogger { line: UInt = #line, function: String = #function ) { - log( logLevel: AirshipLogLevel.error, - logPrivacyLevel: AirshipLogger.logPrivacyLevel, message: message(), fileID: fileID, line: line, @@ -120,10 +112,8 @@ public class AirshipLogger { line: UInt = #line, function: String = #function ) { - log( logLevel: AirshipLogLevel.error, - logPrivacyLevel: AirshipLogger.logPrivacyLevel, message: "🚨Airship Implementation Error🚨: \(message())", fileID: fileID, line: line, @@ -133,7 +123,6 @@ public class AirshipLogger { static func log( logLevel: AirshipLogLevel, - logPrivacyLevel: AirshipLogPrivacyLevel, message: @autoclosure () -> String, fileID: String, line: UInt, @@ -148,7 +137,6 @@ public class AirshipLogger { if skipLogLevelCheck || self.logLevel.rawValue >= logLevel.rawValue { logHandler.log( logLevel: logLevel, - logPrivacyLevel: logPrivacyLevel, message: message(), fileID: fileID, line: line, diff --git a/Airship/AirshipCore/Source/AirshipVersion.swift b/Airship/AirshipCore/Source/AirshipVersion.swift index a956d974f..96809f4f5 100644 --- a/Airship/AirshipCore/Source/AirshipVersion.swift +++ b/Airship/AirshipCore/Source/AirshipVersion.swift @@ -3,7 +3,7 @@ import Foundation public struct AirshipVersion { - public static let version = "18.3.1" + public static let version = "18.4.0" public static func get() -> String { return version } diff --git a/Airship/AirshipCore/Source/AirshipViewUtils.swift b/Airship/AirshipCore/Source/AirshipViewUtils.swift index f4ccd3dec..55ed73de1 100644 --- a/Airship/AirshipCore/Source/AirshipViewUtils.swift +++ b/Airship/AirshipCore/Source/AirshipViewUtils.swift @@ -4,6 +4,12 @@ import Foundation import SwiftUI +/// NOTE: For internal use only. :nodoc: +public extension Color { + static var airshipTappableClear: Color { Color.white.opacity(0.001) } + static var airshipShadowColor: Color { Color.black.opacity(0.33) } +} + /// NOTE: For internal use only. :nodoc: public extension View { /// Wrapper to prevent linter warnings for deprecated onChange method @@ -27,6 +33,23 @@ public extension View { #if !os(watchOS) /// NOTE: For internal use only. :nodoc: public extension UIWindow { + func airshipAddRootController( + _ viewController: T? + ) { + viewController?.modalPresentationStyle = UIModalPresentationStyle.automatic + viewController?.view.isUserInteractionEnabled = true + + if let viewController = viewController, + let rootController = self.rootViewController + { + rootController.addChild(viewController) + viewController.didMove(toParent: rootController) + rootController.view.addSubview(viewController.view) + } + + self.isUserInteractionEnabled = true + } + static func airshipMakeModalReadyWindow( scene: UIWindowScene ) -> UIWindow { diff --git a/Airship/AirshipCore/Source/Analytics.swift b/Airship/AirshipCore/Source/Analytics.swift index ba5026bf0..2e3424b1f 100644 --- a/Airship/AirshipCore/Source/Analytics.swift +++ b/Airship/AirshipCore/Source/Analytics.swift @@ -275,29 +275,26 @@ final class AirshipAnalytics: AirshipAnalyticsProtocol, @unchecked Sendable { return } - /// Upload - let eventBody = event.eventBody( - sendID: self.conversionSendID, - metadata: self.conversionPushMetadata, - formatValue: true - ) - recordEvent( - AirshipEvent(eventType: CustomEvent.eventType, eventData: eventBody) - ) - - /// Feed - let feedBody = event.eventBody( - sendID: self.conversionSendID, - metadata: self.conversionPushMetadata, - formatValue: false - ) - - self.eventFeed.notifyEvent( - .customEvent( - body: feedBody, - value: event.eventValue?.doubleValue ?? 1.0 - ) + AirshipEvent( + eventType: .customEvent, + eventData: event.eventBody( + sendID: self.conversionSendID, + metadata: self.conversionPushMetadata, + formatValue: true + ) + ), + feedEvent: .analytics( + eventType: .customEvent, + body: event.eventBody( + sendID: self.conversionSendID, + metadata: self.conversionPushMetadata, + formatValue: false + ), + value: event.eventValue?.doubleValue + ), + date: self.date.now, + sessionID: self.sessionTracker.sessionState.sessionID ) } @@ -324,28 +321,19 @@ final class AirshipAnalytics: AirshipAnalyticsProtocol, @unchecked Sendable { /// Upload do { + let eventType: EventType = switch(event.boundaryEvent) { + case .enter: .regionEnter + case .exit: .regionExit + } recordEvent( AirshipEvent( - eventType: RegionEvent.eventType, + eventType: eventType, eventData: try event.eventBody(stringifyFields: true) ) ) } catch { AirshipLogger.error("Failed to generate event body \(error)") } - - /// Feed - do { - let body = try event.eventBody(stringifyFields: false) - - if (event.boundaryEvent == .enter) { - eventFeed.notifyEvent(.regionEnter(body: body)) - } else { - eventFeed.notifyEvent(.regionExit(body: body)) - } - } catch { - AirshipLogger.error("Failed to generate event body \(error)") - } } public func trackInstallAttribution( @@ -361,10 +349,38 @@ final class AirshipAnalytics: AirshipAnalyticsProtocol, @unchecked Sendable { } public func recordEvent(_ event: AirshipEvent) { - self.recordEvent(event, date: self.date.now, sessionID: self.sessionTracker.sessionState.sessionID) + self.recordEvent( + event, + date: self.date.now, + sessionID: self.sessionTracker.sessionState.sessionID + ) + } + + private func recordEvent( + _ event: AirshipEvent, + date: Date, + sessionID: String + ) { + self.recordEvent( + event, + feedEvent: AirshipAnalyticsFeed.Event.analytics( + eventType: event.eventType, + body: event.eventData, + value: nil + ), + date: date, + sessionID: sessionID + ) } - private func recordEvent(_ event: AirshipEvent, date: Date, sessionID: String) { + + + private func recordEvent( + _ event: AirshipEvent, + feedEvent: AirshipAnalyticsFeed.Event, + date: Date, + sessionID: String + ) { self.serialQueue.enqueue { guard self.isAnalyticsEnabled else { AirshipLogger.trace( @@ -373,6 +389,8 @@ final class AirshipAnalytics: AirshipAnalyticsProtocol, @unchecked Sendable { return } + await self.eventFeed.notifyEvent(feedEvent) + let eventData = AirshipEventData( body: event.eventData, id: NSUUID().uuidString, @@ -462,7 +480,9 @@ final class AirshipAnalytics: AirshipAnalyticsProtocol, @unchecked Sendable { return } - self.eventFeed.notifyEvent(.screenChange(screen: screen)) + Task { + await self.eventFeed.notifyEvent(.screen(screen: screen)) + } let currentScreen = self.screenState.value.current let screenStartDate = self.screenState.value.startDate diff --git a/Airship/AirshipCore/Source/AnonContactData.swift b/Airship/AirshipCore/Source/AnonContactData.swift index 8c03c45f8..886a28638 100644 --- a/Airship/AirshipCore/Source/AnonContactData.swift +++ b/Airship/AirshipCore/Source/AnonContactData.swift @@ -3,9 +3,15 @@ import Foundation struct AnonContactData: Codable, Sendable { + + struct Channel: Codable, Sendable, Equatable, Hashable { + let channelType: ChannelType + let channelID: String + } + var tags: [String: [String]] var attributes: [String: AirshipJSON] - var channels: [AssociatedChannel] + var channels: [Channel] var subscriptionLists: [String: [ChannelScope]] var isEmpty: Bool { @@ -15,7 +21,7 @@ struct AnonContactData: Codable, Sendable { self.subscriptionLists.isEmpty } - init(tags: [String : [String]], attributes: [String : AirshipJSON], channels: [AssociatedChannel], subscriptionLists: [String : [ChannelScope]]) { + init(tags: [String : [String]], attributes: [String : AirshipJSON], channels: [Channel], subscriptionLists: [String : [ChannelScope]]) { self.tags = tags self.attributes = attributes self.channels = channels @@ -25,7 +31,7 @@ struct AnonContactData: Codable, Sendable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.tags = try container.decode([String : [String]].self, forKey: .tags) - self.channels = try container.decode([AssociatedChannel].self, forKey: .channels) + self.channels = try container.decode([Channel].self, forKey: .channels) self.subscriptionLists = try container.decode([String : [ChannelScope]].self, forKey: .subscriptionLists) do { diff --git a/Airship/AirshipCore/Source/AssociatedChannel.swift b/Airship/AirshipCore/Source/AssociatedChannel.swift index 01be7e80f..eb6f944ad 100644 --- a/Airship/AirshipCore/Source/AssociatedChannel.swift +++ b/Airship/AirshipCore/Source/AssociatedChannel.swift @@ -4,6 +4,7 @@ import Foundation /// Associated channel data. @objc(UAAssociatedChannel) +@available(*, deprecated, message: "Use ContactConflictEvent.ChannelInfo instead") public final class AssociatedChannel: NSObject, Codable, Sendable { /** @@ -18,6 +19,7 @@ public final class AssociatedChannel: NSObject, Codable, Sendable { @objc public let channelID: String + @objc public init(channelType: ChannelType, channelID: String) { self.channelType = channelType diff --git a/Airship/AirshipCore/Source/AudienceDeviceInfoProvider.swift b/Airship/AirshipCore/Source/AudienceDeviceInfoProvider.swift index 7fa1e4fc1..e0bd6bdc8 100644 --- a/Airship/AirshipCore/Source/AudienceDeviceInfoProvider.swift +++ b/Airship/AirshipCore/Source/AudienceDeviceInfoProvider.swift @@ -6,7 +6,7 @@ import Foundation public protocol AudienceDeviceInfoProvider: AnyObject, Sendable { var isAirshipReady: Bool { get } var tags: Set { get } - var channelID: String? { get } + var channelID: String { get async throws } var locale: Locale { get } var appVersion: String? { get } var sdkVersion: String { get } @@ -14,7 +14,8 @@ public protocol AudienceDeviceInfoProvider: AnyObject, Sendable { var isUserOptedInPushNotifications: Bool { get async } var analyticsEnabled: Bool { get } var installDate: Date { get } - var stableContactID: String { get async } + var stableContactInfo: StableContactInfo { get async } + var isChannelCreated: Bool { get } } /// NOTE: For internal use only. :nodoc: @@ -23,11 +24,12 @@ public final class CachingAudienceDeviceInfoProvider: AudienceDeviceInfoProvider private let cachedTags: OneTimeValue> private let cachedLocale: OneTimeValue - private let cachedContactID: OneTimeAsyncValue - private let cachedChannelID: OneTimeValue + private let cachedStableContactInfo: OneTimeAsyncValue + private let cachedChannelID: ThrowingOneTimeAsyncValue private let cachedPermissions: OneTimeAsyncValue<[AirshipPermission : AirshipPermissionStatus]> private let cachedIsUserOptedInPushNotifications: OneTimeAsyncValue private let cachedAnalyticsEnabled: OneTimeValue + private let cachedIsChannelCreated: OneTimeValue public convenience init(contactID: String?) { self.init(deviceInfoProvider: DefaultAudienceDeviceInfoProvider(contactID: contactID)) @@ -44,8 +46,8 @@ public final class CachingAudienceDeviceInfoProvider: AudienceDeviceInfoProvider return deviceInfoProvider.locale } - self.cachedContactID = OneTimeAsyncValue { - return await deviceInfoProvider.stableContactID + self.cachedStableContactInfo = OneTimeAsyncValue { + return await deviceInfoProvider.stableContactInfo } self.cachedPermissions = OneTimeAsyncValue { @@ -60,8 +62,12 @@ public final class CachingAudienceDeviceInfoProvider: AudienceDeviceInfoProvider return deviceInfoProvider.analyticsEnabled } - self.cachedChannelID = OneTimeValue { - return deviceInfoProvider.channelID + self.cachedIsChannelCreated = OneTimeValue { + return deviceInfoProvider.isChannelCreated + } + + self.cachedChannelID = ThrowingOneTimeAsyncValue { + return try await deviceInfoProvider.channelID } } @@ -69,9 +75,9 @@ public final class CachingAudienceDeviceInfoProvider: AudienceDeviceInfoProvider deviceInfoProvider.installDate } - public var stableContactID: String { + public var stableContactInfo: StableContactInfo { get async { - return await cachedContactID.value + return await cachedStableContactInfo.getValue() } } @@ -91,23 +97,31 @@ public final class CachingAudienceDeviceInfoProvider: AudienceDeviceInfoProvider return cachedTags.value } - public var channelID: String? { - return cachedChannelID.value + public var channelID: String { + get async throws { + return try await cachedChannelID.getValue() + } + } + + + public var isChannelCreated: Bool { + return cachedIsChannelCreated.value } + public var locale: Locale { return cachedLocale.value } public var permissions: [AirshipPermission : AirshipPermissionStatus] { get async { - await cachedPermissions.value + await cachedPermissions.getValue() } } public var isUserOptedInPushNotifications: Bool { get async { - return await cachedIsUserOptedInPushNotifications.value + return await cachedIsUserOptedInPushNotifications.getValue() } } @@ -120,9 +134,9 @@ public final class CachingAudienceDeviceInfoProvider: AudienceDeviceInfoProvider /// NOTE: For internal use only. :nodoc: public final class DefaultAudienceDeviceInfoProvider: AudienceDeviceInfoProvider { + private let contactID: String? - public init(contactID: String? = nil) { self.contactID = contactID } @@ -135,14 +149,20 @@ public final class DefaultAudienceDeviceInfoProvider: AudienceDeviceInfoProvider AirshipVersion.version } - public var stableContactID: String { + public var stableContactInfo: StableContactInfo { get async { - if let contactID = self.contactID { - return contactID - } - return await Airship.requireComponent( + let stableInfo = await Airship.requireComponent( ofType: InternalAirshipContactProtocol.self - ).getStableContactID() + ).getStableContactInfo() + + if let contactID { + if (stableInfo.contactID == contactID) { + return stableInfo + } + return StableContactInfo(contactID: contactID, namedUserID: nil) + } + + return stableInfo } } @@ -158,8 +178,20 @@ public final class DefaultAudienceDeviceInfoProvider: AudienceDeviceInfoProvider return Set(Airship.channel.tags) } - public var channelID: String? { - return Airship.channel.identifier + public var isChannelCreated: Bool { + return Airship.channel.identifier != nil + } + + public var channelID: String { + get async { + if let channelID = Airship.channel.identifier { + return channelID + } + for await update in Airship.channel.identifierUpdates { + return update + } + return "" + } } public var locale: Locale { @@ -219,32 +251,44 @@ fileprivate final class OneTimeValue: @unchecked Sendab } } -fileprivate final class OneTimeAsyncValue: @unchecked Sendable { - private let queue: AirshipSerialQueue = AirshipSerialQueue() - private var atomicValue: AirshipAtomicValue = AirshipAtomicValue(nil) +fileprivate actor OneTimeAsyncValue { private var provider: @Sendable () async -> T + private var task: Task? - var cachedValue: T? { - get { - return atomicValue.value + init(provider: @Sendable @escaping () async -> T) { + self.provider = provider + } + + func getValue() async -> T { + if let task { + return await task.value + } + let newTask = Task { + return await provider() } + task = newTask + return await newTask.value } +} - init(provider: @Sendable @escaping () async -> T) { +fileprivate actor ThrowingOneTimeAsyncValue { + private var provider: @Sendable () async throws -> T + private var task: Task? + private var value: T? + + init(provider: @Sendable @escaping () async throws -> T) { self.provider = provider } - var value: T { - get async { - return await queue.runSafe { - if let cached = self.atomicValue.value { - return cached - } else { - let newValue = await self.provider() - self.atomicValue.value = newValue - return newValue - } - } + func getValue() async throws -> T { + if let task, let value = try? await task.value { + return value + } + + let newTask = Task { + return try await provider() } + task = newTask + return try await newTask.value } } diff --git a/Airship/AirshipCore/Source/AudienceOverridesProvider.swift b/Airship/AirshipCore/Source/AudienceOverridesProvider.swift index a42a51660..c5c56464b 100644 --- a/Airship/AirshipCore/Source/AudienceOverridesProvider.swift +++ b/Airship/AirshipCore/Source/AudienceOverridesProvider.swift @@ -17,15 +17,16 @@ protocol AudienceOverridesProvider: Actor { contactID: String, tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, - subscriptionLists: [ScopedSubscriptionListUpdate]? - ) + subscriptionLists: [ScopedSubscriptionListUpdate]?, + channels: [ContactChannelUpdate]? + ) async func channelUpdated( channelID: String, tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, subscriptionLists: [SubscriptionListUpdate]? - ) + ) async func channelOverrides( channelID: String, @@ -41,14 +42,21 @@ protocol AudienceOverridesProvider: Actor { ) async -> ContactAudienceOverrides func contactOverrides() async -> ContactAudienceOverrides + + func notifyPendingChanged() async + + func contactOverrideUpdates( + contactID: String? + ) async -> AsyncStream } actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { + private let updates: CachedList private var pendingChannelOverridesProvider: (@Sendable (String) async -> ChannelAudienceOverrides?)? = nil private var pendingContactOverridesProvider: (@Sendable (String) async -> ContactAudienceOverrides?)? = nil private var stableContactIDProvider: (@Sendable () async -> String)? = nil - + private let overridesUpdates: AirshipAsyncChannel = AirshipAsyncChannel() private static let maxRecordAge: TimeInterval = 600 // 10 minutes @@ -86,18 +94,22 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { contactID: String, tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, - subscriptionLists: [ScopedSubscriptionListUpdate]? - ) { + subscriptionLists: [ScopedSubscriptionListUpdate]?, + channels: [ContactChannelUpdate]? + ) async { self.updates.append( UpdateRecord( recordType: .contact(contactID), tags: tags, attributes: attributes, subscriptionLists: nil, - scopedSubscriptionLists: subscriptionLists + scopedSubscriptionLists: subscriptionLists, + channels: channels ), expiresIn: DefaultAudienceOverridesProvider.maxRecordAge ) + + await self.notifyPendingChanged() } func channelUpdated( @@ -105,17 +117,20 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, subscriptionLists: [SubscriptionListUpdate]? - ) { + ) async { self.updates.append( UpdateRecord( recordType: .channel(channelID), tags: tags, attributes: attributes, subscriptionLists: subscriptionLists, - scopedSubscriptionLists: nil + scopedSubscriptionLists: nil, + channels: nil ), expiresIn: DefaultAudienceOverridesProvider.maxRecordAge ) + + await self.notifyPendingChanged() } func convertAppScopes(scoped: [ScopedSubscriptionListUpdate]) -> [SubscriptionListUpdate] { @@ -212,7 +227,6 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { ) } - func contactOverrides() async -> ContactAudienceOverrides { return await contactOverrides(contactID: nil) } @@ -229,6 +243,7 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { var tags: [TagGroupUpdate] = [] var attributes: [AttributeUpdate] = [] var scopedSubscriptionLists: [ScopedSubscriptionListUpdate] = [] + var channels: [ContactChannelUpdate] = [] // Contact updates self.updates.values.forEach { update in @@ -244,6 +259,10 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { if let updateScopedSubscriptionLists = update.scopedSubscriptionLists { scopedSubscriptionLists += updateScopedSubscriptionLists } + + if let updateChannel = update.channels { + channels += updateChannel + } } } @@ -252,13 +271,48 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { tags += pendingContactOverrides.tags attributes += pendingContactOverrides.attributes scopedSubscriptionLists += pendingContactOverrides.subscriptionLists + channels += pendingContactOverrides.channels } return ContactAudienceOverrides( tags: tags, attributes: attributes, - subscriptionLists: scopedSubscriptionLists + subscriptionLists: scopedSubscriptionLists, + channels: channels + ) + } + + func contactOverrideUpdates( + contactID: String? + ) async -> AsyncStream { + let updates = await self.overridesUpdates.makeStream( + bufferPolicy: .bufferingNewest(1) ) + + let initial: ContactAudienceOverrides = await self.contactOverrides(contactID: contactID) + + return AsyncStream { [weak self] continuation in + continuation.yield(initial) + + let task = Task { [weak self] in + for await _ in updates { + let overrides = await self?.contactOverrides(contactID: contactID) + guard !Task.isCancelled, let overrides else { + return + } + continuation.yield(overrides) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + + func notifyPendingChanged() async { + await self.overridesUpdates.send(true) } private func resolveContactID(contactID: String?) async -> String? { @@ -279,6 +333,7 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { let attributes: [AttributeUpdate]? let subscriptionLists: [SubscriptionListUpdate]? let scopedSubscriptionLists: [ScopedSubscriptionListUpdate]? + let channels: [ContactChannelUpdate]? } fileprivate struct ContactRecord: Sendable { @@ -286,6 +341,7 @@ actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { let tags: [TagGroupUpdate] let attributes: [AttributeUpdate] let subscriptionLists: [ScopedSubscriptionListUpdate] + let channels: [ContactChannelUpdate] } } @@ -294,11 +350,13 @@ struct ContactAudienceOverrides: Sendable { let tags: [TagGroupUpdate] let attributes: [AttributeUpdate] let subscriptionLists: [ScopedSubscriptionListUpdate] + let channels: [ContactChannelUpdate] - init(tags: [TagGroupUpdate] = [], attributes: [AttributeUpdate] = [], subscriptionLists: [ScopedSubscriptionListUpdate] = []) { + init(tags: [TagGroupUpdate] = [], attributes: [AttributeUpdate] = [], subscriptionLists: [ScopedSubscriptionListUpdate] = [], channels: [ContactChannelUpdate] = []) { self.tags = tags self.attributes = attributes self.subscriptionLists = subscriptionLists + self.channels = channels } } diff --git a/Airship/AirshipCore/Source/ChannelAPIClient.swift b/Airship/AirshipCore/Source/ChannelAPIClient.swift index c26d2c538..2b940f474 100644 --- a/Airship/AirshipCore/Source/ChannelAPIClient.swift +++ b/Airship/AirshipCore/Source/ChannelAPIClient.swift @@ -22,7 +22,6 @@ final class ChannelAPIClient: ChannelAPIClientProtocol, Sendable { session: config.requestSession ) } - private func makeURL(path: String) throws -> URL { guard let deviceAPIURL = self.config.deviceAPIURL else { @@ -67,9 +66,8 @@ final class ChannelAPIClient: ChannelAPIClientProtocol, Sendable { return try await session.performHTTPRequest( request ) { data, response in - AirshipLogger.debug( - "Channel creation finished with response: \(response)" - ) + + AirshipLogger.debug("Channel creation finished with response: \(response)") let status = response.statusCode guard status == 200 || status == 201 else { @@ -120,6 +118,9 @@ final class ChannelAPIClient: ChannelAPIClientProtocol, Sendable { ) return try await session.performHTTPRequest(request) { data, response in + + AirshipLogger.debug("Update channel finished with response: \(response)") + return ChannelAPIResponse( channelID: channelID, location: url diff --git a/Airship/AirshipCore/Source/ChannelAuthTokenAPIClient.swift b/Airship/AirshipCore/Source/ChannelAuthTokenAPIClient.swift index 3f690e2cc..3987889d5 100644 --- a/Airship/AirshipCore/Source/ChannelAuthTokenAPIClient.swift +++ b/Airship/AirshipCore/Source/ChannelAuthTokenAPIClient.swift @@ -55,9 +55,8 @@ final class ChannelAuthTokenAPIClient: ChannelAuthTokenAPIClientProtocol, Sendab ) return try await session.performHTTPRequest(request) { data, response in - AirshipLogger.trace( - "Channel auth token request finished with status: \(response.statusCode)" - ); + + AirshipLogger.debug("Channel auth token request finished with response: \(response)") guard response.statusCode == 200 else { return nil diff --git a/Airship/AirshipCore/Source/ContactAPIClient.swift b/Airship/AirshipCore/Source/ContactAPIClient.swift index 21a1d9e1d..c62dc7e58 100644 --- a/Airship/AirshipCore/Source/ContactAPIClient.swift +++ b/Airship/AirshipCore/Source/ContactAPIClient.swift @@ -33,32 +33,42 @@ protocol ContactsAPIClientProtocol: Sendable { contactID: String, channelID: String, channelType: ChannelType - ) async throws -> AirshipHTTPResponse + ) async throws -> AirshipHTTPResponse func registerEmail( contactID: String, address: String, options: EmailRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse + ) async throws -> AirshipHTTPResponse func registerSMS( contactID: String, msisdn: String, options: SMSRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse + ) async throws -> AirshipHTTPResponse func registerOpen( contactID: String, address: String, options: OpenRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse + ) async throws -> AirshipHTTPResponse + + func disassociateChannel( + contactID: String, + disassociateOptions: DisassociateOptions + ) async throws -> AirshipHTTPResponse + + func resend( + resendOptions: ResendOptions + ) async throws -> AirshipHTTPResponse } /// NOTE: For internal use only. :nodoc: final class ContactAPIClient: ContactsAPIClientProtocol { + private let config: RuntimeConfig private let session: AirshipRequestSession @@ -95,7 +105,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { convenience init(config: RuntimeConfig) { self.init(config: config, session: config.requestSession) } - + func resolve( channelID: String, contactID: String?, @@ -144,12 +154,12 @@ final class ContactAPIClient: ContactsAPIClientProtocol { return try await performUpdate(contactID: contactID, requestBody: requestBody) } - + func associateChannel( contactID: String, channelID: String, channelType: ChannelType - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { let requestBody = ContactUpdateRequestBody( attributes: nil, tags: nil, @@ -167,7 +177,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { requestBody: requestBody ).map { response in if (response.isSuccess) { - return AssociatedChannel(channelType: channelType, channelID: channelID) + return ContactAssociateChannelResult(channelType: channelType, channelID: channelID) } else { return nil } @@ -179,7 +189,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { address: String, options: EmailRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { return try await performChannelRegistration( contactID: contactID, requestBody: EmailChannelRegistrationBody( @@ -197,7 +207,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { msisdn: String, options: SMSRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { return try await performChannelRegistration( contactID: contactID, requestBody: SMSRegistrationBody( @@ -215,7 +225,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { address: String, options: OpenRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { return try await performChannelRegistration( contactID: contactID, requestBody: OpenChannelRegistrationBody( @@ -228,6 +238,22 @@ final class ContactAPIClient: ContactsAPIClientProtocol { ) } + + func disassociateChannel( + contactID: String, + disassociateOptions: DisassociateOptions + ) async throws -> AirshipHTTPResponse { + + return try await performDisassociate( + contactID: contactID, + requestBody: disassociateOptions + ) + } + + func resend(resendOptions: ResendOptions) async throws -> AirshipHTTPResponse { + return try await performResend(resendOptions: resendOptions) + } + private func makeURL(path: String) throws -> URL { guard let deviceAPIURL = self.config.deviceAPIURL else { throw AirshipErrors.error("Initial config not resolved.") @@ -257,7 +283,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { contactID: String, requestBody: T, channelType: ChannelType - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { let request = AirshipRequest( url: try self.makeChannelCreateURL(channelType: channelType), headers: [ @@ -273,9 +299,8 @@ final class ContactAPIClient: ContactsAPIClientProtocol { let createResponse: AirshipHTTPResponse = try await self.session.performHTTPRequest( request ) { (data, response) in - AirshipLogger.debug( - "Channel \(channelType) created with response: \(response)" - ) + + AirshipLogger.debug("Channel \(channelType) created with response: \(response)") guard let data = data, response.statusCode == 200 || response.statusCode == 201 else { return nil @@ -314,6 +339,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { let decoder = self.decoder return try await session.performHTTPRequest(request) { (data, response) in + AirshipLogger.debug("Contact identify request finished with response: \(response)") guard response.statusCode == 200, let data = data else { @@ -324,6 +350,78 @@ final class ContactAPIClient: ContactsAPIClientProtocol { } } + private func performDisassociate( + contactID: String, + requestBody: DisassociateOptions + ) async throws -> AirshipHTTPResponse { + AirshipLogger.debug("Disassociating with \(requestBody)") + + let encodedRequestBody = try self.encoder.encode(requestBody) + let requestBodyString = String(data: encodedRequestBody, encoding: .utf8) + AirshipLogger.debug("Encoded request body: \(requestBodyString ?? "Unable to convert data to string")") + + let request = AirshipRequest( + url: try self.makeURL(path: "/api/contacts/disassociate/\(contactID)"), + headers: [ + "Accept": "application/vnd.urbanairship+json; version=3;", + "Content-Type": "application/json" + ], + method: "POST", + auth: .basicAppAuth, + body: encodedRequestBody + ) + + let decoder = self.decoder + return try await session.performHTTPRequest(request) { (data, response) in + AirshipLogger.debug("Update finished with response: \(response)") + + guard response.statusCode == 200, let data = data else { + return nil + } + + return try decoder.decode(ContactDisassociateChannelResult.self, from: data) + } + } + + private func performResend( + resendOptions: ResendOptions + ) async throws -> AirshipHTTPResponse { + let requestBodyData: Data? + + switch resendOptions { + case .channel(let channel): + requestBodyData = try self.encoder.encode(channel) + case .email(let email): + requestBodyData = try self.encoder.encode(email) + case .sms(let sms): + requestBodyData = try self.encoder.encode(sms) + } + + guard let requestBodyData = requestBodyData else { + throw AirshipErrors.error("Unable to encode resend operation data.") + } + + AirshipLogger.debug("Re-sending double opt-in message") + + let request = AirshipRequest( + url: try self.makeURL(path: "/api/channels/resend"), + headers: [ + "Accept": "application/vnd.urbanairship+json; version=3;", + "Content-Type": "application/json" + ], + method: "POST", + auth: .generatedAppToken, + body: requestBodyData + ) + + return try await session.performHTTPRequest(request) { (data, response) in + + AirshipLogger.debug("Update finished with response: \(response)") + + return nil + } + } + private func performUpdate( contactID: String, requestBody: ContactUpdateRequestBody @@ -333,7 +431,7 @@ final class ContactAPIClient: ContactsAPIClientProtocol { let request = AirshipRequest( url: try self.makeURL(path: "/api/contacts/\(contactID)"), headers: [ - "Accept": "application/vnd.urbanairship+json; version=3;", + "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json", "X-UA-Appkey": self.config.appKey, ], @@ -343,9 +441,8 @@ final class ContactAPIClient: ContactsAPIClientProtocol { ) return try await session.performHTTPRequest(request) { (data, response) in - AirshipLogger.debug( - "Update finished with response: \(response)" - ) + + AirshipLogger.debug("Update finished with response: \(response)") return nil } @@ -376,6 +473,156 @@ struct ContactIdentifyResult: Decodable, Equatable { } } +struct ContactAssociateChannelResult: Decodable, Equatable { + public let channelType: ChannelType + public let channelID: String +} + +struct ContactDisassociateChannelResult: Decodable, Equatable { + public let channelID: String + + enum CodingKeys: String, CodingKey { + case channelID = "channel_id" + } +} + +enum DisassociateOptions: Sendable, Equatable, Codable, Hashable { + case channel(Channel) + case email(Email) + case sms(SMS) + + init(channelID: String, channelType: ChannelType, optOut: Bool) { + self = .channel(Channel(channelID: channelID, optOut: optOut, channelType: channelType.stringValue)) + } + + init(emailAddress: String, optOut: Bool) { + self = .email(Email(address: emailAddress, optOut: optOut)) + } + + init(msisdn: String, senderID: String, optOut: Bool) { + self = .sms(SMS(msisdn: msisdn, senderID: senderID, optOut: optOut)) + } + + struct Channel: Sendable, Equatable, Codable, Hashable { + let channelID: String + let optOut: Bool + let channelType: String + + enum CodingKeys: String, CodingKey { + case channelID = "channel_id" + case optOut = "opt_out" + case channelType = "channel_type" + } + } + + struct Email: Sendable, Equatable, Codable, Hashable { + let channelType: String = "email" + let address: String + let optOut: Bool + + enum CodingKeys: String, CodingKey { + case address = "email_address" + case optOut = "opt_out" + case channelType = "channel_type" + } + } + + struct SMS: Sendable, Equatable, Codable, Hashable { + let channelType: String = "sms" + let msisdn: String + let senderID: String + let optOut: Bool + + enum CodingKeys: String, CodingKey { + case msisdn = "msisdn" + case senderID = "sender" + case optOut = "opt_out" + case channelType = "channel_type" + } + } + + enum CodingKeys: String, CodingKey { + case channel + case email + case sms + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .channel(let channel): + try container.encode(channel) + case .email(let email): + try container.encode(email) + case .sms(let sms): + try container.encode(sms) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let channel = try? container.decode(Channel.self) { + self = .channel(channel) + } else if let email = try? container.decode(Email.self) { + self = .email(email) + } else if let sms = try? container.decode(SMS.self) { + self = .sms(sms) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid data for DisassociateOptions") + } + } +} + +enum ResendOptions: Sendable, Equatable, Codable, Hashable { + case channel(Channel) + case email(Email) + case sms(SMS) + + init(channelID: String, channelType: ChannelType) { + self = .channel(Channel(channelType: channelType.stringValue, channelID: channelID)) + } + + init(emailAddress: String) { + self = .email(Email(address: emailAddress)) + } + + init(msisdn: String, senderID: String) { + self = .sms(SMS(msisdn: msisdn, senderID: senderID)) + } + + struct Channel: Sendable, Equatable, Codable, Hashable { + let channelType: String + let channelID: String + + enum CodingKeys: String, CodingKey { + case channelType = "channel_type" + case channelID = "channel_id" + } + } + + struct Email: Sendable, Equatable, Codable, Hashable { + let channelType: String = "email" + let address: String + + enum CodingKeys: String, CodingKey { + case address = "email_address" + case channelType = "channel_type" + } + } + + struct SMS: Sendable, Equatable, Codable, Hashable { + let channelType: String = "sms" + let msisdn: String + let senderID: String + + enum CodingKeys: String, CodingKey { + case channelType = "channel_type" + case msisdn = "msisdn" + case senderID = "sender" + } + } +} + fileprivate struct ContactUpdateRequestBody: Encodable { let attributes: [AttributeOperation]? let tags: TagUpdates? @@ -507,7 +754,7 @@ fileprivate struct OpenChannelRegistrationBody: Encodable { locale: Locale, timezone: String ) { - + self.channel = ChannelPayload( address: address, timezone: timezone, @@ -551,6 +798,26 @@ fileprivate struct OpenChannelRegistrationBody: Encodable { } } +fileprivate struct EmailChannelUpdateBody: Encodable { + let channel: ChannelPartialPayload + + let optInMode: String = "double" + + enum CodingKeys: String, CodingKey { + case channel + case optInMode = "opt_in_mode" + } + + internal struct ChannelPartialPayload: Encodable { + let type: String + + enum CodingKeys: String, CodingKey { + case type + } + } + +} + fileprivate struct EmailChannelRegistrationBody: Encodable { let channel: ChannelPayload let properties: AirshipJSON? diff --git a/Airship/AirshipCore/Source/ContactChannel.swift b/Airship/AirshipCore/Source/ContactChannel.swift new file mode 100644 index 000000000..e982db049 --- /dev/null +++ b/Airship/AirshipCore/Source/ContactChannel.swift @@ -0,0 +1,173 @@ +/* Copyright Airship and Contributors */ + +import Foundation + + +/// Representation of a channel and its registration state after being associated to a contact +public enum ContactChannel: Sendable, Equatable, Codable, Hashable { + case sms(Sms) + case email(Email) + + /// Channel type + public var channelType: ChannelType { + switch (self) { + case .email(_): return .email + case .sms(_): return .sms + } + } + + /// Masked address + public var maskedAddress: String { + switch (self) { + case .email(let email): + switch(email) { + case .pending(let pending): return pending.address.maskEmail + case .registered(let registered): return registered.maskedAddress + } + case .sms(let sms): + switch(sms) { + case .pending(let pending): return pending.address.maskPhoneNumber + case .registered(let registered): return registered.maskedAddress + } + } + } + + /// Checks if its registered or not. + public var isRegistered: Bool { + switch (self) { + case .email(let email): + switch(email) { + case .pending(_): return false + case .registered(_): return false + } + case .sms(let sms): + switch(sms) { + case .pending(_): return false + case .registered(_): return false + } + } + } + + /// SMS channel info + public enum Sms: Sendable, Equatable, Codable, Hashable { + /// Registered channel + case registered(Registered) + + /// Pending registration + case pending(Pending) + + /// Registered info + public struct Registered: Sendable, Equatable, Codable, Hashable { + /// Channel ID + public let channelID: String + + /// Masked MSISDN address. + public let maskedAddress: String + + /// Opt-in status + public let isOptIn: Bool + + /// Identifier from which the SMS opt-in message is received + public let senderID: String + } + + /// Pending info + public struct Pending: Sendable, Equatable, Codable, Hashable { + /// The MSISDN. + public let address: String + + /// Registration options. + public let registrationOptions: SMSRegistrationOptions + } + } + + /// Email channel info + public enum Email: Sendable, Equatable, Codable, Hashable { + /// Registered channel + case registered(Registered) + + /// Pending registration + case pending(Pending) + + /// Registered info + public struct Registered: Sendable, Equatable, Codable, Hashable { + /// Channel ID + public let channelID: String + + /// Masked email address + public let maskedAddress: String + + /// Transactional opted-in value + public let transactionalOptedIn: Date? + + /// Transactional opted-out value + public let transactionalOptedOut: Date? + + /// Commercial opted-in value - used to determine the email opted-in state + public let commercialOptedIn: Date? + + /// Commercial opted-out value + public let commercialOptedOut: Date? + + init(channelID: String, + maskedAddress: String, + transactionalOptedIn: Date? = nil, + transactionalOptedOut: Date? = nil, + commercialOptedIn: Date? = nil, + commercialOptedOut: Date? = nil + ) { + self.channelID = channelID + self.maskedAddress = maskedAddress + self.transactionalOptedIn = transactionalOptedIn + self.transactionalOptedOut = transactionalOptedOut + self.commercialOptedIn = commercialOptedIn + self.commercialOptedOut = commercialOptedOut + } + + } + + /// Pending info + public struct Pending: Sendable, Equatable, Codable, Hashable { + /// The email address. + public let address: String + + /// Registration options. + public let registrationOptions: EmailRegistrationOptions + } + } +} + +/** + * An associative or dissociative update operation + */ +public enum ContactChannelUpdate: Sendable, Equatable, Hashable { + case disassociated(ContactChannel, channelID: String? = nil) + case associated(ContactChannel, channelID: String? = nil) + case associatedAnonChannel(channelType: ChannelType, channelID: String) +} + + + + +private extension String { + var maskEmail: String { + if !self.isEmpty { + let firstLetter = String(self.prefix(1)) + if let atIndex = self.firstIndex(of: "@") { + let suffix = self.suffix(self.count - self.distance(from: self.startIndex, to: atIndex) - 1) + return "\(firstLetter)*******\(suffix)" + } + } + + return self + } + + var maskPhoneNumber: String { + if !self.isEmpty && self.count > 4 { + return ("*******" + self.suffix(4)) + } + + return self + } +} + diff --git a/Airship/AirshipCore/Source/ContactChannelsAPIClient.swift b/Airship/AirshipCore/Source/ContactChannelsAPIClient.swift new file mode 100644 index 000000000..d41625b4f --- /dev/null +++ b/Airship/AirshipCore/Source/ContactChannelsAPIClient.swift @@ -0,0 +1,202 @@ +/* Copyright Airship and Contributors */ + +import Foundation + +// NOTE: For internal use only. :nodoc: +public protocol ContactChannelsAPIClientProtocol: Sendable { + func fetchAssociatedChannelsList( + contactID: String + ) async throws -> AirshipHTTPResponse<[ContactChannel]> +} + +// NOTE: For internal use only. :nodoc: +final class ContactChannelsAPIClient: ContactChannelsAPIClientProtocol { + private let config: RuntimeConfig + private let session: AirshipRequestSession + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateStr = try container.decode(String.self) + + guard let date = AirshipDateFormatter.date(fromISOString: dateStr) else { + throw AirshipErrors.error("Invalid date \(dateStr)") + } + return date + }) + return decoder + }() + + init(config: RuntimeConfig, session: AirshipRequestSession) { + self.config = config + self.session = session + } + + convenience init(config: RuntimeConfig) { + self.init(config: config, session: config.requestSession) + } + + func fetchAssociatedChannelsList( + contactID: String + ) async throws -> AirshipHTTPResponse<[ContactChannel]> { + AirshipLogger.debug("Retrieving associated channels list") + + let request = AirshipRequest( + url: try self.makeURL(path: "/api/contacts/associated_types/\(contactID)"), + headers: [ + "Accept": "application/vnd.urbanairship+json; version=3;", + "Content-Type": "application/json" + ], + method: "GET", + auth: .contactAuthToken(identifier: contactID) + ) + + return try await self.session.performHTTPRequest( + request + ) { (data, response) in + + AirshipLogger.debug("Fetching associated channels list finished with response: \(response)") + + guard response.statusCode == 200, let data = data else { + return nil + } + + + let result = try self.decoder.decode( + ContactChannelsResponseBody.self, + from: data + ) + + return result.channels.compactMap { channel in + channel.contactChannel + } + } + } + + private func makeURL(path: String) throws -> URL { + guard let deviceAPIURL = self.config.deviceAPIURL else { + throw AirshipErrors.error("Initial config not resolved.") + } + + let urlString = "\(deviceAPIURL)\(path)" + + guard let url = URL(string: "\(deviceAPIURL)\(path)") else { + throw AirshipErrors.error("Invalid ContactAPIClient URL: \(String(describing: urlString))") + } + + return url + } +} + +fileprivate struct ContactChannelsResponseBody: Decodable, Sendable { + let channels: [Channel] + + enum CodingKeys: String, CodingKey { + case channels = "channels" + } + + enum Channel: Decodable, Sendable { + case sms(SMSChannel) + case email(EmailChannel) + case unknown + + enum DeviceType: String, Decodable, Sendable { + case email + case sms + } + + enum CodingKeys: String, CodingKey { + case deviceType = "type" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let deviceType = try? container.decode(DeviceType.self, forKey: .deviceType) + let singleValueContainer = try decoder.singleValueContainer() + guard let deviceType else { + self = .unknown + return + } + + switch (deviceType) { + case .email: + self = .email( + try singleValueContainer.decode(EmailChannel.self) + ) + case .sms: + self = .sms( + try singleValueContainer.decode(SMSChannel.self) + ) + } + } + } + + struct SMSChannel: Decodable, Sendable { + var channelID: String + var sender: String + var isOptIn: Bool + var deIdentifiedAddress: String + + + enum CodingKeys: String, CodingKey { + case channelID = "channel_id" + case isOptIn = "opt_in" + case sender = "sender" + case deIdentifiedAddress = "msisdn" + } + } + + struct EmailChannel: Decodable, Sendable { + var channelID: String + var deIdentifiedAddress: String + var commericalOptedIn: Date? + var commericalOptedOut: Date? + + var transactionalOptedIn: Date? + var transactionalOptedOut: Date? + + + enum CodingKeys: String, CodingKey { + case channelID = "channel_id" + case deIdentifiedAddress = "email_address" + case commericalOptedIn = "commercial_opted_in" + case commericalOptedOut = "commercial_opted_out" + case transactionalOptedIn = "transactional_opted_in" + case transactionalOptedOut = "transactional_opted_out" + } + } +} + +fileprivate extension ContactChannelsResponseBody.Channel { + var contactChannel: ContactChannel? { + switch(self) { + case .email(let email): + return .email( + .registered( + ContactChannel.Email.Registered( + channelID: email.channelID, + maskedAddress: email.deIdentifiedAddress, + transactionalOptedIn: email.transactionalOptedIn, + transactionalOptedOut: email.transactionalOptedOut, + commercialOptedIn: email.commericalOptedIn, + commercialOptedOut: email.commericalOptedOut + ) + ) + ) + case .sms(let sms): + return .sms( + .registered( + ContactChannel.Sms.Registered( + channelID: sms.channelID, + maskedAddress: sms.deIdentifiedAddress, + isOptIn: sms.isOptIn, + senderID: sms.sender + ) + ) + ) + case .unknown: + return nil + } + } +} diff --git a/Airship/AirshipCore/Source/ContactChannelsProvider.swift b/Airship/AirshipCore/Source/ContactChannelsProvider.swift new file mode 100644 index 000000000..9a2c63efe --- /dev/null +++ b/Airship/AirshipCore/Source/ContactChannelsProvider.swift @@ -0,0 +1,425 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import Combine + +/** + * Contact channels provider protocol for receiving contact updates. + * @note For internal use only. :nodoc: + */ +protocol ContactChannelsProviderProtocol: AnyActor { + func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream +} + +/// Provides a stream of contact updates at a regular interval +final actor ContactChannelsProvider: ContactChannelsProviderProtocol { + private let audienceOverrides: AudienceOverridesProvider + private let apiClient: ContactChannelsAPIClientProtocol + private let maxChannelListCacheAge: TimeInterval + private let overridesApplier: OverridesApplier = OverridesApplier() + private let taskSleeper: AirshipTaskSleeper + private let privacyManager: AirshipPrivacyManager + private var resolvers: [String: Resolver] = [:] + private let date: AirshipDateProtocol + + init( + audienceOverrides: AudienceOverridesProvider, + apiClient: ContactChannelsAPIClientProtocol, + date: AirshipDateProtocol = AirshipDate.shared, + taskSleeper: AirshipTaskSleeper = .shared, + maxChannelListCacheAgeSeconds: TimeInterval = 600, + privacyManager: AirshipPrivacyManager + ) { + self.audienceOverrides = audienceOverrides + self.apiClient = apiClient + self.taskSleeper = taskSleeper + self.maxChannelListCacheAge = maxChannelListCacheAgeSeconds + self.privacyManager = privacyManager + self.date = date + } + + private func getResolver(contactID: String, lastContactID: String?) -> Resolver { + // The resolver for the lastContactID can always be dropped, but we + // can't assume the contactID is the current stable contact ID since + // its an async stream and we might not be on the last element. + + if let lastContactID { + resolvers[lastContactID] = nil + } + + if let resolver = resolvers[contactID] { + return resolver + } + + let resolver = Resolver( + contactID: contactID, + audienceOverrides: audienceOverrides, + apiClient: apiClient, + maxChannelListCacheAge: maxChannelListCacheAge, + taskSleeper: taskSleeper, + overridesApplier: overridesApplier, + privacyManager: privacyManager, + date: self.date + ) + + resolvers[contactID] = resolver + + return resolver + } + + /// Returns the latest contact channel result stream from the latest stable contact ID + nonisolated func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream { + return AsyncStream { continuation in + let fetchTask = Task { [weak self] in + var resolverTask: Task? + var lastContactID: String? = nil + for await contactID in stableContactIDUpdates { + resolverTask?.cancel() + guard !Task.isCancelled else { return } + + guard + let resolver = await self?.getResolver( + contactID: contactID, + lastContactID: lastContactID + ) + else { + return + } + + resolverTask = Task { + for await update in await resolver.contactUpdates() { + guard !Task.isCancelled else { return } + continuation.yield(update) + } + } + + lastContactID = contactID + } + } + + continuation.onTermination = { _ in + fetchTask.cancel() + } + } + } + + /// Manages the contact update API calls including backoff and override application + fileprivate actor Resolver { + private let contactID: String + private let audienceOverrides: AudienceOverridesProvider + private let apiClient: ContactChannelsAPIClientProtocol + private let cachedChannelsList: CachedValue<[ContactChannel]> + private let fetchQueue: AirshipSerialQueue = AirshipSerialQueue() + private let maxChannelListCacheAge: TimeInterval + private let taskSleeper: AirshipTaskSleeper + private let overridesApplier: OverridesApplier + private let privacyManager: AirshipPrivacyManager + + private static let initialBackoff: TimeInterval = 8.0 + private static let maxBackoff: TimeInterval = 64.0 + private var lastResults: [String: ContactChannelsResult] = [:] + + init( + contactID: String, + audienceOverrides: AudienceOverridesProvider, + apiClient: ContactChannelsAPIClientProtocol, + maxChannelListCacheAge: TimeInterval, + taskSleeper: AirshipTaskSleeper, + overridesApplier: OverridesApplier, + privacyManager: AirshipPrivacyManager, + date: AirshipDateProtocol + ) { + self.contactID = contactID + self.audienceOverrides = audienceOverrides + self.apiClient = apiClient + self.maxChannelListCacheAge = maxChannelListCacheAge + self.taskSleeper = taskSleeper + self.overridesApplier = overridesApplier + self.privacyManager = privacyManager + self.cachedChannelsList = CachedValue(date: date) + } + + func contactUpdates() -> AsyncStream { + let id = UUID().uuidString + + return AsyncStream { continuation in + let refreshTask = Task { + var backoff = Self.initialBackoff + + repeat { + let fetched = await self.fetch() + let workingResult: ContactChannelsResult = if fetched.isSuccess { + fetched + } else if let lastResult = lastResults[id], lastResult.isSuccess { + lastResult + } else { + fetched + } + + guard !Task.isCancelled else { return } + + let overrideUpdates = await self.audienceOverrides.contactOverrideUpdates( + contactID: contactID + ) + + let updateTask = Task { + for await overrides in overrideUpdates { + guard !Task.isCancelled else { + return + } + + let result = await overridesApplier.applyUpdates( + result: workingResult, + overrides: overrides + ) + + if (lastResults[id] != result) { + continuation.yield(result) + lastResults[id] = result + } + } + } + + if (fetched.isSuccess) { + try await self.taskSleeper.sleep( + timeInterval: cachedChannelsList.timeRemaining + ) + backoff = Self.initialBackoff + } else { + try await self.taskSleeper.sleep( + timeInterval: backoff + ) + if backoff < Self.maxBackoff { + backoff = backoff * 2 + } + } + + updateTask.cancel() + } while (!Task.isCancelled) + } + + continuation.onTermination = { _ in + refreshTask.cancel() + } + } + } + + private func fetch() async -> ContactChannelsResult { + guard privacyManager.isEnabled(.contacts) else { + return .error(.contactsDisabled) + } + + return await self.fetchQueue.runSafe { [cachedChannelsList, apiClient, contactID, maxChannelListCacheAge] in + if let cached = cachedChannelsList.value { + return .success(cached) + } + + do { + let response = try await apiClient.fetchAssociatedChannelsList( + contactID: contactID + ) + + guard response.isSuccess, let list = response.result else { + throw AirshipErrors.error("Failed to fetch associated channels list") + } + + cachedChannelsList.set( + value: list, + expiresIn: maxChannelListCacheAge + ) + return .success(list) + } catch { + AirshipLogger.warn( + "Received error when fetching contact channels \(error))" + ) + + return .error(.failedToFetchContacts) + } + } + } + } +} + +public enum ContactChannelErrors: Error, Equatable, Sendable, Hashable { + case contactsDisabled + case failedToFetchContacts +} + +public enum ContactChannelsResult: Equatable, Sendable, Hashable { + case success([ContactChannel]) + case error(ContactChannelErrors) + + public var channels: [ContactChannel] { + get throws { + switch(self) { + case .error(let error): throw error + case .success(let channels): return channels + } + } + } + + public var isSuccess: Bool { + switch(self) { + case .error(_): return false + case .success(_): return true + } + } +} + + +fileprivate actor OverridesApplier { + private var addressToChannelIDMap: [String: String] = [:] + + func applyUpdates(result: ContactChannelsResult, overrides: ContactAudienceOverrides) -> ContactChannelsResult { + guard + case .success(let channels) = result, + !overrides.channels.isEmpty + else { + return result + } + + var mutated = channels + + overrides.channels.forEach { update in + switch(update) { + case .associated(let channel, let channelID): + if let address = channel.canonicalAddress, let channelID { + self.addressToChannelIDMap[address] = channelID + } + case .disassociated(let channel, let channelID): + if let address = channel.canonicalAddress, let channelID { + self.addressToChannelIDMap[address] = channelID + } + case .associatedAnonChannel(_, _): + // no-op + break + } + } + + for update in overrides.channels { + switch(update) { + case .associated(let channel, _): + let found = mutated.contains( + where: { + isMatch( + channel: $0, + update: update + ) + } + ) + + if (!found) { + mutated.append(channel) + } + + + case .disassociated(_, _): + mutated.removeAll { + isMatch( + channel: $0, + update: update + ) + } + case .associatedAnonChannel(_, _): + // no-op + break + } + } + + return .success(mutated) + } + + private func isMatch( + channel: ContactChannel, + update: ContactChannelUpdate + ) -> Bool { + let canonicalAddress = channel.canonicalAddress + let resolvedChannelID = resolveChannelID( + channelID: channel.channelID, + canonicalAddress: canonicalAddress + ) + + let updateCanonicalAddress = update.canonicalAddress + let updateChannelID = resolveChannelID( + channelID: update.channelID, + canonicalAddress: updateCanonicalAddress + ) + + if let resolvedChannelID, resolvedChannelID == updateChannelID { + return true + } + + if let canonicalAddress, canonicalAddress == updateCanonicalAddress { + return true + } + + return false + } + + private func resolveChannelID( + channelID: String?, + canonicalAddress: String? + ) -> String? { + if let channelID { + return channelID + } + + if let canonicalAddress { + return addressToChannelIDMap[canonicalAddress] + } + + return nil + } +} + + +extension ContactChannelUpdate { + var canonicalAddress: String? { + switch (self) { + case .associated(let channel, _): return channel.canonicalAddress + case .disassociated(let channel, _): return channel.canonicalAddress + case .associatedAnonChannel(_, _): return nil + } + } + + var channelID: String? { + switch (self) { + case .associated(let channel, let channelID): return channelID ?? channel.channelID + case .disassociated(let channel, let channelID): return channelID ?? channel.channelID + case .associatedAnonChannel(_, let channelID): return channelID + } + } +} + +extension ContactChannel { + var channelID: String? { + switch (self) { + case .email(let email): + switch(email) { + case .pending(_): return nil + case .registered(let info): return info.channelID + } + case .sms(let sms): + switch(sms) { + case .pending(_): return nil + case .registered(let info): return info.channelID + } + } + } + + var canonicalAddress: String? { + switch (self) { + case .email(let email): + switch(email) { + case .pending(let info): return info.address + case .registered(_): return nil + } + case .sms(let sms): + switch(sms) { + case .pending(let info): return "(\(info.address):\(info.registrationOptions.senderID)" + case .registered(_): return nil + } + } + } +} + diff --git a/Airship/AirshipCore/Source/ContactConflictEvent.swift b/Airship/AirshipCore/Source/ContactConflictEvent.swift index 1168356c0..e84632e16 100644 --- a/Airship/AirshipCore/Source/ContactConflictEvent.swift +++ b/Airship/AirshipCore/Source/ContactConflictEvent.swift @@ -39,9 +39,21 @@ public final class ContactConflictEvent: NSObject, @unchecked Sendable { /** * Associated channels. + * @deprecated */ @objc - public let channels: [AssociatedChannel] + @available(*, deprecated, message: "Use associatedChannels instead") + public var channels: [AssociatedChannel] { + associatedChannels.map { info in + AssociatedChannel(channelType: info.channelType, channelID: info.channelID) + } + } + + /** + * Associated channels. + */ + @objc + public let associatedChannels: [ChannelInfo] /** * Default constructor. @@ -55,13 +67,13 @@ public final class ContactConflictEvent: NSObject, @unchecked Sendable { init( tags: [String: [String]], attributes: [String: AirshipJSON], - channels: [AssociatedChannel], + associatedChannels: [ChannelInfo], subscriptionLists: [String: [ChannelScope]], conflictingNamedUserID: String? ) { self.tags = tags - self.channels = channels + self.associatedChannels = associatedChannels self.subscriptionLists = subscriptionLists self.conflictingNamedUserID = conflictingNamedUserID self.attributes = attributes.compactMapValues { $0.unWrap() } @@ -76,7 +88,7 @@ public final class ContactConflictEvent: NSObject, @unchecked Sendable { self.attributes == other.attributes && self.subscriptionLists == other.subscriptionLists && self.conflictingNamedUserID == other.conflictingNamedUserID && - self.channels == other.channels + self.associatedChannels == other.associatedChannels } public override var hash: Int { @@ -85,9 +97,34 @@ public final class ContactConflictEvent: NSObject, @unchecked Sendable { result = 31 * result + attributes.hashValue result = 31 * result + subscriptionLists.hashValue result = 31 * result + conflictingNamedUserID.hashValue - result = 31 * result + channels.hashValue + result = 31 * result + associatedChannels.hashValue return result + } + @objc(UAContactConflictEventChannelInfo) + public final class ChannelInfo: NSObject, Codable, Sendable { + + /** + * Channel type + */ + @objc + public let channelType: ChannelType + + /** + * channel ID + */ + @objc + public let channelID: String + + + @objc + public init(channelType: ChannelType, channelID: String) { + self.channelType = channelType + self.channelID = channelID + super.init() + } } } + + diff --git a/Airship/AirshipCore/Source/ContactManager.swift b/Airship/AirshipCore/Source/ContactManager.swift index 085a6af14..46e9ebc6e 100644 --- a/Airship/AirshipCore/Source/ContactManager.swift +++ b/Airship/AirshipCore/Source/ContactManager.swift @@ -1,7 +1,6 @@ import Foundation actor ContactManager: ContactManagerProtocol { - private static let operationsKey = "Contact.operationEntries" private static let legacyOperationsKey = "Contact.operations" // operations without the date private static let contactInfoKey = "Contact.contactInfo" @@ -112,7 +111,7 @@ actor ContactManager: ContactManagerProtocol { ContactManager.updateTaskID, type: .serial ) { [weak self] _ in - if (try await self?.perfromNextOperation() != false) { + if (try await self?.performNextOperation() != false) { return .success } return .failure @@ -221,16 +220,16 @@ actor ContactManager: ContactManagerProtocol { return nil } - return ContactIDInfo( contactID: lastContactInfo.contactID, isStable: self.isContactIDStable(), + namedUserID: lastContactInfo.namedUserID, resolveDate: lastContactInfo.resolveDate ?? .distantPast ) } // Worker -> one at a time - private func perfromNextOperation() async throws -> Bool { + private func performNextOperation() async throws -> Bool { guard self.isEnabled else { AirshipLogger.trace("Contact manager is not enabled, unable to perform operation") return true @@ -248,7 +247,7 @@ actor ContactManager: ContactManagerProtocol { // Make sure we have a valid token so we know we are operating on // the correct contact ID to hopefully avoid any error logs if the // contact ID changes in the middle of an update - if tokenIfValid() == nil { + if tokenIfValid() == nil { let resolveResult = try await performOperation(.resolve) self.yieldContactUpdates() if (!resolveResult) { @@ -387,9 +386,16 @@ actor ContactManager: ContactManagerProtocol { case .associateChannel(let channelID, let type): return try await performAssociateChannelOperation( - channelID: channelID, + channelID: channelID, type: type ) + + case .disassociateChannel(let channel): + return try await performDisassociateChannel( + channel: channel + ) + case .resend(channel: let channel): + return try await performResend(channel: channel) } } @@ -443,46 +449,197 @@ actor ContactManager: ContactManagerProtocol { address: String, options: EmailRegistrationOptions ) async throws -> Bool { - let result = try await self.apiClient.registerEmail( - contactID: try requireContactID(), + let contactID = try requireContactID() + + let response = try await self.apiClient.registerEmail( + contactID: contactID, address: address, options: options, locale: self.localeManager.currentLocale ) - return result.isOperationComplete + if response.isSuccess, let result = response.result { + await self.contactUpdated( + contactID: contactID, + channelUpdates: [ + .associated( + .email( + .pending( + ContactChannel.Email.Pending( + address: address, + registrationOptions: options + ) + ) + ), + channelID: result.channelID + ) + ] + ) + } + + return response.isOperationComplete } - private func performRegisterSMSOperation(msisdn: String, options: SMSRegistrationOptions) async throws -> Bool { - let result = try await self.apiClient.registerSMS( - contactID: try requireContactID(), + private func performRegisterSMSOperation( + msisdn: String, + options: SMSRegistrationOptions + ) async throws -> Bool { + let contactID = try requireContactID() + + let response = try await self.apiClient.registerSMS( + contactID: contactID, msisdn: msisdn, options: options, locale: self.localeManager.currentLocale ) - return result.isOperationComplete + if response.isSuccess, let result = response.result { + await self.contactUpdated( + contactID: contactID, + channelUpdates: [ + .associated( + .sms( + .pending( + ContactChannel.Sms.Pending( + address: msisdn, + registrationOptions: options + ) + ) + ), + channelID: result.channelID + ) + ] + ) + } + return response.isOperationComplete } private func performRegisterOpenChannelOperation(address: String, options: OpenRegistrationOptions) async throws -> Bool { - let result = try await self.apiClient.registerOpen( - contactID: try requireContactID(), + let contactID = try requireContactID() + + let response = try await self.apiClient.registerOpen( + contactID: contactID, address: address, options: options, locale: self.localeManager.currentLocale ) - return result.isOperationComplete + if response.isSuccess, let result = response.result { + await self.contactUpdated( + contactID: contactID, + channelUpdates: [ + /// TODO: Backend does not support open channels for ContactChannel yet + .associatedAnonChannel( + channelType: .open, + channelID: result.channelID + ) + ] + ) + } + + return response.isOperationComplete } - private func performAssociateChannelOperation(channelID: String, type: ChannelType) async throws -> Bool { - let result = try await self.apiClient.associateChannel( - contactID: try requireContactID(), + private func performAssociateChannelOperation( + channelID: String, + type: ChannelType + ) async throws -> Bool { + let contactID = try requireContactID() + let response = try await self.apiClient.associateChannel( + contactID: contactID, channelID: channelID, channelType: type ) - return result.isOperationComplete + if response.isSuccess { + await self.contactUpdated( + contactID: contactID, + channelUpdates: [ + .associatedAnonChannel(channelType: type, channelID: channelID) + ] + ) + } + return response.isOperationComplete + } + + private func performDisassociateChannel( + channel: ContactChannel + ) async throws -> Bool { + let contactID = try requireContactID() + + + let options: DisassociateOptions = switch(channel) { + case .email(let email): + switch(email) { + case .registered(let info): + DisassociateOptions( + channelID: info.channelID, + channelType: .email, + optOut: false + ) + case .pending(let info): + DisassociateOptions( + emailAddress: info.address, + optOut: false + ) + } + case .sms(let sms): + switch(sms) { + case .registered(let info): + DisassociateOptions( + channelID: info.channelID, + channelType: .sms, + optOut: false + ) + case .pending(let info): + DisassociateOptions( + msisdn: info.address, + senderID: info.registrationOptions.senderID, + optOut: false + ) + } + } + + let response = try await self.apiClient.disassociateChannel( + contactID: contactID, + disassociateOptions: options + ) + + if response.isSuccess, let result = response.result { + await self.contactUpdated( + contactID: contactID, + channelUpdates: [.disassociated(channel, channelID: result.channelID)] + ) + } + + return response.isOperationComplete + } + + private func performResend( + channel: ContactChannel + ) async throws -> Bool { + let resendOptions:ResendOptions = switch(channel) { + case .email(let email): + switch(email) { + case .registered(let info): + ResendOptions(channelID: info.channelID, channelType: .email) + case .pending(let info): + ResendOptions(emailAddress: info.address) + } + case .sms(let sms): + switch(sms) { + case .registered(let info): + ResendOptions(channelID: info.channelID, channelType: channel.channelType) + case .pending(let info): + ResendOptions(msisdn: info.address, senderID: info.registrationOptions.senderID) + } + } + + let response = try await self.apiClient.resend( + resendOptions: resendOptions + ) + + return response.isOperationComplete } private func performUpdateOperation( @@ -524,6 +681,7 @@ actor ContactManager: ContactManagerProtocol { var attributes: [AttributeUpdate] = [] var subscriptionLists: [ScopedSubscriptionListUpdate] = [] let operations = operationEntries.map { $0.operation } + var channels: [ContactChannelUpdate] = [] var lastOperationNamedUser: String? = nil @@ -562,12 +720,62 @@ actor ContactManager: ContactManagerProtocol { continue } + + if case .registerSMS(let address, let options) = operation { + channels.append( + .associated( + .sms( + .pending( + ContactChannel.Sms.Pending( + address: address, + registrationOptions: options + ) + ) + ) + ) + ) + continue + } + + if case .registerEmail(let address, let options) = operation { + channels.append( + .associated( + .email( + .pending( + ContactChannel.Email.Pending( + address: address, + registrationOptions: options + ) + ) + ) + ) + ) + continue + } + + if case .disassociateChannel(let channel) = operation { + channels.append( + .disassociated(channel) + ) + continue + } + + if case .associateChannel(let channelID, let channelType) = operation { + channels.append( + .associatedAnonChannel( + channelType: channelType, + channelID: channelID + ) + ) + continue + } } return ContactAudienceOverrides( tags: tags, attributes: attributes, - subscriptionLists: subscriptionLists + subscriptionLists: subscriptionLists, + channels: channels ) } @@ -633,7 +841,6 @@ actor ContactManager: ContactManagerProtocol { operations: group, mergedOperation: mergedUpdate ) - case .identify(_): fallthrough case .reset: // A series of resets and identifies can be skipped and only the last reset or identify @@ -770,7 +977,7 @@ actor ContactManager: ContactManagerProtocol { ContactConflictEvent( tags: anonData.tags, attributes: anonData.attributes, - channels: anonData.channels, + associatedChannels: anonData.channels.map { .init(channelType: $0.channelType, channelID: $0.channelID) }, subscriptionLists: anonData.subscriptionLists, conflictingNamedUserID: namedUserID ) @@ -843,20 +1050,21 @@ actor ContactManager: ContactManagerProtocol { tagGroupUpdates: [TagGroupUpdate]? = nil, attributeUpdates: [AttributeUpdate]? = nil, subscriptionListsUpdates: [ScopedSubscriptionListUpdate]? = nil, - channel: AssociatedChannel? = nil + channelUpdates: [ContactChannelUpdate]? = nil ) async { guard let contactInfo = self.lastContactInfo, contactInfo.contactID == contactID else { return } - if tagGroupUpdates?.isEmpty == false || attributeUpdates?.isEmpty == false || subscriptionListsUpdates?.isEmpty == false { + if tagGroupUpdates?.isEmpty == false || attributeUpdates?.isEmpty == false || subscriptionListsUpdates?.isEmpty == false || channelUpdates?.isEmpty == false { await self.onAudienceUpdatedCallback?( ContactAudienceUpdate( contactID: contactID, tags: tagGroupUpdates, attributes: attributeUpdates, - subscriptionLists: subscriptionListsUpdates + subscriptionLists: subscriptionListsUpdates, + contactChannels: channelUpdates ) ) } @@ -865,7 +1073,7 @@ actor ContactManager: ContactManagerProtocol { let data = self.anonData var tags = data?.tags ?? [:] var attributes = data?.attributes ?? [:] - var channels = data?.channels ?? [] + var channels = Set(data?.channels ?? []) var subscriptionLists = data?.subscriptionLists ?? [:] tags = AudienceUtils.applyTagUpdates( @@ -883,14 +1091,26 @@ actor ContactManager: ContactManagerProtocol { updates: subscriptionListsUpdates ) - if let channel = channel { - channels.append(channel) + channelUpdates?.forEach { channelUpdate in + switch(channelUpdate) { + case .disassociated(let contactChannel, let channelID): + if let channelID = channelID ?? contactChannel.channelID { + channels.remove(.init(channelType: contactChannel.channelType, channelID: channelID)) + } + case .associated(let contactChannel, let channelID): + if let channelID = channelID ?? contactChannel.channelID { + channels.insert(.init(channelType: contactChannel.channelType, channelID: channelID)) + } + case .associatedAnonChannel(let channelType, let channelID): + channels.insert(.init(channelType: channelType, channelID: channelID)) + } } + self.anonData = AnonContactData( tags: tags, attributes: attributes, - channels: channels, + channels: Array(channels), subscriptionLists: subscriptionLists ) } diff --git a/Airship/AirshipCore/Source/ContactManagerProtocol.swift b/Airship/AirshipCore/Source/ContactManagerProtocol.swift index 556a3c0c6..f59cb6b13 100644 --- a/Airship/AirshipCore/Source/ContactManagerProtocol.swift +++ b/Airship/AirshipCore/Source/ContactManagerProtocol.swift @@ -3,8 +3,9 @@ import Foundation protocol ContactManagerProtocol: Actor, AuthTokenProvider { var contactUpdates: AsyncStream { get } - + func onAudienceUpdated(onAudienceUpdatedCallback: (@Sendable (ContactAudienceUpdate) async -> Void)?) + func addOperation(_ operation: ContactOperation) func generateDefaultContactIDIfNotSet() -> Void @@ -23,17 +24,28 @@ struct ContactAudienceUpdate: Equatable, Sendable { let tags: [TagGroupUpdate]? let attributes: [AttributeUpdate]? let subscriptionLists: [ScopedSubscriptionListUpdate]? + let contactChannels: [ContactChannelUpdate]? + + init(contactID: String, tags: [TagGroupUpdate]? = nil, attributes: [AttributeUpdate]? = nil, subscriptionLists: [ScopedSubscriptionListUpdate]? = nil, contactChannels: [ContactChannelUpdate]? = nil) { + self.contactID = contactID + self.tags = tags + self.attributes = attributes + self.subscriptionLists = subscriptionLists + self.contactChannels = contactChannels + } } struct ContactIDInfo: Equatable, Sendable { let contactID: String + let namedUserID: String? let isStable: Bool let resolveDate: Date - init(contactID: String, isStable: Bool, resolveDate: Date = Date.distantPast) { + init(contactID: String, isStable: Bool, namedUserID: String?, resolveDate: Date = Date.distantPast) { self.contactID = contactID self.isStable = isStable self.resolveDate = resolveDate + self.namedUserID = namedUserID } } @@ -43,3 +55,14 @@ enum ContactUpdate: Equatable, Sendable { case conflict(ContactConflictEvent) } +/// NOTE: For internal use only. :nodoc: +public struct StableContactInfo: Sendable, Equatable { + public let contactID: String + public let namedUserID: String? + + public init(contactID: String, namedUserID: String? = nil) { + self.contactID = contactID + self.namedUserID = namedUserID + } +} + diff --git a/Airship/AirshipCore/Source/ContactOperation.swift b/Airship/AirshipCore/Source/ContactOperation.swift index 09a54d491..e54024ec2 100644 --- a/Airship/AirshipCore/Source/ContactOperation.swift +++ b/Airship/AirshipCore/Source/ContactOperation.swift @@ -3,6 +3,7 @@ import Foundation + /// NOTE: For internal use only. :nodoc: enum ContactOperation: Codable, Equatable, Sendable { var type: OperationType { @@ -16,6 +17,8 @@ enum ContactOperation: Codable, Equatable, Sendable { case .registerSMS(_, _): return .registerSMS case .registerOpen(_, _): return .registerOpen case .associateChannel(_, _): return .associateChannel + case .disassociateChannel(_): return .disassociateChannel + case .resend(_): return .resend } } @@ -29,6 +32,8 @@ enum ContactOperation: Codable, Equatable, Sendable { case registerSMS case registerOpen case associateChannel + case disassociateChannel + case resend } case update( @@ -60,6 +65,13 @@ enum ContactOperation: Codable, Equatable, Sendable { channelType: ChannelType ) + case disassociateChannel( + channel: ContactChannel + ) + + case resend( + channel: ContactChannel + ) enum CodingKeys: String, CodingKey { case payload @@ -79,9 +91,11 @@ enum ContactOperation: Codable, Equatable, Sendable { case channelType case date case required + case channelOptions + case dissociateChannelInfo + case resendInfo } - func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -135,9 +149,17 @@ enum ContactOperation: Codable, Equatable, Sendable { try payloadContainer.encode(channelID, forKey: .channelID) try payloadContainer.encode(channelType, forKey: .channelType) try container.encode(OperationType.associateChannel, forKey: .type) - } + case .disassociateChannel(let info): + var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) + try payloadContainer.encode(info, forKey: .dissociateChannelInfo) + try container.encode(OperationType.disassociateChannel, forKey: .type) + case .resend(let info): + var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) + try payloadContainer.encode(info, forKey: .resendInfo) + try container.encode(OperationType.resend, forKey: .type) + } } init(from decoder: Decoder) throws { @@ -185,6 +207,7 @@ enum ContactOperation: Codable, Equatable, Sendable { forKey: .options ) ) + case .registerSMS: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .registerSMS( @@ -197,7 +220,7 @@ enum ContactOperation: Codable, Equatable, Sendable { forKey: .options ) ) - + case .registerOpen: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .registerOpen( @@ -223,7 +246,25 @@ enum ContactOperation: Codable, Equatable, Sendable { forKey: .channelType ) ) - + + case .disassociateChannel: + let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) + self = .disassociateChannel( + channel: try payloadContainer.decode( + ContactChannel.self, + forKey: .dissociateChannelInfo + ) + ) + + case .resend: + let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) + self = .resend ( + channel: try payloadContainer.decode( + ContactChannel.self, + forKey: .resendInfo + ) + ) + case .resolve: self = .resolve diff --git a/Airship/AirshipCore/Source/ContactProtocol.swift b/Airship/AirshipCore/Source/ContactProtocol.swift index 11c9c1414..dab6f3b80 100644 --- a/Airship/AirshipCore/Source/ContactProtocol.swift +++ b/Airship/AirshipCore/Source/ContactProtocol.swift @@ -130,7 +130,7 @@ public protocol AirshipContactProtocol: AirshipBaseContactProtocol { /// The named user ID current value publisher. var namedUserIDPublisher: AnyPublisher { get } - /// Conflict event publisher + /// Conflict event publisher. var conflictEventPublisher: AnyPublisher { get } /// Notifies any edits to the subscription lists. @@ -139,8 +139,41 @@ public protocol AirshipContactProtocol: AirshipBaseContactProtocol { /// Fetches subscription lists. /// - Returns: Subscriptions lists. func fetchSubscriptionLists() async throws -> [String: [ChannelScope]] + + /// SMS validator delegate to allow overriding the default SMS validation + /// - Returns: Bool indicating if SMS is valid. + var smsValidatorDelegate: SMSValidatorDelegate? { get set } + + /** + * Validates MSISDN + * - Parameters: + * - msisdn: The mobile phone number to validate. + * - sender: The identifier given to the sender of the SMS message. + */ + func validateSMS(_ msisdn: String, sender: String) async throws -> Bool + + /** + * Re-sends the double opt in prompt via the pending or registered channel. + * - Parameters: + * - channel: The pending or registered channel to resend the double opt-in prompt to. + */ + func resend(_ channel: ContactChannel) + + /** + * Opts out and disassociates channel + * - Parameters: + * - channel: The channel to opt-out and disassociate + */ + func disassociateChannel(_ channel: ContactChannel) + + /// Contact channel updates stream. + var contactChannelUpdates: AsyncStream { get } + + /// Contact channel updates publisher. + var contactChannelPublisher: AnyPublisher { get } } + protocol InternalAirshipContactProtocol: AirshipContactProtocol { var contactID: String? { get async } var authTokenProvider: AuthTokenProvider { get } @@ -149,5 +182,7 @@ protocol InternalAirshipContactProtocol: AirshipContactProtocol { var contactIDInfo: ContactIDInfo? { get async } var contactIDUpdates: AnyPublisher { get } -} + func getStableContactInfo() async -> StableContactInfo + +} diff --git a/Airship/AirshipCore/Source/ContactSubscriptionListClient.swift b/Airship/AirshipCore/Source/ContactSubscriptionListClient.swift index c30059b34..2a1a94098 100644 --- a/Airship/AirshipCore/Source/ContactSubscriptionListClient.swift +++ b/Airship/AirshipCore/Source/ContactSubscriptionListClient.swift @@ -39,7 +39,9 @@ final class ContactSubscriptionListAPIClient: ContactSubscriptionListAPIClientPr ) return try await session.performHTTPRequest(request) { data, response in + AirshipLogger.debug("Fetch subscription lists finished with response: \(response)") + guard response.statusCode == 200, let data = data else { return nil } diff --git a/Airship/AirshipCore/Source/DefaultLogHandler.swift b/Airship/AirshipCore/Source/DefaultLogHandler.swift index 6d197edfd..4a4f5c5ef 100644 --- a/Airship/AirshipCore/Source/DefaultLogHandler.swift +++ b/Airship/AirshipCore/Source/DefaultLogHandler.swift @@ -13,7 +13,6 @@ class DefaultLogHandler: AirshipLogHandler { func log( logLevel: AirshipLogLevel, - logPrivacyLevel: AirshipLogPrivacyLevel, message: String, fileID: String, line: UInt, @@ -22,7 +21,7 @@ class DefaultLogHandler: AirshipLogHandler { let logMessage = "[\(logLevel.initial)] \(fileID) \(function) [Line \(line)] \(message)" if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) { - switch logPrivacyLevel { + switch Airship.logPrivacyLevel { case .private: DefaultLogHandler.logger.log( level: logLevel.logType, diff --git a/Airship/AirshipCore/Source/DeferredAPIClient.swift b/Airship/AirshipCore/Source/DeferredAPIClient.swift index 1900baa3e..ef6fe7015 100644 --- a/Airship/AirshipCore/Source/DeferredAPIClient.swift +++ b/Airship/AirshipCore/Source/DeferredAPIClient.swift @@ -55,7 +55,8 @@ final class DeferredAPIClient: DeferredAPIClientProtocol { AirshipLogger.trace("Resolving deferred with request \(request) body \(body)") return try await session.performHTTPRequest(request) { data, response in - AirshipLogger.trace("Resolving deferred respoinse \(response)") + + AirshipLogger.debug("Resolving deferred response finished with response: \(response)") if (response.statusCode == 200) { return data diff --git a/Airship/AirshipCore/Source/DeviceAudienceSelector.swift b/Airship/AirshipCore/Source/DeviceAudienceSelector.swift index 27d481900..cd024011a 100644 --- a/Airship/AirshipCore/Source/DeviceAudienceSelector.swift +++ b/Airship/AirshipCore/Source/DeviceAudienceSelector.swift @@ -105,7 +105,7 @@ public extension DeviceAudienceSelector { return false } - guard checkTestDevices(deviceInfoProvider: deviceInfoProvider) else { + guard try await checkTestDevices(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Test device condition not met for audience: \(self)") return false } @@ -130,7 +130,7 @@ public extension DeviceAudienceSelector { return false } - guard await checkHash(deviceInfoProvider: deviceInfoProvider) else { + guard try await checkHash(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Hash condition not met for audience: \(self)") return false } @@ -216,16 +216,18 @@ public extension DeviceAudienceSelector { } - private func checkTestDevices(deviceInfoProvider: AudienceDeviceInfoProvider) -> Bool { + private func checkTestDevices(deviceInfoProvider: AudienceDeviceInfoProvider) async throws -> Bool { guard let testDevices = self.testDevices else { return true } - guard let channel = deviceInfoProvider.channelID else { + + guard deviceInfoProvider.isChannelCreated else { return false } - let digest = AirshipUtils.sha256Digest(input: channel).subdata(with: NSMakeRange(0, 16)) + let channelID = try await deviceInfoProvider.channelID + let digest = AirshipUtils.sha256Digest(input: channelID).subdata(with: NSMakeRange(0, 16)) return testDevices.contains { testDevice in AirshipBase64.data(from: testDevice) == digest } @@ -252,15 +254,14 @@ public extension DeviceAudienceSelector { } } - private func checkHash(deviceInfoProvider: AudienceDeviceInfoProvider) async -> Bool { + private func checkHash(deviceInfoProvider: AudienceDeviceInfoProvider) async throws -> Bool { guard let hash = self.hashSelector else { return true } - let contactID = await deviceInfoProvider.stableContactID - guard let channelID = deviceInfoProvider.channelID else { - return false - } + let contactID = await deviceInfoProvider.stableContactInfo.contactID + let channelID = try await deviceInfoProvider.channelID + return hash.evaluate(channelID: channelID, contactID: contactID) } diff --git a/Airship/AirshipCore/Source/EmailRegistrationOptions.swift b/Airship/AirshipCore/Source/EmailRegistrationOptions.swift index 8724546cb..850465100 100644 --- a/Airship/AirshipCore/Source/EmailRegistrationOptions.swift +++ b/Airship/AirshipCore/Source/EmailRegistrationOptions.swift @@ -12,7 +12,7 @@ public final class EmailRegistrationOptions: NSObject, Codable, Sendable { let transactionalOptedIn: Date? /** - * Commercial opted-in value + * Commercial opted-in value - used to determine the email opt-in state during double opt-in */ let commercialOptedIn: Date? diff --git a/Airship/AirshipCore/Source/EnvironmentValues.swift b/Airship/AirshipCore/Source/EnvironmentValues.swift index 00440d9ba..700346fbb 100644 --- a/Airship/AirshipCore/Source/EnvironmentValues.swift +++ b/Airship/AirshipCore/Source/EnvironmentValues.swift @@ -15,6 +15,11 @@ private struct VisibleEnvironmentKey: EnvironmentKey { static let defaultValue: Bool = false } +private struct PagerPageIndexKey: EnvironmentKey { + static let defaultValue: Int = -1 +} + + private struct LayoutStateEnvironmentKey: EnvironmentKey { static let defaultValue: LayoutState = LayoutState.empty } @@ -35,6 +40,11 @@ public extension EnvironmentValues { set { self[VisibleEnvironmentKey.self] = newValue } } + var pageIndex: Int { + get { self[PagerPageIndexKey.self] } + set { self[PagerPageIndexKey.self] = newValue } + } + internal var layoutState: LayoutState { get { self[LayoutStateEnvironmentKey.self] } set { self[LayoutStateEnvironmentKey.self] = newValue } diff --git a/Airship/AirshipCore/Source/EventAPIClient.swift b/Airship/AirshipCore/Source/EventAPIClient.swift index 08660ecdb..cf57401e9 100644 --- a/Airship/AirshipCore/Source/EventAPIClient.swift +++ b/Airship/AirshipCore/Source/EventAPIClient.swift @@ -55,6 +55,9 @@ final class EventAPIClient: EventAPIClientProtocol { // Perform the upload return try await self.session.performHTTPRequest(request) { _, response in + + AirshipLogger.debug("Upload event finished with response: \(response)") + return EventUploadTuningInfo( maxTotalStoreSizeKB: response.unsignedInt( forHeader: "X-UA-Max-Total" @@ -77,7 +80,7 @@ final class EventAPIClient: EventAPIClientProtocol { format: "%f", eventData.date.timeIntervalSince1970 ) - eventBody["type"] = eventData.type + eventBody["type"] = eventData.type.reportingName guard diff --git a/Airship/AirshipCore/Source/EventStore.swift b/Airship/AirshipCore/Source/EventStore.swift index dcfd28cf2..93f108b67 100644 --- a/Airship/AirshipCore/Source/EventStore.swift +++ b/Airship/AirshipCore/Source/EventStore.swift @@ -222,7 +222,7 @@ actor EventStore { into: context ) as? EventData { eventData.sessionID = event.sessionID - eventData.type = event.type + eventData.type = event.type.reportingName eventData.identifier = event.id eventData.data = try event.body.toData() eventData.storeDate = event.date @@ -258,6 +258,7 @@ actor EventStore { guard let sessionID = internalEventData.sessionID, let id = internalEventData.identifier, let type = internalEventData.type, + let convertedType = EventType.allCases.first(where: { $0.reportingName == type }), let date = date(internalEventData: internalEventData) else { throw AirshipErrors.error("Invalid event data") @@ -268,7 +269,7 @@ actor EventStore { id: id, date: date, sessionID: sessionID, - type: type + type: convertedType ) } } diff --git a/Airship/AirshipCore/Source/ExperimentManager.swift b/Airship/AirshipCore/Source/ExperimentManager.swift index 0c6b9af1b..e3ee73011 100644 --- a/Airship/AirshipCore/Source/ExperimentManager.swift +++ b/Airship/AirshipCore/Source/ExperimentManager.swift @@ -23,18 +23,17 @@ final class ExperimentManager: ExperimentDataProvider { self.date = date } - public func evaluateExperiments(info: MessageInfo, deviceInfoProvider: AudienceDeviceInfoProvider) async throws -> ExperimentResult? { + public func evaluateExperiments( + info: MessageInfo, + deviceInfoProvider: AudienceDeviceInfoProvider + ) async throws -> ExperimentResult? { let experiments = await getExperiments(info: info) guard !experiments.isEmpty else { return nil } - let contactID = await deviceInfoProvider.stableContactID - guard let channelID = deviceInfoProvider.channelID else { - // Since we pull this after a stable contact ID this should never happen. Ideally we have - // a way to wait for it like we do the contact ID. - throw AirshipErrors.error("Channel ID missing, unable to evaluate hold out groups.") - } + let contactID = await deviceInfoProvider.stableContactInfo.contactID + let channelID = try await deviceInfoProvider.channelID var evaluatedMetadata: [AirshipJSON] = [] var isMatch: Bool = false diff --git a/Airship/AirshipCore/Source/Media.swift b/Airship/AirshipCore/Source/Media.swift index 3dc133ee2..3ca2fcff8 100644 --- a/Airship/AirshipCore/Source/Media.swift +++ b/Airship/AirshipCore/Source/Media.swift @@ -10,8 +10,12 @@ struct Media: View { let model: MediaModel let constraints: ViewConstraints - + @State + private var mediaID: UUID = UUID() private let defaultAspectRatio = 16.0 / 9.0 + @EnvironmentObject var pagerState: PagerState + @Environment(\.pageIndex) var pageIndex + private var contentMode: ContentMode { var contentMode = ContentMode.fill @@ -67,7 +71,12 @@ struct Media: View { type: model.mediaType, accessibilityLabel: model.contentDescription, video: model.video - ) + ) { + pagerState.setMediaReady(pageIndex: pageIndex, id: mediaID, isReady: true) + } + .onAppear { + pagerState.registerMedia(pageIndex: pageIndex, id: mediaID) + } .applyIf(self.constraints.width != nil || self.constraints.height != nil) { $0.aspectRatio(CGFloat(model.video?.aspectRatio ?? defaultAspectRatio), contentMode: self.contentMode) } diff --git a/Airship/AirshipCore/Source/MediaWebView.swift b/Airship/AirshipCore/Source/MediaWebView.swift index d69bcc503..535978327 100644 --- a/Airship/AirshipCore/Source/MediaWebView.swift +++ b/Airship/AirshipCore/Source/MediaWebView.swift @@ -15,8 +15,11 @@ struct MediaWebView: UIViewRepresentable { let type: MediaType let accessibilityLabel: String? let video: Video? + let onMediaReady: @MainActor () -> Void @Environment(\.isVisible) var isVisible @State private var isLoaded: Bool = false + @State private var isMediaReady: Bool = false + @EnvironmentObject var pagerState: PagerState @MainActor func makeUIView(context: Context) -> WKWebView { @@ -25,20 +28,28 @@ struct MediaWebView: UIViewRepresentable { @MainActor func updateUIView(_ uiView: WKWebView, context: Context) { - switch (isVisible, isLoaded) { - case (true, true): - handleAutoplayingVideos(uiView: uiView) - case (false, true): - resetMedias(uiView: uiView) - pauseMedias(uiView: uiView) - default: + if (pagerState.inProgress) { + switch (isVisible, isLoaded) { + case (true, true): + handleAutoplayingVideos(uiView: uiView) + case (false, true): + resetMedias(uiView: uiView) + pauseMedias(uiView: uiView) + default: + pauseMedias(uiView: uiView) + } + } else { pauseMedias(uiView: uiView) } } @MainActor func createWebView(context: Context) -> WKWebView { + let contentController = WKUserContentController() + contentController.add(makeCoordinator(), name: "callback") + let config = WKWebViewConfiguration() + config.userContentController = contentController config.allowsInlineMediaPlayback = true config.allowsPictureInPictureMediaPlayback = true @@ -62,6 +73,10 @@ struct MediaWebView: UIViewRepresentable { """, @@ -104,12 +119,15 @@ struct MediaWebView: UIViewRepresentable { 'autoplay': %@, 'mute': %@, 'loop': %@ + }, + events: { + 'onReady': onPlayerReady } }); } function onPlayerReady(event) { - event.target.playVideo(); + webkit.messageHandlers.callback.postMessage('mediaReady'); } @@ -171,10 +189,12 @@ struct MediaWebView: UIViewRepresentable { @MainActor func resetMedias(uiView: WKWebView) { - if type == .video { - uiView.evaluateJavaScript("videoElement.currentTime = 0;") - } else if type == .youtube { - uiView.evaluateJavaScript("player.seekTo(0);") + if video?.autoplay ?? false { + if type == .video { + uiView.evaluateJavaScript("videoElement.currentTime = 0;") + } else if type == .youtube { + uiView.evaluateJavaScript("player.seekTo(0);") + } } } @@ -189,21 +209,34 @@ struct MediaWebView: UIViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(self, isLoaded: $isLoaded) + Coordinator(self, isLoaded: $isLoaded, onMediaReady: onMediaReady) } - class Coordinator: NSObject, WKNavigationDelegate { + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { var parent: MediaWebView var isLoaded: Binding + var onMediaReady: @MainActor () -> Void - init(_ parent: MediaWebView, isLoaded: Binding) { + init(_ parent: MediaWebView, isLoaded: Binding, onMediaReady: @escaping @MainActor () -> Void) { self.parent = parent self.isLoaded = isLoaded + self.onMediaReady = onMediaReady } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { isLoaded.wrappedValue = true } + + @MainActor + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let response = message.body as? String else { + return + } + + if (response == "mediaReady") { + onMediaReady() + } + } } } diff --git a/Airship/AirshipCore/Source/MeteredUsageAPIClient.swift b/Airship/AirshipCore/Source/MeteredUsageAPIClient.swift index 1fb8cba7c..93f516dc0 100644 --- a/Airship/AirshipCore/Source/MeteredUsageAPIClient.swift +++ b/Airship/AirshipCore/Source/MeteredUsageAPIClient.swift @@ -71,7 +71,7 @@ final class MeteredUsageAPIClient : MeteredUsageAPIClientProtocol { // Perform the upload let result = try await self.session.performHTTPRequest(request) - AirshipLogger.trace("Usage result: \(result)") + AirshipLogger.debug("Usage result: \(result)") return result } diff --git a/Airship/AirshipCore/Source/Pager.swift b/Airship/AirshipCore/Source/Pager.swift index df07f5473..9c4226a2d 100644 --- a/Airship/AirshipCore/Source/Pager.swift +++ b/Airship/AirshipCore/Source/Pager.swift @@ -98,6 +98,7 @@ struct Pager: View { \.isVisible, self.isVisible && i == index.wrappedValue ) + .environment(\.pageIndex, i) .accessibilityHidden(!(self.isVisible && i == index.wrappedValue)) } .frame( @@ -135,6 +136,7 @@ struct Pager: View { makePager(index: index) .onReceive(pagerState.$pageIndex) { value in + pagerState.pages = self.model.items.map { PageState( identifier: $0.identifier, @@ -459,8 +461,8 @@ struct Pager: View { pageIndex: Int ) { - self.pagerState.resetProgress() - + self.pagerState.preparePageChange() + guard pageIndex >= 0 else { return } guard pageIndex != index.wrappedValue || self.pagerState.pages.count == 1 else { return } guard pageIndex < self.pagerState.pages.count else { return } diff --git a/Airship/AirshipCore/Source/PagerState.swift b/Airship/AirshipCore/Source/PagerState.swift index 4e5b4d458..9a29c528d 100644 --- a/Airship/AirshipCore/Source/PagerState.swift +++ b/Airship/AirshipCore/Source/PagerState.swift @@ -31,13 +31,22 @@ struct PageState: Sendable { @MainActor class PagerState: ObservableObject { - @Published var pageIndex: Int = 0 + @Published var pageIndex: Int = 0 { + didSet { + updateInProgress() + } + } @Published var pages: [PageState] = [] @Published var progress: Double = 0.0 @Published var completed: Bool = false /// Used to pause/resume a story - var inProgress: Bool = true + @Published var inProgress: Bool = true + + private var isManuallyPaused = false + + private var mediaReadyState: [MediaKey: Bool] = [:] + var currentPage: PageState { get { pages[pageIndex] } set { pages[pageIndex] = newValue } @@ -54,18 +63,50 @@ class PagerState: ObservableObject { } func pause() { - self.inProgress = false + self.isManuallyPaused = true + updateInProgress() } func resume() { - self.inProgress = true + self.isManuallyPaused = false + updateInProgress() } - func resetProgress() { + func preparePageChange() { self.progress = 0.0 } - + + func registerMedia(pageIndex: Int, id: UUID) { + let key = MediaKey(pageIndex: pageIndex, id: id) + guard mediaReadyState[key] == nil else { return } + mediaReadyState[key] = false + } + + func setMediaReady(pageIndex: Int, id: UUID, isReady: Bool) { + let key = MediaKey(pageIndex: pageIndex, id: id) + mediaReadyState[key] = true + updateInProgress() + } + func markAutomatedActionExecuted(_ identifier: String) { self.currentPage.markAutomatedActionExecuted(identifier) } + + private func updateInProgress() { + let isMediaReady = !mediaReadyState.contains(where: { key, isReady in + key.pageIndex == pageIndex && isReady == false + }) + + let update = isMediaReady && !isManuallyPaused + if self.inProgress != update { + self.inProgress = update + } + } + + struct MediaKey: Hashable, Equatable { + let pageIndex: Int + let id: UUID + } } + + diff --git a/Airship/AirshipCore/Source/RemoteConfig.swift b/Airship/AirshipCore/Source/RemoteConfig.swift index 4c70b34b4..614740a68 100644 --- a/Airship/AirshipCore/Source/RemoteConfig.swift +++ b/Airship/AirshipCore/Source/RemoteConfig.swift @@ -1,13 +1,14 @@ /* Copyright Airship and Contributors */ /// NOTE: For internal use only. :nodoc: -struct RemoteConfig: Codable, Equatable, Sendable { +public struct RemoteConfig: Codable, Equatable, Sendable { let airshipConfig: AirshipConfig? let meteredUsageConfig: MeteredUsageConfig? let fetchContactRemoteData: Bool? let contactConfig: ContactConfig? let disabledFeatures: AirshipFeature? + public let iaaConfig: IAAConfig? var remoteDataRefreshInterval: TimeInterval? { return remoteDataRefreshIntervalMilliseconds?.timeInterval @@ -21,7 +22,8 @@ struct RemoteConfig: Codable, Equatable, Sendable { fetchContactRemoteData: Bool? = nil, contactConfig: ContactConfig? = nil, disabledFeatures: AirshipFeature? = nil, - remoteDataRefreshIntervalMilliseconds: Int64? = nil + remoteDataRefreshIntervalMilliseconds: Int64? = nil, + iaaConfig: IAAConfig? = nil ) { self.airshipConfig = airshipConfig self.meteredUsageConfig = meteredUsageConfig @@ -29,6 +31,7 @@ struct RemoteConfig: Codable, Equatable, Sendable { self.contactConfig = contactConfig self.disabledFeatures = disabledFeatures self.remoteDataRefreshIntervalMilliseconds = remoteDataRefreshIntervalMilliseconds + self.iaaConfig = iaaConfig } enum CodingKeys: String, CodingKey { @@ -38,6 +41,7 @@ struct RemoteConfig: Codable, Equatable, Sendable { case contactConfig = "contact_config" case disabledFeatures = "disabled_features" case remoteDataRefreshIntervalMilliseconds = "remote_data_refresh_interval" + case iaaConfig = "in_app_config" } struct ContactConfig: Codable, Equatable, Sendable { @@ -91,6 +95,55 @@ struct RemoteConfig: Codable, Equatable, Sendable { case meteredUsageURL = "metered_usage_url" } } + + public struct IAAConfig: Codable, Equatable, Sendable { + public let retryingQueue: RetryingQueueConfig? + public let additionalAudienceConfig: AdditionalAudienceCheckConfig? + + enum CodingKeys: String, CodingKey { + case retryingQueue = "queue" + case additionalAudienceConfig = "additional_audience_check" + } + } + + public struct RetryingQueueConfig: Codable, Equatable, Sendable { + public let maxConcurrentOperations: UInt? + public let maxPendingResults: UInt? + public let initialBackoff: TimeInterval? + public let maxBackOff: TimeInterval? + + enum CodingKeys: String, CodingKey { + case maxConcurrentOperations = "max_concurrent_operations" + case maxPendingResults = "max_pending_results" + case initialBackoff = "initial_back_off_seconds" + case maxBackOff = "max_back_off_seconds" + } + } + + public struct AdditionalAudienceCheckConfig: Codable, Equatable, Sendable { + public let isEnabled: Bool + public let context: AirshipJSON? + public let url: String? + + enum CodingKeys: String, CodingKey { + case isEnabled = "enabled" + case context + case url + } + + public init(isEnabled: Bool, context: AirshipJSON?, url: String?) { + self.isEnabled = isEnabled + self.context = context + self.url = url + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false + context = try container.decodeIfPresent(AirshipJSON.self, forKey: .context) + url = try container.decodeIfPresent(String.self, forKey: .url) + } + } } fileprivate extension Int64 { diff --git a/Airship/AirshipCore/Source/RemoteDataAPIClient.swift b/Airship/AirshipCore/Source/RemoteDataAPIClient.swift index 93fed213d..574e84037 100644 --- a/Airship/AirshipCore/Source/RemoteDataAPIClient.swift +++ b/Airship/AirshipCore/Source/RemoteDataAPIClient.swift @@ -61,11 +61,13 @@ final class RemoteDataAPIClient: RemoteDataAPIClientProtocol { AirshipLogger.debug("Request to update remote data: \(request)") return try await self.session.performHTTPRequest(request) { data, response in + + AirshipLogger.debug("Fetching remote data finished with response: \(response)") + guard response.statusCode == 200, let data = data else { return nil } - let remoteDataResponse = try self.decoder.decode(RemoteDataResponse.self, from: data) let remoteDataInfo = try remoteDataInfoBlock(response.value(forHTTPHeaderField: "Last-Modified")) diff --git a/Airship/AirshipCore/Source/RetailEventTemplate.swift b/Airship/AirshipCore/Source/RetailEventTemplate.swift index 4945bba94..252e0b87f 100644 --- a/Airship/AirshipCore/Source/RetailEventTemplate.swift +++ b/Airship/AirshipCore/Source/RetailEventTemplate.swift @@ -40,7 +40,7 @@ public class RetailEventTemplate: NSObject { public var eventDescription: String? /** - * The brand.. + * The brand. */ @objc public var brand: String? @@ -58,6 +58,12 @@ public class RetailEventTemplate: NSObject { } } + /** + * The currency. + */ + @objc + public var currency: String? + private var _isNewItem: Bool? private let eventName: String private let source: String? @@ -382,6 +388,7 @@ public class RetailEventTemplate: NSObject { propertyDictionary["wishlist_name"] = self.wishlistName propertyDictionary["wishlist_id"] = self.wishlistID propertyDictionary["description"] = self.eventDescription + propertyDictionary["currency"] = self.currency let event = CustomEvent(name: self.eventName) event.templateType = "retail" diff --git a/Airship/AirshipCore/Source/RuntimeConfig.swift b/Airship/AirshipCore/Source/RuntimeConfig.swift index e973043a4..a3282f305 100644 --- a/Airship/AirshipCore/Source/RuntimeConfig.swift +++ b/Airship/AirshipCore/Source/RuntimeConfig.swift @@ -183,7 +183,7 @@ public final class RuntimeConfig: NSObject, @unchecked Sendable { private let notificationCenter: NotificationCenter /// NOTE: For internal use only. :nodoc: - var remoteConfig: RemoteConfig { + public var remoteConfig: RemoteConfig { return self.remoteConfigCache.remoteConfig } diff --git a/Airship/AirshipCore/Source/SMSValidator.swift b/Airship/AirshipCore/Source/SMSValidator.swift new file mode 100644 index 000000000..def1931cc --- /dev/null +++ b/Airship/AirshipCore/Source/SMSValidator.swift @@ -0,0 +1,206 @@ +/* Copyright Airship and Contributors */ + +import Foundation + +protocol SMSValidatorProtocol: Sendable { + var delegate: SMSValidatorDelegate? { get set } + + func validateSMS(msisdn: String, sender: String) async throws -> Bool +} + +/// Delegate for overriding the default SMS validation +public protocol SMSValidatorDelegate { + + /** + * Validates a given MSISDN. + * - Parameters: + * - msisdn: The msisdn to validate. + * - sender: The identifier given to the sender of the SMS message. + * - Returns: `true` if the phone number is valid, otherwise `false`. + */ + @MainActor + func validateSMS(msisdn: String, sender: String) async throws -> Bool +} + +struct SMSValidationResult: Decodable { + let ok: Bool + let valid: Bool +} + +/// NOTE: For internal use only. :nodoc: +protocol SMSValidatorAPIClientProtocol: Sendable { + func validateSMS( + msisdn: String, + sender: String + ) async throws -> AirshipHTTPResponse +} + +class SMSValidator: SMSValidatorProtocol, @unchecked Sendable { + let apiClient: SMSValidatorAPIClientProtocol + var delegate: SMSValidatorDelegate? + + /// Stores up to 10 most recent SMS validation results. + private var resultsCache: [String] = [] + private var resultsLookup: [String: Bool] = [:] + + init(apiClient: SMSValidatorAPIClientProtocol, delegate: SMSValidatorDelegate? = nil) { + self.apiClient = apiClient + Task { @MainActor in + self.delegate = delegate + } + } + + private func cacheResult(key: String, result: Bool) { + if resultsCache.count >= 10 { + let oldestKey = resultsCache.removeFirst() + resultsLookup.removeValue(forKey: oldestKey) + } + resultsCache.append(key) + resultsLookup[key] = result + } + + private func hasCachedResult(key: String) -> Bool? { + return resultsLookup[key] + } + + @MainActor + func validateSMS(msisdn: String, sender: String) async throws -> Bool { + let compoundKey = sender + msisdn + + if let cachedResult = hasCachedResult(key: compoundKey) { + return cachedResult + } + + if let delegate = delegate { + let isValid = try await delegate.validateSMS(msisdn: msisdn, sender: sender) + cacheResult(key: compoundKey, result: isValid) + return isValid + } + + let response = try await apiClient.validateSMS(msisdn: msisdn, sender: sender) + guard let isValid = response.result else { + throw AirshipErrors.error("Response result from SMS validation API should not be nil.") + } + + cacheResult(key: compoundKey, result: isValid) + return isValid + } +} + +/// NOTE: For internal use only. :nodoc: +final class SMSValidatorAPIClient: SMSValidatorAPIClientProtocol { + private let config: RuntimeConfig + private let session: AirshipRequestSession + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in + let container = try decoder.singleValueContainer() + let dateStr = try container.decode(String.self) + + guard let date = AirshipDateFormatter.date(fromISOString: dateStr) else { + throw AirshipErrors.error("Invalid date \(dateStr)") + } + return date + }) + return decoder + }() + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .custom({ date, encoder in + var container = encoder.singleValueContainer() + try container.encode( + AirshipDateFormatter.string(fromDate: date, format: .isoDelimitter) + ) + }) + return encoder + }() + + init(config: RuntimeConfig, session: AirshipRequestSession) { + self.config = config + self.session = session + } + + convenience init(config: RuntimeConfig) { + self.init(config: config, session: config.requestSession) + } + + func validateSMS( + msisdn: String, + sender: String + ) async throws -> AirshipHTTPResponse { + return try await performSMSValidation( + requestBody: SMSValidationBody( + sender: sender, + msisdn: msisdn + ), + channelType: ChannelType.sms + ) + } + + fileprivate func performSMSValidation( + requestBody: T, + channelType: ChannelType + ) async throws -> AirshipHTTPResponse { + let request = AirshipRequest( + url: try makeURL(path: "/api/channels/sms/validate"), + headers: [ + "Accept": "application/vnd.urbanairship+json; version=3;", + "Content-Type": "application/json" + ], + method: "POST", + auth: .generatedAppToken, + body: try self.encoder.encode(requestBody) + ) + + let decoder = self.decoder + return try await self.session.performHTTPRequest( + request + ) { (data, response) in + AirshipLogger.debug( + "SMS Channel validation finished with response: \(response)" + ) + + guard let data = data, response.statusCode >= 200, response.statusCode < 300 else { + throw AirshipErrors.error("Invalid request made in performSMSValidation") + } + + let result = try decoder.decode(SMSValidationResult.self, from: data) + + return result.valid + } + } + + private func makeURL(path: String) throws -> URL { + guard let deviceAPIURL = self.config.deviceAPIURL else { + throw AirshipErrors.error("Initial config not resolved.") + } + + let urlString = "\(deviceAPIURL)\(path)" + + guard let url = URL(string: "\(deviceAPIURL)\(path)") else { + throw AirshipErrors.error("Invalid ContactAPIClient URL: \(String(describing: urlString))") + } + + return url + } +} + +fileprivate struct SMSValidationBody: Encodable { + let sender: String + let msisdn: String + + init( + sender: String, + msisdn: String + ) { + self.sender = sender + self.msisdn = msisdn + } + + enum CodingKeys: String, CodingKey { + case sender = "sender" + case msisdn = "msisdn" + } +} diff --git a/Airship/AirshipCore/Source/SessionEvent.swift b/Airship/AirshipCore/Source/SessionEvent.swift index 44fe0b190..99474e578 100644 --- a/Airship/AirshipCore/Source/SessionEvent.swift +++ b/Airship/AirshipCore/Source/SessionEvent.swift @@ -3,11 +3,11 @@ import Foundation struct SessionEvent: Sendable, Equatable { - let type: EventType + let type: SessionEventType let date: Date let sessionState: SessionState - enum EventType: Sendable, Equatable { + enum SessionEventType: Sendable, Equatable { case foregroundInit case backgroundInit case foreground diff --git a/Airship/AirshipCore/Source/SessionTracker.swift b/Airship/AirshipCore/Source/SessionTracker.swift index 9a9a15d75..db7d2babe 100644 --- a/Airship/AirshipCore/Source/SessionTracker.swift +++ b/Airship/AirshipCore/Source/SessionTracker.swift @@ -106,7 +106,7 @@ final class SessionTracker: SessionTrackerProtocol { } @MainActor - private func addEvent(_ type: SessionEvent.EventType, date: Date? = nil) { + private func addEvent(_ type: SessionEvent.SessionEventType, date: Date? = nil) { AirshipLogger.debug("Added session event \(type) state: \(self.sessionState)") self.eventsContinuation.yield( diff --git a/Airship/AirshipCore/Source/SubscriptionListAPIClient.swift b/Airship/AirshipCore/Source/SubscriptionListAPIClient.swift index 38699e394..bb892d98c 100644 --- a/Airship/AirshipCore/Source/SubscriptionListAPIClient.swift +++ b/Airship/AirshipCore/Source/SubscriptionListAPIClient.swift @@ -50,6 +50,9 @@ final class SubscriptionListAPIClient: SubscriptionListAPIClientProtocol { ) return try await session.performHTTPRequest(request) { data, response in + + AirshipLogger.debug("Fetching subscription list finished with response: \(response)") + guard response.statusCode == 200 else { return nil } diff --git a/Airship/AirshipCore/Tests/AirshipCacheTest.swift b/Airship/AirshipCore/Tests/AirshipCacheTest.swift index 417177024..30fb27abd 100644 --- a/Airship/AirshipCore/Tests/AirshipCacheTest.swift +++ b/Airship/AirshipCore/Tests/AirshipCacheTest.swift @@ -95,3 +95,13 @@ final class AirshipCacheTest: XCTestCase { } } +public enum TestAirshipCoreDataCache { + static func makeCache(date: AirshipDateProtocol) -> AirshipCache { + return CoreDataAirshipCache( + coreData: CoreDataAirshipCache.makeCoreData(appKey: UUID().uuidString)!, + appVersion: "version", + sdkVersion: "sdk", + date: date + ) + } +} diff --git a/Airship/AirshipCore/Tests/AirshipContactTest.swift b/Airship/AirshipCore/Tests/AirshipContactTest.swift index f46f437e2..a68558cee 100644 --- a/Airship/AirshipCore/Tests/AirshipContactTest.swift +++ b/Airship/AirshipCore/Tests/AirshipContactTest.swift @@ -7,6 +7,8 @@ import XCTest class AirshipContactTest: XCTestCase { private let channel: TestChannel = TestChannel() private let apiClient: TestContactSubscriptionListAPIClient = TestContactSubscriptionListAPIClient() + private let contactChannelsProvider: TestContactChannelsProvider = TestContactChannelsProvider() + private let apiChannel: TestChannelsListAPIClient = TestChannelsListAPIClient() private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter( notificationCenter: NotificationCenter() ) @@ -15,7 +17,7 @@ class AirshipContactTest: XCTestCase { private let audienceOverridesProvider: DefaultAudienceOverridesProvider = DefaultAudienceOverridesProvider() private let contactManager: TestContactManager = TestContactManager() private var contactQueue: AirshipAsyncSerialQueue! - + private let smsValidator: TestSMSValidator = TestSMSValidator() private var contact: AirshipContact! private var privacyManager: AirshipPrivacyManager! private var config: RuntimeConfig! @@ -43,11 +45,13 @@ class AirshipContactTest: XCTestCase { config: config, channel: self.channel, privacyManager: self.privacyManager, - subscriptionListAPIClient: self.apiClient, + subscriptionListAPIClient: self.apiClient, + contactChannelsProvider: self.contactChannelsProvider, date: self.date, notificationCenter: self.notificationCenter, audienceOverridesProvider: self.audienceOverridesProvider, contactManager: self.contactManager, + smsValidator: self.smsValidator, serialQueue: contactQueue ) } @@ -125,7 +129,7 @@ class AirshipContactTest: XCTestCase { ) await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some contact ID", isStable: false) + ContactIDInfo(contactID: "some contact ID", isStable: false, namedUserID: nil) ) setupContact() @@ -154,7 +158,7 @@ class AirshipContactTest: XCTestCase { func testStableVerifiedContactID() async throws { await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: false) + ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) ) let contactManager = self.contactManager @@ -173,6 +177,7 @@ class AirshipContactTest: XCTestCase { ContactIDInfo( contactID: "some-other-contact-id", isStable: false, + namedUserID: nil, resolveDate: date.addingTimeInterval(-AirshipContact.defaultVerifiedContactIDAge) ) ) @@ -181,11 +186,12 @@ class AirshipContactTest: XCTestCase { ContactIDInfo( contactID: "some-stable-contact-id", isStable: true, + namedUserID: nil, resolveDate: date.addingTimeInterval(-AirshipContact.defaultVerifiedContactIDAge) ) ) await contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-stable-verified-contact-id", isStable: true, resolveDate: date) + ContactIDInfo(contactID: "some-stable-verified-contact-id", isStable: true, namedUserID: nil, resolveDate: date) ) let payload = await payloadTask.value @@ -197,7 +203,7 @@ class AirshipContactTest: XCTestCase { let date = self.date.now await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: true, resolveDate: date) + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil, resolveDate: date) ) let channel = self.channel @@ -220,7 +226,7 @@ class AirshipContactTest: XCTestCase { let date = self.date.now await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: true, resolveDate: date.addingTimeInterval(-1)) + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil, resolveDate: date.addingTimeInterval(-1)) ) let contactManager = self.contactManager @@ -236,7 +242,7 @@ class AirshipContactTest: XCTestCase { await fulfillment(of: [payloadTaskStarted]) await contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-stable-verified-contact-id", isStable: true, resolveDate: date) + ContactIDInfo(contactID: "some-stable-verified-contact-id", isStable: true, namedUserID: nil, resolveDate: date) ) let payload = await payloadTask.value @@ -247,7 +253,7 @@ class AirshipContactTest: XCTestCase { func testExtendRegistrationPaylaodOnChannelCreate() async throws { self.channel.identifier = nil await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: false) + ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) ) XCTAssertEqual(1, self.channel.extenders.count) let payload = await self.channel.channelPayload @@ -371,8 +377,14 @@ class AirshipContactTest: XCTestCase { } func testAssociateChannel() async throws { - self.contact.associateChannel("some-channel-id", type: .email) - await self.verifyOperations([.associateChannel(channelID: "some-channel-id", channelType: .email)]) + self.contact.associateChannel( + "some-channel-id", + type: .email + ) + await self.verifyOperations([.associateChannel( + channelID: "some-channel-id", + channelType: .email + )]) } func testEdits() async throws { @@ -431,7 +443,7 @@ class AirshipContactTest: XCTestCase { func testFetchSubscriptionLists() async throws { await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: true) + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) let apiResult: [String: [ChannelScope]] = ["neat": [.web]] @@ -452,7 +464,7 @@ class AirshipContactTest: XCTestCase { func testFetchSubscriptionListsCached() async throws { await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: true) + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) var apiResult: [String: [ChannelScope]] = ["neat": [.web]] @@ -491,7 +503,7 @@ class AirshipContactTest: XCTestCase { func testFetchSubscriptionListsCachedDifferentContactID() async throws { await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: true) + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) var apiResult: [String: [ChannelScope]] = ["neat": [ChannelScope.web]] @@ -519,7 +531,7 @@ class AirshipContactTest: XCTestCase { // Resolve a new contact ID await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-other-contact-id", isStable: true) + ContactIDInfo(contactID: "some-other-contact-id", isStable: true, namedUserID: nil) ) self.apiClient.fetchSubscriptionListsCallback = { @@ -540,7 +552,7 @@ class AirshipContactTest: XCTestCase { func testFetchWaitsForStableContactID() async throws { await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: false) + ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) ) let apiResult: [String: [ChannelScope]] = ["neat": [.web]] @@ -560,11 +572,11 @@ class AirshipContactTest: XCTestCase { DispatchQueue.main.async { Task { await contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-other-contact-id", isStable: false) + ContactIDInfo(contactID: "some-other-contact-id", isStable: false, namedUserID: nil) ) await contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-stable-contact-id", isStable: true) + ContactIDInfo(contactID: "some-stable-contact-id", isStable: true, namedUserID: nil) ) } } @@ -580,7 +592,7 @@ class AirshipContactTest: XCTestCase { func testFetchSubscriptionListsOverrides() async throws { await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: true) + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) let apiResult: [String: [ChannelScope]] = ["neat": [.web, .app]] @@ -601,7 +613,7 @@ class AirshipContactTest: XCTestCase { attributes: nil, subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "neat", type: .unsubscribe, scope: .web, date: self.date.now) - ] + ], channels: [] ) // Pending @@ -618,7 +630,7 @@ class AirshipContactTest: XCTestCase { func testFetchSubscriptionListsFails() async throws { await self.contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "some-contact-id", isStable: true) + ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) self.apiClient.fetchSubscriptionListsCallback = { @@ -648,7 +660,7 @@ class AirshipContactTest: XCTestCase { ], subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "some list", type: .unsubscribe, scope: .app, date: self.date.now) - ] + ], contactChannels: [] ) let pending = ContactAudienceOverrides( @@ -683,7 +695,7 @@ class AirshipContactTest: XCTestCase { ], subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "some list", type: .unsubscribe, scope: .app, date: self.date.now) - ] + ], contactChannels: [] ) let updateBar = ContactAudienceUpdate( @@ -696,7 +708,7 @@ class AirshipContactTest: XCTestCase { ], subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "some other list", type: .unsubscribe, scope: .app, date: self.date.now) - ] + ], contactChannels: [] ) await self.contactManager.dispatchAudienceUpdate(updateFoo) @@ -705,11 +717,11 @@ class AirshipContactTest: XCTestCase { let contactManager = self.contactManager Task.detached(priority: .high) { await contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "foo", isStable: false) + ContactIDInfo(contactID: "foo", isStable: false, namedUserID: nil) ) await contactManager.setCurrentContactIDInfo( - ContactIDInfo(contactID: "bar", isStable: true) + ContactIDInfo(contactID: "bar", isStable: true, namedUserID: nil) ) } @@ -749,7 +761,7 @@ class AirshipContactTest: XCTestCase { let event = ContactConflictEvent( tags: [:], attributes: [:], - channels: [], + associatedChannels: [], subscriptionLists: [:], conflictingNamedUserID: "neat" ) @@ -770,7 +782,7 @@ class AirshipContactTest: XCTestCase { let event = ContactConflictEvent( tags: [:], attributes: [:], - channels: [], + associatedChannels: [], subscriptionLists: [:], conflictingNamedUserID: "neat" ) @@ -810,9 +822,16 @@ class AirshipContactTest: XCTestCase { } +fileprivate class TestSMSValidator: SMSValidatorProtocol, @unchecked Sendable { + var delegate: SMSValidatorDelegate? = nil -fileprivate actor TestContactManager: ContactManagerProtocol { + func validateSMS(msisdn: String, sender: String) async throws -> Bool { + true + } +} + +fileprivate actor TestContactManager: ContactManagerProtocol { private var _currentNamedUserID: String? = nil private var _currentContactIDInfo: ContactIDInfo? = nil private var _pendingAudienceOverrides = ContactAudienceOverrides() @@ -820,6 +839,12 @@ fileprivate actor TestContactManager: ContactManagerProtocol { let contactUpdates: AsyncStream let contactUpdatesContinuation: AsyncStream.Continuation + let channelUpdates: AsyncStream<[ContactChannel]> + let channelUpdatesContinuation: AsyncStream<[ContactChannel]>.Continuation + + func validateSMS(_ msisdn: String, sender: String) async throws -> Bool { + return true + } private(set) var operations: [ContactOperation] = [] var generateDefaultContactIDCalled: Bool = false @@ -829,6 +854,10 @@ fileprivate actor TestContactManager: ContactManagerProtocol { self.contactUpdates, self.contactUpdatesContinuation ) = AsyncStream.airshipMakeStreamWithContinuation() + ( + self.channelUpdates, + self.channelUpdatesContinuation + ) = AsyncStream<[ContactChannel]>.airshipMakeStreamWithContinuation() } func onAudienceUpdated( diff --git a/Airship/AirshipCore/Tests/AirshipEventsTest.swift b/Airship/AirshipCore/Tests/AirshipEventsTest.swift index 458db203f..3557184f1 100644 --- a/Airship/AirshipCore/Tests/AirshipEventsTest.swift +++ b/Airship/AirshipCore/Tests/AirshipEventsTest.swift @@ -39,7 +39,7 @@ class AirshipEventsTest: XCTestCase { push: EventTestPush() ) - XCTAssertEqual(event.eventType, "app_init") + XCTAssertEqual(event.eventType.reportingName, "app_init") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -76,7 +76,7 @@ class AirshipEventsTest: XCTestCase { push: EventTestPush() ) - XCTAssertEqual(event.eventType, "app_init") + XCTAssertEqual(event.eventType.reportingName, "app_init") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -112,7 +112,7 @@ class AirshipEventsTest: XCTestCase { push: EventTestPush() ) - XCTAssertEqual(event.eventType, "app_foreground") + XCTAssertEqual(event.eventType.reportingName, "app_foreground") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -141,7 +141,7 @@ class AirshipEventsTest: XCTestCase { push: EventTestPush() ) - XCTAssertEqual(event.eventType, "app_background") + XCTAssertEqual(event.eventType.reportingName, "app_background") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -159,7 +159,7 @@ class AirshipEventsTest: XCTestCase { deviceToken: "some-token" ) - XCTAssertEqual(event.eventType, "device_registration") + XCTAssertEqual(event.eventType.reportingName, "device_registration") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -180,7 +180,7 @@ class AirshipEventsTest: XCTestCase { let event = AirshipEvents.pushReceivedEvent(notification: notification) - XCTAssertEqual(event.eventType, "push_received") + XCTAssertEqual(event.eventType.reportingName, "push_received") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -198,7 +198,7 @@ class AirshipEventsTest: XCTestCase { let event = AirshipEvents.pushReceivedEvent(notification: notification) - XCTAssertEqual(event.eventType, "push_received") + XCTAssertEqual(event.eventType.reportingName, "push_received") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -221,7 +221,7 @@ class AirshipEventsTest: XCTestCase { duration: 1 ) - XCTAssertEqual(event.eventType, "screen_tracking") + XCTAssertEqual(event.eventType.reportingName, "screen_tracking") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -285,7 +285,7 @@ class AirshipEventsTest: XCTestCase { iAdImpressionDate: Date(timeIntervalSince1970: 99.0) ) - XCTAssertEqual(event.eventType, "install_attribution") + XCTAssertEqual(event.eventType.reportingName, "install_attribution") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -298,7 +298,7 @@ class AirshipEventsTest: XCTestCase { let event = AirshipEvents.installAttirbutionEvent() - XCTAssertEqual(event.eventType, "install_attribution") + XCTAssertEqual(event.eventType.reportingName, "install_attribution") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -335,7 +335,7 @@ class AirshipEventsTest: XCTestCase { responseText: "some response text" ) - XCTAssertEqual(event.eventType, "interactive_notification_action") + XCTAssertEqual(event.eventType.reportingName, "interactive_notification_action") XCTAssertEqual(event.priority, .high) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } diff --git a/Airship/AirshipCore/Tests/AirshipHTTPResponseTest.swift b/Airship/AirshipCore/Tests/AirshipHTTPResponseTest.swift new file mode 100644 index 000000000..2f6772591 --- /dev/null +++ b/Airship/AirshipCore/Tests/AirshipHTTPResponseTest.swift @@ -0,0 +1,12 @@ +/* Copyright Airship and Contributors */ + +import XCTest + +@testable import AirshipCore + +public extension AirshipHTTPResponse { + + static func make(result: T?, statusCode: Int, headers: [String: String]) -> AirshipHTTPResponse { + return .init(result: result, statusCode: statusCode, headers: headers) + } +} diff --git a/Airship/AirshipCore/Tests/AnalyticsTest.swift b/Airship/AirshipCore/Tests/AnalyticsTest.swift index 1bb13ce9c..2d26106d4 100644 --- a/Airship/AirshipCore/Tests/AnalyticsTest.swift +++ b/Airship/AirshipCore/Tests/AnalyticsTest.swift @@ -76,7 +76,7 @@ class AnalyticsTest: XCTestCase { ) } - XCTAssertEqual("screen_tracking", events[0].type) + XCTAssertEqual("screen_tracking", events[0].type.reportingName) } func testScreenTrackingTerminate() async throws { @@ -94,7 +94,7 @@ class AnalyticsTest: XCTestCase { notificationCenter.post(name: AppStateTracker.didEnterBackgroundNotification) } - XCTAssertEqual("screen_tracking", events[0].type) + XCTAssertEqual("screen_tracking", events[0].type.reportingName) } func testScreenTracking() async throws { @@ -111,7 +111,7 @@ class AnalyticsTest: XCTestCase { let body: AirshipJSON = events[0].body - XCTAssertEqual("screen_tracking", events[0].type) + XCTAssertEqual("screen_tracking", events[0].type.reportingName) XCTAssertEqual("test_screen", body.object?["screen"]?.string) XCTAssertEqual("3.000", body.object?["duration"]?.string) @@ -263,11 +263,11 @@ class AnalyticsTest: XCTestCase { func testAddEvent() throws { let expectation = XCTestExpectation() self.eventManager.addEventCallabck = { event in - XCTAssertEqual("valid", event.type) + XCTAssertEqual("app_background", event.type.reportingName) expectation.fulfill() } - self.analytics.recordEvent(AirshipEvent(priority: .normal, eventType: "valid", eventData: .string("body"))) + self.analytics.recordEvent(AirshipEvent(priority: .normal, eventType: .appBackground, eventData: .string("body"))) wait(for: [expectation], timeout: 5.0) } @@ -283,7 +283,7 @@ class AnalyticsTest: XCTestCase { "neat": "id", ] - XCTAssertEqual("associate_identifiers", events[0].type) + XCTAssertEqual("associate_identifiers", events[0].type.reportingName) XCTAssertEqual( try AirshipJSON.wrap(expectedData), events[0].body @@ -334,11 +334,11 @@ class AnalyticsTest: XCTestCase { } func testScreenEventFeed() async throws { - var feed = self.analytics.eventFeed.updates.makeAsyncIterator() + var feed = await self.analytics.eventFeed.updates.makeAsyncIterator() await self.analytics.trackScreen("some screen") let next = await feed.next() - XCTAssertEqual(next, .screenChange(screen: "some screen")) + XCTAssertEqual(next, .screen(screen: "some screen")) } func testRegionEventEventFeed() async throws { @@ -348,21 +348,53 @@ class AnalyticsTest: XCTestCase { boundaryEvent: .enter )! - var feed = self.analytics.eventFeed.updates.makeAsyncIterator() + var feed = await self.analytics.eventFeed.updates.makeAsyncIterator() self.analytics.recordRegionEvent(event) let next = await feed.next() - XCTAssertEqual(next, .regionEnter(body: try event.eventBody(stringifyFields: false))) + XCTAssertEqual(next, .analytics(eventType: .regionEnter, body: try event.eventBody(stringifyFields: false), value: nil)) } func testForwardCustomEvents() async throws { + let event = CustomEvent(name: "foo", value: 10.0) + + var feed = await self.analytics.eventFeed.updates.makeAsyncIterator() + self.analytics.recordCustomEvent(event) + + let next = await feed.next() + XCTAssertEqual( + next, + .analytics( + eventType: .customEvent, + body: event.eventBody( + sendID: nil, + metadata: nil, + formatValue: false + ), + value: 10.0 + ) + ) + } + + func testForwardCustomEventNoValue() async throws { let event = CustomEvent(name: "foo") - var feed = self.analytics.eventFeed.updates.makeAsyncIterator() + var feed = await self.analytics.eventFeed.updates.makeAsyncIterator() self.analytics.recordCustomEvent(event) let next = await feed.next() - XCTAssertEqual(next, .customEvent(body: event.eventBody(sendID: nil, metadata: nil, formatValue: false), value: 1.0)) + XCTAssertEqual( + next, + .analytics( + eventType: .customEvent, + body: event.eventBody( + sendID: nil, + metadata: nil, + formatValue: false + ), + value: nil + ) + ) } func testSDKExtensions() async throws { @@ -497,7 +529,25 @@ class AnalyticsTest: XCTestCase { SessionEvent(type: .backgroundInit, date: date, sessionState: SessionState()) ) } - XCTAssertEqual(["app_background", "app_foreground", "app_foreground_init", "app_background_init"], events.map { $0.type }) + + XCTAssertEqual( + [ + EventType.appBackground.reportingName, + EventType.appForeground.reportingName, + EventType.appInit.reportingName, + EventType.appInit.reportingName + ], + events.map { $0.type.reportingName } + ) + XCTAssertEqual( + [ + .string("app_background"), + .string("app_foreground"), + .string("app_foreground_init"), + .string("app_background_init") + ], + events.map { $0.body } + ) XCTAssertEqual([date, date, date, date], events.map { $0.date }) } } @@ -548,18 +598,20 @@ final class TestEventManager: EventManagerProtocol, @unchecked Sendable { final class TestSessionEventFactory: SessionEventFactoryProtocol, @unchecked Sendable { func make(event: SessionEvent) -> AirshipEvent { + let eventType: EventType = switch(event.type) { + case .backgroundInit, .foregroundInit: .appInit + case .background: .appBackground + case .foreground: .appForeground + } + let name: String = switch(event.type) { - case .backgroundInit: - "app_background_init" - case .foregroundInit: - "app_foreground_init" - case .background: - "app_background" - case .foreground: - "app_foreground" + case .backgroundInit: "app_background_init" + case .foregroundInit: "app_foreground_init" + case .background: "app_background" + case .foreground: "app_foreground" } - return AirshipEvent(eventType: name, eventData: AirshipJSON.null) + return AirshipEvent(eventType: eventType, eventData: AirshipJSON.string(name)) } } diff --git a/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift b/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift index 5416ef929..a48bab93d 100644 --- a/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift +++ b/Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift @@ -275,7 +275,7 @@ class ChannelAudienceManagerTest: XCTestCase { subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "bar", type: .subscribe, scope: .app, date: Date()), ScopedSubscriptionListUpdate(listId: "baz", type: .unsubscribe, scope: .app, date: Date()) - ] + ], channels: [] ) diff --git a/Airship/AirshipCore/Tests/ContactAPIClientTest.swift b/Airship/AirshipCore/Tests/ContactAPIClientTest.swift index c129ad7af..bff1b13cc 100644 --- a/Airship/AirshipCore/Tests/ContactAPIClientTest.swift +++ b/Airship/AirshipCore/Tests/ContactAPIClientTest.swift @@ -222,7 +222,7 @@ class ContactAPIClientTest: XCTestCase { """ .data(using: .utf8) let date = Date() - let response = try await contactAPIClient.registerEmail( + let response = try await contactAPIClient.registerEmail( contactID: "some-contact-id", address: "ua@airship.com", options: EmailRegistrationOptions.options( @@ -234,64 +234,68 @@ class ContactAPIClientTest: XCTestCase { ) XCTAssertTrue(response.isSuccess) - XCTAssertEqual("some-channel", response.result!.channelID) - XCTAssertEqual(.email, response.result!.channelType) - - let previousRequest = self.session.previousRequest! - XCTAssertNotNil(previousRequest) - XCTAssertEqual( - "https://example.com/api/channels/restricted/email", - previousRequest.url!.absoluteString - ) - - let previousBody = try JSONSerialization.jsonObject( - with: previousRequest.body!, - options: [] - ) as! [String : AnyHashable] - - let previousExpectedBody: [String : AnyHashable] = [ - "channel": [ - "type": "email", - "address": "ua@airship.com", - "timezone": TimeZone.current.identifier, - "locale_country": "CA", - "locale_language": "fr", - "transactional_opted_in": AirshipDateFormatter.string(fromDate: date, format: .isoDelimitter), - ], - "opt_in_mode": "double", - "properties": [ - "interests": "newsletter" - ], - ] - - XCTAssertEqual( - previousBody, - previousExpectedBody - ) - - let lastRequest = self.session.lastRequest! - XCTAssertEqual( - "https://example.com/api/contacts/some-contact-id", - lastRequest.url!.absoluteString - ) - - let lastBody = try JSONSerialization.jsonObject( - with: lastRequest.body!, - options: [] - ) as! [String : AnyHashable] + if let associatedChannel = response.result, case .email = associatedChannel.channelType { + XCTAssertEqual("some-channel", associatedChannel.channelID) + let previousRequest = self.session.previousRequest! + XCTAssertNotNil(previousRequest) + XCTAssertEqual( + "https://example.com/api/channels/restricted/email", + previousRequest.url!.absoluteString + ) + + let previousBody = try JSONSerialization.jsonObject( + with: previousRequest.body!, + options: [] + ) as! [String : AnyHashable] + + let previousExpectedBody: [String : AnyHashable] = [ + "channel": [ + "type": "email", + "address": "ua@airship.com", + "timezone": TimeZone.current.identifier, + "locale_country": "CA", + "locale_language": "fr", + "transactional_opted_in": AirshipDateFormatter.string(fromDate: date, format: .isoDelimitter), + ], + "opt_in_mode": "double", + "properties": [ + "interests": "newsletter" + ], + ] - let lastExpectedBody:[String : AnyHashable] = [ - "associate": [ - [ - "device_type": "email", - "channel_id": "some-channel", + XCTAssertEqual( + previousBody, + previousExpectedBody + ) + + let lastRequest = self.session.lastRequest! + XCTAssertEqual( + "https://example.com/api/contacts/some-contact-id", + lastRequest.url!.absoluteString + ) + + let lastBody = try JSONSerialization.jsonObject( + with: lastRequest.body!, + options: [] + ) as! [String : AnyHashable] + + let lastExpectedBody:[String : AnyHashable] = [ + "associate": [ + [ + "device_type": "email", + "channel_id": "some-channel", + ] ] ] - ] - XCTAssertEqual( - lastBody, - lastExpectedBody - ) + XCTAssertEqual( + lastBody, + lastExpectedBody + ) + } else { + XCTAssertThrowsError("Error: Invalid associated channel type") + } + + } func testRegisterSMS() async throws { @@ -310,54 +314,58 @@ class ContactAPIClientTest: XCTestCase { ) XCTAssertTrue(response.isSuccess) - XCTAssertEqual("some-channel", response.result!.channelID) - XCTAssertEqual(.sms, response.result!.channelType) - - let previousRequest = self.session.previousRequest! - XCTAssertNotNil(previousRequest) - XCTAssertEqual( - "https://example.com/api/channels/restricted/sms", - previousRequest.url!.absoluteString - ) - let previousBody = try JSONSerialization.jsonObject( - with: previousRequest.body!, - options: [] - ) - let previousExpectedBody: Any = [ - "msisdn": "15035556789", - "sender": "28855", - "timezone": TimeZone.current.identifier, - "locale_country": currentLocale.getRegionCode(), - "locale_language": currentLocale.getLanguageCode(), - ] - XCTAssertEqual( - previousBody as! NSDictionary, - previousExpectedBody as! NSDictionary - ) - - let lastRequest = self.session.lastRequest! - XCTAssertEqual( - "https://example.com/api/contacts/some-contact-id", - lastRequest.url!.absoluteString - ) - - let lastBody = try JSONSerialization.jsonObject( - with: lastRequest.body!, - options: [] - ) - let lastExpectedBody: Any = [ - "associate": [ - [ - "device_type": "sms", - "channel_id": "some-channel", + if let associatedChannel = response.result, case .sms = associatedChannel.channelType { + XCTAssertEqual("some-channel", associatedChannel.channelID) + + let previousRequest = self.session.previousRequest! + XCTAssertNotNil(previousRequest) + XCTAssertEqual( + "https://example.com/api/channels/restricted/sms", + previousRequest.url!.absoluteString + ) + + let previousBody = try JSONSerialization.jsonObject( + with: previousRequest.body!, + options: [] + ) + let previousExpectedBody: Any = [ + "msisdn": "15035556789", + "sender": "28855", + "timezone": TimeZone.current.identifier, + "locale_country": currentLocale.getRegionCode(), + "locale_language": currentLocale.getLanguageCode(), + ] + XCTAssertEqual( + previousBody as! NSDictionary, + previousExpectedBody as! NSDictionary + ) + + let lastRequest = self.session.lastRequest! + XCTAssertEqual( + "https://example.com/api/contacts/some-contact-id", + lastRequest.url!.absoluteString + ) + + let lastBody = try JSONSerialization.jsonObject( + with: lastRequest.body!, + options: [] + ) + let lastExpectedBody: Any = [ + "associate": [ + [ + "device_type": "sms", + "channel_id": "some-channel", + ] ] ] - ] - XCTAssertEqual( - lastBody as! NSDictionary, - lastExpectedBody as! NSDictionary - ) + XCTAssertEqual( + lastBody as! NSDictionary, + lastExpectedBody as! NSDictionary + ) + } else { + XCTAssertThrowsError("Error: Invalid associated channel type") + } } func testRegisterOpen() async throws { @@ -379,96 +387,290 @@ class ContactAPIClientTest: XCTestCase { ) XCTAssertTrue(response.isSuccess) - XCTAssertEqual("some-channel", response.result!.channelID) - XCTAssertEqual(.open, response.result!.channelType) + if let associatedChannel = response.result, case .open = associatedChannel.channelType { + XCTAssertEqual("some-channel", associatedChannel.channelID) + + let previousRequest = self.session.previousRequest! + XCTAssertNotNil(previousRequest) + XCTAssertEqual( + "https://example.com/api/channels/restricted/open", + previousRequest.url!.absoluteString + ) + + let previousBody = try JSONSerialization.jsonObject( + with: previousRequest.body!, + options: [] + ) + let previousExpectedBody: [String: Any] = [ + "channel": [ + "type": "open", + "address": "open_address", + "timezone": TimeZone.current.identifier, + "locale_country": currentLocale.getRegionCode(), + "locale_language": currentLocale.getLanguageCode(), + "opt_in": true, + "open": [ + "open_platform_name": "my_platform", + "identifiers": [ + "model": "4", + "category": "1", + ], + ] as [String : Any], + ] as [String : Any] + ] + XCTAssertEqual( + previousBody as! NSDictionary, + previousExpectedBody as NSDictionary + ) + + let lastRequest = self.session.lastRequest! + XCTAssertEqual( + "https://example.com/api/contacts/some-contact-id", + lastRequest.url!.absoluteString + ) + + let lastBody = try JSONSerialization.jsonObject( + with: lastRequest.body!, + options: [] + ) + let lastExpectedBody: Any = [ + "associate": [ + [ + "device_type": "open", + "channel_id": "some-channel", + ] + ] + ] + XCTAssertEqual( + lastBody as! NSDictionary, + lastExpectedBody as! NSDictionary + ) + } else { + XCTAssertThrowsError("Error: Invalid associated channel type") + } + } + + func testAssociateChannel() async throws { + let response = try await contactAPIClient.associateChannel( + contactID: "some-contact-id", + channelID: "some-channel", + channelType: .sms + ) + + XCTAssertTrue(response.isSuccess) + + if let associatedChannel = response.result, case .sms = associatedChannel.channelType { + XCTAssertEqual("some-channel", associatedChannel.channelID) + + let request = self.session.lastRequest! + XCTAssertEqual( + "https://example.com/api/contacts/some-contact-id", + request.url!.absoluteString + ) + + let body = try JSONSerialization.jsonObject( + with: request.body!, + options: [] + ) + let expectedBody: Any = [ + "associate": [ + [ + "device_type": "sms", + "channel_id": "some-channel", + ] + ] + ] + XCTAssertEqual(body as! NSDictionary, expectedBody as! NSDictionary) + } else { + XCTAssertThrowsError("Error: Invalid associated channel type") + } + } + + func testDisassociateRegistered() async throws { + let expectedChannelType: ChannelType = .email + let expectedChannelID: String = "some channel" + let expectedContactID: String = "contact" + + let response = try await contactAPIClient.disassociateChannel( + contactID: expectedContactID, + disassociateOptions: DisassociateOptions( + channelID: expectedChannelID, + channelType: expectedChannelType, + optOut: true + ) + ) + XCTAssertTrue(response.isSuccess) - let previousRequest = self.session.previousRequest! - XCTAssertNotNil(previousRequest) + let request = self.session.lastRequest! XCTAssertEqual( - "https://example.com/api/channels/restricted/open", - previousRequest.url!.absoluteString + "https://example.com/api/contacts/disassociate/\(expectedContactID)", + request.url!.absoluteString ) - let previousBody = try JSONSerialization.jsonObject( - with: previousRequest.body!, + let body = try JSONSerialization.jsonObject( + with: request.body!, options: [] + ) as! [String: Any] + + let expectedBody = [ + "channel_type": expectedChannelType.stringValue, + "channel_id": expectedChannelID, + "opt_out": true + ] as [String : Any] + + XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) + } + + func testDisassociatePendingEmail() async throws { + let expectedChannelType: ChannelType = .email + let expectedEmailAddress: String = "some@email.com" + let expectedContactID: String = "contact" + + let response = try await contactAPIClient.disassociateChannel( + contactID: expectedContactID, + disassociateOptions: DisassociateOptions( + emailAddress: expectedEmailAddress, + optOut: false + ) ) - let previousExpectedBody: [String: Any] = [ - "channel": [ - "type": "open", - "address": "open_address", - "timezone": TimeZone.current.identifier, - "locale_country": currentLocale.getRegionCode(), - "locale_language": currentLocale.getLanguageCode(), - "opt_in": true, - "open": [ - "open_platform_name": "my_platform", - "identifiers": [ - "model": "4", - "category": "1", - ], - ] as [String : Any], - ] as [String : Any] - ] + + XCTAssertTrue(response.isSuccess) + + let request = self.session.lastRequest! XCTAssertEqual( - previousBody as! NSDictionary, - previousExpectedBody as NSDictionary + "https://example.com/api/contacts/disassociate/\(expectedContactID)", + request.url!.absoluteString ) - let lastRequest = self.session.lastRequest! + let body = try JSONSerialization.jsonObject( + with: request.body!, + options: [] + ) as! [String: Any] + + let expectedBody = [ + "channel_type": expectedChannelType.stringValue, + "email_address": expectedEmailAddress, + "opt_out": false + ] as [String : Any] + + XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) + } + + func testDisassociatePendingSMS() async throws { + let expectedChannelType: ChannelType = .sms + let expectedMSISDN: String = "12345" + let expectedSender: String = "56789" + + let expectedContactID: String = "contact" + + let response = try await contactAPIClient.disassociateChannel(contactID: expectedContactID, disassociateOptions: DisassociateOptions(msisdn: expectedMSISDN, senderID: expectedSender, optOut: false)) + + XCTAssertTrue(response.isSuccess) + + let request = self.session.lastRequest! XCTAssertEqual( - "https://example.com/api/contacts/some-contact-id", - lastRequest.url!.absoluteString + "https://example.com/api/contacts/disassociate/\(expectedContactID)", + request.url!.absoluteString ) - let lastBody = try JSONSerialization.jsonObject( - with: lastRequest.body!, + let body = try JSONSerialization.jsonObject( + with: request.body!, options: [] - ) - let lastExpectedBody: Any = [ - "associate": [ - [ - "device_type": "open", - "channel_id": "some-channel", - ] - ] - ] + ) as! [String: Any] + + let expectedBody = [ + "channel_type": expectedChannelType.stringValue, + "msisdn": expectedMSISDN, + "sender": expectedSender, + "opt_out": false + ] as [String : Any] + + XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) + } + + func testResendEmail() async throws { + let expectedChannelType: ChannelType = .email + let expectedEmail: String = "test@email.com" + + let expectedResendOptions = ResendOptions(emailAddress: expectedEmail) + + let response = try await contactAPIClient.resend(resendOptions: expectedResendOptions) + XCTAssertTrue(response.isSuccess) + + let request = self.session.lastRequest! XCTAssertEqual( - lastBody as! NSDictionary, - lastExpectedBody as! NSDictionary + "https://example.com/api/channels/resend", + request.url!.absoluteString ) + + let body = try JSONSerialization.jsonObject( + with: request.body!, + options: [] + ) as! [String: Any] + + let expectedBody = [ + "channel_type": expectedChannelType.stringValue, + "email_address": expectedEmail + ] as [String : Any] + + XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) } - func testAssociateChannel() async throws { - let response = try await contactAPIClient.associateChannel( - contactID: "some-contact-id", - channelID: "some-channel", - channelType: .sms - ) + func testResendSMS() async throws { + let expectedChannelType: ChannelType = .sms + let expectedMSISDN: String = "1234" + let expectedSenderID: String = "1234" + + let expectedResendOptions = ResendOptions(msisdn: expectedMSISDN, senderID: expectedSenderID) + let response = try await contactAPIClient.resend(resendOptions: expectedResendOptions) XCTAssertTrue(response.isSuccess) - XCTAssertEqual("some-channel", response.result!.channelID) - XCTAssertEqual(.sms, response.result!.channelType) let request = self.session.lastRequest! XCTAssertEqual( - "https://example.com/api/contacts/some-contact-id", + "https://example.com/api/channels/resend", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] + ) as! [String: Any] + + let expectedBody = [ + "channel_type": expectedChannelType.stringValue, + "sender": expectedSenderID, + "msisdn": expectedMSISDN + ] as [String : Any] + + XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) + } + + func testResendChannel() async throws { + let expectedChannelType: ChannelType = .email + let expectedChannelID: String = "some channel" + let expectedResendOptions = ResendOptions(channelID: expectedChannelID, channelType: expectedChannelType) + + let response = try await contactAPIClient.resend(resendOptions: expectedResendOptions) + XCTAssertTrue(response.isSuccess) + + let request = self.session.lastRequest! + XCTAssertEqual( + "https://example.com/api/channels/resend", + request.url!.absoluteString ) - let expectedBody: Any = [ - "associate": [ - [ - "device_type": "sms", - "channel_id": "some-channel", - ] - ] - ] - XCTAssertEqual(body as! NSDictionary, expectedBody as! NSDictionary) + + let body = try JSONSerialization.jsonObject( + with: request.body!, + options: [] + ) as! [String: Any] + + let expectedBody = [ + "channel_type": expectedChannelType.stringValue, + "channel_id": expectedChannelID + ] as [String : Any] + + XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) } func testUpdate() async throws { diff --git a/Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift b/Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift new file mode 100644 index 000000000..45cb87258 --- /dev/null +++ b/Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift @@ -0,0 +1,274 @@ +/* Copyright Airship and Contributors */ + +import XCTest + +@testable import AirshipCore +class ContactChannelsProviderTest: XCTestCase { + private var audienceOverridesProvider: DefaultAudienceOverridesProvider! + private var provider: ContactChannelsProvider! + var apiClient: TestContactChannelsAPIClient! + private var privacyManager: AirshipPrivacyManager! + private var dataStore: PreferenceDataStore! + private var notificationCenter: AirshipNotificationCenter! + private var taskSleeper: TestSleeper! + private var date: UATestDate = UATestDate(dateOverride: Date()) + + private let testChannels1: [ContactChannel] = [ + .email( + .registered( + ContactChannel.Email.Registered( + channelID: UUID().uuidString, + maskedAddress: "****@email.com" + ) + ) + ), + .sms( + .registered( + ContactChannel.Sms.Registered( + channelID: UUID().uuidString, + maskedAddress: "****@email.com", + isOptIn: true, + senderID: "123" + ) + ) + ) + ] + + + private let testChannels2: [ContactChannel] = [ + .email( + .registered( + ContactChannel.Email.Registered( + channelID: UUID().uuidString, + maskedAddress: "****@email.com" + ) + ) + ), + .email( + .registered( + ContactChannel.Email.Registered( + channelID: UUID().uuidString, + maskedAddress: "****@email.com" + ) + ) + ) + ] + + private let testChannels3: [ContactChannel] = [ + .sms( + .registered( + ContactChannel.Sms.Registered( + channelID: UUID().uuidString, + maskedAddress: "****@email.com", + isOptIn: false, + senderID: "123" + ) + ) + ) + ] + + override func setUp() async throws { + try await super.setUp() + + self.audienceOverridesProvider = DefaultAudienceOverridesProvider() + self.apiClient = TestContactChannelsAPIClient() + self.dataStore = PreferenceDataStore(appKey: UUID().uuidString) + self.taskSleeper = TestSleeper() + self.notificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) + self.privacyManager = await AirshipPrivacyManager( + dataStore: self.dataStore, + config: RuntimeConfig(config: AirshipConfig(), dataStore: self.dataStore), + defaultEnabledFeatures: .all, + notificationCenter: self.notificationCenter + ) + + self.provider = ContactChannelsProvider( + audienceOverrides: self.audienceOverridesProvider, + apiClient: self.apiClient, + date: self.date, + taskSleeper: self.taskSleeper, + privacyManager: self.privacyManager + ) + } + + override func tearDown() async throws { + try await super.tearDown() + + self.audienceOverridesProvider = nil + self.apiClient = nil + self.dataStore = nil + self.taskSleeper = nil + self.notificationCenter = nil + self.privacyManager = nil + self.provider = nil + } + + func testPrivacyManagerDisabled() async { + self.privacyManager.disableFeatures(.contacts) + + let contactIDStream = AsyncStream { continuation in + continuation.yield("test-contact-id-1") + continuation.finish() + } + + var resultStream = provider.contactChannels(stableContactIDUpdates: contactIDStream).makeAsyncIterator() + let result = await resultStream.next() + XCTAssertEqual(result, .error(.contactsDisabled)) + } + + func testContactChannelsSuccess() async { + let contactIDChannel = AirshipAsyncChannel() + + var resultStream = provider.contactChannels( + stableContactIDUpdates: await contactIDChannel.makeStream() + ).makeAsyncIterator() + + + self.apiClient.fetchResponse = AirshipHTTPResponse( + result: self.testChannels1, + statusCode: 200, + headers: [:] + ) + await contactIDChannel.send("test-contact-id-1") + var result = await resultStream.next() + XCTAssertEqual(result, .success(self.testChannels1)) + + self.apiClient.fetchResponse = AirshipHTTPResponse( + result: self.testChannels2, + statusCode: 200, + headers: [:] + ) + await contactIDChannel.send("test-contact-id-2") + result = await resultStream.next() + XCTAssertEqual(result, .success(self.testChannels2)) + + self.apiClient.fetchResponse = AirshipHTTPResponse( + result: self.testChannels3, + statusCode: 200, + headers: [:] + ) + await contactIDChannel.send("test-contact-id-3") + result = await resultStream.next() + XCTAssertEqual(result, .success(self.testChannels3)) + + XCTAssertEqual(self.apiClient.fetchAssociatedChannelsCallCount, 3) + } + + func testContactChannelsFailure() async { + let contactIDStream = AsyncStream { continuation in + continuation.yield("test-contact-id") + continuation.finish() + } + + + self.apiClient.fetchResponse = AirshipHTTPResponse(result: [], statusCode: 500, headers: [:]) + + var resultStream = provider.contactChannels(stableContactIDUpdates: contactIDStream).makeAsyncIterator() + let result = await resultStream.next() + XCTAssertEqual(result, .error(.failedToFetchContacts)) + } + + func testEmptyContactChannelUpdates() async { + let contactIDStream = AsyncStream { continuation in + continuation.yield("test-contact-id-1") + continuation.finish() + } + + self.apiClient.fetchResponse = AirshipHTTPResponse(result: [], statusCode: 200, headers: [:]) + + var resultStream = provider.contactChannels(stableContactIDUpdates: contactIDStream).makeAsyncIterator() + let result = await resultStream.next() + XCTAssertEqual(result, .success([])) + } + + func testBackoffOnFailure() async { + let contactIDStream = AsyncStream { continuation in + continuation.yield("test-contact-id-1") + continuation.finish() + } + + self.apiClient.fetchResponse = AirshipHTTPResponse(result: [], statusCode: 500, headers: [:]) + var sleepUpdates = await self.taskSleeper.sleepUpdates.makeAsyncIterator() + + var results = provider.contactChannels(stableContactIDUpdates: contactIDStream).makeAsyncIterator() + _ = await results.next() + + + for backoff in [8.0, 16.0, 32.0, 64.0, 64.0] { + let next = await sleepUpdates.next() + XCTAssertEqual(next, backoff) + await self.taskSleeper.advance() + } + + } + + func testRefreshRateOnSuccess() async { + let contactIDStream = AsyncStream { continuation in + continuation.yield("test-contact-id-1") + continuation.finish() + } + + var results = provider.contactChannels(stableContactIDUpdates: contactIDStream).makeAsyncIterator() + self.apiClient.fetchResponse = AirshipHTTPResponse(result: [], statusCode: 200, headers: [:]) + + _ = await results.next() + await self.taskSleeper.advance() + + let sleeps = await self.taskSleeper.sleeps + XCTAssertEqual(sleeps, [600]) + } +} + + +class TestContactChannelsAPIClient: ContactChannelsAPIClientProtocol, @unchecked Sendable { + internal init( + fetchAssociatedChannelsCallCount: Int = 0, + fetchedContactIDs: [String] = [], + fetchResponse: AirshipHTTPResponse<[ContactChannel]>? = nil + ) { + self.fetchAssociatedChannelsCallCount = fetchAssociatedChannelsCallCount + self.fetchedContactIDs = fetchedContactIDs + self.fetchResponse = fetchResponse + } + + var fetchAssociatedChannelsCallCount = 0 + var fetchedContactIDs: [String] = [] + var fetchResponse: AirshipHTTPResponse<[ContactChannel]>? + + func fetchAssociatedChannelsList(contactID: String) async throws -> AirshipHTTPResponse<[ContactChannel]> { + fetchAssociatedChannelsCallCount += 1 + fetchedContactIDs.append(contactID) + + return fetchResponse! + } +} + +private actor TestSleeper: AirshipTaskSleeper, @unchecked Sendable { + + private let channel = AirshipAsyncChannel() + var sleepUpdates: AsyncStream { + get async { + await channel.makeStream() + } + } + + + func advance() { + continuations.forEach { + $0.resume() + } + continuations.removeAll() + } + + + var sleeps: [TimeInterval] = [] + var continuations: [CheckedContinuation] = [] + + func sleep(timeInterval: TimeInterval) async throws { + sleeps.append(timeInterval) + await channel.send(timeInterval) + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } +} diff --git a/Airship/AirshipCore/Tests/ContactManagerTest.swift b/Airship/AirshipCore/Tests/ContactManagerTest.swift index 6dcca8ec6..1d0d9637a 100644 --- a/Airship/AirshipCore/Tests/ContactManagerTest.swift +++ b/Airship/AirshipCore/Tests/ContactManagerTest.swift @@ -142,6 +142,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -199,6 +200,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -236,6 +238,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ), @@ -243,6 +246,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: false, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -336,6 +340,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: false, + namedUserID: nil, resolveDate: self.date.now ) ), @@ -343,6 +348,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.nonAnonIdentifyResponse.contact.contactID, isStable: true, + namedUserID: "some named user", resolveDate: self.date.now ) ), @@ -454,6 +460,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.nonAnonIdentifyResponse.contact.contactID, isStable: false, + namedUserID: nil, resolveDate: self.date.now ) ), @@ -461,6 +468,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -492,6 +500,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -612,6 +621,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: contactInfo!.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -657,6 +667,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: contactInfo.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -669,6 +680,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: contactInfo.contactID, isStable: false, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -684,6 +696,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: contactInfo.contactID, isStable: true, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -696,6 +709,7 @@ final class ContactManagerTest: XCTestCase { ContactIDInfo( contactID: contactInfo.contactID, isStable: false, + namedUserID: nil, resolveDate: self.date.now ) ) @@ -844,9 +858,7 @@ final class ContactManagerTest: XCTestCase { XCTAssertEqual(locale, self.localeManager.currentLocale) register.fulfill() return AirshipHTTPResponse( - result: AssociatedChannel( - channelType: .email, channelID: "some channel" - ), + result: .init(channelType: .email, channelID: "some channel"), statusCode: 200, headers: [:] ) @@ -893,9 +905,7 @@ final class ContactManagerTest: XCTestCase { XCTAssertEqual(locale, self.localeManager.currentLocale) register.fulfill() return AirshipHTTPResponse( - result: AssociatedChannel( - channelType: .open, channelID: "some channel" - ), + result: .init(channelType: .open, channelID: "some channel"), statusCode: 200, headers: [:] ) @@ -932,16 +942,189 @@ final class ContactManagerTest: XCTestCase { // Then register the channel let register = XCTestExpectation() - self.apiClient.registerSMSCallback = { contactID, address, options, locale in - XCTAssertEqual(contactID, self.anonIdentifyResponse.contact.contactID) + self.apiClient.registerSMSCallback = {contactID, address, options, locale in XCTAssertEqual(address, expectedAddress) XCTAssertEqual(options, options) XCTAssertEqual(locale, self.localeManager.currentLocale) register.fulfill() return AirshipHTTPResponse( - result: AssociatedChannel( - channelType: .open, channelID: "some channel" - ), + result: .init(channelType: .sms, channelID: "some channel"), + statusCode: 200, + headers: [:] + ) + } + + let result = try await self.workManager.launchTask( + request: AirshipWorkRequest( + workID: ContactManager.updateTaskID + ) + ) + XCTAssertEqual(result, .success) + + await self.fulfillmentCompat(of: [resolve, register], timeout: 10) + } + + func testResendEmail() async throws { + let expectedAddress: String = "example@email.com" + + let expectedResendOptions = ResendOptions(emailAddress: expectedAddress) + + // Should resolve contact first after checking the token + let resolve = XCTestExpectation() + self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in + resolve.fulfill() + return AirshipHTTPResponse( + result: self.anonIdentifyResponse, + statusCode: 200, + headers: [:] + ) + } + + let pendingChannel = makePendingEmailContactChannel(address: expectedAddress) + + await self.contactManager.addOperation( + .resend(channel: pendingChannel) + ) + + let resend = XCTestExpectation() + self.apiClient.resendCallback = { resendOptions in + XCTAssertEqual(resendOptions, expectedResendOptions) + resend.fulfill() + return AirshipHTTPResponse( + result: true, + statusCode: 200, + headers: [:] + ) + } + + let result = try await self.workManager.launchTask( + request: AirshipWorkRequest( + workID: ContactManager.updateTaskID + ) + ) + + XCTAssertEqual(result, .success) + + await self.fulfillmentCompat(of: [resolve, resend], timeout: 10) + } + + func testResendSMS() async throws { + let expectedMSISDN: String = "12345" + let expectedSenderID: String = "1111" + + let expectedResendOptions = ResendOptions(msisdn: expectedMSISDN, senderID: expectedSenderID) + + // Should resolve contact first after checking the token + let resolve = XCTestExpectation() + self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in + resolve.fulfill() + return AirshipHTTPResponse( + result: self.anonIdentifyResponse, + statusCode: 200, + headers: [:] + ) + } + + let pendingChannel = makePendingSMSContactChannel(msisdn: expectedMSISDN, sender: expectedSenderID) + + await self.contactManager.addOperation( + .resend(channel: pendingChannel) + ) + + let resend = XCTestExpectation() + self.apiClient.resendCallback = { resendOptions in + XCTAssertEqual(resendOptions, expectedResendOptions) + resend.fulfill() + return AirshipHTTPResponse( + result: true, + statusCode: 200, + headers: [:] + ) + } + + let result = try await self.workManager.launchTask( + request: AirshipWorkRequest( + workID: ContactManager.updateTaskID + ) + ) + + XCTAssertEqual(result, .success) + + await self.fulfillmentCompat(of: [resolve, resend], timeout: 10) + } + + func testResendChannel() async throws { + let expectedChannelID = "12345" + let expectedChannelType: ChannelType = ChannelType.email + + let expectedResendOptions = ResendOptions(channelID: expectedChannelID, channelType: expectedChannelType) + + // Should resolve contact first after checking the token + let resolve = XCTestExpectation() + self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in + resolve.fulfill() + return AirshipHTTPResponse( + result: self.anonIdentifyResponse, + statusCode: 200, + headers: [:] + ) + } + + let registeredChannel = makeRegisteredContactChannel(from: expectedChannelID) + + await self.contactManager.addOperation( + .resend(channel: registeredChannel) + ) + + let resend = XCTestExpectation() + self.apiClient.resendCallback = { resendOptions in + XCTAssertEqual(resendOptions, expectedResendOptions) + resend.fulfill() + return AirshipHTTPResponse( + result: true, + statusCode: 200, + headers: [:] + ) + } + + let result = try await self.workManager.launchTask( + request: AirshipWorkRequest( + workID: ContactManager.updateTaskID + ) + ) + + XCTAssertEqual(result, .success) + + await self.fulfillmentCompat(of: [resolve, resend], timeout: 10) + } + + func testDisassociate() async throws { + let expectedChannelID = "12345" + let registeredChannel = makeRegisteredContactChannel(from: expectedChannelID) + + await self.contactManager.addOperation( + .disassociateChannel(channel: registeredChannel) + ) + + // Should resolve contact first + let resolve = XCTestExpectation() + self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in + resolve.fulfill() + return AirshipHTTPResponse( + result: self.anonIdentifyResponse, + statusCode: 200, + headers: [:] + ) + } + + // Then disassociate the channel + let register = XCTestExpectation() + self.apiClient.disassociateChannelCallback = { contactID, channelID, type in + XCTAssertEqual(channelID, expectedChannelID) + XCTAssertEqual(type, ChannelType.email.stringValue) + register.fulfill() + return AirshipHTTPResponse( + result: ContactDisassociateChannelResult(channelID: channelID), statusCode: 200, headers: [:] ) @@ -959,7 +1142,10 @@ final class ContactManagerTest: XCTestCase { func testAssociateChannel() async throws { await self.contactManager.addOperation( - .associateChannel(channelID: "some channel", channelType: .open) + .associateChannel( + channelID: "some channel", + channelType: .open + ) ) // Should resolve contact first @@ -975,15 +1161,13 @@ final class ContactManagerTest: XCTestCase { // Then register the channel let register = XCTestExpectation() - self.apiClient.associateChannelCallback = { contactID, address, type in - XCTAssertEqual(contactID, self.anonIdentifyResponse.contact.contactID) - XCTAssertEqual(address, "some channel") + self.apiClient.associateChannelCallback = { contactID, channelID, type in + XCTAssertEqual(contactID, "some contact") + XCTAssertEqual(channelID, "some channel") XCTAssertEqual(type, .open) register.fulfill() return AirshipHTTPResponse( - result: AssociatedChannel( - channelType: .open, channelID: "some channel" - ), + result: .init(channelType: type, channelID: "some channel"), statusCode: 200, headers: [:] ) @@ -1138,7 +1322,7 @@ final class ContactManagerTest: XCTestCase { let expctedConflictEvent = ContactConflictEvent( tags: ["some group": ["tag"]], attributes: ["some attribute": .string("cool")], - channels: [], + associatedChannels: [], subscriptionLists: ["some list": [.app]], conflictingNamedUserID: "some named user" ) @@ -1161,6 +1345,40 @@ final class ContactManagerTest: XCTestCase { return collected } + + private func makePendingEmailContactChannel(address: String) -> ContactChannel { + return .email( + .pending( + ContactChannel.Email.Pending( + address: address, + registrationOptions: .options(properties: nil, doubleOptIn: true) + ) + ) + ) + } + + private func makePendingSMSContactChannel(msisdn: String, sender: String) -> ContactChannel { + return .sms( + .pending( + ContactChannel.Sms.Pending( + address: msisdn, + registrationOptions: .optIn(senderID: sender) + ) + ) + ) + } + + private func makeRegisteredContactChannel(from channelID: String) -> ContactChannel { + return .email( + .registered( + ContactChannel.Email.Registered( + channelID: channelID, + maskedAddress: "****@email.com" + ) + ) + ) + } + private func verifyUpdates(_ expected: [ContactUpdate], file: StaticString = #filePath, line: UInt = #line) async { let collected = await self.collectUpdates(count: expected.count) XCTAssertEqual(collected, expected, file: file, line: line) diff --git a/Airship/AirshipCore/Tests/ContactRemoteDataProviderTest.swift b/Airship/AirshipCore/Tests/ContactRemoteDataProviderTest.swift index 9fc045e6c..e19eba336 100644 --- a/Airship/AirshipCore/Tests/ContactRemoteDataProviderTest.swift +++ b/Airship/AirshipCore/Tests/ContactRemoteDataProviderTest.swift @@ -31,7 +31,7 @@ final class ContactRemoteDataProviderDelegateTest: XCTestCase { } func testIsRemoteDataInfoUpToDate() async throws { - contact.contactIDInfo = ContactIDInfo(contactID: "some-contact-id", isStable: true) + contact.contactIDInfo = ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) let locale = Locale(identifier: "br") let randomValue = 1003 @@ -72,7 +72,7 @@ final class ContactRemoteDataProviderDelegateTest: XCTestCase { XCTAssertFalse(isUpToDate) // Different contact ID - contact.contactIDInfo = ContactIDInfo(contactID: "some-other-contact-id", isStable: true) + contact.contactIDInfo = ContactIDInfo(contactID: "some-other-contact-id", isStable: true, namedUserID: nil) isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, @@ -81,7 +81,7 @@ final class ContactRemoteDataProviderDelegateTest: XCTestCase { XCTAssertFalse(isUpToDate) // Unstable contact ID - contact.contactIDInfo = ContactIDInfo(contactID: "some-contact-id", isStable: false) + contact.contactIDInfo = ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, diff --git a/Airship/AirshipCore/Tests/DeferredResolverTest.swift b/Airship/AirshipCore/Tests/DeferredResolverTest.swift index c9f0e656a..4b9e0bba8 100644 --- a/Airship/AirshipCore/Tests/DeferredResolverTest.swift +++ b/Airship/AirshipCore/Tests/DeferredResolverTest.swift @@ -314,22 +314,6 @@ final class DeferredResolverTest: XCTestCase { XCTAssertEqual(anotherResult, .success(body)) } - - - - - - // 307 with location retries right away, 307 with location and retry after, - // testResolve - - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - } diff --git a/Airship/AirshipCore/Tests/DeviceAudienceSelectorTest.swift b/Airship/AirshipCore/Tests/DeviceAudienceSelectorTest.swift index e41b7b7ce..6f1d80112 100644 --- a/Airship/AirshipCore/Tests/DeviceAudienceSelectorTest.swift +++ b/Airship/AirshipCore/Tests/DeviceAudienceSelectorTest.swift @@ -291,12 +291,12 @@ final class DeviceAudienceSelectorTest: XCTestCase, @unchecked Sendable { self.testDeviceInfo.channelID = "not a match" - self.testDeviceInfo.stableContactID = "not a match" + self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "not a match") try await assertFalse { try await audience.evaluate(deviceInfoProvider: self.testDeviceInfo) } - self.testDeviceInfo.stableContactID = "match" + self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") try await assertTrue { try await audience.evaluate(deviceInfoProvider: self.testDeviceInfo) } @@ -320,7 +320,7 @@ final class DeviceAudienceSelectorTest: XCTestCase, @unchecked Sendable { ) self.testDeviceInfo.channelID = "not a match" - self.testDeviceInfo.stableContactID = "not a match" + self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "not a match") try await assertFalse { try await audience.evaluate(deviceInfoProvider: self.testDeviceInfo) } diff --git a/Airship/AirshipCore/Tests/EventTestUtils.swift b/Airship/AirshipCore/Tests/EventTestUtils.swift index f19b0acf5..846618387 100644 --- a/Airship/AirshipCore/Tests/EventTestUtils.swift +++ b/Airship/AirshipCore/Tests/EventTestUtils.swift @@ -5,13 +5,13 @@ import AirshipCore extension AirshipEventData { - static func makeTestData() -> AirshipEventData { + static func makeTestData(type: EventType = .appInit) -> AirshipEventData { return AirshipEventData( body: try! AirshipJSON.wrap(["cool": "story"]), id: UUID().uuidString, date: Date(), sessionID: UUID().uuidString, - type: UUID().uuidString + type: type ) } } diff --git a/Airship/AirshipCore/Tests/ExperimentManagerTest.swift b/Airship/AirshipCore/Tests/ExperimentManagerTest.swift index 08c0d9ee1..acc54d293 100644 --- a/Airship/AirshipCore/Tests/ExperimentManagerTest.swift +++ b/Airship/AirshipCore/Tests/ExperimentManagerTest.swift @@ -16,7 +16,7 @@ final class ExperimentManagerTest: XCTestCase { override func setUpWithError() throws { self.deviceInfo.channelID = "channel-id" - self.deviceInfo.stableContactID = "some-contact-id" + self.deviceInfo.stableContactInfo = StableContactInfo(contactID: "some-contact-id") self.subject = ExperimentManager( dataStore: PreferenceDataStore(appKey: UUID().uuidString), @@ -109,7 +109,7 @@ final class ExperimentManagerTest: XCTestCase { )! XCTAssertFalse(result.isMatch) - XCTAssertEqual(self.deviceInfo.stableContactID, result.contactID) + XCTAssertEqual(self.deviceInfo.stableContactInfo.contactID, result.contactID) XCTAssertEqual(self.deviceInfo.channelID, result.channelID) XCTAssertEqual( @@ -135,7 +135,7 @@ final class ExperimentManagerTest: XCTestCase { audienceSelector: audienceSelector2 ) - self.deviceInfo.stableContactID = "active-contact-id" + self.deviceInfo.stableContactInfo = StableContactInfo(contactID: "active-contact-id") self.remoteData.payloads = [createPayload([ experiment1.toString, @@ -187,7 +187,7 @@ final class ExperimentManagerTest: XCTestCase { ) ) - self.deviceInfo.stableContactID = "active-contact-id" + self.deviceInfo.stableContactInfo = StableContactInfo(contactID: "active-contact-id") self.remoteData.payloads = [createPayload([ experiment1.toString, diff --git a/Airship/AirshipCore/Tests/ProjectValidationTest.swift b/Airship/AirshipCore/Tests/ProjectValidationTest.swift index 8eadadc20..c90ab848f 100644 --- a/Airship/AirshipCore/Tests/ProjectValidationTest.swift +++ b/Airship/AirshipCore/Tests/ProjectValidationTest.swift @@ -125,11 +125,16 @@ class ProjectValidationTest: XCTestCase { sourcePaths: [String], excludeFiles: [String] = [] ) { + guard let sourceRootURL = self.sourceRootURL else { + XCTFail("Source root is nil - check xcodeprojPath") + return + } + let excludeUrls = excludeFiles.compactMap { - sourceRootURL!.appendingPathComponent($0) + sourceRootURL.appendingPathComponent($0) } let directories = sourcePaths.compactMap { - sourceRootURL!.appendingPathComponent($0) + sourceRootURL.appendingPathComponent($0) } let filesFromTarget = Set(getSourceFiles(target: target)) diff --git a/Airship/AirshipCore/Tests/RemoteConfigTest.swift b/Airship/AirshipCore/Tests/RemoteConfigTest.swift index 4c9fb3c94..5c8a45bd4 100644 --- a/Airship/AirshipCore/Tests/RemoteConfigTest.swift +++ b/Airship/AirshipCore/Tests/RemoteConfigTest.swift @@ -37,7 +37,14 @@ final class RemoteConfigTest: XCTestCase { "foreground_resolve_interval_ms":400 }, "fetch_contact_remote_data":true, - "disabled_features": ["push", "analytics"] + "disabled_features": ["push", "analytics"], + "in_app_config": { + "additional_audience_check": { + "enabled": true, + "context": "json-value", + "url": "https://test.url" + } + } } """ @@ -58,7 +65,10 @@ final class RemoteConfigTest: XCTestCase { foregroundIntervalMilliseconds: 400, channelRegistrationMaxResolveAgeMilliseconds: 300 ), - disabledFeatures: [.push, .analytics] + disabledFeatures: [.push, .analytics], + iaaConfig: .init( + retryingQueue: nil, + additionalAudienceConfig: .init(isEnabled: true, context: .string("json-value"), url: "https://test.url")) ) let config = try self.decoder.decode(RemoteConfig.self, from: json.data(using: .utf8)!) diff --git a/Airship/AirshipCore/Tests/RemoteDataTest.swift b/Airship/AirshipCore/Tests/RemoteDataTest.swift index beb0ddce9..171e5e934 100644 --- a/Airship/AirshipCore/Tests/RemoteDataTest.swift +++ b/Airship/AirshipCore/Tests/RemoteDataTest.swift @@ -70,7 +70,7 @@ final class RemoteDataTest: AirshipBaseTest { func testContactUpdateEnqueuesRefresh() { XCTAssertEqual(0, testWorkManager.workRequests.count) self.testContact.contactIDUpdatesSubject.send( - ContactIDInfo(contactID: "some id", isStable: true) + ContactIDInfo(contactID: "some id", isStable: true, namedUserID: nil) ) XCTAssertEqual(1, testWorkManager.workRequests.count) } diff --git a/Airship/AirshipCore/Tests/RetailEventTemplateTest.swift b/Airship/AirshipCore/Tests/RetailEventTemplateTest.swift index 6a9a4a775..fb1316c61 100644 --- a/Airship/AirshipCore/Tests/RetailEventTemplateTest.swift +++ b/Airship/AirshipCore/Tests/RetailEventTemplateTest.swift @@ -51,7 +51,8 @@ final class RetailEventTemplateTest: XCTestCase { template.transactionID = "1122334455" template.brand = "Airship" template.isNewItem = true - + template.currency = "USD" + let event = template.createEvent() XCTAssertEqual("browsed", event.data["event_name"] as? String, "Unexpected event name.") @@ -64,6 +65,8 @@ final class RetailEventTemplateTest: XCTestCase { XCTAssertEqual("Airship", event.properties["brand"] as? String, "Unexpected category.") XCTAssertEqual(true, event.properties["new_item"] as? Bool, "Unexpected new item value.") XCTAssertEqual("retail", event.data["template_type"] as? String, "Unexpected event template type.") + XCTAssertEqual("USD", event.properties["currency"] as? String) + } /** diff --git a/Airship/AirshipCore/Tests/SMSValidatorAPIClientTest.swift b/Airship/AirshipCore/Tests/SMSValidatorAPIClientTest.swift new file mode 100644 index 000000000..dad6e5897 --- /dev/null +++ b/Airship/AirshipCore/Tests/SMSValidatorAPIClientTest.swift @@ -0,0 +1,81 @@ +/* Copyright Airship and Contributors */ + +import XCTest + +@testable import AirshipCore + +class SMSValidatorAPIClientTest: XCTestCase { + + private let session: TestAirshipRequestSession = TestAirshipRequestSession() + private var smsValidatorAPIClient: SMSValidatorAPIClient! + private var config: RuntimeConfig! + private let currentLocale = Locale(identifier: "fr-CA") + + override func setUpWithError() throws { + let airshipConfig = AirshipConfig() + airshipConfig.deviceAPIURL = "https://example.com" + airshipConfig.requireInitialRemoteConfigEnabled = false + self.config = RuntimeConfig( + config: airshipConfig, + dataStore: PreferenceDataStore(appKey: UUID().uuidString) + ) + self.session.response = HTTPURLResponse( + url: URL(string: "https://contacts_test")!, + statusCode: 200, + httpVersion: "", + headerFields: [String: String]() + ) + + self.smsValidatorAPIClient = SMSValidatorAPIClient( + config: self.config, + session: self.session + ) + } + + func testValidateSMS() async throws { + + self.session.response = HTTPURLResponse( + url: URL(string: "https://example.com/api/channels/sms/validate")!, + statusCode: 200, + httpVersion: "", + headerFields: [String: String]() + ) + + self.session.data = """ + { + "ok": true, + "valid" : true + } + """ + .data(using: .utf8) + + let response = try await smsValidatorAPIClient.validateSMS( + msisdn: "18222111000", + sender: "14222111000" + ) + + XCTAssertTrue(response.isSuccess) + XCTAssertNotNil(response.result) + XCTAssertTrue(response.result!) + + let lastRequest = self.session.lastRequest! + XCTAssertNotNil(lastRequest) + XCTAssertEqual( + "https://example.com/api/channels/sms/validate", + lastRequest.url!.absoluteString + ) + + let lastBody = try JSONSerialization.jsonObject( + with: lastRequest.body!, + options: [] + ) + let lastExpectedBody: Any = [ + "msisdn": "18222111000", + "sender": "14222111000" + ] + XCTAssertEqual( + lastBody as! NSDictionary, + lastExpectedBody as! NSDictionary + ) + } +} diff --git a/Airship/AirshipCore/Tests/SMSValidatorTest.swift b/Airship/AirshipCore/Tests/SMSValidatorTest.swift new file mode 100644 index 000000000..0f66aa090 --- /dev/null +++ b/Airship/AirshipCore/Tests/SMSValidatorTest.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import AirshipCore + +class SMSValidatorTests: XCTestCase { + var smsValidator: SMSValidator! + var testAPIClient: TestSMSValidatorAPIClient! + + override func setUp() { + super.setUp() + testAPIClient = TestSMSValidatorAPIClient() + smsValidator = SMSValidator(apiClient: testAPIClient) + } + + override func tearDown() { + smsValidator = nil + testAPIClient = nil + super.tearDown() + } + + /// Test when API returns true validator does also + func testValidMSISDNReturnsTrue() async throws { + let msisdn = "1234567890" + let sender = "TestSender" + testAPIClient.validationResult = true + + let result = try await smsValidator.validateSMS(msisdn: msisdn, sender: sender) + + XCTAssertTrue(result) + } + + /// Test when API returns false validator does also + func testInvalidMSISDNReturnsFalse() async throws { + let msisdn = "1234567890" + let sender = "TestSender" + testAPIClient.validationResult = false + + let result = try await smsValidator.validateSMS(msisdn: msisdn, sender: sender) + + XCTAssertFalse(result) + } + + /// Test when first API result is invalid no subsequent calls to API are made and validator returns false for each + func testPreviouslyFailedValidationReturnsFalse() async throws { + let msisdn = "1234567890" + let sender = "TestSender" + testAPIClient.validationResult = false + + let result1 = try await smsValidator.validateSMS(msisdn: msisdn, sender: sender) + let result2 = try await smsValidator.validateSMS(msisdn: msisdn, sender: sender) + + XCTAssertFalse(result1) + XCTAssertFalse(result2) + XCTAssertEqual(testAPIClient.validationCallCount, 1) + } +} + +class TestSMSValidatorAPIClient: SMSValidatorAPIClientProtocol, @unchecked Sendable { + var validationResult: Bool = true + var lastMSISDN: String? + var lastSender: String? + var validationCallCount = 0 + + func validateSMS(msisdn: String, sender: String) async throws -> AirshipHTTPResponse { + validationCallCount += 1 + lastMSISDN = msisdn + lastSender = sender + return AirshipHTTPResponse(result: validationResult, statusCode: 200, headers: ["cool" : "headers"]) + } +} diff --git a/Airship/AirshipCore/Tests/TestAnalytics.swift b/Airship/AirshipCore/Tests/TestAnalytics.swift index d8611a450..43ce18e46 100644 --- a/Airship/AirshipCore/Tests/TestAnalytics.swift +++ b/Airship/AirshipCore/Tests/TestAnalytics.swift @@ -28,6 +28,10 @@ public class TestAnalytics: InternalAnalyticsProtocol, AirshipComponent, @unchec public func recordEvent(_ event: AirshipEvent) { self.events.append(event) + + Task { + await eventFeed.notifyEvent(.analytics(eventType: event.eventType, body: event.eventData)) + } } private let screen = AirshipMainActorValue(nil) diff --git a/Airship/AirshipCore/Tests/TestAudienceChecker.swift b/Airship/AirshipCore/Tests/TestAudienceChecker.swift index 76e49f220..b7cbad29a 100644 --- a/Airship/AirshipCore/Tests/TestAudienceChecker.swift +++ b/Airship/AirshipCore/Tests/TestAudienceChecker.swift @@ -18,14 +18,21 @@ final class TestAudienceChecker: DeviceAudienceChecker, @unchecked Sendable { } final class TestAudienceDeviceInfoProvider: AudienceDeviceInfoProvider, @unchecked Sendable { + var channelID: String = UUID().uuidString + + var stableContactInfo: StableContactInfo = StableContactInfo( + contactID: "stable", + namedUserID: nil + ) + + var isChannelCreated: Bool = true + var sdkVersion: String = AirshipVersion.version var isAirshipReady: Bool = true var tags: Set = Set() - var channelID: String? = nil - var locale: Locale = Locale.current var appVersion: String? = nil @@ -38,5 +45,4 @@ final class TestAudienceDeviceInfoProvider: AudienceDeviceInfoProvider, @uncheck var installDate: Date = Date() - var stableContactID: String = "stable" } diff --git a/Airship/AirshipCore/Tests/TestContact.swift b/Airship/AirshipCore/Tests/TestContact.swift index daea9b137..d5e210b58 100644 --- a/Airship/AirshipCore/Tests/TestContact.swift +++ b/Airship/AirshipCore/Tests/TestContact.swift @@ -4,9 +4,49 @@ import Combine @testable import AirshipCore class TestContact: InternalAirshipContactProtocol, AirshipComponent, @unchecked Sendable { - func notifyRemoteLogin() { + var contactChannelUpdates: AsyncStream = AsyncStream.init { _ in } + + var contactChannelPublisher: AnyPublisher = Just(.success([])).eraseToAnyPublisher() + + func getStableContactInfo() async -> StableContactInfo { + return StableContactInfo(contactID: await getStableContactID(), namedUserID: namedUserID) + } + + init() {} + + func registerEmail(_ address: String, options: AirshipCore.EmailRegistrationOptions) { + + } + + func registerSMS(_ msisdn: String, options: AirshipCore.SMSRegistrationOptions) { + + } + + func registerOpen(_ address: String, options: AirshipCore.OpenRegistrationOptions) { + + } + + var smsValidatorDelegate: (any AirshipCore.SMSValidatorDelegate)? + + func resend(_ channel: AirshipCore.ContactChannel) { } + + func disassociateChannel(_ channel: AirshipCore.ContactChannel) { + + } + + func associateChannel(_ channelID: String, type: AirshipCore.ChannelType) { + + } + + var SMSValidatorDelegate: SMSValidatorDelegate? + func validateSMS(_ msisdn: String, sender: String) async throws -> Bool { + true + } + + func notifyRemoteLogin() { + } var contactIDInfo: AirshipCore.ContactIDInfo? = nil @@ -41,7 +81,6 @@ class TestContact: InternalAirshipContactProtocol, AirshipComponent, @unchecked public var conflictEventPublisher: AnyPublisher { conflictEventSubject.eraseToAnyPublisher() } - private let namedUserUpdatesSubject = PassthroughSubject() public var namedUserIDPublisher: AnyPublisher { @@ -60,7 +99,6 @@ class TestContact: InternalAirshipContactProtocol, AirshipComponent, @unchecked return self.namedUserID } - public var isComponentEnabled: Bool = true public var namedUserID: String? @@ -105,28 +143,6 @@ class TestContact: InternalAirshipContactProtocol, AirshipComponent, @unchecked editor.apply() } - public func registerEmail( - _ address: String, - options: EmailRegistrationOptions - ) { - // TODO - } - - public func registerSMS(_ msisdn: String, options: SMSRegistrationOptions) { - // TODO - } - - public func registerOpen( - _ address: String, - options: OpenRegistrationOptions - ) { - // TODO - } - - public func associateChannel(_ channelID: String, type: ChannelType) { - // TODO - } - public func editSubscriptionLists() -> ScopedSubscriptionListEditor { return subscriptionListEditor! } diff --git a/Airship/AirshipCore/Tests/TestContactAPIClient.swift b/Airship/AirshipCore/Tests/TestContactAPIClient.swift index 94a19c5bc..99212f69e 100644 --- a/Airship/AirshipCore/Tests/TestContactAPIClient.swift +++ b/Airship/AirshipCore/Tests/TestContactAPIClient.swift @@ -3,30 +3,41 @@ import Foundation @testable import AirshipCore class TestContactAPIClient: ContactsAPIClientProtocol, @unchecked Sendable { - var resolveCallback: - ((String, String?, String?) async throws -> AirshipHTTPResponse)? + ((String, String?, String?) async throws -> AirshipHTTPResponse)? var identifyCallback: - ((String, String, String?, String?) async throws -> AirshipHTTPResponse)? + ((String, String, String?, String?) async throws -> AirshipHTTPResponse)? var resetCallback: - ((String, String?) async throws -> AirshipHTTPResponse)? + ((String, String?) async throws -> AirshipHTTPResponse)? + + var resendCallback: + ((ResendOptions) async throws -> AirshipHTTPResponse)? var updateCallback: - ((String, [TagGroupUpdate]?, [AttributeUpdate]?, [ScopedSubscriptionListUpdate]?) async throws -> AirshipHTTPResponse)? + ((String, [TagGroupUpdate]?, [AttributeUpdate]?, [ScopedSubscriptionListUpdate]?) async throws -> AirshipHTTPResponse)? var associateChannelCallback: - ((String, String, ChannelType) async throws -> AirshipHTTPResponse)? + ((String, String, ChannelType) async throws -> AirshipHTTPResponse)? + + var disassociateChannelCallback: + ((Bool, String, String) async throws -> AirshipHTTPResponse)? + + var disassociateEmailCallback: + ((Bool, String, String) async throws -> AirshipHTTPResponse)? + + var disassociateSMSCallback: + ((Bool, String, String, String) async throws -> AirshipHTTPResponse)? var registerEmailCallback: - ((String, String, EmailRegistrationOptions, Locale) async throws -> AirshipHTTPResponse)? + ((String, String, EmailRegistrationOptions, Locale) async throws -> AirshipHTTPResponse)? var registerSMSCallback: - ((String, String, SMSRegistrationOptions, Locale) async throws -> AirshipHTTPResponse)? + ((String, String, SMSRegistrationOptions, Locale) async throws -> AirshipHTTPResponse)? var registerOpenCallback: - ((String, String, OpenRegistrationOptions, Locale) async throws -> AirshipHTTPResponse)? + ((String, String, OpenRegistrationOptions, Locale) async throws -> AirshipHTTPResponse)? init() {} @@ -63,11 +74,10 @@ class TestContactAPIClient: ContactsAPIClientProtocol, @unchecked Sendable { return try await updateCallback!(contactID, tagGroupUpdates, attributeUpdates, subscriptionListUpdates) } - public func associateChannel( - contactID: String, - channelID: String, - channelType: ChannelType - ) async throws -> AirshipHTTPResponse { + func associateChannel(contactID: String, + channelID: String, + channelType: ChannelType + ) async throws -> AirshipHTTPResponse { return try await associateChannelCallback!(contactID, channelID, channelType) } @@ -76,7 +86,7 @@ class TestContactAPIClient: ContactsAPIClientProtocol, @unchecked Sendable { address: String, options: EmailRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { return try await registerEmailCallback!(contactID, address, options, locale) } @@ -85,16 +95,34 @@ class TestContactAPIClient: ContactsAPIClientProtocol, @unchecked Sendable { msisdn: String, options: SMSRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { return try await registerSMSCallback!(contactID, msisdn, options, locale) } - + public func registerOpen( contactID: String, address: String, options: OpenRegistrationOptions, locale: Locale - ) async throws -> AirshipHTTPResponse { + ) async throws -> AirshipHTTPResponse { return try await registerOpenCallback!(contactID, address, options, locale) } + + func resend(resendOptions: ResendOptions) async throws -> AirshipHTTPResponse { + return try await resendCallback!(resendOptions) + } + + func disassociateChannel( + contactID: String, + disassociateOptions: DisassociateOptions + ) async throws -> AirshipHTTPResponse { + switch disassociateOptions { + case .channel(let channel): + return try await disassociateChannelCallback!(true, channel.channelID, channel.channelType) + case .email(let email): + return try await disassociateEmailCallback!(false, email.address, email.channelType) + case .sms(let sms): + return try await disassociateSMSCallback!(false, sms.msisdn, sms.senderID, sms.channelType) + } + } } diff --git a/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift b/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift index c5f1ff3e1..a921af5d1 100644 --- a/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift +++ b/Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift @@ -15,3 +15,30 @@ class TestContactSubscriptionListAPIClient: ContactSubscriptionListAPIClientProt } } + +actor TestContactChannelsProvider: ContactChannelsProviderProtocol, @unchecked Sendable { + nonisolated func contactChannels(stableContactIDUpdates: AsyncStream) -> AsyncStream { + return AsyncStream { _ in } + } + + func contactUpdates(contactID: String) async throws -> AsyncStream<[ContactChannel]> { + return AsyncStream<[ContactChannel]> { _ in } + } + + init() {} +} + + +//ContactChannelsProviderProtocol +//ContactChannelsAPIClientProtocol + +class TestChannelsListAPIClient: ContactChannelsAPIClientProtocol, @unchecked Sendable { + + func fetchAssociatedChannelsList( + contactID: String + ) async throws -> AirshipHTTPResponse<[ContactChannel]> { + return AirshipHTTPResponse(result: nil, statusCode: 200, headers: [:]) + } + + init() {} +} diff --git a/Airship/AirshipDebug/Source/AirshipDebugManager.swift b/Airship/AirshipDebug/Source/AirshipDebugManager.swift index bf6712a92..97cb7838d 100644 --- a/Airship/AirshipDebug/Source/AirshipDebugManager.swift +++ b/Airship/AirshipDebug/Source/AirshipDebugManager.swift @@ -111,7 +111,7 @@ public final class AirshipDebugManager: @unchecked Sendable { let airshipEvent = AirshipEvent( identifier: incoming.id, - type: incoming.type, + type: incoming.type.reportingName, date: incoming.date, body: body ) diff --git a/Airship/AirshipFeatureFlags/Source/DeferredFlagResolver.swift b/Airship/AirshipFeatureFlags/Source/DeferredFlagResolver.swift index 0bdbc4648..4289849b8 100644 --- a/Airship/AirshipFeatureFlags/Source/DeferredFlagResolver.swift +++ b/Airship/AirshipFeatureFlags/Source/DeferredFlagResolver.swift @@ -9,7 +9,7 @@ protocol FeatureFlagDeferredResolverProtocol: AnyActor { func resolve( request: DeferredRequest, flagInfo: FeatureFlagInfo - ) async throws -> FeatureFlag + ) async throws -> DeferredFlagResponse } actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { @@ -23,7 +23,7 @@ actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { private let date: AirshipDateProtocol private let taskSleeper: AirshipTaskSleeper - private var pendingTasks: [String: Task] = [:] + private var pendingTasks: [String: Task] = [:] private var backOffDates: [String: Date] = [:] init( @@ -41,7 +41,7 @@ actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { func resolve( request: DeferredRequest, flagInfo: FeatureFlagInfo - ) async throws -> FeatureFlag { + ) async throws -> DeferredFlagResponse { let requestID = [ flagInfo.name, @@ -54,11 +54,11 @@ actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { _ = try? await pendingTasks[requestID]?.value let task = Task { - if let cached: FeatureFlag = await self.cache.getCachedValue(key: requestID) { + if let cached: DeferredFlagResponse = await self.cache.getCachedValue(key: requestID) { return cached } - let flag = try await self.fetchFlag( + let result = try await self.fetchFlag( request: request, requestID: requestID, flagInfo: flagInfo, @@ -71,8 +71,8 @@ actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { ttl = max(ttl, ttlSeconds) } - await self.cache.setCachedValue(flag, key: requestID, ttl: ttl) - return flag + await self.cache.setCachedValue(result, key: requestID, ttl: ttl) + return result } pendingTasks[requestID] = task @@ -84,41 +84,24 @@ actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { requestID: String, flagInfo: FeatureFlagInfo, allowRetry: Bool - ) async throws -> FeatureFlag { + ) async throws -> DeferredFlagResponse { let now = self.date.now if let backOffDate = backOffDates[requestID], backOffDate > now { try await self.taskSleeper.sleep( timeInterval: backOffDate.timeIntervalSince(now) ) } - + let result = await deferredResolver.resolve(request: request) { data in - return try AirshipJSON.defaultDecoder.decode(DeferredFlagResult.self, from: data) + return try AirshipJSON.defaultDecoder.decode(DeferredFlag.self, from: data) } switch(result) { - case .success(let body): - return FeatureFlag( - name: flagInfo.name, - isEligible: body.isEligible, - exists: true, - variables: body.variables, - reportingInfo: FeatureFlag.ReportingInfo( - reportingMetadata: body.reportingMetadata, - contactID: request.contactID, - channelID: request.channelID - ) - ) - + case .success(let flag): + return .found(flag) case .notFound: - return FeatureFlag( - name: flagInfo.name, - isEligible: false, - exists: false, - variables: nil, - reportingInfo: nil - ) + return .notFound case .retriableError(let retryAfter): let backoff = retryAfter ?? FeatureFlagDeferredResolver.defaultBackoff @@ -148,9 +131,14 @@ actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { } } -fileprivate struct DeferredFlagResult : Codable, Equatable { +enum DeferredFlagResponse: Codable, Equatable { + case notFound + case found(DeferredFlag) +} + +struct DeferredFlag: Codable, Equatable { let isEligible: Bool - let variables: AirshipJSON? + let variables: FeatureFlagVariables? let reportingMetadata: AirshipJSON enum CodingKeys: String, CodingKey { case isEligible = "is_eligible" diff --git a/Airship/AirshipFeatureFlags/Source/FeatureFlagAnalytics.swift b/Airship/AirshipFeatureFlags/Source/FeatureFlagAnalytics.swift index fcf8185a5..369b09004 100644 --- a/Airship/AirshipFeatureFlags/Source/FeatureFlagAnalytics.swift +++ b/Airship/AirshipFeatureFlags/Source/FeatureFlagAnalytics.swift @@ -12,8 +12,6 @@ protocol FeatureFlagAnalyticsProtocol: Sendable { } final class FeatureFlagAnalytics: FeatureFlagAnalyticsProtocol { - private static let interactionEventType: String = "feature_flag_interaction" - private let airshipAnalytics: InternalAnalyticsProtocol private enum FlagKeys { @@ -58,12 +56,11 @@ final class FeatureFlagAnalytics: FeatureFlagAnalyticsProtocol { } let airshipEvent = AirshipEvent( - eventType: Self.interactionEventType, + eventType: .featureFlagInteraction, eventData: eventBody ) airshipAnalytics.recordEvent(airshipEvent) - airshipAnalytics.eventFeed.notifyEvent(.featureFlagInteraction(body: eventBody)) } } diff --git a/Airship/AirshipFeatureFlags/Source/FeatureFlagManager.swift b/Airship/AirshipFeatureFlags/Source/FeatureFlagManager.swift index aea962b4b..bcdb18526 100644 --- a/Airship/AirshipFeatureFlags/Source/FeatureFlagManager.swift +++ b/Airship/AirshipFeatureFlags/Source/FeatureFlagManager.swift @@ -153,80 +153,141 @@ public final class FeatureFlagManager: Sendable { let flagInfos = remoteDataFeatureFlagInfo.flagInfos let deviceInfoProvider = deviceInfoProviderFactory() - guard !flagInfos.isEmpty else { - return FeatureFlag( - name: name, - isEligible: false, - exists: false, - variables: nil, - reportingInfo: nil - ) - } - for flagInfo in flagInfos { - if let audienceSelector = flagInfo.audienceSelector { - let result = try? await self.audienceChecker.evaluate( - audience: audienceSelector, - newUserEvaluationDate: flagInfo.created, - deviceInfoProvider: deviceInfoProvider - ) + for (index, flagInfo) in flagInfos.enumerated() { + let isLast = index == (flagInfos.count - 1) + let isLocallyEligible = try await self.isLocallyEligible( + flagInfo: flagInfo, + deviceInfoProvider: deviceInfoProvider + ) - if (result != true) { - continue - } + // We are not locally eligible and have other flags skip + if !isLast, !isLocallyEligible { + continue } - switch (flagInfo.flagPayload) { + let flag: FeatureFlag = switch (flagInfo.flagPayload) { case .deferredPayload(let deferredInfo): - let request = DeferredRequest( - url: deferredInfo.deferred.url, - channelID: deviceInfoProvider.channelID!, - contactID: await deviceInfoProvider.stableContactID, - locale: deviceInfoProvider.locale, - notificationOptIn: await deviceInfoProvider.isUserOptedInPushNotifications + try await evaluateDeferred( + flagInfo: flagInfo, + isLocallyEligible: isLocallyEligible, + deferredInfo: deferredInfo, + deviceInfoProvider: deviceInfoProvider ) - - return try await deferredResolver.resolve(request: request, flagInfo: flagInfo) - case .staticPayload(let staticInfo): - let variables = await evaluateVariables( - staticInfo.variables, + try await evaluateStatic( flagInfo: flagInfo, + isLocallyEligible: isLocallyEligible, + staticInfo: staticInfo, deviceInfoProvider: deviceInfoProvider ) - return FeatureFlag( - name: name, - isEligible: true, - exists: true, - variables: variables?.data, - reportingInfo: FeatureFlag.ReportingInfo( - reportingMetadata: variables?.reportingMetadata ?? flagInfo.reportingMetadata, - contactID: await deviceInfoProvider.stableContactID, - channelID: deviceInfoProvider.channelID - ) - ) + } + + /// If the flag is eligible or the last flag return + if flag.isEligible || isLast { + return flag } } - return FeatureFlag( - name: name, - isEligible: false, - exists: true, - variables: nil, - reportingInfo: FeatureFlag.ReportingInfo( - reportingMetadata: flagInfos.last?.reportingMetadata ?? .null, - contactID: await deviceInfoProvider.stableContactID, - channelID: deviceInfoProvider.channelID + return FeatureFlag.makeNotFound(name: name) + } + + private func isLocallyEligible( + flagInfo: FeatureFlagInfo, + deviceInfoProvider: AudienceDeviceInfoProvider + ) async throws -> Bool { + guard let audienceSelector = flagInfo.audienceSelector else { + return true + } + + return try await self.audienceChecker.evaluate( + audience: audienceSelector, + newUserEvaluationDate: flagInfo.created, + deviceInfoProvider: deviceInfoProvider + ) + } + + private func evaluateDeferred( + flagInfo: FeatureFlagInfo, + isLocallyEligible: Bool, + deferredInfo: FeatureFlagPayload.DeferredInfo, + deviceInfoProvider: AudienceDeviceInfoProvider + ) async throws -> FeatureFlag { + + guard isLocallyEligible else { + return try await FeatureFlag.makeFound( + name: flagInfo.name, + isEligible: false, + deviceInfoProvider: deviceInfoProvider, + reportingMetadata: flagInfo.reportingMetadata, + variables: nil + ) + } + + let request = DeferredRequest( + url: deferredInfo.deferred.url, + channelID: try await deviceInfoProvider.channelID, + contactID: await deviceInfoProvider.stableContactInfo.contactID, + locale: deviceInfoProvider.locale, + notificationOptIn: await deviceInfoProvider.isUserOptedInPushNotifications + ) + + let deferredFlagResult = try await deferredResolver.resolve( + request: request, + flagInfo: flagInfo + ) + + switch(deferredFlagResult) { + case .notFound: + return FeatureFlag.makeNotFound(name: flagInfo.name) + + case .found(let deferredFlag): + let variables = await evaluateVariables( + deferredFlag.variables, + flagInfo: flagInfo, + isEligible: deferredFlag.isEligible, + deviceInfoProvider: deviceInfoProvider + ) + + return try await FeatureFlag.makeFound( + name: flagInfo.name, + isEligible: deferredFlag.isEligible, + deviceInfoProvider: deviceInfoProvider, + reportingMetadata: deferredFlag.reportingMetadata, + variables: variables ) + } + } + + private func evaluateStatic( + flagInfo: FeatureFlagInfo, + isLocallyEligible: Bool, + staticInfo: FeatureFlagPayload.StaticInfo, + deviceInfoProvider: AudienceDeviceInfoProvider + ) async throws -> FeatureFlag { + let variables = await evaluateVariables( + staticInfo.variables, + flagInfo: flagInfo, + isEligible: isLocallyEligible, + deviceInfoProvider: deviceInfoProvider + ) + + return try await FeatureFlag.makeFound( + name: flagInfo.name, + isEligible: isLocallyEligible, + deviceInfoProvider: deviceInfoProvider, + reportingMetadata: flagInfo.reportingMetadata, + variables: variables ) } private func evaluateVariables( _ variables: FeatureFlagVariables?, flagInfo: FeatureFlagInfo, + isEligible: Bool, deviceInfoProvider: AudienceDeviceInfoProvider ) async -> VariableResult? { - guard let variables = variables else { + guard let variables = variables, isEligible else { return nil } @@ -257,8 +318,43 @@ public final class FeatureFlagManager: Sendable { } } - struct VariableResult { - let data: AirshipJSON? - let reportingMetadata: AirshipJSON? + +} + + +fileprivate struct VariableResult { + let data: AirshipJSON? + let reportingMetadata: AirshipJSON? +} + +fileprivate extension FeatureFlag { + static func makeNotFound(name: String) -> FeatureFlag { + return FeatureFlag( + name: name, + isEligible: false, + exists: false, + variables: nil, + reportingInfo: nil + ) + } + + static func makeFound( + name: String, + isEligible: Bool, + deviceInfoProvider: AudienceDeviceInfoProvider, + reportingMetadata: AirshipJSON, + variables: VariableResult? + ) async throws -> FeatureFlag { + return FeatureFlag( + name: name, + isEligible: isEligible, + exists: true, + variables: variables?.data, + reportingInfo: FeatureFlag.ReportingInfo( + reportingMetadata: variables?.reportingMetadata ?? reportingMetadata, + contactID: await deviceInfoProvider.stableContactInfo.contactID, + channelID: try await deviceInfoProvider.channelID + ) + ) } } diff --git a/Airship/AirshipFeatureFlags/Source/FeatureFlagPayload.swift b/Airship/AirshipFeatureFlags/Source/FeatureFlagPayload.swift index b73a804ef..18c46aa14 100644 --- a/Airship/AirshipFeatureFlags/Source/FeatureFlagPayload.swift +++ b/Airship/AirshipFeatureFlags/Source/FeatureFlagPayload.swift @@ -185,12 +185,12 @@ enum FeatureFlagPayload: Decodable, Equatable { } } -enum FeatureFlagVariables: Decodable, Equatable { +enum FeatureFlagVariables: Codable, Equatable { case fixed(AirshipJSON?) - case variant( [VariablesVariant]) + case variant([VariablesVariant]) - struct VariablesVariant: Decodable, Equatable { + struct VariablesVariant: Codable, Equatable { let id: String let audienceSelector: DeviceAudienceSelector? let reportingMetadata: AirshipJSON @@ -204,7 +204,7 @@ enum FeatureFlagVariables: Decodable, Equatable { } } - private enum FeatureFlagVariableType: String, Decodable { + private enum FeatureFlagVariableType: String, Codable { case fixed case variant } @@ -230,6 +230,19 @@ enum FeatureFlagVariables: Decodable, Equatable { ) } } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .fixed(let data): + try container.encode(FeatureFlagVariableType.fixed, forKey: .type) + try container.encodeIfPresent(data, forKey: .data) + case .variant(let variants): + try container.encode(FeatureFlagVariableType.variant, forKey: .type) + try container.encode(variants, forKey: .variants) + } + } } diff --git a/Airship/AirshipFeatureFlags/Tests/FeatureFlagAnalyticsTest.swift b/Airship/AirshipFeatureFlags/Tests/FeatureFlagAnalyticsTest.swift index 6b2269df1..e16bd896a 100644 --- a/Airship/AirshipFeatureFlags/Tests/FeatureFlagAnalyticsTest.swift +++ b/Airship/AirshipFeatureFlags/Tests/FeatureFlagAnalyticsTest.swift @@ -3,7 +3,7 @@ import XCTest @testable import AirshipFeatureFlags -import AirshipCore +@testable import AirshipCore final class FeatureFlagAnalyticsTest: XCTestCase { private let airshipAnalytics: TestAnalytics = TestAnalytics() @@ -71,7 +71,7 @@ final class FeatureFlagAnalyticsTest: XCTestCase { XCTAssertEqual(1, self.airshipAnalytics.events.count) let event = self.airshipAnalytics.events.first! - XCTAssertEqual("feature_flag_interaction", event.eventType) + XCTAssertEqual("feature_flag_interaction", event.eventType.reportingName) XCTAssertEqual(AirshipEventPriority.normal, event.priority) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -98,7 +98,7 @@ final class FeatureFlagAnalyticsTest: XCTestCase { XCTAssertEqual(1, self.airshipAnalytics.events.count) let event = self.airshipAnalytics.events.first! - XCTAssertEqual("feature_flag_interaction", event.eventType) + XCTAssertEqual("feature_flag_interaction", event.eventType.reportingName) XCTAssertEqual(AirshipEventPriority.normal, event.priority) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @@ -113,13 +113,14 @@ final class FeatureFlagAnalyticsTest: XCTestCase { ) ) - var feed = self.airshipAnalytics.eventFeed.updates.makeAsyncIterator() + var feed = await self.airshipAnalytics.eventFeed.updates.makeAsyncIterator() + self.analytics.trackInteraction(flag: flag) let event = self.airshipAnalytics.events.first! XCTAssertEqual(1, self.airshipAnalytics.events.count) let next = await feed.next() - XCTAssertEqual(next, .featureFlagInteraction(body: event.eventData)) + XCTAssertEqual(next, .analytics(eventType: .featureFlagInteraction, body: event.eventData)) } } diff --git a/Airship/AirshipFeatureFlags/Tests/FeatureFlagDeferredResolverTest.swift b/Airship/AirshipFeatureFlags/Tests/FeatureFlagDeferredResolverTest.swift index 2bf47340f..256e3e2ca 100644 --- a/Airship/AirshipFeatureFlags/Tests/FeatureFlagDeferredResolverTest.swift +++ b/Airship/AirshipFeatureFlags/Tests/FeatureFlagDeferredResolverTest.swift @@ -45,71 +45,71 @@ final class FeatureFlagDeferredResolverTest: XCTestCase { ) } - func testResolveEligible() async throws { + func testResolve() async throws { let expectation = expectation(description: "flag resolved") + self.deferredResolver.onData = { request in expectation.fulfill() XCTAssertEqual(request, self.request) let data = try! AirshipJSON.wrap([ - "is_eligible": true, - "variables": ["var": "one"], + "is_eligible": false, "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) } - let flag = try await self.resolver.resolve( + let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) - let expected = FeatureFlag( - name: flagInfo.name, - isEligible: true, - exists: true, - variables: try! AirshipJSON.wrap(["var": "one"]), - reportingInfo: FeatureFlag.ReportingInfo( - reportingMetadata: try! AirshipJSON.wrap(["reporting": "reporting"]), - contactID: request.contactID, - channelID: request.channelID + let expected = DeferredFlagResponse.found( + DeferredFlag( + isEligible: false, + variables: nil, + reportingMetadata: try! AirshipJSON.wrap(["reporting": "reporting"]) ) ) - XCTAssertEqual(expected, flag) + + XCTAssertEqual(expected, result) await fulfillment(of: [expectation]) } - func testResolveInelegible() async throws { + func testResolveVariables() async throws { let expectation = expectation(description: "flag resolved") self.deferredResolver.onData = { request in expectation.fulfill() XCTAssertEqual(request, self.request) let data = try! AirshipJSON.wrap([ - "is_eligible": false, + "is_eligible": true, + "variables": [ + "type": "fixed", + "data": [ + "var": "one" + ] + ], "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) } - let flag = try await self.resolver.resolve( + let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) - let expected = FeatureFlag( - name: flagInfo.name, - isEligible: false, - exists: true, - reportingInfo: FeatureFlag.ReportingInfo( - reportingMetadata: try! AirshipJSON.wrap(["reporting": "reporting"]), - contactID: request.contactID, - channelID: request.channelID + let expected = DeferredFlagResponse.found( + DeferredFlag( + isEligible: true, + variables: .fixed(try! AirshipJSON.wrap(["var": "one"])), + reportingMetadata: try! AirshipJSON.wrap(["reporting": "reporting"]) ) ) - XCTAssertEqual(expected, flag) + XCTAssertEqual(expected, result) await fulfillment(of: [expectation]) } @@ -122,18 +122,12 @@ final class FeatureFlagDeferredResolverTest: XCTestCase { } - let flag = try await self.resolver.resolve( + let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) - let expected = FeatureFlag( - name: flagInfo.name, - isEligible: false, - exists: false - ) - - XCTAssertEqual(expected, flag) + XCTAssertEqual(DeferredFlagResponse.notFound, result) await fulfillment(of: [expectation]) @@ -251,25 +245,18 @@ final class FeatureFlagDeferredResolverTest: XCTestCase { return .notFound } - let expected = FeatureFlag( - name: flagInfo.name, - isEligible: false, - exists: false - ) - - let flag = try await self.resolver.resolve( + let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) - XCTAssertEqual(expected, flag) + XCTAssertEqual(DeferredFlagResponse.notFound, result) } func testCache() async throws { self.deferredResolver.onData = { _ in let data = try! AirshipJSON.wrap([ "is_eligible": true, - "variables": ["var": "one"], "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) @@ -289,9 +276,19 @@ final class FeatureFlagDeferredResolverTest: XCTestCase { ].joined(separator: ":") let entry = await self.cache.entry(key: expectedKey)! + + + let expectedValue = DeferredFlagResponse.found( + DeferredFlag( + isEligible: true, + variables: nil, + reportingMetadata: try! AirshipJSON.wrap(["reporting": "reporting"]) + ) + ) + XCTAssertEqual( - try JSONDecoder().decode(FeatureFlag.self, from: entry.data), - flag + try JSONDecoder().decode(DeferredFlagResponse.self, from: entry.data), + expectedValue ) XCTAssertEqual(entry.ttl, 60.0) @@ -311,7 +308,6 @@ final class FeatureFlagDeferredResolverTest: XCTestCase { self.deferredResolver.onData = { _ in let data = try! AirshipJSON.wrap([ "is_eligible": true, - "variables": ["var": "one"], "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) @@ -331,7 +327,7 @@ final class FeatureFlagDeferredResolverTest: XCTestCase { evaluationOptions: EvaluationOptions(ttlMS: 120000) ) - let flag = try await self.resolver.resolve( + let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) @@ -346,8 +342,8 @@ final class FeatureFlagDeferredResolverTest: XCTestCase { let entry = await self.cache.entry(key: expectedKey)! XCTAssertEqual( - try JSONDecoder().decode(FeatureFlag.self, from: entry.data), - flag + try JSONDecoder().decode(DeferredFlagResponse.self, from: entry.data), + result ) XCTAssertEqual(entry.ttl, 120.0) @@ -387,6 +383,7 @@ fileprivate struct CacheEntry: Sendable { let data: Data let ttl: TimeInterval } + fileprivate actor TestCache: AirshipCache { private var values: [String: CacheEntry] = [:] diff --git a/Airship/AirshipFeatureFlags/Tests/FeatureFlagManagerTest.swift b/Airship/AirshipFeatureFlags/Tests/FeatureFlagManagerTest.swift index 8c9b2ab86..01d81009d 100644 --- a/Airship/AirshipFeatureFlags/Tests/FeatureFlagManagerTest.swift +++ b/Airship/AirshipFeatureFlags/Tests/FeatureFlagManagerTest.swift @@ -131,7 +131,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -170,7 +170,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -207,7 +207,105 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, + channelID: self.deviceInfoProvider.channelID + ) + ) + XCTAssertEqual(expected, flag) + } + + func testAudienceMissLastInfoStatic() async throws { + self.remoteDataAccess.status = .upToDate + self.remoteDataAccess.flagInfos = [ + FeatureFlagInfo( + id: "some ID", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting 1"), + audienceSelector: DeviceAudienceSelector(newUser: true), + flagPayload: .staticPayload( + FeatureFlagPayload.StaticInfo(variables: nil) + ) + ), + FeatureFlagInfo( + id: "some other ID", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting 2"), + audienceSelector: DeviceAudienceSelector(newUser: true), + flagPayload: .staticPayload( + FeatureFlagPayload.StaticInfo( + variables: .fixed(.string("some variables")) + ) + ) + ), + + ] + + self.audienceChecker.onEvaluate = { _, _, _ in + return false + } + + let flag = try await featureFlagManager.flag(name: "foo") + let expected = FeatureFlag( + name: "foo", + isEligible: false, + exists: true, + variables: nil, + reportingInfo: FeatureFlag.ReportingInfo( + reportingMetadata: .string("reporting 2"), + contactID: self.deviceInfoProvider.stableContactInfo.contactID, + channelID: self.deviceInfoProvider.channelID + ) + ) + XCTAssertEqual(expected, flag) + } + + func testAudienceMissLastInfoDeferred() async throws { + self.remoteDataAccess.status = .upToDate + self.remoteDataAccess.flagInfos = [ + FeatureFlagInfo( + id: "some ID", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting 1"), + audienceSelector: DeviceAudienceSelector(newUser: true), + flagPayload: .staticPayload( + FeatureFlagPayload.StaticInfo(variables: nil) + ) + ), + FeatureFlagInfo( + id: "some other ID", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting 2"), + audienceSelector: DeviceAudienceSelector(newUser: true), + flagPayload: .deferredPayload( + FeatureFlagPayload.DeferredInfo( + deferred: .init(url: URL(string: "some-url://")!) + ) + ) + ), + + ] + + self.audienceChecker.onEvaluate = { _, _, _ in + return false + } + + let flag = try await featureFlagManager.flag(name: "foo") + let expected = FeatureFlag( + name: "foo", + isEligible: false, + exists: true, + variables: nil, + reportingInfo: FeatureFlag.ReportingInfo( + reportingMetadata: .string("reporting 2"), + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -259,7 +357,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: AirshipJSON.string("flagInfo2 variables"), reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -319,13 +417,136 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: variables[1].data, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: variables[1].reportingMetadata, - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } + + func testVariantVariablesDeferred() async throws { + let variables: [FeatureFlagVariables.VariablesVariant] = [ + FeatureFlagVariables.VariablesVariant( + id: "variant 1", + audienceSelector: DeviceAudienceSelector(tagSelector: .tag("1")), + reportingMetadata: AirshipJSON.string("Variant reporting"), + data: AirshipJSON.string("variant1 variables") + ), + FeatureFlagVariables.VariablesVariant( + id: "variant 2", + audienceSelector: DeviceAudienceSelector(tagSelector: .tag("2")), + reportingMetadata: AirshipJSON.string("Variant reporting"), + data: AirshipJSON.string("variant2 variables") + ), + FeatureFlagVariables.VariablesVariant( + id: "variant 3", + audienceSelector: DeviceAudienceSelector(tagSelector: .tag("3")), + reportingMetadata: AirshipJSON.string("Variant reporting"), + data: AirshipJSON.string("variant3 variables") + ) + ] + let flagInfo = FeatureFlagInfo( + id: "some ID", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting"), + flagPayload: .deferredPayload( + FeatureFlagPayload.DeferredInfo( + deferred: .init(url: URL(string: "some-url://")!) + ) + ) + ) + + let deferredResponse = DeferredFlagResponse.found( + DeferredFlag(isEligible: true, variables: .variant(variables), reportingMetadata: .string("reporting two")) + ) + + let expectedFlag = FeatureFlag( + name: "foo", + isEligible: true, + exists: true, + variables: variables[1].data, + reportingInfo: FeatureFlag.ReportingInfo( + reportingMetadata: .string("Variant reporting"), + contactID: self.deviceInfoProvider.stableContactInfo.contactID, + channelID: self.deviceInfoProvider.channelID + ) + ) + + self.remoteDataAccess.flagInfos = [ + flagInfo + ] + + self.audienceChecker.onEvaluate = { selector, _, _ in + // match second variant + return selector == variables[1].audienceSelector + } + + await self.deferredResolver.setOnResolve { _, _ in + return deferredResponse + } + + let result = try await featureFlagManager.flag(name: "foo") + XCTAssertEqual(result, expectedFlag) + } + + func testVariantVariablesDeferredNoMatch() async throws { + let variables: [FeatureFlagVariables.VariablesVariant] = [ + FeatureFlagVariables.VariablesVariant( + id: "variant 1", + audienceSelector: DeviceAudienceSelector(tagSelector: .tag("1")), + reportingMetadata: AirshipJSON.string("Variant reporting"), + data: AirshipJSON.string("variant1 variables") + ), + ] + let flagInfo = FeatureFlagInfo( + id: "some ID", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting"), + flagPayload: .deferredPayload( + FeatureFlagPayload.DeferredInfo( + deferred: .init(url: URL(string: "some-url://")!) + ) + ) + ) + + let deferredResponse = DeferredFlagResponse.found( + DeferredFlag(isEligible: false, variables: .variant(variables), reportingMetadata: .string("reporting two")) + ) + + let expectedFlag = FeatureFlag( + name: "foo", + isEligible: false, + exists: true, + variables: nil, + reportingInfo: FeatureFlag.ReportingInfo( + reportingMetadata: .string("reporting two"), + contactID: self.deviceInfoProvider.stableContactInfo.contactID, + channelID: self.deviceInfoProvider.channelID + ) + ) + + self.remoteDataAccess.flagInfos = [ + flagInfo + ] + + self.audienceChecker.onEvaluate = { selector, _, _ in + // match second variant + return selector == variables[1].audienceSelector + } + + await self.deferredResolver.setOnResolve { _, _ in + return deferredResponse + } + + let result = try await featureFlagManager.flag(name: "foo") + XCTAssertEqual(result, expectedFlag) + } + func testVariantVariablesNoMatch() async throws { let variables: [FeatureFlagVariables.VariablesVariant] = [ @@ -367,7 +588,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: flagInfo.reportingMetadata, - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -402,7 +623,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -437,7 +658,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -572,7 +793,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting two"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -623,7 +844,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting two"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -638,7 +859,7 @@ final class AirshipFeatureFlagsTest: XCTestCase { variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting two"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -662,14 +883,18 @@ final class AirshipFeatureFlagsTest: XCTestCase { ) ) - let flag = FeatureFlag( + let deferredResponse = DeferredFlagResponse.found( + DeferredFlag(isEligible: false, variables: nil, reportingMetadata: .string("reporting two")) + ) + + let expectedFlag = FeatureFlag( name: "foo", isEligible: false, - exists: false, + exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: .string("reporting two"), - contactID: self.deviceInfoProvider.stableContactID, + contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) @@ -684,16 +909,16 @@ final class AirshipFeatureFlagsTest: XCTestCase { await self.deferredResolver.setOnResolve { [deviceInfoProvider] request, info in XCTAssertEqual(request.url, URL(string: "some-url://")) - XCTAssertEqual(request.contactID, deviceInfoProvider.stableContactID) + XCTAssertEqual(request.contactID, deviceInfoProvider.stableContactInfo.contactID) XCTAssertEqual(request.channelID, deviceInfoProvider.channelID) XCTAssertEqual(request.locale, deviceInfoProvider.locale) XCTAssertEqual(request.notificationOptIn, deviceInfoProvider.isUserOptedInPushNotifications) XCTAssertEqual(flagInfo, info) - return flag + return deferredResponse } let result = try await featureFlagManager.flag(name: "foo") - XCTAssertEqual(result, flag) + XCTAssertEqual(result, expectedFlag) } func testDeferredLocalAudience() async throws { @@ -728,6 +953,87 @@ final class AirshipFeatureFlagsTest: XCTestCase { XCTAssertFalse(result.isEligible) } + func testMultipleDeferred() async throws { + self.remoteDataAccess.flagInfos = [ + FeatureFlagInfo( + id: "one", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting one"), + audienceSelector: DeviceAudienceSelector(newUser: false), + flagPayload: .deferredPayload( + FeatureFlagPayload.DeferredInfo( + deferred: .init(url: URL(string: "some-url://")!) + ) + ) + ), + FeatureFlagInfo( + id: "two", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting two"), + audienceSelector: DeviceAudienceSelector(newUser: true), + flagPayload: .deferredPayload( + FeatureFlagPayload.DeferredInfo( + deferred: .init(url: URL(string: "some-url://")!) + ) + ) + ), + FeatureFlagInfo( + id: "three", + created: Date(), + lastUpdated: Date(), + name: "foo", + reportingMetadata: .string("reporting three"), + flagPayload: .deferredPayload( + FeatureFlagPayload.DeferredInfo( + deferred: .init(url: URL(string: "some-url://")!) + ) + ) + ) + ] + + self.audienceChecker.onEvaluate = { selector, newUserDate, _ in + return selector.newUser == true + } + + await self.deferredResolver.setOnResolve { request, info in + DeferredFlagResponse.found( + DeferredFlag( + isEligible: info.id == "three", + variables: nil, + reportingMetadata: info.reportingMetadata + ) + ) + } + + let expectedFlag = FeatureFlag( + name: "foo", + isEligible: true, + exists: true, + variables: nil, + reportingInfo: FeatureFlag.ReportingInfo( + reportingMetadata: .string("reporting three"), + contactID: self.deviceInfoProvider.stableContactInfo.contactID, + channelID: self.deviceInfoProvider.channelID + ) + ) + + let result = try await featureFlagManager.flag(name: "foo") + XCTAssertEqual(expectedFlag, result) + + let resolved = await self.deferredResolver.resolvedFlagInfos + XCTAssertEqual( + [ + self.remoteDataAccess.flagInfos[1], + self.remoteDataAccess.flagInfos[2] + ], + resolved + ) + } + func testDeferredOutOfDate() async throws { let flagInfo = FeatureFlagInfo( @@ -900,7 +1206,9 @@ final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unchecked Senda var tags: Set = Set() - var channelID: String? = UUID().uuidString + var isChannelCreated: Bool = true + + var channelID: String = UUID().uuidString var locale: Locale = Locale.current @@ -914,19 +1222,23 @@ final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unchecked Senda var installDate: Date = Date() - var stableContactID: String = UUID().uuidString + var stableContactInfo: StableContactInfo = StableContactInfo(contactID: UUID().uuidString) } final actor TestFeatureFlagResolver: FeatureFlagDeferredResolverProtocol { - var onResolve: ((DeferredRequest, FeatureFlagInfo) async throws -> FeatureFlag)? + var resolvedFlagInfos: [FeatureFlagInfo] = [] + + var onResolve: ((DeferredRequest, FeatureFlagInfo) async throws -> DeferredFlagResponse)? - func setOnResolve(onResolve: @escaping @Sendable (DeferredRequest, FeatureFlagInfo) async throws -> FeatureFlag) { + func setOnResolve(onResolve: @escaping @Sendable (DeferredRequest, FeatureFlagInfo) async throws -> DeferredFlagResponse) { self.onResolve = onResolve } - func resolve(request: DeferredRequest, flagInfo: FeatureFlagInfo) async throws -> FeatureFlag { - try await self.onResolve!(request, flagInfo) + + func resolve(request: DeferredRequest, flagInfo: FeatureFlagInfo) async throws -> DeferredFlagResponse { + resolvedFlagInfos.append(flagInfo) + return try await self.onResolve!(request, flagInfo) } } diff --git a/Airship/AirshipFeatureFlags/Tests/FeatureFlagVariablesTest.swift b/Airship/AirshipFeatureFlags/Tests/FeatureFlagVariablesTest.swift new file mode 100644 index 000000000..65416a0c0 --- /dev/null +++ b/Airship/AirshipFeatureFlags/Tests/FeatureFlagVariablesTest.swift @@ -0,0 +1,223 @@ +/* Copyright Airship and Contributors */ + +import XCTest + +@testable +import AirshipCore + +@testable +import AirshipFeatureFlags + +final class FeatureFlagVariablesTest: XCTestCase { + + func testCodableVariant() throws { + let json = """ + { + "type":"variant", + "variants":[ + { + "id":"dda26cb5-e40b-4bc8-abb1-eb88240f7fd7", + "reporting_metadata":{ + "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925", + "variant_id":"dda26cb5-e40b-4bc8-abb1-eb88240f7fd7" + }, + "audience_selector":{ + "hash":{ + "audience_hash":{ + "hash_prefix":"686f2c15-cf8c-47a6-ae9f-e749fc792a9d:", + "num_hash_buckets":100, + "hash_identifier":"contact", + "hash_algorithm":"farm_hash" + }, + "audience_subset":{ + "min_hash_bucket":0, + "max_hash_bucket":9 + } + } + }, + "data":{ + "arbitrary_key_1":"some_value", + "arbitrary_key_2":"some_other_value" + } + }, + { + "id":"15422380-ce8f-49df-a7b1-9755b88ec0ef", + "reporting_metadata":{ + "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925", + "variant_id":"15422380-ce8f-49df-a7b1-9755b88ec0ef" + }, + "audience_selector":{ + "hash":{ + "audience_hash":{ + "hash_prefix":"686f2c15-cf8c-47a6-ae9f-e749fc792a9d:", + "num_hash_buckets":100, + "hash_identifier":"contact", + "hash_algorithm":"farm_hash" + }, + "audience_subset":{ + "min_hash_bucket":0, + "max_hash_bucket":19 + } + } + }, + "data":{ + "arbitrary_key_1":"different_value", + "arbitrary_key_2":"different_other_value" + } + }, + { + "id":"40e08a3d-8901-40fc-a01a-e6c263bec895", + "reporting_metadata":{ + "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925", + "variant_id":"40e08a3d-8901-40fc-a01a-e6c263bec895" + }, + "data":{ + "arbitrary_key_1":"some default value", + "arbitrary_key_2":"some other default value" + } + } + ] + } + """ + + let decoded: FeatureFlagVariables = try JSONDecoder().decode( + FeatureFlagVariables.self, + from: json.data(using: .utf8)! + ) + + let expected = FeatureFlagVariables.variant( + [ + .init( + id: "dda26cb5-e40b-4bc8-abb1-eb88240f7fd7", + audienceSelector: DeviceAudienceSelector( + hashSelector: AudienceHashSelector( + hash: .init( + prefix: "686f2c15-cf8c-47a6-ae9f-e749fc792a9d:", + property: .contact, + algorithm: .farm, + seed: nil, + numberOfBuckets: 100, + overrides: nil + ), + bucket: .init(min: 0, max: 9) + ) + ), + reportingMetadata: try AirshipJSON.wrap( + [ + "flag_id": "27f26d85-0550-4df5-85f0-7022fa7a5925", + "variant_id": "dda26cb5-e40b-4bc8-abb1-eb88240f7fd7" + ] + ), + data: try AirshipJSON.wrap( + [ + "arbitrary_key_1": "some_value", + "arbitrary_key_2": "some_other_value" + ] + ) + ), + .init( + id: "15422380-ce8f-49df-a7b1-9755b88ec0ef", + audienceSelector: DeviceAudienceSelector( + hashSelector: AudienceHashSelector( + hash: .init( + prefix: "686f2c15-cf8c-47a6-ae9f-e749fc792a9d:", + property: .contact, + algorithm: .farm, + seed: nil, + numberOfBuckets: 100, + overrides: nil + ), + bucket: .init(min: 0, max: 19) + ) + ), + reportingMetadata: try AirshipJSON.wrap( + [ + "flag_id": "27f26d85-0550-4df5-85f0-7022fa7a5925", + "variant_id": "15422380-ce8f-49df-a7b1-9755b88ec0ef" + ] + ), + data: try AirshipJSON.wrap( + [ + "arbitrary_key_1": "different_value", + "arbitrary_key_2": "different_other_value" + ] + ) + ), + .init( + id: "40e08a3d-8901-40fc-a01a-e6c263bec895", + audienceSelector: nil, + reportingMetadata: try AirshipJSON.wrap( + [ + "flag_id": "27f26d85-0550-4df5-85f0-7022fa7a5925", + "variant_id": "40e08a3d-8901-40fc-a01a-e6c263bec895" + ] + ), + data: try AirshipJSON.wrap( + [ + "arbitrary_key_1": "some default value", + "arbitrary_key_2": "some other default value" + ] + ) + ) + ] + ) + + XCTAssertEqual(decoded, expected) + + let encoded = String(data: try JSONEncoder().encode(decoded), encoding: .utf8) + XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.from(json: encoded)) + } + + func testCodableFixed() throws { + let json = """ + { + "type":"fixed", + "data":{ + "arbitrary_key_1":"some_value", + "arbitrary_key_2":"some_other_value" + } + } + """ + + let decoded: FeatureFlagVariables = try JSONDecoder().decode( + FeatureFlagVariables.self, + from: json.data(using: .utf8)! + ) + + let expected = FeatureFlagVariables.fixed( + try AirshipJSON.wrap( + [ + "arbitrary_key_1": "some_value", + "arbitrary_key_2": "some_other_value" + ] + ) + ) + + + XCTAssertEqual(decoded, expected) + + let encoded = String(data: try JSONEncoder().encode(decoded), encoding: .utf8) + XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.from(json: encoded)) + } + + func testCodableFixedNullData() throws { + let json = """ + { + "type":"fixed" + } + """ + + let decoded: FeatureFlagVariables = try JSONDecoder().decode( + FeatureFlagVariables.self, + from: json.data(using: .utf8)! + ) + + let expected = FeatureFlagVariables.fixed(nil) + + XCTAssertEqual(decoded, expected) + + let encoded = String(data: try JSONEncoder().encode(decoded), encoding: .utf8) + XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.from(json: encoded)) + } +} + diff --git a/Airship/AirshipMessageCenter/Source/Views/MessageCenterListView.swift b/Airship/AirshipMessageCenter/Source/Views/MessageCenterListView.swift index 2b98331dd..aed60665e 100644 --- a/Airship/AirshipMessageCenter/Source/Views/MessageCenterListView.swift +++ b/Airship/AirshipMessageCenter/Source/Views/MessageCenterListView.swift @@ -163,8 +163,10 @@ public struct MessageCenterListView: View { let content = ZStack { makeList() - .modifier(ListBackgroundModifier()) - .background(listBackgroundColor) + .applyIf(listBackgroundColor != nil, transform: { view in + view.modifier(ListBackgroundModifier()) + view.background(listBackgroundColor) + }) .opacity(self.listOpacity) .animation(.easeInOut(duration: 0.5), value: self.listOpacity) .onChange(of: self.messageIDs) { ids in diff --git a/Airship/AirshipPreferenceCenter/Source/PreferenceCenter.swift b/Airship/AirshipPreferenceCenter/Source/PreferenceCenter.swift index 0de74c0c4..ada38549e 100644 --- a/Airship/AirshipPreferenceCenter/Source/PreferenceCenter.swift +++ b/Airship/AirshipPreferenceCenter/Source/PreferenceCenter.swift @@ -178,7 +178,6 @@ public final class PreferenceCenter: NSObject, Sendable { private final class Delegates: @unchecked Sendable { @MainActor weak var openDelegate: PreferenceCenterOpenDelegate? - } extension PreferenceCenter { diff --git a/Airship/AirshipPreferenceCenter/Source/PreferenceCenterResources.swift b/Airship/AirshipPreferenceCenter/Source/PreferenceCenterResources.swift index 54098bd35..23d922d98 100644 --- a/Airship/AirshipPreferenceCenter/Source/PreferenceCenterResources.swift +++ b/Airship/AirshipPreferenceCenter/Source/PreferenceCenterResources.swift @@ -19,7 +19,7 @@ class PreferenceCenterResources { } extension String { - var preferenceCenterlocalizedString: String { + var preferenceCenterLocalizedString: String { return PreferenceCenterResources.localizedString(key: self) ?? self } } diff --git a/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig+ContactManagement.swift b/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig+ContactManagement.swift new file mode 100644 index 000000000..da57abf75 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig+ContactManagement.swift @@ -0,0 +1,595 @@ +/* Copyright Airship and Contributors */ + +import Foundation + +#if canImport(AirshipCore) +import AirshipCore +#endif + +public extension PreferenceCenterConfig { + + /// Contact management item - base container object for contact management in the preference center + class ContactManagementItem: Decodable, Equatable, PreferenceCenterConfigItem, @unchecked Sendable { + /// The contact management item's type. + public let type = PreferenceCenterConfigItemType.contactManagement + + /// The contact management item's identifier. + public let identifier: String + + /// The contact management item's channel platform - for example: email or sms. + public let platform: Platform + + // The common title and optional description + public let display: CommonDisplay + + // The add prompt + public let addChannel: AddChannel? + + /// The remove prompt + public let removeChannel: RemoveChannel? + + /// The empty message label that's visible when no channels of this type have been added + public let emptyMessage: String? + + /// The section's display conditions. + public let conditions: [Condition]? + + enum CodingKeys: String, CodingKey { + case identifier = "id" + case platform = "platform" + case display = "display" + case emptyMessage = "empty_message" + case addChannel = "add" + case removeChannel = "remove" + case registrationOptions = "registration_options" + case conditions = "conditions" + case email = "email" + case sms = "sms" + } + + public init( + identifier: String, + platform: Platform, + display: CommonDisplay, + emptyMessage: String? = nil, + addChannel: AddChannel? = nil, + removeChannel: RemoveChannel? = nil, + conditions: [Condition]? = nil + ) { + self.identifier = identifier + self.platform = platform + self.display = display + self.emptyMessage = emptyMessage + self.addChannel = addChannel + self.removeChannel = removeChannel + self.conditions = conditions + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.identifier = try container.decode(String.self, forKey: .identifier) + + let platformType = try container.decode(String.self, forKey: CodingKeys.platform) + switch platformType { + case CodingKeys.email.rawValue: + self.platform = .email( + try container.decode(Email.self, forKey: .registrationOptions) + ) + case CodingKeys.sms.rawValue: + self.platform = .sms( + try container.decode(SMS.self, forKey: .registrationOptions) + ) + default: + throw AirshipErrors.error("Unable to parse platform") + } + + self.display = try container.decode(CommonDisplay.self, forKey: .display) + self.addChannel = try container.decodeIfPresent(AddChannel.self, forKey: .addChannel) + self.removeChannel = try container.decodeIfPresent(RemoveChannel.self, forKey: .removeChannel) + self.emptyMessage = try container.decodeIfPresent(String.self, forKey: .emptyMessage) + self.conditions = try container.decodeIfPresent([Condition].self, forKey: .conditions) + } + + public static func == ( + lhs: ContactManagementItem, + rhs: ContactManagementItem + ) -> Bool { + return lhs.identifier == rhs.identifier && + lhs.platform == rhs.platform && + lhs.display == rhs.display && + lhs.addChannel == rhs.addChannel && + lhs.removeChannel == rhs.removeChannel && + lhs.emptyMessage == rhs.emptyMessage && + lhs.conditions == rhs.conditions + } + + /// Platform + public enum Platform: Decodable, Equatable { + case sms(SMS) + case email(Email) + + var stringValue: String { + switch self { + case .sms: return "sms" + case .email: return "email" + } + } + + var errorMessages: ErrorMessages? { + switch self { + case .sms(let sms): + return sms.errorMessages + case .email: + return nil + } + } + + public var description: String { + return stringValue + } + } + + /// Pending label that appears after channel list item is added. Resend button appears after interval. + public struct PendingLabel: Decodable, Equatable { + + /// The interval in seconds to wait before resend button appears + public let intervalInSeconds: Int + + /// The message that displays when a channel is pending + public let message: String + + /// Resend button that appears after the given interval + public let button: LabeledButton + + /// Resend prompt after successfully resending + public let resendSuccessPrompt: ActionableMessage? + + enum CodingKeys: String, CodingKey { + case intervalInSeconds = "interval" + case message = "message" + case button = "button" + case resendSuccessPrompt = "on_success" + } + + public init( + intervalInSeconds: Int, + message: String, + button: LabeledButton, + resendSuccessPrompt: ActionableMessage? = nil + ) { + self.intervalInSeconds = intervalInSeconds + self.message = message + self.button = button + self.resendSuccessPrompt = resendSuccessPrompt + } + } + + /// Email registration options + public struct Email: Decodable, Equatable { + + /// Text placeholder for email address + public var placeholder: String? + + /// The label for the email address + public var addressLabel: String + + /// Additional JSON payload + public var properties: AirshipJSON? + + /// Label with resend button + public var pendingLabel: PendingLabel + + enum CodingKeys: String, CodingKey { + case placeholder = "placeholder_text" + case properties = "properties" + case addressLabel = "address_label" + case pendingLabel = "resend" + } + + public init( + placeholder: String?, + addressLabel: String, + pendingLabel: PendingLabel, + properties: AirshipJSON? = nil + ) { + self.placeholder = placeholder + self.addressLabel = addressLabel + self.pendingLabel = pendingLabel + self.properties = properties + } + } + + /// SMS registration options + public struct SMS: Decodable, Equatable { + + /// List of sender ids - the identifiers for the senders of the SMS verification message + public var senders: [SMSSenderInfo] + + /// Country code label + public var countryLabel: String + + /// MSISDN Label + public var msisdnLabel: String + + /// Label with resend button + public var pendingLabel: PendingLabel + + /// Error messages that can result of attempting to add a MSISDN + public var errorMessages: ErrorMessages + + enum CodingKeys: String, CodingKey { + case senders = "senders" + case countryLabel = "country_label" + case msisdnLabel = "msisdn_label" + case pendingLabel = "resend" + case errorMessages = "error_messages" + } + + public init( + senders: [SMSSenderInfo], + countryLabel: String, + msisdnLabel: String, + pendingLabel: PendingLabel, + errorMessages: ErrorMessages + ) { + self.senders = senders + self.countryLabel = countryLabel + self.msisdnLabel = msisdnLabel + self.pendingLabel = pendingLabel + self.errorMessages = errorMessages + } + } + + /// Reusable container for holding a title and optional description. + public struct CommonDisplay: Decodable, Equatable { + + /// Title text. + public let title: String + + /// Subtitle text. + public let subtitle: String? + + enum CodingKeys: String, CodingKey { + case title = "name" + case subtitle = "description" + } + + public init(title: String, subtitle: String? = nil) { + self.title = title + self.subtitle = subtitle + } + } + + /// The label message that appears when a channel listing is empty. + public struct EmptyMessage: Decodable, Equatable { + + /// The empty message's text. + public let text: String + + /// The empty message's content description. + public let contentDescription: String? + + enum CodingKeys: String, CodingKey { + case text = "text" + case contentDescription = "content_description" + } + + public init( + text: String, + contentDescription: String? = nil + ) { + + self.text = text + self.contentDescription = contentDescription + } + } + + /// The container for the add prompt button and resulting add prompt. + public struct AddChannel: Decodable, Equatable { + + /// The add channel prompt view that appears when the add channel button is tapped. + public let view: AddChannelPrompt + + /// The labeled button that surfaces the add channel prompt. + public let button: LabeledButton + + enum CodingKeys: String, CodingKey { + case view = "view" + case button = "button" + } + + public init( + view: AddChannelPrompt, + button: LabeledButton + ) { + self.view = view + self.button = button + } + } + + /// The container for the remove channel button and resulting remove prompt for adding a channel to a channel list. + public struct RemoveChannel: Decodable, Equatable { + + /// The remove channel prompt view that appears when the remove channel button is tapped. + public let view: RemoveChannelPrompt + + /// The icon button that surfaces the remove channel prompt. + public let button: IconButton + + enum CodingKeys: String, CodingKey { + case view = "view" + case button = "button" + } + + public init( + view: RemoveChannelPrompt, + button: IconButton + ) { + + self.view = view + self.button = button + } + } + + public struct RemoveChannelPrompt: Decodable, Equatable { + + /// Optional additional prompt display info. + public let display: PromptDisplay + + /// The prompt display that appears when a channel is removed. + public let onSuccess: ActionableMessage? + + /// Close button info primarily for passing content descriptions. + public let closeButton: IconButton? + + /// Cancel button. + public let cancelButton: LabeledButton? + + /// The labeled button that initiates the removal of a channel on tap. + public let submitButton: LabeledButton? + + enum CodingKeys: String, CodingKey { + case display = "display" + case onSuccess = "on_success" + case submitButton = "submit_button" + case closeButton = "close_button" + case cancelButton = "cancel_button" + } + + public init( + display: PromptDisplay, + onSuccess: ActionableMessage? = nil, + submitButton: LabeledButton? = nil, + closeButton: IconButton? = nil, + cancelButton: LabeledButton? = nil + ) { + self.display = display + self.onSuccess = onSuccess + self.submitButton = submitButton + self.closeButton = closeButton + self.cancelButton = cancelButton + } + } + + /// A more dynamic version of common display that includes a footer and error message. + public struct PromptDisplay: Decodable, Equatable { + + /// Title text. + public let title: String + + /// Body text. + public let body: String? + + /// Footer text that can contain markdown. + public let footer: String? + + enum CodingKeys: String, CodingKey { + case title = "title" + case body = "description" + case footer = "footer" + } + + public init( + title: String, + body: String? = nil, + footer: String? = nil + ) { + + self.title = title + self.body = body + self.footer = footer + } + } + + public struct AddChannelPrompt: Decodable, Equatable { + + /// The item text display. + public let display: PromptDisplay + + /// The submission message. + public let onSubmit: ActionableMessage? + + /// The close button. + public let closeButton: IconButton? + + /// The cancel prompt button. + public let cancelButton: LabeledButton? + + /// The submit prompt button. + public let submitButton: LabeledButton + + enum CodingKeys: String, CodingKey { + case display = "display" + case onSubmit = "on_submit" + case cancelButton = "cancel_button" + case submitButton = "submit_button" + case closeButton = "close_button" + } + + public init( + display: PromptDisplay, + onSubmit: ActionableMessage? = nil, + cancelButton: LabeledButton? = nil, + submitButton: LabeledButton, + closeButton: IconButton? = nil + ) { + self.display = display + self.onSubmit = onSubmit + self.cancelButton = cancelButton + self.submitButton = submitButton + self.closeButton = closeButton + } + } + + public struct IconButton: Codable, Equatable { + /// The button's content description. + public let contentDescription: String? + + enum CodingKeys: String, CodingKey { + case contentDescription = "content_description" + } + + public init( + contentDescription: String? = nil + ) { + self.contentDescription = contentDescription + } + } + + /// Alert button info. + public struct LabeledButton: Decodable, Equatable { + + /// The button's text. + public let text: String + + /// The button's content description. + public let contentDescription: String? + + enum CodingKeys: String, CodingKey { + case text = "text" + case contentDescription = "content_description" + } + + public init( + text: String, + contentDescription: String? = nil + ) { + + self.text = text + self.contentDescription = contentDescription + } + } + + /// Alert display info + public struct ActionableMessage: Decodable, Equatable { + + /// Title text. + public let title: String + + /// Body text. + public let body: String? + + /// Labeled button for submitting the action or closing the prompt. + public let button: LabeledButton + + enum CodingKeys: String, CodingKey { + case title = "name" + case body = "description" + case button = "button" + } + + public init( + title: String, + body: String?, + button: LabeledButton + ) { + self.title = title + self.body = body + self.button = button + } + } + + /// Error message container for showing error messages on the add channel prompt + public struct ErrorMessages: Codable, Equatable { + var invalidMessage: String + var defaultMessage: String + + enum CodingKeys: String, CodingKey { + case invalidMessage = "invalid" + case defaultMessage = "default" + } + } + + /// The info used to populate the add channel prompt sender input for SMS. + public struct SMSSenderInfo: Decodable, Identifiable, Equatable, Hashable { + public var id: String { + return senderId + } + + /// The senderId is the number from which the SMS is sent. + public var senderId: String + + /// Placeholder text. + public var placeholderText: String + + /// Country code. + public var countryCode: String + + /// Country display name, for example - United Kingdom. + public var displayName: String + + enum CodingKeys: String, CodingKey { + case senderId = "sender_id" + case placeholderText = "placeholder_text" + case countryCode = "country_code" + case displayName = "display_name" + } + + public init( + senderId: String, + placeholderText: String, + countryCode: String, + displayName: String + ) { + self.senderId = senderId + self.placeholderText = placeholderText + self.countryCode = countryCode + self.displayName = displayName + } + + static let none = SMSSenderInfo( + senderId: "none", + placeholderText: "none", + countryCode: "none", + displayName: "none" + ) + } + } +} + +extension PreferenceCenterConfig.ContactManagementItem.Platform: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.PendingLabel: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.Email: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.SMS: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.CommonDisplay: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.AddChannel: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.RemoveChannel: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.RemoveChannelPrompt: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.PromptDisplay: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.AddChannelPrompt: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.LabeledButton: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.ActionableMessage: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem.SMSSenderInfo: Encodable {} diff --git a/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig.swift b/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig.swift index d5ec7838d..172b232a1 100644 --- a/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig.swift +++ b/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig.swift @@ -63,6 +63,13 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { && self.options == object.options } + public static func == (lhs: PreferenceCenterConfig, rhs: PreferenceCenterConfig) -> Bool { + return lhs.identifier == rhs.identifier && + lhs.sections == rhs.sections && + lhs.display == rhs.display && + lhs.options == rhs.options + } + /// Config options. @objc(UAPreferenceCenterConfigOptions) public final class Options: NSObject, Decodable, Sendable { @@ -135,6 +142,10 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { return self.title == object.title && self.subtitle == object.subtitle } + + public static func == (lhs: PreferenceCenterConfig.CommonDisplay, rhs: PreferenceCenterConfig.CommonDisplay) -> Bool { + return lhs.title == rhs.title && lhs.subtitle == rhs.subtitle + } } @objc(UAPreferenceCenterConfigNotificationOptInCondition) @@ -279,6 +290,13 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { && self.items == object.items && self.conditions == object.conditions } + + public static func == (lhs: PreferenceCenterConfig.CommonSection, rhs: PreferenceCenterConfig.CommonSection) -> Bool { + return lhs.identifier == rhs.identifier && + lhs.items == rhs.items && + lhs.display == rhs.display && + lhs.conditions == rhs.conditions + } } /// Labeled section break info. @@ -335,6 +353,71 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { } } + /// Contact Management section. + @objc(UAPreferenceCenterConfigContactManagementSection) + public final class ContactManagementSection: NSObject, Decodable, + PreferenceCenterConfigSection + { + + /// The section's type. + @objc + public let type = PreferenceCenterConfigSectionType.common + + /// The section's identifier. + @objc + public let identifier: String + + /// The section's items. + public let items: [Item] + + @objc(items) + public var _items: [PreferenceCenterConfigItem] { + return self.items.map { $0.info } + } + + /// The section's display info. + @objc + public let display: CommonDisplay? + + /// The section's display conditions. + public let conditions: [Condition]? + + @objc(conditions) + public var _conditions: [PreferenceConfigCondition]? { + self.conditions?.map { $0.info } + } + + public init( + identifier: String, + items: [Item], + display: CommonDisplay? = nil, + conditions: [Condition]? = nil + ) { + self.identifier = identifier + self.items = items + self.display = display + self.conditions = conditions + } + + enum CodingKeys: String, CodingKey { + case identifier = "id" + case display = "display" + case items = "items" + case conditions = "conditions" + } + + public override func isEqual(_ object: Any?) -> Bool { + guard let object = object as? CommonSection else { + return false + } + + return self.identifier == object.identifier + && self.display == object.display + && self.items == object.items + && self.conditions == object.conditions + } + } + /// Preference config section. public enum Section: Decodable, Equatable, Sendable { @@ -366,7 +449,19 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { ) } } + + public static func == (lhs: PreferenceCenterConfig.Section, rhs: PreferenceCenterConfig.Section) -> Bool { + switch (lhs, rhs) { + case (.common(let lhsSection), .common(let rhsSection)): + return lhsSection == rhsSection + case (.labeledSectionBreak(let lhsSection), .labeledSectionBreak(let rhsSection)): + return lhsSection == rhsSection + default: + return false + } + } } + /// Channel subscription item info. @objc(UAPreferenceCenterConfigChannelSubscription) public final class ChannelSubscription: NSObject, Decodable, PreferenceCenterConfigItem, Sendable { @@ -672,7 +767,7 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { @objc(UAPreferenceCenterConfigAlertButton) public final class Button: NSObject, Decodable, Sendable { - /// The buttton's text. + /// The button's text. @objc public let text: String @@ -682,7 +777,7 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { let actionJSON: AirshipJSON - /// Actions paylaod to run on tap + /// Actions payload to run on tap @objc public var actions: Any? { return self.actionJSON.unWrap() @@ -758,13 +853,17 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { } } } + + /// Contact management item + /// Config item. public enum Item: Decodable, Equatable, Sendable { case channelSubscription(ChannelSubscription) case contactSubscription(ContactSubscription) case contactSubscriptionGroup(ContactSubscriptionGroup) case alert(Alert) + case contactManagement(ContactManagementItem) enum CodingKeys: String, CodingKey { case type = "type" @@ -794,6 +893,8 @@ public final class PreferenceCenterConfig: NSObject, Decodable, Sendable { ) case .alert: self = .alert((try singleValueContainer.decode(Alert.self))) + case .contactManagement: + self = .contactManagement((try singleValueContainer.decode(ContactManagementItem.self))) } } } @@ -809,6 +910,7 @@ public enum PreferenceCenterConfigConditionType: Int, CustomStringConvertible, /// Notification opt-in condition. case notificationOptIn + var stringValue: String { switch self { case .notificationOptIn: @@ -848,7 +950,6 @@ public protocol PreferenceConfigCondition: Sendable { public enum PreferenceCenterConfigItemType: Int, CustomStringConvertible, Equatable, Sendable { - /// Channel subscription type. case channelSubscription @@ -861,12 +962,16 @@ public enum PreferenceCenterConfigItemType: Int, CustomStringConvertible, /// Alert type. case alert + /// Contact management + case contactManagement + var stringValue: String { switch self { case .channelSubscription: return "channel_subscription" case .contactSubscription: return "contact_subscription" case .contactSubscriptionGroup: return "contact_subscription_group" case .alert: return "alert" + case .contactManagement: return "contact_management" } } @@ -878,6 +983,7 @@ public enum PreferenceCenterConfigItemType: Int, CustomStringConvertible, case "contact_subscription": return .contactSubscription case "contact_subscription_group": return .contactSubscriptionGroup case "alert": return .alert + case "contact_management": return .contactManagement default: throw AirshipErrors.error("invalid item \(value)") } @@ -899,13 +1005,12 @@ public protocol PreferenceCenterConfigItem: Sendable { @objc var identifier: String { get } } - + /// Preference config section type. @objc(UAPreferenceCenterConfigSectionType) public enum PreferenceCenterConfigSectionType: Int, CustomStringConvertible, Equatable, Sendable { - /// Common section type. case common @@ -961,6 +1066,7 @@ extension PreferenceCenterConfig.Item { case .contactSubscription(let info): return info case .contactSubscriptionGroup(let info): return info case .alert(let info): return info + case .contactManagement(let info): return info } } } @@ -1002,4 +1108,156 @@ extension PreferenceCenterConfig { }) }) } + + public func containsContactManagement() -> Bool { + return self.sections.contains(where: { section in + guard case .common(let info) = section else { return false } + return info.items.contains(where: { item in + return item.info.type == .contactManagement + }) + }) + } +} + +// MARK: Encodable support for testing + +extension PreferenceCenterConfig { + func prettyPrintedJSON() throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let jsonData = try encoder.encode(self) + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw NSError(domain: "JSONEncoding", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to convert JSON data to string."]) + } + + return jsonString + } +} + +extension PreferenceCenterConfig: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(identifier, forKey: .identifier) + try container.encode(sections, forKey: .sections) + try container.encodeIfPresent(display, forKey: .display) + try container.encodeIfPresent(options, forKey: .options) + } +} + +extension PreferenceCenterConfig.Options: Encodable {} + +extension PreferenceCenterConfig.CommonDisplay: Encodable {} + +extension PreferenceCenterConfig.NotificationOptInCondition: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(optInStatus.rawValue, forKey: .optInStatus) + } +} + +extension PreferenceCenterConfig.Condition: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .notificationOptIn(let condition): + try container.encode(condition.type.description, forKey: .type) + try condition.encode(to: encoder) + } + } +} + +extension PreferenceCenterConfig.CommonSection: Encodable {} + +extension PreferenceCenterConfig.LabeledSectionBreak: Encodable {} + +extension PreferenceCenterConfig.Section: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .common(let section): + try container.encode(section.type.description, forKey: .type) + try section.encode(to: encoder) + case .labeledSectionBreak(let section): + try container.encode(section.type.description, forKey: .type) + try section.encode(to: encoder) + } + } +} + +extension PreferenceCenterConfig.ChannelSubscription: Encodable {} + +extension PreferenceCenterConfig.ContactSubscriptionGroup: Encodable {} + +extension PreferenceCenterConfig.ContactSubscriptionGroup.Component: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(_scopes, forKey: ._scopes) + try container.encode(display, forKey: .display) + } +} + +extension PreferenceCenterConfig.ContactSubscription: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(identifier, forKey: .identifier) + try container.encode(display, forKey: .display) + try container.encode(subscriptionID, forKey: .subscriptionID) +// try container.encode(_conditions, forKey: .conditions) + try container.encode(_scopes, forKey: ._scopes) + } +} + +extension ChannelScopes: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values.map { $0.rawValue }) + } +} + +extension PreferenceCenterConfig.Alert: Encodable {} + +extension PreferenceCenterConfig.Alert.Button: Encodable {} + +extension PreferenceCenterConfigConditionType : Encodable {} + +extension PreferenceCenterConfig.Alert.Display: Encodable {} + +extension PreferenceCenterConfig.ContactManagementItem: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(identifier, forKey: .identifier) + try container.encode(platform, forKey: .platform) + try container.encode(display, forKey: .display) + try container.encodeIfPresent(emptyMessage, forKey: .emptyMessage) + try container.encodeIfPresent(addChannel, forKey: .addChannel) + try container.encodeIfPresent(removeChannel, forKey: .removeChannel) + try container.encodeIfPresent(conditions, forKey: .conditions) + } +} + +extension PreferenceCenterConfig.Item: Encodable { + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .channelSubscription(let item): + try container.encode(item.type.description, forKey: .type) + try item.encode(to: encoder) + case .contactSubscription(let item): + try container.encode(item.type.description, forKey: .type) + try item.encode(to: encoder) + case .contactSubscriptionGroup(let item): + try container.encode(item.type.description, forKey: .type) + try item.encode(to: encoder) + case .alert(let item): + try container.encode(item.type.description, forKey: .type) + try item.encode(to: encoder) + case .contactManagement(let item): + try container.encode(item.type.description, forKey: .type) + try item.encode(to: encoder) + } + } } diff --git a/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterDecoder.swift b/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterDecoder.swift index e3d100ecb..fc83cdd84 100644 --- a/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterDecoder.swift +++ b/Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterDecoder.swift @@ -5,7 +5,6 @@ import Foundation #if canImport(AirshipCore) import AirshipCore #endif - class PreferenceCenterDecoder { private static let decoder: JSONDecoder = { var decoder = JSONDecoder() @@ -13,16 +12,6 @@ class PreferenceCenterDecoder { return decoder }() - class func decodeConfig( - jsonConfig: [AnyHashable: Any] - ) throws -> PreferenceCenterConfig { - let data = try JSONSerialization.data( - withJSONObject: jsonConfig, - options: [] - ) - return try decodeConfig(data: data) - } - class func decodeConfig(data: Data) throws -> PreferenceCenterConfig { return try self.decoder.decode(PreferenceCenterConfig.self, from: data) } diff --git a/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterTheme.swift b/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterTheme.swift index bfc660dc1..d7d787dff 100644 --- a/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterTheme.swift +++ b/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterTheme.swift @@ -4,7 +4,7 @@ import Foundation import SwiftUI import UIKit -/// Preferenece Center theme +/// Preference Center theme public struct PreferenceCenterTheme: Equatable, Sendable { /// View controller theme @@ -21,6 +21,9 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Alert theme public var alert: Alert? = nil + + /// Contact management theme + public var contactManagement: ContactManagement? = nil /// Channel subscription item theme public var channelSubscription: ChannelSubscription? = nil @@ -62,7 +65,10 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Window background color public var backgroundColor: UIColor? = nil - public init(navigationBar: NavigationBar? = nil, backgroundColor: UIColor? = nil) { + public init( + navigationBar: NavigationBar? = nil, + backgroundColor: UIColor? = nil + ) { self.navigationBar = navigationBar self.backgroundColor = backgroundColor } @@ -88,7 +94,14 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// The retry message appearance public var retryMessageAppearance: TextAppearance? = nil - public init(subtitleAppearance: TextAppearance? = nil, retryButtonBackgroundColor: Color? = nil, retryButtonLabelAppearance: TextAppearance? = nil, retryButtonLabel: String? = nil, retryMessage: String? = nil, retryMessageAppearance: TextAppearance? = nil) { + public init( + subtitleAppearance: TextAppearance? = nil, + retryButtonBackgroundColor: Color? = nil, + retryButtonLabelAppearance: TextAppearance? = nil, + retryButtonLabel: String? = nil, + retryMessage: String? = nil, + retryMessageAppearance: TextAppearance? = nil + ) { self.subtitleAppearance = subtitleAppearance self.retryButtonBackgroundColor = retryButtonBackgroundColor self.retryButtonLabelAppearance = retryButtonLabelAppearance @@ -98,7 +111,7 @@ public struct PreferenceCenterTheme: Equatable, Sendable { } } - /// Text apperance + /// Text appearance public struct TextAppearance: Equatable, Sendable { /// The text font public var font: Font? = nil @@ -106,7 +119,10 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// The text color public var color: Color? = nil - public init(font: Font? = nil, color: Color? = nil) { + public init( + font: Font? = nil, + color: Color? = nil + ) { self.font = font self.color = color } @@ -123,7 +139,11 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Chip label appearance public var labelAppearance: TextAppearance? = nil - public init(checkColor: Color? = nil, borderColor: Color? = nil, labelAppearance: TextAppearance? = nil) { + public init( + checkColor: Color? = nil, + borderColor: Color? = nil, + labelAppearance: TextAppearance? = nil + ) { self.checkColor = checkColor self.borderColor = borderColor self.labelAppearance = labelAppearance @@ -138,7 +158,10 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Subtitle appearance public var subtitleAppearance: TextAppearance? = nil - public init(titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil) { + public init( + titleAppearance: TextAppearance? = nil, + subtitleAppearance: TextAppearance? = nil + ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance } @@ -152,7 +175,10 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Background color public var backgroundColor: Color? = nil - public init(titleAppearance: TextAppearance? = nil, backgroundColor: Color? = nil) { + public init( + titleAppearance: TextAppearance? = nil, + backgroundColor: Color? = nil + ) { self.titleAppearance = titleAppearance self.backgroundColor = backgroundColor } @@ -172,7 +198,12 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Button background color public var buttonBackgroundColor: Color? = nil - public init(titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, buttonLabelAppearance: TextAppearance? = nil, buttonBackgroundColor: Color? = nil) { + public init( + titleAppearance: TextAppearance? = nil, + subtitleAppearance: TextAppearance? = nil, + buttonLabelAppearance: TextAppearance? = nil, + buttonBackgroundColor: Color? = nil + ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.buttonLabelAppearance = buttonLabelAppearance @@ -180,6 +211,54 @@ public struct PreferenceCenterTheme: Equatable, Sendable { } } + /// Contact management item theme + public struct ContactManagement: Equatable, Sendable { + + /// Background color + public var backgroundColor: Color? = nil + + /// Title appearance + public var titleAppearance: TextAppearance? = nil + + /// Subtitle appearance + public var subtitleAppearance: TextAppearance? = nil + + /// List title appearance + public var listTitleAppearance: TextAppearance? = nil + + /// List subtitle appearance + public var listSubtitleAppearance: TextAppearance? = nil + + /// Error appearance + public var errorAppearance: TextAppearance? = nil + + /// Button label appearance + public var buttonLabelAppearance: TextAppearance? = nil + + /// Button background color + public var buttonBackgroundColor: Color? = nil + + /// Destructive button background color - used submit button background color when removing channels + public var buttonDestructiveBackgroundColor: Color? = nil + + public init( + backgroundColor: Color? = nil, + titleAppearance: TextAppearance? = nil, + subtitleAppearance: TextAppearance? = nil, + errorAppearance: TextAppearance? = nil, + buttonLabelAppearance: TextAppearance? = nil, + buttonBackgroundColor: Color? = nil, + buttonDestructiveBackgroundColor: Color? = nil + ) { + self.titleAppearance = titleAppearance + self.subtitleAppearance = subtitleAppearance + self.errorAppearance = errorAppearance + self.buttonLabelAppearance = buttonLabelAppearance + self.buttonBackgroundColor = buttonBackgroundColor + self.buttonDestructiveBackgroundColor = buttonDestructiveBackgroundColor + } + } + /// Channel subscription item theme public struct ChannelSubscription: Equatable, Sendable { /// Title appearance @@ -188,10 +267,17 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Subtitle appearance public var subtitleAppearance: TextAppearance? = nil + /// Empty appearance - for when a section has an empty message set + public var emptyTextAppearance: TextAppearance? = nil + /// Toggle tint color public var toggleTintColor: Color? = nil - public init(titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, toggleTintColor: Color? = nil) { + public init( + titleAppearance: TextAppearance? = nil, + subtitleAppearance: TextAppearance? = nil, + toggleTintColor: Color? = nil + ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.toggleTintColor = toggleTintColor @@ -209,7 +295,11 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Toggle tint color public var toggleTintColor: Color? = nil - public init(titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, toggleTintColor: Color? = nil) { + public init( + titleAppearance: TextAppearance? = nil, + subtitleAppearance: TextAppearance? = nil, + toggleTintColor: Color? = nil + ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.toggleTintColor = toggleTintColor @@ -227,19 +317,24 @@ public struct PreferenceCenterTheme: Equatable, Sendable { /// Chip theme public var chip: Chip? = nil - public init(titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, chip: Chip? = nil) { + public init( + titleAppearance: TextAppearance? = nil, + subtitleAppearance: TextAppearance? = nil, + chip: Chip? = nil + ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.chip = chip } } - + public init( viewController: PreferenceCenterTheme.ViewController? = nil, preferenceCenter: PreferenceCenterTheme.PreferenceCenter? = nil, commonSection: CommonSection? = nil, labeledSectionBreak: LabeledSectionBreak? = nil, alert: Alert? = nil, + contactManagement: ContactManagement? = nil, channelSubscription: ChannelSubscription? = nil, contactSubscription: ContactSubscription? = nil, contactSubscriptionGroup: ContactSubscriptionGroup? = nil @@ -249,6 +344,7 @@ public struct PreferenceCenterTheme: Equatable, Sendable { self.commonSection = commonSection self.labeledSectionBreak = labeledSectionBreak self.alert = alert + self.contactManagement = contactManagement self.channelSubscription = channelSubscription self.contactSubscription = contactSubscription self.contactSubscriptionGroup = contactSubscriptionGroup @@ -288,3 +384,64 @@ extension PreferenceCenterTheme { return try PreferenceCenterThemeLoader.fromPlist(plist) } } + +extension ProgressView { + @ViewBuilder + func airshipSetTint(color: Color) -> some View { + if #available(iOS 15, *) { + self.tint(color) + } else { + self.accentColor(color) + } + } +} + +extension Color { + + /// Inverts the color - used for inverted primary and secondary colors on LabeledButtons + func inverted() -> Color { + let uiColor = UIColor(self) + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let invertedColor = Color(red: Double(1 - red), green: Double(1 - green), blue: Double(1 - blue), opacity: Double(alpha)) + return invertedColor + } + + /** + ** Derives secondary variant for a particular color by shifting a given color's RGBA values + ** by the difference between the current primary and secondary colors + **/ + func secondaryVariant(for colorScheme: ColorScheme) -> Color { + /// Convert target color to UIColor + let targetUIColor = UIColor(self) + + /// Convert primary and secondary colors to UIColor + let primaryUIColor = UIColor(.primary) + let secondaryUIColor = UIColor(.secondary) + + /// Calculate RGBA differences between primary and secondary + var primaryRed: CGFloat = 0, primaryGreen: CGFloat = 0, primaryBlue: CGFloat = 0, primaryAlpha: CGFloat = 0 + primaryUIColor.getRed(&primaryRed, green: &primaryGreen, blue: &primaryBlue, alpha: &primaryAlpha) + + var secondaryRed: CGFloat = 0, secondaryGreen: CGFloat = 0, secondaryBlue: CGFloat = 0, secondaryAlpha: CGFloat = 0 + secondaryUIColor.getRed(&secondaryRed, green: &secondaryGreen, blue: &secondaryBlue, alpha: &secondaryAlpha) + + let redDiff = secondaryRed - primaryRed + let greenDiff = secondaryGreen - primaryGreen + let blueDiff = secondaryBlue - primaryBlue + let alphaDiff = secondaryAlpha - primaryAlpha + + /// Apply the differences to the target color + var targetRed: CGFloat = 0, targetGreen: CGFloat = 0, targetBlue: CGFloat = 0, targetAlpha: CGFloat = 0 + if targetUIColor.getRed(&targetRed, green: &targetGreen, blue: &targetBlue, alpha: &targetAlpha) { + let newRed = colorScheme == .light ? max(targetRed - redDiff, 0) : min(targetRed + redDiff, 1) + let newGreen = colorScheme == .light ? max(targetGreen - greenDiff, 0) : min(targetGreen + greenDiff, 1) + let newBlue = colorScheme == .light ? max(targetBlue - blueDiff, 0) : min(targetBlue + blueDiff, 1) + let newAlpha = targetAlpha + alphaDiff + + return Color(UIColor(red: newRed, green: newGreen, blue: newBlue, alpha: newAlpha)) + } else { + return self /// Return the original color if unable to modify + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterThemeLoader.swift b/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterThemeLoader.swift index 3dc027177..0c4d8d127 100644 --- a/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterThemeLoader.swift +++ b/Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterThemeLoader.swift @@ -224,7 +224,7 @@ extension PreferenceCenterThemeLoader.FontConfig { } extension PreferenceCenterThemeLoader.Config.TextAppearance { - func toTextApperance() throws -> PreferenceCenterTheme.TextAppearance { + func toTextAppearance() throws -> PreferenceCenterTheme.TextAppearance { return PreferenceCenterTheme.TextAppearance( font: try self.font?.toFont(), color: self.color?.airshipToColor() @@ -237,7 +237,7 @@ extension PreferenceCenterThemeLoader.Config.Chip { return PreferenceCenterTheme.Chip( checkColor: self.checkColor?.airshipToColor(), borderColor: self.borderColor?.airshipToColor(), - labelAppearance: try self.labelAppearance?.toTextApperance() + labelAppearance: try self.labelAppearance?.toTextAppearance() ) } } @@ -254,8 +254,8 @@ extension PreferenceCenterThemeLoader.Config.NavigationBar { extension PreferenceCenterThemeLoader.Config.CommonSection { func toCommonSection() throws -> PreferenceCenterTheme.CommonSection { return PreferenceCenterTheme.CommonSection( - titleAppearance: try self.titleAppearance?.toTextApperance(), - subtitleAppearance: try self.subtitleAppearance?.toTextApperance() + titleAppearance: try self.titleAppearance?.toTextAppearance(), + subtitleAppearance: try self.subtitleAppearance?.toTextAppearance() ) } } @@ -265,7 +265,7 @@ extension PreferenceCenterThemeLoader.Config.LabeledSectionBreak { -> PreferenceCenterTheme.LabeledSectionBreak { return PreferenceCenterTheme.LabeledSectionBreak( - titleAppearance: try self.titleAppearance?.toTextApperance(), + titleAppearance: try self.titleAppearance?.toTextAppearance(), backgroundColor: self.backgroundColor?.airshipToColor() ) } @@ -276,8 +276,8 @@ extension PreferenceCenterThemeLoader.Config.ChannelSubscription { -> PreferenceCenterTheme.ChannelSubscription { return PreferenceCenterTheme.ChannelSubscription( - titleAppearance: try self.titleAppearance?.toTextApperance(), - subtitleAppearance: try self.subtitleAppearance?.toTextApperance(), + titleAppearance: try self.titleAppearance?.toTextAppearance(), + subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), toggleTintColor: self.toggleTintColor?.airshipToColor() ) } @@ -288,8 +288,8 @@ extension PreferenceCenterThemeLoader.Config.ContactSubscription { -> PreferenceCenterTheme.ContactSubscription { return PreferenceCenterTheme.ContactSubscription( - titleAppearance: try self.titleAppearance?.toTextApperance(), - subtitleAppearance: try self.subtitleAppearance?.toTextApperance(), + titleAppearance: try self.titleAppearance?.toTextAppearance(), + subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), toggleTintColor: self.toggleTintColor?.airshipToColor() ) } @@ -300,8 +300,8 @@ extension PreferenceCenterThemeLoader.Config.ContactSubscriptionGroup { -> PreferenceCenterTheme.ContactSubscriptionGroup { return PreferenceCenterTheme.ContactSubscriptionGroup( - titleAppearance: try self.titleAppearance?.toTextApperance(), - subtitleAppearance: try self.subtitleAppearance?.toTextApperance(), + titleAppearance: try self.titleAppearance?.toTextAppearance(), + subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), chip: try self.chip?.toChip() ) } @@ -310,10 +310,9 @@ extension PreferenceCenterThemeLoader.Config.ContactSubscriptionGroup { extension PreferenceCenterThemeLoader.Config.Alert { func toAlert() throws -> PreferenceCenterTheme.Alert { return PreferenceCenterTheme.Alert( - titleAppearance: try self.titleAppearance?.toTextApperance(), - subtitleAppearance: try self.subtitleAppearance?.toTextApperance(), - buttonLabelAppearance: try self.buttonLabelAppearance? - .toTextApperance(), + titleAppearance: try self.titleAppearance?.toTextAppearance(), + subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), + buttonLabelAppearance: try self.buttonLabelAppearance?.toTextAppearance(), buttonBackgroundColor: self.buttonBackgroundColor?.airshipToColor() ) } @@ -322,15 +321,15 @@ extension PreferenceCenterThemeLoader.Config.Alert { extension PreferenceCenterThemeLoader.Config.PreferenceCenter { func toPreferenceCenter() throws -> PreferenceCenterTheme.PreferenceCenter { return PreferenceCenterTheme.PreferenceCenter( - subtitleAppearance: try self.subtitleAppearance?.toTextApperance(), + subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), retryButtonBackgroundColor: self.retryButtonBackgroundColor? .airshipToColor(), retryButtonLabelAppearance: try self.retryButtonLabelAppearance? - .toTextApperance(), + .toTextAppearance(), retryButtonLabel: self.retryButtonLabel, retryMessage: self.retryMessage, retryMessageAppearance: try self.retryMessageAppearance? - .toTextApperance() + .toTextAppearance() ) } } diff --git a/Airship/AirshipPreferenceCenter/Source/view/CommonSectionView.swift b/Airship/AirshipPreferenceCenter/Source/view/CommonSectionView.swift index 9e26833ed..c6403959b 100644 --- a/Airship/AirshipPreferenceCenter/Source/view/CommonSectionView.swift +++ b/Airship/AirshipPreferenceCenter/Source/view/CommonSectionView.swift @@ -26,10 +26,18 @@ public struct CommonSectionView: View { @State private var displayConditionsMet: Bool = true + init( + section: PreferenceCenterConfig.CommonSection, + state: PreferenceCenterState + ) { + self.section = section + self.state = state + } + @ViewBuilder public var body: some View { let configuration = CommonSectionViewStyleConfiguration( - section: self.section, + section: self.section, state: self.state, displayConditionsMet: self.displayConditionsMet, preferenceCenterTheme: self.preferenceCenterTheme @@ -86,7 +94,7 @@ extension CommonSectionViewStyle where Self == DefaultCommonSectionViewStyle { } } -/// The default comon section view style +/// The default common section view style public struct DefaultCommonSectionViewStyle: CommonSectionViewStyle { public static let titleAppearance = PreferenceCenterTheme.TextAppearance( @@ -99,6 +107,11 @@ public struct DefaultCommonSectionViewStyle: CommonSectionViewStyle { color: .secondary ) + public static let emptyTextAppearance = PreferenceCenterTheme.TextAppearance( + font: .system(size: 14), + color: .gray.opacity(0.80) + ) + @ViewBuilder public func makeBody(configuration: Configuration) -> some View { let section = configuration.section @@ -131,7 +144,10 @@ public struct DefaultCommonSectionViewStyle: CommonSectionViewStyle { } ForEach(0.. some View { switch item { case .alert(let item): - PreferenceCenterAlertView(item: item, state: state) + PreferenceCenterAlertView(item: item, state: state).transition(.opacity) case .channelSubscription(let item): ChannelSubscriptionView(item: item, state: state) Divider() @@ -155,6 +171,12 @@ public struct DefaultCommonSectionViewStyle: CommonSectionViewStyle { case .contactSubscriptionGroup(let item): ContactSubscriptionGroupView(item: item, state: state) Divider() + case .contactManagement(let item): + PreferenceCenterContactManagementView( + item: item, + state: state + ) + Divider() } } } diff --git a/Airship/AirshipPreferenceCenter/Source/view/ConditionsMonitor.swift b/Airship/AirshipPreferenceCenter/Source/view/ConditionsMonitor.swift index 40ab3bef7..d0e930f0c 100644 --- a/Airship/AirshipPreferenceCenter/Source/view/ConditionsMonitor.swift +++ b/Airship/AirshipPreferenceCenter/Source/view/ConditionsMonitor.swift @@ -51,7 +51,6 @@ class ConditionsMonitor: ObservableObject { } .eraseToAnyPublisher() } - } @MainActor @@ -80,3 +79,4 @@ class ConditionsMonitor: ObservableObject { } } + diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptView.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptView.swift new file mode 100644 index 000000000..323384ef3 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptView.swift @@ -0,0 +1,194 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI +import Combine + +public enum AddChannelState { + case failedInvalid + case failedDefault + case succeeded + case ready + case loading +} + +struct AddChannelPromptView: View, @unchecked Sendable { + @StateObject + private var viewModel: AddChannelPromptViewModel + + /// The minimum alert width - as defined by Apple + private let promptMinWidth = 270.0 + + /// The maximum alert width + private let promptMaxWidth = 420.0 + + init(viewModel: AddChannelPromptViewModel) { + _viewModel = StateObject( + wrappedValue: viewModel + ) + } + + private var errorMessage: String? { + switch self.viewModel.state { + case .failedInvalid: + return self.viewModel.platform?.errorMessages?.invalidMessage + case .failedDefault: + return self.viewModel.platform?.errorMessages?.defaultMessage + default: + return nil + } + } + + @ViewBuilder + var foregroundContent: some View { + switch self.viewModel.state { + case .succeeded: + if self.viewModel.item.onSubmit != nil { + /// When we have submitted successfully users see a follow up prompt telling them to check their messaging app, email inbox, etc. + ResultPromptView( + item: self.viewModel.item.onSubmit, + theme: viewModel.theme + ) { + viewModel.onSubmit() + } + .transition(.opacity) + } else { + Rectangle() + .foregroundColor(Color.clear) + .onAppear { + viewModel.onSubmit() + } + } + case .ready, .loading, .failedInvalid, .failedDefault: + promptView + } + } + + @ViewBuilder + var body: some View { + foregroundContent.backgroundWithCloseAction { + self.viewModel.onCancel() + } + .frame(minWidth: promptMinWidth, maxWidth: promptMaxWidth) + } + + // MARK: Prompt view + @ViewBuilder + private var titleText: some View { + Text(self.viewModel.item.display.title) + .textAppearance( + viewModel.theme?.titleAppearance, + base: DefaultContactManagementSectionStyle.titleAppearance + ) + } + + @ViewBuilder + private var bodyText: some View { + if let bodyText = self.viewModel.item.display.body { + Text(bodyText) + .textAppearance( + viewModel.theme?.subtitleAppearance, + base: DefaultContactManagementSectionStyle.subtitleAppearance + ) + } + } + + @ViewBuilder + private var errorText: some View { + if self.viewModel.state == .failedDefault || self.viewModel.state == .failedInvalid, + let errorMessage = errorMessage { + ErrorLabel( + message: errorMessage, + theme: viewModel.theme + ) + .transition(.opacity) + } + } + + @ViewBuilder + private var submitButton: some View { + let isLoading = viewModel.state == .loading + HStack { + Spacer() + + /// Submit button + LabeledButton( + item: viewModel.item.submitButton, + isEnabled: viewModel.isInputFormatValid, + isLoading: viewModel.state == .loading, + theme: viewModel.theme + ) { + viewModel.attemptSubmission() + } + .disabled(isLoading) + .applyIf(isLoading) { content in + content.overlay( + ProgressView(), + alignment: .center + ) + } + } + } + + @ViewBuilder + private var footer: some View { + /// Footer + if let footer = self.viewModel.item.display.footer { + Text(LocalizedStringKey(footer)) /// Markdown parsing in iOS15+ + .textAppearance( + viewModel.theme?.subtitleAppearance, + base: DefaultContactManagementSectionStyle.subtitleAppearance + ) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } + } + + @ViewBuilder + private var promptViewContent: some View { + VStack(alignment: .leading, spacing: 12) { + titleText.padding(.trailing, 16) // Pad out to prevent aliasing with the close button + bodyText + + /// Channel Input text fields + ChannelTextField( + platform: viewModel.platform, + selectedSender: $viewModel.selectedSender, + inputText: $viewModel.inputText, + theme: viewModel.theme + ) + + errorText + submitButton + footer + } + } + + private var promptView: some View { + GeometryReader { proxy in + promptViewContent + .padding(16) + .addBackground(theme: viewModel.theme) + .addPreferenceCloseButton( + dismissButtonColor: .primary, + dismissIconResource: "xmark", + contentDescription: nil, + onUserDismissed: { + self.viewModel.onCancel() + }) + .padding(16) + .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY) + .transition(.opacity) + .onChange(of: viewModel.inputText) { newValue in + let isValid = viewModel.validateInputFormat() + + withAnimation { + self.viewModel.isInputFormatValid = isValid + + if isValid { + self.viewModel.state = .ready + } + } + } + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptViewModel.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptViewModel.swift new file mode 100644 index 000000000..3589bc0a3 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptViewModel.swift @@ -0,0 +1,181 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI +import Combine + +/// Imported for Logger and Contact calls +#if canImport(AirshipCore) +import AirshipCore +#endif + +internal class AddChannelPromptViewModel: ObservableObject { + @Published var state: AddChannelState = .ready + @Published var selectedSender: PreferenceCenterConfig.ContactManagementItem.SMSSenderInfo + @Published var inputText = "" + @Published var isInputFormatValid = false + + var theme: PreferenceCenterTheme.ContactManagement? + + internal let item: PreferenceCenterConfig.ContactManagementItem.AddChannelPrompt + internal let platform: PreferenceCenterConfig.ContactManagementItem.Platform? + internal let onCancel: () -> Void + internal let onRegisterSMS: (_ msisdn: String, _ senderID: String) -> Void + internal let onRegisterEmail: (_ email: String) -> Void + + internal init( + item: PreferenceCenterConfig.ContactManagementItem.AddChannelPrompt, + theme: PreferenceCenterTheme.ContactManagement?, + registrationOptions: PreferenceCenterConfig.ContactManagementItem.Platform?, + onCancel: @escaping () -> Void, + onRegisterSMS: @escaping (_ msisdn: String, _ senderID: String) -> Void, + onRegisterEmail: @escaping (_ email: String) -> Void + ) { + self.item = item + self.theme = theme + self.platform = registrationOptions + self.onCancel = onCancel + self.onRegisterSMS = onRegisterSMS + self.onRegisterEmail = onRegisterEmail + self.selectedSender = .none + } + + /// Attempts submission and updates state based on results of attempt + @MainActor + internal func attemptSubmission() { + Task { + if platform?.channelType == .sms { + await attemptSMSSubmission() + } else { + /// Email we just assume is good to go after format check + /// if a failure occurs it will be determined by the channel update response + /// in the channel list view + onValidationSucceeded() + } + } + } + + @MainActor + private func attemptSMSSubmission() async { + do { + let formattedMSISDN = formattedMSISDN(countryCode: selectedSender.countryCode, number: inputText) + + /// Only start to load when we are sure it's not a duplicate failed request + onStartLoading() + + /// Attempt validation call + let passedValidation = try await validateSMS(msisdn: formattedMSISDN, sender: selectedSender.senderId) + + if passedValidation { + onValidationSucceeded() + } else { + onValidationFailed() + } + + return + } catch { + AirshipLogger.error(error.localizedDescription) + } + + /// Even if an error is thrown, if this ever is hit something went wrong, show it as a generic error + onValidationError() + } + + internal func onSubmit() { + if let platform = platform { + switch platform { + case .sms(_): + let formattedNumber = formattedMSISDN(countryCode: selectedSender.countryCode, number: inputText) + onRegisterSMS(formattedNumber, selectedSender.senderId) + case .email(_): + let formattedEmail = formattedEmail(email: inputText) + onRegisterEmail(formattedEmail) + } + } + } + + @MainActor + internal func onStartLoading() { + withAnimation { + self.state = .loading + } + } + + @MainActor + internal func onValidationSucceeded() { + withAnimation { + self.state = .succeeded + } + } + + @MainActor + private func onValidationFailed() { + withAnimation { + self.state = .failedInvalid + } + } + + @MainActor + private func onValidationError() { + withAnimation { + self.state = .failedDefault + } + } +} + +// MARK: Remote operations + +extension AddChannelPromptViewModel { + @MainActor + private func validateSMS(msisdn: String, sender: String) async throws -> Bool { + if let delegate = Airship.contact.smsValidatorDelegate { + let result = try await delegate.validateSMS(msisdn: msisdn, sender: sender) + AirshipLogger.trace("Validating phone number through delegate") + return result + } else { + let result = try await Airship.contact.validateSMS(msisdn, sender: sender) + AirshipLogger.trace("Using default phone number validator") + return result + } + } +} + +// MARK: Utilities +extension AddChannelPromptViewModel { + + /// Format for MSISDN standards - including removing plus, dashes, spaces etc. + /// Formatting behind the scenes like this makes sense because there are lots of valid ways to show + /// Phone numbers like 1.503.867.5309 1-504-867-5309. This also allows us to strip the "+" from the country code + func formattedMSISDN(countryCode: String, number: String) -> String { + let msisdn = countryCode + number + let allowedCharacters = CharacterSet.decimalDigits + let formatted = msisdn.unicodeScalars.filter { allowedCharacters.contains($0) } + return String(formatted) + } + + /// Just trim spaces for emails to be helpful + func formattedEmail(email: String) -> String { + let trimmedText = email.replacingOccurrences(of: " ", with: "") + return String(trimmedText) + } + + /// Initial validation that unlocks the submit button. Email is currently only validated via this method. + @MainActor + internal func validateInputFormat() -> Bool { + if let platform = self.platform { + switch platform { + case .email(_): + let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" + let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + return emailPredicate.evaluate(with: self.inputText) + case .sms(_): + let formatted = formattedMSISDN(countryCode: self.selectedSender.countryCode, number: self.inputText) + let msisdnRegex = "^[1-9]\\d{1,14}$" + let msisdnPredicate = NSPredicate(format: "SELF MATCHES %@", msisdnRegex) + return msisdnPredicate.evaluate(with: formatted) + } + } else { + return false + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListView.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListView.swift new file mode 100644 index 000000000..51d35fc65 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListView.swift @@ -0,0 +1,283 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI +import Combine + +#if canImport(AirshipCore) +import AirshipCore +#endif + +// MARK: Channel list view for a given section +struct ChannelListView: View { + let item: PreferenceCenterConfig.ContactManagementItem + + @ObservedObject + var state: PreferenceCenterState + + @Environment(\.airshipPreferenceCenterTheme) + private var theme: PreferenceCenterTheme + + @State + private var hideView: Bool = false + + @State + private var disposable: AirshipMainActorCancellableBlock? + + @State + private var subscriptions: Set = [] + + @State + private var selectedChannel: ContactChannel? + + @State + private var pendingShowFlags:[String: Bool] = [:] + private func pendingFlagsBinding(for key: String) -> Binding { + return .init( + get: { self.pendingShowFlags[key, default: false] }, + set: { self.pendingShowFlags[key] = $0 }) + } + + @State + private var resendShowFlags:[String: Bool] = [:] + private func resendFlagsBinding(for key: String) -> Binding { + return .init( + get: { self.resendShowFlags[key, default: true] }, + set: { self.resendShowFlags[key] = $0 }) + } + + @ViewBuilder + private var removePromptView: some View { + if let view = self.item.removeChannel { + RemoveChannelPromptView( + item: view, + theme: self.theme.contactManagement) { + dismissPrompt() + } optOutAction: { + if let channel = self.selectedChannel { + Airship.contact.disassociateChannel(channel) + self.selectedChannel = nil + } + dismissPrompt() + } + } + } + + @ViewBuilder + private func makeAddButton(model: PreferenceCenterConfig.ContactManagementItem.LabeledButton) -> some View { + LabeledButton( + type: .outlineType, + item: model, + theme: self.theme.contactManagement + ) { + /// Avoid displaying if no view is supplied + if self.item.addChannel?.view != nil { + self.disposable = ChannelListView.showModalView( + rootView: addChannelPromptView, + theme: self.theme.contactManagement + ) + } + } + } + + private func pendingLabelModelForType(type: PreferenceCenterConfig.ContactManagementItem.Platform) -> PreferenceCenterConfig.ContactManagementItem.PendingLabel? { + switch type { + case .sms(let options): + return options.pendingLabel + case .email(let options): + return options.pendingLabel + } + } + + @ViewBuilder + private var headerTitleView: some View { + Text(self.item.display.title) + .textAppearance( + theme.contactManagement?.titleAppearance, + base: DefaultContactManagementSectionStyle.titleAppearance + ) + } + + @ViewBuilder + private var headerSubtitleView: some View { + if let subtitle = self.item.display.subtitle { + Text(subtitle) + .textAppearance( + theme.contactManagement?.subtitleAppearance, + base: DefaultContactManagementSectionStyle.subtitleAppearance + ) + } + } + + @ViewBuilder + private var headerView: some View { + HStack { + VStack(alignment: .leading, spacing: 8) { + headerTitleView + headerSubtitleView + } + Spacer() + } + } + + private func resend(_ channel: ContactChannel) { + self.disposable = ChannelListView.showModalView( + rootView: resendPromptView, + theme: self.theme.contactManagement + ) + self.selectedChannel = channel + } + + private func remove(_ channel: ContactChannel) { + self.disposable = ChannelListView.showModalView( + rootView: removePromptView, + theme: self.theme.contactManagement + ) + self.selectedChannel = channel + } + + @ViewBuilder + private var channelListView:some View { + ForEach(Array(self.$state.channelsList.wrappedValue.filter(with: self.item.platform.channelType)), id: \.self) { channel in + ChannelListViewCell(viewModel: ChannelListCellViewModel(channel: channel, + pendingLabelModel: pendingLabelModelForType(type: item.platform), + onResend: { + resend(channel) + }, onRemove: { + remove(channel) + }, onDismiss: dismissPrompt)) + } + } + + internal init(item: PreferenceCenterConfig.ContactManagementItem, state: PreferenceCenterState) { + self.item = item + self.state = state + } + + var body: some View { + if !self.hideView { + VStack(alignment: .leading) { + Section { + VStack(alignment: .leading) { + if self.$state.channelsList.wrappedValue.filter(with: self.item.platform.channelType).isEmpty { + EmptySectionLabel(label: item.emptyMessage) { + withAnimation { + self.hideView = true + } + } + } else { + channelListView + } + if let model = self.item.addChannel?.button { + makeAddButton(model: model) + } + } + } header: { + headerView + } + } + .padding(.bottom, 8) + } + } +} + +extension ChannelListView { + // MARK: Prompt functions + + private func registerSMS(msisdn: String, sender: String) { + let options = SMSRegistrationOptions.optIn( + senderID: sender + ) + + Airship.contact.registerSMS(msisdn, options: options) + + dismissPrompt() + } + + private func registerEmail(email: String) { + let options = EmailRegistrationOptions.options(properties: nil, doubleOptIn: true) + + Airship.contact.registerEmail(email, options: options) + + dismissPrompt() + } + + // MARK: Prompt Views + + @ViewBuilder + private var resendPromptView: some View { + /// When we have submitted successfully users see a follow up prompt telling them to check their messaging app, email inbox, etc. + ResultPromptView( + item: pendingLabelModelForType(type: item.platform)?.resendSuccessPrompt, + theme: theme.contactManagement + ) { + if let channel = self.selectedChannel { + Airship.contact.resend(channel) + self.selectedChannel = nil + } + dismissPrompt() + } + .transition(.opacity) + } + + @ViewBuilder + private var addChannelPromptView: some View { + if let view = self.item.addChannel?.view { + let viewModel = AddChannelPromptViewModel(item: view, + theme: self.theme.contactManagement, + registrationOptions: self.item.platform, + onCancel: dismissPrompt, + onRegisterSMS: registerSMS, + onRegisterEmail: registerEmail) + + + AddChannelPromptView(viewModel: viewModel) + .transition(.opacity) + } + } + + /// Pretty sure we use this extra window because creating the shadow view in the way we like is a pain since this isn't the top level view + /// Can probably improve on this and keep this more self-contained. + @MainActor + static func showModalView( + rootView: some View, + theme: PreferenceCenterTheme.ContactManagement? + ) -> AirshipMainActorCancellableBlock? { + + guard let scene = try? AirshipSceneManager.shared.lastActiveScene else { + AirshipLogger.error("Unable to display, missing scene.") + return nil + } + + let window: UIWindow? = UIWindow(windowScene: scene) + + let disposable = AirshipMainActorCancellableBlock { + DispatchQueue.main.async { + window?.animateOut() + } + } + + let viewController = ChannelListViewHostingController( + rootView: rootView, + backgroundColor: .clear.withAlphaComponent(0.5) + ) + + window?.rootViewController = viewController + window?.alpha = 0 + window?.animateIn() + + return disposable + } + + private func dismissPrompt() { + self.disposable?.cancel() + } +} + +extension PreferenceCenterConfig.ContactManagementItem.Platform { + var channelType: ChannelType { + switch self { + case .sms: return .sms + case .email: return .email + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListViewCell.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListViewCell.swift new file mode 100644 index 000000000..3cae18009 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListViewCell.swift @@ -0,0 +1,244 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI + +#if canImport(AirshipCore) +import AirshipCore +#endif + + +class ChannelListCellViewModel: ObservableObject { + let channel: ContactChannel + let pendingLabelModel: PreferenceCenterConfig.ContactManagementItem.PendingLabel? + + @Published + internal var isPendingLabelShowing: Bool = false + + @Published + internal var isResendShowing: Bool = false + + let onResend: () -> Void + let onRemove: () -> Void + let onDismiss: () -> Void + + private let pendingLabelHideDelaySeconds: Double = 30 + private var resendLabelHideDelaySeconds: Double { Double(pendingLabelModel?.intervalInSeconds ?? 15) } + + private var hidePendingLabelTask:Task? + private var hideResendButtonTask:Task? + + init(channel: ContactChannel, + pendingLabelModel: PreferenceCenterConfig.ContactManagementItem.PendingLabel?, + onResend: @escaping () -> (), + onRemove: @escaping () -> Void, + onDismiss: @escaping () -> Void) { + self.channel = channel + self.pendingLabelModel = pendingLabelModel + self.onResend = onResend + self.onRemove = onRemove + self.onDismiss = onDismiss + + initializePendingLabel() + } + + private func initializePendingLabel() { + temporarilyShowPendingLabel() + + /// If we are initializing the cell as a pending cell, assume it's recent + /// hide the resend button for the interval set on the pending label model + if !channel.isRegistered { + temporarilyHideResend() + } else { + isResendShowing = true + } + } + + func temporarilyShowPendingLabel() { + withAnimation { + isPendingLabelShowing = true + } + + hidePendingLabelTask?.cancel() + hidePendingLabelTask = Task { @MainActor [weak self] in + guard let self = self, !Task.isCancelled else { return} + + try? await Task.sleep(nanoseconds: UInt64(pendingLabelHideDelaySeconds * 1_000_000_000)) + + guard !Task.isCancelled else { return} + + withAnimation { + self.isPendingLabelShowing = false + } + } + } + + /// Used to temporarily hide the resend button for the interval provided by the model, also used to hide the button after each tap + func temporarilyHideResend() { + withAnimation { + isResendShowing = false + } + + hideResendButtonTask?.cancel() + hideResendButtonTask = Task { @MainActor [weak self] in + guard let self = self, !Task.isCancelled else { return} + + try? await Task.sleep(nanoseconds: UInt64(resendLabelHideDelaySeconds * 1_000_000_000)) + + guard !Task.isCancelled else { return} + + withAnimation { + self.isResendShowing = true + } + } + } +} + +struct ChannelListViewCell: View { + @StateObject private var viewModel: ChannelListCellViewModel + + @Environment(\.airshipPreferenceCenterTheme) + private var theme: PreferenceCenterTheme + + init(viewModel: ChannelListCellViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + @ViewBuilder + private var pendingLabelView: some View { + HStack(spacing: 8) { + if let pendingText = viewModel.pendingLabelModel?.message { + Text(pendingText).textAppearance( + theme.contactManagement?.listSubtitleAppearance, + base: DefaultContactManagementSectionStyle.listSubtitleAppearance + ) + } + + if viewModel.isResendShowing { + resendButton + } + + Spacer() + } + } + + @ViewBuilder + private var resendButton: some View { + if let resendTitle = viewModel.pendingLabelModel?.button.text { + Button { + viewModel.temporarilyHideResend() + viewModel.onResend() + } label: { + Text(resendTitle).textAppearance( + theme.contactManagement?.listSubtitleAppearance, + base: DefaultContactManagementSectionStyle.resendButtonTitleAppearance + ) + } + } + } + + private var trashButton: some View { + Button(action: { + viewModel.onRemove() + }) { + Image(systemName: "trash") + .foregroundColor(theme.contactManagement?.titleAppearance?.color ?? .primary) + } + } + + @ViewBuilder + private func makeCellLabel(iconSystemName: String, labelText: String) -> some View { + HStack { + Image(systemName: iconSystemName) + .font(.system(size: 16)) + .foregroundColor(theme.contactManagement?.titleAppearance?.color ?? DefaultColors.secondaryText) + Text(labelText).textAppearance( + theme.contactManagement?.listSubtitleAppearance, + base: DefaultContactManagementSectionStyle.listTitleAppearance + ) + } + } + + @ViewBuilder + private func makeErrorLabel(labelText: String) -> some View { + ErrorLabel(message: labelText, theme: self.theme.contactManagement) + } + + @ViewBuilder + private func makePendingLabel(channel: ContactChannel) -> some View { + if (channel.isRegistered) { + let isOptedIn = channel.isOptedIn + if viewModel.isPendingLabelShowing, !isOptedIn { + pendingLabelView + } + } else { + if viewModel.isPendingLabelShowing { + pendingLabelView + } + } + + } + + @ViewBuilder + private func makeCell(channel: ContactChannel) -> some View { + VStack(alignment: .leading, spacing: 8) { + let cellText = channel.maskedAddress.replacingAsterisksWithBullets() + if channel.channelType == .email { + makeCellLabel(iconSystemName: "envelope", labelText: cellText) + } else { + makeCellLabel(iconSystemName: "phone", labelText: cellText) + } + + makePendingLabel(channel: channel) + } + } + + private var cellBody: some View { + VStack(alignment: .leading) { + Divider() + HStack { + makeCell(channel: viewModel.channel) + Spacer() + trashButton + } + .padding(3) + } + } + + var body: some View { + cellBody + } +} + + +extension ContactChannel { + var isOptedIn: Bool { + switch (self) { + case .email(let email): + switch(email) { + case .pending(_): return false + case .registered(let info): + guard let optedOut = info.commercialOptedOut else { + return info.commercialOptedIn != nil + } + return info.commercialOptedIn?.compare(optedOut) == .orderedDescending /// Make sure optedIn date is after opt out date if both exist +#if canImport(AirshipCore) + @unknown default: + return false +#endif + } + case .sms(let sms): + switch(sms) { + case .pending(_): return false + case .registered(let info): return info.isOptIn +#if canImport(AirshipCore) + @unknown default: + return false +#endif + } +#if canImport(AirshipCore) + @unknown default: + return false +#endif + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListViewHostingController.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListViewHostingController.swift new file mode 100644 index 000000000..83eb3649d --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListViewHostingController.swift @@ -0,0 +1,21 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +class ChannelListViewHostingController: UIHostingController where Content: View { + init( + rootView: Content, + backgroundColor: UIColor? = nil + ) { + super.init(rootView: rootView) + if let backgroundColor = backgroundColor { + self.view.backgroundColor = backgroundColor + } + } + + @objc + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/BackgroundShape.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/BackgroundShape.swift new file mode 100644 index 000000000..859eb2b71 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/BackgroundShape.swift @@ -0,0 +1,14 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI + +// MARK: Background +struct BackgroundShape: View { + var color: Color + var body: some View { + Rectangle() + .fill(color) + .cornerRadius(10) + .shadow(radius: 5) + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ChannelTextField.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ChannelTextField.swift new file mode 100644 index 000000000..78356b60c --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ChannelTextField.swift @@ -0,0 +1,153 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI +import Combine + +// MARK: Channel text field +public struct ChannelTextField: View { + @Environment(\.colorScheme) var colorScheme + + private let placeHolderPadding = EdgeInsets(top: 4, leading: 15, bottom: 4, trailing: 4) + + private var senders: [PreferenceCenterConfig.ContactManagementItem.SMSSenderInfo]? + + private var platform: PreferenceCenterConfig.ContactManagementItem.Platform? + + @Binding var selectedSender: PreferenceCenterConfig.ContactManagementItem.SMSSenderInfo + + @State var selectedSenderID: String = "" + + @Binding var inputText: String + + @State + private var placeholder: String = "" + + private let fieldCornerRadius: CGFloat = 4 + + /// The preference center theme + var theme: PreferenceCenterTheme.ContactManagement? + + private var smsOptions: PreferenceCenterConfig.ContactManagementItem.SMS? + private var emailOptions: PreferenceCenterConfig.ContactManagementItem.Email? + + public init( + platform: PreferenceCenterConfig.ContactManagementItem.Platform?, + selectedSender: Binding, + inputText: Binding, + theme: PreferenceCenterTheme.ContactManagement? + ) { + self.platform = platform + _selectedSender = selectedSender + _inputText = inputText + + self.theme = theme + + if let platform = self.platform { + switch platform { + case .sms(let options): + self.senders = options.senders + smsOptions = options + case .email(let options): + emailOptions = options + } + } + + self.placeholder = makePlaceholder() + } + + public var body: some View { + countryPicker + VStack { + HStack(spacing:2) { + textFieldLabel + textField + } + .padding(10) + .background(backgroundView) + } + } + + @ViewBuilder + private var countryPicker: some View { + if let senders = self.senders, (senders.count >= 1) { + HStack(spacing:10) { + if let smsOptions = smsOptions { + Text(smsOptions.countryLabel) + } + Spacer() + Picker("senders", selection: $selectedSenderID) { + ForEach(senders, id: \.self) { + Text($0.countryCode.countryFlag() + " " + $0.countryCode + " ").tag($0.senderId) + } + } + .accentColor(DefaultColors.primaryText) + .airshipOnChangeOf(self.selectedSenderID, { newVal in + if let sender = senders.first(where: { $0.senderId == newVal }) { + selectedSender = sender + /// Update placeholder with selection + placeholder = makePlaceholder() + } + }) + .onAppear { + /// Ensure initial value is set + if let sender = self.senders?.first { + self.selectedSenderID = sender.senderId + } + } + } + .padding(10) + .background(backgroundView) + } + } + + private var textField: some View { + TextField(makePlaceholder(), text: $inputText) + .padding(self.placeHolderPadding) + .keyboardType(keyboardType) + } + + @ViewBuilder + private var textFieldLabel: some View { + if let smsOptions = smsOptions { + Text(smsOptions.msisdnLabel) + } else if let emailOptions = emailOptions { + Text(emailOptions.addressLabel) + } + } + + @ViewBuilder + private var backgroundView: some View { + let backgroundColor = theme?.backgroundColor ?? DefaultContactManagementSectionStyle.backgroundColor + + RoundedRectangle(cornerRadius: fieldCornerRadius).foregroundColor(backgroundColor.secondaryVariant(for: colorScheme).opacity(0.2)) + } + + // MARK: Keyboard type + private var keyboardType: UIKeyboardType { + if let platform = self.platform { + switch platform { + case .sms(_): + return .decimalPad + case .email(_): + return .emailAddress + } + } else { + return .default + } + } + + // MARK: Placeholder + private func makePlaceholder() -> String { + let defaultPlaceholder = "" + if let platform = self.platform { + switch platform { + case .sms(_): + return self.selectedSender.placeholderText + case .email(let emailRegistrationOption): + return emailRegistrationOption.placeholder ?? defaultPlaceholder + } + } else { + return defaultPlaceholder + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/EmptySectionLabel.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/EmptySectionLabel.swift new file mode 100644 index 000000000..eb68cbb4a --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/EmptySectionLabel.swift @@ -0,0 +1,36 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI + +struct EmptySectionLabel: View { + static let padding = EdgeInsets(top: 0, leading: 25, bottom: 5, trailing: 0) + + // The empty message + var label: String? + + /// The preference center theme + var theme: PreferenceCenterTheme.ChannelSubscription? + + var action: (()->())? + + public var body: some View { + if let label = label { + VStack(alignment: .leading) { + HStack(spacing:12) { + Image(systemName: "info.circle") + .font(.system(size: 16)) + .foregroundColor(.primary.opacity(0.5)) + /// Message + Text(label) + .textAppearance( + theme?.emptyTextAppearance, + base: DefaultContactManagementSectionStyle.subtitleAppearance + ) + Spacer() + } + } + .transition(.opacity) + .padding(5) + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ErrorLabel.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ErrorLabel.swift new file mode 100644 index 000000000..cbbed97a4 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ErrorLabel.swift @@ -0,0 +1,31 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI + +/// Error text view that appears under the add channel fields when an error occurs +public struct ErrorLabel: View { + + public var message: String? + var theme: PreferenceCenterTheme.ContactManagement? + + public init( + message: String?, + theme: PreferenceCenterTheme.ContactManagement? + ) { + self.message = message + self.theme = theme + } + + public var body: some View { + if let errorMessage = self.message { + HStack (alignment: .top){ + Text(errorMessage) + .textAppearance( + theme?.errorAppearance, + base: DefaultContactManagementSectionStyle.errorAppearance + ) + .lineLimit(2) + } + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/LabeledButton.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/LabeledButton.swift new file mode 100644 index 000000000..8c5b2f5da --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/LabeledButton.swift @@ -0,0 +1,116 @@ +/* Copyright Airship and Contributors */ +import SwiftUI + +#if canImport(AirshipCore) +import AirshipCore +#endif + +enum LabeledButtonType { + case defaultType + case destructiveType + case outlineType +} + +struct LabeledButton: View { + var item: PreferenceCenterConfig.ContactManagementItem.LabeledButton + var type: LabeledButtonType = .defaultType + + var isEnabled: Bool + var isLoading: Bool + + var theme: PreferenceCenterTheme.ContactManagement? + var action: ()->() + + private let cornerRadius: CGFloat = 8 + private let disabledOpacity: CGFloat = 0.5 + private let buttonPadding: EdgeInsets = EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12) + private let outlineWidth: CGFloat = 1 + + private let minButtonWidth: CGFloat = 44 + + init( + type: LabeledButtonType = .defaultType, + item: PreferenceCenterConfig.ContactManagementItem.LabeledButton, + isEnabled: Bool = true, + isLoading: Bool = false, + theme: PreferenceCenterTheme.ContactManagement?, + action: @escaping () -> () + ) { + self.item = item + self.isEnabled = isEnabled + self.isLoading = isLoading + self.theme = theme + self.action = action + self.type = type + } + + private var backgroundColor: Color { + return theme?.buttonBackgroundColor ?? DefaultContactManagementSectionStyle.buttonBackgroundColor + } + + private var destructiveBackgroundColor: Color { + return theme?.buttonDestructiveBackgroundColor ?? DefaultContactManagementSectionStyle.buttonDestructiveBackgroundColor + } + + @ViewBuilder + private var buttonLabel: some View { + Text(self.item.text) + .textAppearance( + theme?.buttonLabelAppearance, + base: typedAppearance + ) + .opacity((isEnabled ? 1 : disabledOpacity)) + .airshipApplyIf(isLoading) { + $0 + .opacity(0) /// Hide the text underneath the loader + .overlay( + ProgressView() + .airshipSetTint(color: typedAppearance.color ?? DefaultColors.primaryInvertedText) + ) + } + } + + private var typedAppearance: PreferenceCenterTheme.TextAppearance { + switch type { + case .defaultType: + return DefaultContactManagementSectionStyle.buttonLabelAppearance + case .destructiveType: + return DefaultContactManagementSectionStyle.buttonLabelDestructiveAppearance + case .outlineType: + return DefaultContactManagementSectionStyle.buttonLabelOutlineAppearance + } + } + + private var typedBackgroundColor: Color { + switch type { + case .defaultType: + return (!isEnabled ? backgroundColor.opacity(disabledOpacity) : backgroundColor) + case .destructiveType: + return (!isEnabled ? destructiveBackgroundColor.opacity(disabledOpacity) : destructiveBackgroundColor) + case .outlineType: + return Color.clear + } + } + + var body: some View { + Button { + action() + } label: { + buttonLabel + } + .frame(minWidth: minButtonWidth) + .padding(buttonPadding) + .background(typedBackgroundColor) + .cornerRadius(cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(type == .outlineType ? DefaultColors.primaryText : Color.clear, lineWidth: type == .outlineType ? outlineWidth : 0) + ) + .accessibilityLabel(item.contentDescription ?? "") + .disabled(!isEnabled) + .optAccessibilityLabel( + string: self.item.contentDescription + ) + } +} + diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/PreferenceCloseButton.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/PreferenceCloseButton.swift new file mode 100644 index 000000000..39016d325 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/PreferenceCloseButton.swift @@ -0,0 +1,98 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI + +struct PreferenceCloseButton: View { + internal init(dismissIconColor: Color, dismissIconResource:String, onTap: @escaping () -> ()) { + self.dismissIconColor = dismissIconColor + self.dismissIconResource = dismissIconResource + self.onTap = onTap + } + + let dismissIconColor: Color + let dismissIconResource: String + + let onTap: () -> () + + private let opacity: CGFloat = 0.64 + private let defaultPadding: CGFloat = 24 + + private let height: CGFloat = 24 + private let width: CGFloat = 24 + + private let tappableHeight: CGFloat = 48 + private let tappableWidth: CGFloat = 48 + + private func imageExistsInBundle(name: String) -> Bool { + return UIImage(named: name) != nil + } + + /// Check bundle and system for resource name + /// If system image assume it's an icon and add a circular background + @ViewBuilder + private var dismissButtonImage: some View { + imageExistsInBundle(name: dismissIconResource) ? + AnyView(Image(dismissIconResource) + .resizable() + .frame(width: width/2, height: height/2)) : + AnyView(Image(systemName: dismissIconResource) + .resizable() + .frame(width: width/2, height: height/2) + .foregroundColor(dismissIconColor) + .padding(8) + .clipShape(Circle())) + } + + var body: some View { + Button(action: onTap) { + VStack(alignment:.center, spacing:0) { + Spacer() + dismissButtonImage + .opacity(opacity) + + Spacer() + } + }.frame(width: tappableWidth, height: tappableHeight) + .accessibilityLabel("Dismiss") + } +} + +#Preview { + PreferenceCloseButton(dismissIconColor: .primary, + dismissIconResource: "xmark", + onTap: {}) + .background(Color.green) +} + +extension View { + @ViewBuilder + func addBackground(theme: PreferenceCenterTheme.ContactManagement?) -> some View { + self.background( + BackgroundShape( + color: theme?.backgroundColor ?? DefaultContactManagementSectionStyle.backgroundColor + ) + ) + } + + @ViewBuilder + func addPreferenceCloseButton( + dismissButtonColor: Color, + dismissIconResource: String, + contentDescription: String?, + circleColor:Color? = nil, + onUserDismissed: @escaping () -> Void + ) -> some View { + ZStack(alignment: .topTrailing) { // Align close button to the top trailing corner + self.zIndex(0) + PreferenceCloseButton( + dismissIconColor: dismissButtonColor, + dismissIconResource: dismissIconResource, + onTap: onUserDismissed + ) + .airshipApplyIf(contentDescription != nil, transform: { view in + view.accessibilityLabel(contentDescription!) + }) + .zIndex(1) + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/ContactManagementView.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ContactManagementView.swift new file mode 100644 index 000000000..bc85cb95f --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ContactManagementView.swift @@ -0,0 +1,229 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI +import Combine + +#if canImport(AirshipCore) +import AirshipCore +#endif + +public struct PreferenceCenterContactManagementView: View { + + /// The item's config + public let item: PreferenceCenterConfig.ContactManagementItem + + /// The preference state + @ObservedObject + public var state: PreferenceCenterState + + @Environment(\.airshipContactManagementSectionStyle) + private var style + + @Environment(\.airshipPreferenceCenterTheme) + private var theme: PreferenceCenterTheme + + @State + private var displayConditionsMet: Bool = true + + public init( + item: PreferenceCenterConfig.ContactManagementItem, + state: PreferenceCenterState + ) { + self.item = item + self.state = state + } + + @ViewBuilder + public var body: some View { + let configuration = ContactManagementSectionStyleConfiguration( + section: self.item, + state: self.state, + displayConditionsMet: self.displayConditionsMet, + preferenceCenterTheme: self.theme + ) + + style.makeBody(configuration: configuration) + .preferenceConditions( + self.item.conditions, + binding: self.$displayConditionsMet + ) + } +} + +/// The labeled section break style configuration +public struct ContactManagementSectionStyleConfiguration { + + /// The section config + public let section: PreferenceCenterConfig.ContactManagementItem + + /// The preference state + public let state: PreferenceCenterState + + /// If the display conditions are met for this item + public let displayConditionsMet: Bool + + /// The preference center theme + public let preferenceCenterTheme: PreferenceCenterTheme +} + + +extension View { + /// Sets the contact management section style + /// - Parameters: + /// - style: The style + public func ContactManagementSectionStyle(_ style: S) -> some View + where S: ContactManagementSectionStyle { + self.environment( + \.airshipContactManagementSectionStyle, + AnyContactManagementSectionStyle(style: style) + ) + } +} + +/// Contact management section style +public protocol ContactManagementSectionStyle { + associatedtype Body: View + typealias Configuration = ContactManagementSectionStyleConfiguration + func makeBody(configuration: Self.Configuration) -> Self.Body +} + +extension ContactManagementSectionStyle +where Self == DefaultContactManagementSectionStyle { + + /// Default style + public static var defaultStyle: Self { + return .init() + } +} + +internal struct DefaultColors { + static let primaryText: Color = Color(UIColor { (traitCollection: UITraitCollection) -> UIColor in + return traitCollection.userInterfaceStyle == .dark ? UIColor.white : AirshipColorUtils.color("#333333") ?? UIColor.label + }) + + static let primaryInvertedText: Color = Color(UIColor { (traitCollection: UITraitCollection) -> UIColor in + return (traitCollection.userInterfaceStyle == .dark ? AirshipColorUtils.color("#333333") : UIColor.white) ?? UIColor.systemBackground + }) + + static let secondaryText: Color = Color(UIColor { (traitCollection: UITraitCollection) -> UIColor in + return (traitCollection.userInterfaceStyle == .dark ? UIColor.white : AirshipColorUtils.color("#666666")) ?? UIColor.secondaryLabel + }) + + static let secondaryBackground: Color = Color(UIColor { (traitCollection: UITraitCollection) -> UIColor in + return (traitCollection.userInterfaceStyle == .dark ? AirshipColorUtils.color("#272727") : UIColor.secondarySystemBackground) ?? UIColor.secondarySystemBackground + }) + + static let linkBlue: Color = Color(UIColor { (traitCollection: UITraitCollection) -> UIColor in + return (traitCollection.userInterfaceStyle == .dark ? AirshipColorUtils.color("#619AFF") : AirshipColorUtils.color("#316BF2")) ?? UIColor.secondaryLabel + }) + + static let alertRed: Color = Color(UIColor { (traitCollection: UITraitCollection) -> UIColor in + return (traitCollection.userInterfaceStyle == .dark ? AirshipColorUtils.color("#FF677F") : AirshipColorUtils.color("#E6193B")) ?? UIColor.red + }) + + static let destructiveRed: Color = Color(UIColor { (traitCollection: UITraitCollection) -> UIColor in + return (traitCollection.userInterfaceStyle == .dark ? AirshipColorUtils.color("#B20D25") : AirshipColorUtils.color("#B9142B")) ?? UIColor.red + }) +} + +// MARK: - DEFAULT Contact Management View +/// Default contact management section style. Also styles alert views. +public struct DefaultContactManagementSectionStyle: ContactManagementSectionStyle { + static let backgroundColor = DefaultColors.secondaryBackground + + static let titleAppearance = PreferenceCenterTheme.TextAppearance( + font: .headline, + color: DefaultColors.primaryText + ) + + static let subtitleAppearance = PreferenceCenterTheme.TextAppearance( + font: .subheadline, + color: DefaultColors.primaryText + ) + + static let resendButtonTitleAppearance = PreferenceCenterTheme.TextAppearance( + font: .caption.weight(.bold), + color: DefaultColors.linkBlue + ) + + static let listTitleAppearance = PreferenceCenterTheme.TextAppearance( + font: .callout.weight(.regular), + color: DefaultColors.secondaryText + ) + + static let listSubtitleAppearance = PreferenceCenterTheme.TextAppearance( + font: .caption.weight(.regular), + color: DefaultColors.secondaryText + ) + + static let errorAppearance = PreferenceCenterTheme.TextAppearance( + font: .footnote.weight(.medium), + color: DefaultColors.alertRed + ) + + static let buttonLabelAppearance = PreferenceCenterTheme.TextAppearance( + font: .headline.weight(.bold), + color: DefaultColors.primaryInvertedText + ) + + static let buttonLabelDestructiveAppearance = PreferenceCenterTheme.TextAppearance( + font: .headline.weight(.bold), + color: .white + ) + + static let buttonLabelOutlineAppearance = PreferenceCenterTheme.TextAppearance( + font: .headline.weight(.bold), + color: DefaultColors.primaryText + ) + + static let buttonBackgroundColor = DefaultColors.primaryText + static let buttonDestructiveBackgroundColor = DefaultColors.destructiveRed + + @ViewBuilder + public func makeBody(configuration: Configuration) -> some View { + if configuration.displayConditionsMet { + DefaultContactManagementView(configuration: configuration) + } + } +} + +private struct DefaultContactManagementView: View { + + /// The item's config + public let configuration: ContactManagementSectionStyleConfiguration + + var body: some View { + ChannelListView( + item: configuration.section, + state: configuration.state + ) + .transition(.opacity) + } +} + +struct AnyContactManagementSectionStyle: ContactManagementSectionStyle { + @ViewBuilder + private var _makeBody: (Configuration) -> AnyView + + init(style: S) { + _makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + @ViewBuilder + func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} + +struct ContactManagementSectionStyleKey: EnvironmentKey { + static var defaultValue = AnyContactManagementSectionStyle(style: .defaultStyle) +} + +extension EnvironmentValues { + var airshipContactManagementSectionStyle: AnyContactManagementSectionStyle { + get { self[ContactManagementSectionStyleKey.self] } + set { self[ContactManagementSectionStyleKey.self] = newValue } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/RemoveChannelPromptView.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/RemoveChannelPromptView.swift new file mode 100644 index 000000000..8af8ae5ab --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/RemoveChannelPromptView.swift @@ -0,0 +1,144 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +/// Prompt that appears when a opt-out button tap occurs. +struct RemoveChannelPromptView: View { + var item: PreferenceCenterConfig.ContactManagementItem.RemoveChannel + + /// The preference center theme + var theme: PreferenceCenterTheme.ContactManagement? + + var onCancel: ()->() + var optOutAction: ()->() + + /// The minimum alert width - as defined by Apple + private let promptMinWidth = 270.0 + + /// The maximum alert width + private let promptMaxWidth = 420.0 + + private func dismiss() { + onCancel() + } + + @ViewBuilder + private var promptViewContent: some View { + VStack(alignment: .leading, spacing: 8) { + titleText.padding(.trailing, 16) // Pad out to prevent aliasing with the close button + bodyText + buttonView + footer + } + } + + var promptView: some View { + GeometryReader { proxy in + promptViewContent + .padding(16) + .addBackground(theme: theme) + .addPreferenceCloseButton( + dismissButtonColor: .primary, + dismissIconResource: "xmark", + contentDescription: item.view.closeButton?.contentDescription, + onUserDismissed: { + onCancel() + }) + .padding(16) + .position(x: proxy.frame(in: .local).midX, y: proxy.frame(in: .local).midY) + .transition(.opacity) + } + } + + @ViewBuilder + private var titleText: some View { + /// Title + Text(item.view.display.title) + .textAppearance( + theme?.titleAppearance, + base: DefaultContactManagementSectionStyle.titleAppearance + ) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private var bodyText: some View { + /// Body + if let body = item.view.display.body { + Text(body) + .textAppearance( + theme?.subtitleAppearance, + base: DefaultContactManagementSectionStyle.subtitleAppearance + ) + .multilineTextAlignment(.leading) + } + } + + @ViewBuilder + private var footer: some View { + /// Footer + if let footer = item.view.display.footer { + Spacer() + .frame(height: 20) + Text(footer) + .textAppearance( + theme?.subtitleAppearance, + base: DefaultContactManagementSectionStyle.subtitleAppearance + ) + } + } + + @ViewBuilder + private var buttonView: some View { + let noText = item.view.onSuccess?.title == nil && item.view.onSuccess?.body == nil + if item.view.cancelButton != nil { + HStack { + Spacer() + HStack(alignment: .center, spacing: 12) { + cancelButton + submitButton + } + Spacer() + } + .padding(.top, noText ? 24 : 0) + } else { + HStack { + Spacer() + submitButton.padding(.top, noText ? 24 : 0) + } + } + } + + @ViewBuilder + private var submitButton: some View { + if let optOutButton = item.view.submitButton { + /// Opt out Button + LabeledButton( + type: .destructiveType, + item: optOutButton, + theme: self.theme, + action: optOutAction) + } + } + + @ViewBuilder + private var cancelButton: some View { + if let cancelButton = item.view.cancelButton { + HStack { + Spacer() + /// Opt out Button + LabeledButton( + type: .outlineType, + item: cancelButton, + theme: self.theme, + action: onCancel + ) + } + } + } + + var body: some View { + promptView.frame(minWidth: promptMinWidth, maxWidth: promptMaxWidth) + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/Contact management/ResultPromptView.swift b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ResultPromptView.swift new file mode 100644 index 000000000..240d7f88d --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/Contact management/ResultPromptView.swift @@ -0,0 +1,75 @@ +/* Copyright Airship and Contributors */ + +import SwiftUI + +/// Prompt that appears within the Add Channel Prompt View when an opt-in operation is initiated +/// Subject to configuration by the user it tells users to check their email inbox, message app, etc. +/// Purely informational and it's only behavior is dismissing itself and it's parent the AddChannelPromptView +public struct ResultPromptView: View { + var item: PreferenceCenterConfig.ContactManagementItem.ActionableMessage? + + /// The preference center theme + var theme: PreferenceCenterTheme.ContactManagement? + + var onDismiss: () -> Void + + private var alertInternalPadding: CGFloat = 16 + private var alertExternalPadding: CGFloat = 16 + + /// The minimum alert width - as defined by Apple + private let promptMinWidth = 270.0 + + /// The maximum alert width + private let promptMaxWidth = 420.0 + + public init( + item: PreferenceCenterConfig.ContactManagementItem.ActionableMessage?, + theme: PreferenceCenterTheme.ContactManagement?, + onDismiss: @escaping () -> Void + ) { + self.item = item + self.theme = theme + self.onDismiss = onDismiss + } + + public var body: some View { + if let item = self.item { + VStack(alignment: .leading, spacing: 8) { + + /// Title + Text(item.title) + .textAppearance( + theme?.titleAppearance, + base: DefaultContactManagementSectionStyle.titleAppearance + ) + + if let body = item.body { + /// Body + Text(body) + .textAppearance( + theme?.subtitleAppearance, + base: DefaultContactManagementSectionStyle.subtitleAppearance + ) + } + + HStack { + Spacer() + /// Button + LabeledButton( + item: item.button, + theme: self.theme, + action: onDismiss + ) + } + } + .padding(alertInternalPadding) + .background( + BackgroundShape( + color: theme?.backgroundColor ?? DefaultContactManagementSectionStyle.backgroundColor + ) + ) + .padding(alertExternalPadding) + .frame(minWidth: promptMinWidth, maxWidth: promptMaxWidth) + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/ContactSubscriptionGroupView.swift b/Airship/AirshipPreferenceCenter/Source/view/ContactSubscriptionGroupView.swift index 6452f69db..bc13f221e 100644 --- a/Airship/AirshipPreferenceCenter/Source/view/ContactSubscriptionGroupView.swift +++ b/Airship/AirshipPreferenceCenter/Source/view/ContactSubscriptionGroupView.swift @@ -74,7 +74,7 @@ extension View { } } -/// The contaction subscription group item style config +/// The contact subscription group item style config public struct ContactSubscriptionGroupStyleConfiguration { public let item: PreferenceCenterConfig.ContactSubscriptionGroup /// The preference state @@ -145,7 +145,7 @@ public struct DefaultContactSubscriptionGroupStyle: .contactSubscriptionGroup if configuration.displayConditionsMet { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 8) { if let title = item.display?.title { Text(title) .textAppearance( diff --git a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterAlertView.swift b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterAlertView.swift index 7bad453bb..06a7c49ec 100644 --- a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterAlertView.swift +++ b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterAlertView.swift @@ -17,7 +17,7 @@ public struct PreferenceCenterAlertView: View { @ObservedObject public var state: PreferenceCenterState - @Environment(\.airshipPrefenceCenterAlertStyle) + @Environment(\.airshipPreferenceCenterAlertStyle) private var style @Environment(\.airshipPreferenceCenterTheme) @@ -33,7 +33,7 @@ public struct PreferenceCenterAlertView: View { @ViewBuilder public var body: some View { - let configuration = PrefernceCenterAlertStyleConfiguration( + let configuration = PreferenceCenterAlertStyleConfiguration( item: self.item, state: self.state, displayConditionsMet: self.displayConditionsMet, @@ -52,17 +52,17 @@ extension View { /// Sets the alert style /// - Parameters: /// - style: The style - public func prefernceCenterAlertStyle(_ style: S) -> some View - where S: PrefernceCenterAlertStyle { + public func PreferenceCenterAlertStyle(_ style: S) -> some View + where S: PreferenceCenterAlertStyle { self.environment( - \.airshipPrefenceCenterAlertStyle, - AnyPrefernceCenterAlertStyle(style: style) + \.airshipPreferenceCenterAlertStyle, + AnyPreferenceCenterAlertStyle(style: style) ) } } /// Preference Center alert style configuration -public struct PrefernceCenterAlertStyleConfiguration { +public struct PreferenceCenterAlertStyleConfiguration { /// The item config public let item: PreferenceCenterConfig.Alert @@ -77,14 +77,14 @@ public struct PrefernceCenterAlertStyleConfiguration { } /// Preference Center alert style -public protocol PrefernceCenterAlertStyle { +public protocol PreferenceCenterAlertStyle { associatedtype Body: View - typealias Configuration = PrefernceCenterAlertStyleConfiguration + typealias Configuration = PreferenceCenterAlertStyleConfiguration func makeBody(configuration: Self.Configuration) -> Self.Body } -extension PrefernceCenterAlertStyle -where Self == DefaultPrefernceCenterAlertStyle { +extension PreferenceCenterAlertStyle +where Self == DefaultPreferenceCenterAlertStyle { /// Default style public static var defaultStyle: Self { @@ -93,7 +93,7 @@ where Self == DefaultPrefernceCenterAlertStyle { } /// The default Preference Center alert style -public struct DefaultPrefernceCenterAlertStyle: PrefernceCenterAlertStyle { +public struct DefaultPreferenceCenterAlertStyle: PreferenceCenterAlertStyle { static let titleAppearance = PreferenceCenterTheme.TextAppearance( font: .headline, @@ -137,7 +137,7 @@ public struct DefaultPrefernceCenterAlertStyle: PrefernceCenterAlertStyle { Text(title) .textAppearance( itemTheme?.titleAppearance, - base: DefaultPrefernceCenterAlertStyle + base: DefaultPreferenceCenterAlertStyle .titleAppearance ) } @@ -146,7 +146,7 @@ public struct DefaultPrefernceCenterAlertStyle: PrefernceCenterAlertStyle { Text(subtitle) .textAppearance( itemTheme?.subtitleAppearance, - base: DefaultPrefernceCenterAlertStyle + base: DefaultPreferenceCenterAlertStyle .subtitleAppearance ) } @@ -168,7 +168,7 @@ public struct DefaultPrefernceCenterAlertStyle: PrefernceCenterAlertStyle { .textAppearance( itemTheme?.buttonLabelAppearance, base: - DefaultPrefernceCenterAlertStyle + DefaultPreferenceCenterAlertStyle .buttonLabelAppearance ) .padding(.horizontal, 8) @@ -196,11 +196,11 @@ public struct DefaultPrefernceCenterAlertStyle: PrefernceCenterAlertStyle { } } -struct AnyPrefernceCenterAlertStyle: PrefernceCenterAlertStyle { +struct AnyPreferenceCenterAlertStyle: PreferenceCenterAlertStyle { @ViewBuilder private var _makeBody: (Configuration) -> AnyView - init(style: S) { + init(style: S) { _makeBody = { configuration in AnyView(style.makeBody(configuration: configuration)) } @@ -212,13 +212,13 @@ struct AnyPrefernceCenterAlertStyle: PrefernceCenterAlertStyle { } } -struct PrefernceCenterAlertStyleKey: EnvironmentKey { - static var defaultValue = AnyPrefernceCenterAlertStyle(style: .defaultStyle) +struct PreferenceCenterAlertStyleKey: EnvironmentKey { + static var defaultValue = AnyPreferenceCenterAlertStyle(style: .defaultStyle) } extension EnvironmentValues { - var airshipPrefenceCenterAlertStyle: AnyPrefernceCenterAlertStyle { - get { self[PrefernceCenterAlertStyleKey.self] } - set { self[PrefernceCenterAlertStyleKey.self] = newValue } + var airshipPreferenceCenterAlertStyle: AnyPreferenceCenterAlertStyle { + get { self[PreferenceCenterAlertStyleKey.self] } + set { self[PreferenceCenterAlertStyleKey.self] = newValue } } } diff --git a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterState.swift b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterState.swift index 2a4a543fd..403a741f6 100644 --- a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterState.swift +++ b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterState.swift @@ -16,25 +16,32 @@ public class PreferenceCenterState: ObservableObject { private var contactSubscriptions: [String: Set] private var channelSubscriptions: Set - private var channelUpdates: AnyCancellable? - private var contactUpdates: AnyCancellable? + + @Published + var channelsList: [ContactChannel] = [] + + private var subscriptions: Set = [] private let subscriber: PreferenceSubscriber /// Default constructor. /// - Parameters: /// - config: The preference config - /// - contactSubscriptions: The relavent contact subscriptions - /// - channelSubscriptions: The relavent channel subscriptions. + /// - contactSubscriptions: The relevant contact subscriptions + /// - channelSubscriptions: The relevant channel subscriptions. + /// - channelsLists: The relevant channels list. public convenience init( config: PreferenceCenterConfig, contactSubscriptions: [String: Set] = [:], - channelSubscriptions: Set = Set() + channelSubscriptions: Set = Set(), + channelsList: [ContactChannel] = [], + channelUpdates: AsyncStream? = nil ) { self.init( config: config, contactSubscriptions: contactSubscriptions, channelSubscriptions: channelSubscriptions, + channelUpdates: channelUpdates, subscriber: PreferenceCenterState.makeSubscriber() ) } @@ -43,28 +50,42 @@ public class PreferenceCenterState: ObservableObject { config: PreferenceCenterConfig, contactSubscriptions: [String: Set] = [:], channelSubscriptions: Set = Set(), + channelUpdates: AsyncStream? = nil, subscriber: PreferenceSubscriber ) { - self.config = config self.contactSubscriptions = contactSubscriptions self.channelSubscriptions = channelSubscriptions self.subscriber = subscriber self.subscribeToUpdates() + + if let channelUpdates { + Task { @MainActor [weak self] in + for await update in channelUpdates { + if case .success(let channels) = update { + self?.channelsList = channels + AirshipLogger.info("Preference center channel updated") + } + + } + } + } } /// Subscribes to updates from the Airship instance private func subscribeToUpdates() { - self.channelUpdates = self.subscriber.channelSubscriptionListEdits + self.subscriber.channelSubscriptionListEdits .sink { edit in self.processChannelEdit(edit) } + .store(in: &subscriptions) - self.contactUpdates = self.subscriber.contactSubscriptionListEdits + self.subscriber.contactSubscriptionListEdits .sink { edit in self.processContactEdit(edit) } + .store(in: &subscriptions) } /// Checks if the channel is subscribed to the preference state @@ -81,7 +102,7 @@ public class PreferenceCenterState: ObservableObject { /// - scope: The channel scope /// - Returns: true if any the contact is subscribed for that scope, otherwise false. public func isContactSubscribed(_ listID: String, scope: ChannelScope) - -> Bool + -> Bool { let containsSubscription = self.contactSubscriptions[listID]? .contains { @@ -105,7 +126,7 @@ public class PreferenceCenterState: ObservableObject { /// - scopes: The channel scopes /// - Returns: true if the contact is subscribed to any of the scopes, otherwise false. public func isContactSubscribed(_ listID: String, scopes: [ChannelScope]) - -> Bool + -> Bool { return scopes.contains { scope in isContactSubscribed(listID, scope: scope) @@ -158,7 +179,7 @@ public class PreferenceCenterState: ObservableObject { switch edit { case .subscribe(let listID, let scope): var scopes = - self.contactSubscriptions[listID] ?? Set() + self.contactSubscriptions[listID] ?? Set() scopes.insert(scope) self.contactSubscriptions[listID] = scopes case .unsubscribe(let listID, let scope): @@ -198,16 +219,13 @@ public class PreferenceCenterState: ObservableObject { } return AirshipPreferenceSubscriber() } + } protocol PreferenceSubscriber { - var channelSubscriptionListEdits: AnyPublisher - { - get - } - var contactSubscriptionListEdits: - AnyPublisher - { get } + + var channelSubscriptionListEdits: AnyPublisher { get } + var contactSubscriptionListEdits: AnyPublisher { get } func updateChannelSubscription( _ listID: String, @@ -222,9 +240,11 @@ protocol PreferenceSubscriber { } class PreviewPreferenceSubscriber: PreferenceSubscriber { + private let channelEditsSubject = PassthroughSubject< SubscriptionListEdit, Never >() + var channelSubscriptionListEdits: AnyPublisher { return channelEditsSubject.eraseToAnyPublisher() @@ -234,7 +254,7 @@ class PreviewPreferenceSubscriber: PreferenceSubscriber { ScopedSubscriptionListEdit, Never >() var contactSubscriptionListEdits: - AnyPublisher + AnyPublisher { return contactEditsSubject.eraseToAnyPublisher() } @@ -269,7 +289,7 @@ class AirshipPreferenceSubscriber: PreferenceSubscriber { } var contactSubscriptionListEdits: - AnyPublisher + AnyPublisher { return Airship.contact.subscriptionListEdits } @@ -298,3 +318,29 @@ class AirshipPreferenceSubscriber: PreferenceSubscriber { } } } + +extension PreferenceCenterState: CustomStringConvertible { + public var description: String { + var description = "PreferenceCenterState:\n" + description += "- Config ID: \(config.identifier)\n" + description += "- Contact Subscriptions: \(contactSubscriptions.description)\n" + description += "- Channel Subscriptions: \(channelSubscriptions.description)\n" + description += "- Channels List: \(describeChannelsList())\n" + + return description + } + + private func describeChannelsList() -> String { + channelsList.map { associatedChannel in + "\(associatedChannel)" + }.joined(separator: "; ") + } +} + +extension [ContactChannel] { + func filter(with type: ChannelType) -> [ContactChannel] { + return self.filter { channel in + channel.channelType == type + } + } +} diff --git a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterUtils.swift b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterUtils.swift new file mode 100644 index 000000000..73dcf00d6 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterUtils.swift @@ -0,0 +1,313 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +#if canImport(AirshipCore) +import AirshipCore +#endif + +internal extension View { + @ViewBuilder + func backgroundWithCloseAction(onClose: (()->())?) -> some View { + ZStack { + Rectangle() + .foregroundColor(Color.clear) + .background(Color.airshipTappableClear.ignoresSafeArea(.all)).simultaneousGesture(TapGesture().onEnded { _ in + if let onClose = onClose { + onClose() + } + }).zIndex(0) + self.zIndex(1) + } + } +} + +internal extension String { + func countryFlag() -> String { + return countryPhoneCodeToEmoji[self] ?? self + } + + func replacingAsterisksWithBullets() -> String { + return self.replacingOccurrences(of: "*", with: "●") + } + + private func hideMidChars(_ value: String) -> String { + return String(value.enumerated().map { index, char in + return [0, value.count, value.count].contains(index) ? char : "*" + }) + } + + func deletePrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { return self } + return String(self.dropFirst(prefix.count)) + } +} + +internal extension UIWindow { + static func makeModalReadyWindow( + scene: UIWindowScene + ) -> UIWindow { + let window: UIWindow = UIWindow(windowScene: scene) + window.accessibilityViewIsModal = false + window.alpha = 0 + window.makeKeyAndVisible() + window.isUserInteractionEnabled = false + + return window + } + + func animateIn() { + self.windowLevel = .alert + self.makeKeyAndVisible() + self.isUserInteractionEnabled = true + + UIView.animate( + withDuration: 0.3, + animations: { + self.alpha = 1 + }, + completion: { _ in + } + ) + } + + func animateOut() { + UIView.animate( + withDuration: 0.3, + animations: { + self.alpha = 0 + }, + completion: { _ in + self.isHidden = true + self.isUserInteractionEnabled = false + self.removeFromSuperview() + } + ) + } +} + +let countryPhoneCodeToEmoji: [String: String] = [ + "+1": "🇺🇸", // USA + "+44": "🇬🇧", // United Kingdom + "+49": "🇩🇪", // Germany + "+33": "🇫🇷", // France + "+81": "🇯🇵", // Japan + "+39": "🇮🇹", // Italy + "+91": "🇮🇳", // India + "+86": "🇨🇳", // China + "+7": "🇷🇺", // Russia + "+55": "🇧🇷", // Brazil + "+61": "🇦🇺", // Australia + "+27": "🇿🇦", // South Africa + "+82": "🇰🇷", // South Korea + "+34": "🇪🇸", // Spain + "+46": "🇸🇪", // Sweden + "+48": "🇵🇱", // Poland + "+47": "🇳🇴", // Norway + "+31": "🇳🇱", // Netherlands + "+51": "🇵🇪", // Peru + "+52": "🇲🇽", // Mexico + "+65": "🇸🇬", // Singapore + "+64": "🇳🇿", // New Zealand + "+90": "🇹🇷", // Turkey + "+20": "🇪🇬", // Egypt + "+60": "🇲🇾", // Malaysia + "+63": "🇵🇭", // Philippines + "+966": "🇸🇦", // Saudi Arabia + "+971": "🇦🇪", // United Arab Emirates + "+973": "🇧🇭", // Bahrain + "+965": "🇰🇼", // Kuwait + "+974": "🇶🇦", // Qatar + "+93": "🇦🇫", // Afghanistan + "+355": "🇦🇱", // Albania + "+213": "🇩🇿", // Algeria + "+1684": "🇦🇸", // American Samoa + "+376": "🇦🇩", // Andorra + "+244": "🇦🇴", // Angola + "+1264": "🇦🇮", // Anguilla + "+1268": "🇦🇬", // Antigua & Barbuda + "+54": "🇦🇷", // Argentina + "+374": "🇦🇲", // Armenia + "+297": "🇦🇼", // Aruba + "+43": "🇦🇹", // Austria + "+994": "🇦🇿", // Azerbaijan + "+1242": "🇧🇸", // Bahamas + "+880": "🇧🇩", // Bangladesh + "+1246": "🇧🇧", // Barbados + "+375": "🇧🇾", // Belarus + "+32": "🇧🇪", // Belgium + "+501": "🇧🇿", // Belize + "+229": "🇧🇯", // Benin + "+1441": "🇧🇲", // Bermuda + "+975": "🇧🇹", // Bhutan + "+591": "🇧🇴", // Bolivia + "+387": "🇧🇦", // Bosnia & Herzegovina + "+267": "🇧🇼", // Botswana + "+246": "🇮🇴", // British Indian Ocean Territory + "+1284": "🇻🇬", // British Virgin Islands + "+673": "🇧🇳", // Brunei + "+359": "🇧🇬", // Bulgaria + "+226": "🇧🇫", // Burkina Faso + "+257": "🇧🇮", // Burundi + "+855": "🇰🇭", // Cambodia + "+237": "🇨🇲", // Cameroon + "+238": "🇨🇻", // Cape Verde + "+1345": "🇰🇾", // Cayman Islands + "+236": "🇨🇫", // Central African Republic + "+235": "🇹🇩", // Chad + "+56": "🇨🇱", // Chile + "+57": "🇨🇴", // Colombia + "+269": "🇰🇲", // Comoros + "+242": "🇨🇬", // Congo - Brazzaville + "+243": "🇨🇩", // Congo - Kinshasa + "+682": "🇨🇰", // Cook Islands + "+506": "🇨🇷", // Costa Rica + "+385": "🇭🇷", // Croatia + "+53": "🇨🇺", // Cuba + "+599": "🇨🇼", // Curaçao + "+357": "🇨🇾", // Cyprus + "+420": "🇨🇿", // Czechia + "+45": "🇩🇰", // Denmark + "+253": "🇩🇯", // Djibouti + "+1767": "🇩🇲", // Dominica + "+1809": "🇩🇴", // Dominican Republic + "+593": "🇪🇨", // Ecuador + "+503": "🇸🇻", // El Salvador + "+240": "🇬🇶", // Equatorial Guinea + "+291": "🇪🇷", // Eritrea + "+372": "🇪🇪", // Estonia + "+268": "🇸🇿", // Eswatini + "+251": "🇪🇹", // Ethiopia + "+500": "🇫🇰", // Falkland Islands + "+298": "🇫🇴", // Faroe Islands + "+679": "🇫🇯", // Fiji + "+358": "🇫🇮", // Finland + "+594": "🇬🇫", // French Guiana + "+689": "🇵🇫", // French Polynesia + "+241": "🇬🇦", // Gabon + "+220": "🇬🇲", // Gambia + "+995": "🇬🇪", // Georgia + "+233": "🇬🇭", // Ghana + "+350": "🇬🇮", // Gibraltar + "+30": "🇬🇷", // Greece + "+299": "🇬🇱", // Greenland + "+1473": "🇬🇩", // Grenada + "+1671": "🇬🇺", // Guam + "+502": "🇬🇹", // Guatemala + "+224": "🇬🇳", // Guinea + "+245": "🇬🇼", // Guinea-Bissau + "+592": "🇬🇾", // Guyana + "+509": "🇭🇹", // Haiti + "+504": "🇭🇳", // Honduras + "+852": "🇭🇰", // Hong Kong SAR China + "+36": "🇭🇺", // Hungary + "+354": "🇮🇸", // Iceland + "+62": "🇮🇩", // Indonesia + "+98": "🇮🇷", // Iran + "+964": "🇮🇶", // Iraq + "+353": "🇮🇪", // Ireland + "+972": "🇮🇱", // Israel + "+225": "🇨🇮", // Ivory Coast + "+1876": "🇯🇲", // Jamaica + "+962": "🇯🇴", // Jordan + "+254": "🇰🇪", // Kenya + "+686": "🇰🇮", // Kiribati + "+383": "🇽🇰", // Kosovo + "+856": "🇱🇦", // Laos + "+371": "🇱🇻", // Latvia + "+961": "🇱🇧", // Lebanon + "+266": "🇱🇸", // Lesotho + "+231": "🇱🇷", // Liberia + "+218": "🇱🇾", // Libya + "+423": "🇱🇮", // Liechtenstein + "+370": "🇱🇹", // Lithuania + "+352": "🇱🇺", // Luxembourg + "+853": "🇲🇴", // Macao SAR China + "+261": "🇲🇬", // Madagascar + "+265": "🇲🇼", // Malawi + "+960": "🇲🇻", // Maldives + "+223": "🇲🇱", // Mali + "+356": "🇲🇹", // Malta + "+692": "🇲🇭", // Marshall Islands + "+596": "🇲🇶", // Martinique + "+222": "🇲🇷", // Mauritania + "+230": "🇲🇺", // Mauritius + "+691": "🇫🇲", // Micronesia + "+373": "🇲🇩", // Moldova + "+377": "🇲🇨", // Monaco + "+976": "🇲🇳", // Mongolia + "+382": "🇲🇪", // Montenegro + "+1664": "🇲🇸", // Montserrat + "+212": "🇲🇦", // Morocco + "+258": "🇲🇿", // Mozambique + "+95": "🇲🇲", // Myanmar + "+264": "🇳🇦", // Namibia + "+674": "🇳🇷", // Nauru + "+977": "🇳🇵", // Nepal + "+687": "🇳🇨", // New Caledonia + "+505": "🇳🇮", // Nicaragua + "+227": "🇳🇪", // Niger + "+234": "🇳🇬", // Nigeria + "+683": "🇳🇺", // Niue + "+672": "🇳🇫", // Norfolk Island + "+1670": "🇲🇵", // Northern Mariana Islands + "+389": "🇲🇰", // North Macedonia + "+968": "🇴🇲", // Oman + "+92": "🇵🇰", // Pakistan + "+680": "🇵🇼", // Palau + "+970": "🇵🇸", // Palestinian Territories + "+507": "🇵🇦", // Panama + "+675": "🇵🇬", // Papua New Guinea + "+595": "🇵🇾", // Paraguay + "+40": "🇷🇴", // Romania + "+250": "🇷🇼", // Rwanda + "+262": "🇷🇪", // Réunion + "+1869": "🇰🇳", // St. Kitts & Nevis + "+1758": "🇱🇨", // St. Lucia + "+590": "🇲🇫", // St. Martin + "+508": "🇵🇲", // St. Pierre & Miquelon + "+1784": "🇻🇨", // St. Vincent & Grenadines + "+685": "🇼🇸", // Samoa + "+378": "🇸🇲", // San Marino + "+239": "🇸🇹", // São Tomé & Príncipe + "+221": "🇸🇳", // Senegal + "+381": "🇷🇸", // Serbia + "+248": "🇸🇨", // Seychelles + "+232": "🇸🇱", // Sierra Leone + "+1721": "🇸🇽", // Sint Maarten + "+421": "🇸🇰", // Slovakia + "+386": "🇸🇮", // Slovenia + "+677": "🇸🇧", // Solomon Islands + "+252": "🇸🇴", // Somalia + "+211": "🇸🇸", // South Sudan + "+94": "🇱🇰", // Sri Lanka + "+249": "🇸🇩", // Sudan + "+597": "🇸🇷", // Suriname + "+41": "🇨🇭", // Switzerland + "+963": "🇸🇾", // Syria + "+886": "🇹🇼", // Taiwan + "+992": "🇹🇯", // Tajikistan + "+255": "🇹🇿", // Tanzania + "+66": "🇹🇭", // Thailand + "+670": "🇹🇱", // Timor-Leste + "+228": "🇹🇬", // Togo + "+690": "🇹🇰", // Tokelau + "+676": "🇹🇴", // Tonga + "+1868": "🇹🇹", // Trinidad & Tobago + "+216": "🇹🇳", // Tunisia + "+993": "🇹🇲", // Turkmenistan + "+1649": "🇹🇨", // Turks & Caicos Islands + "+688": "🇹🇻", // Tuvalu + "+256": "🇺🇬", // Uganda + "+380": "🇺🇦", // Ukraine + "+598": "🇺🇾", // Uruguay + "+998": "🇺🇿", // Uzbekistan + "+678": "🇻🇺", // Vanuatu + "+58": "🇻🇪", // Venezuela + "+84": "🇻🇳", // Vietnam + "+681": "🇼🇫", // Wallis & Futuna + "+967": "🇾🇪", // Yemen + "+260": "🇿🇲", // Zambia + "++263": "🇿🇼" // Zimbabwe +] diff --git a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterView.swift b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterView.swift index 0769ad7d7..1325846b9 100644 --- a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterView.swift +++ b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterView.swift @@ -43,9 +43,9 @@ public struct PreferenceCenterList: View { /// The default constructor /// - Parameters: - /// - preferenceCenterID: The preference center ID - /// - onLoad: An optional load block to load the view phase - /// - onPhaseChange: A callback when the phase changed + /// - preferenceCenterID: The preference center ID + /// - onLoad: An optional load block to load the view phase + /// - onPhaseChange: A callback when the phase changed public init( preferenceCenterID: String, onLoad: (@Sendable (String) async -> PreferenceCenterViewPhase)? = nil, @@ -99,194 +99,93 @@ public struct PreferenceCenterList: View { .receive(on: RunLoop.main) .dropFirst() .eraseToAnyPublisher() - } } +/// Preference Center View +public struct PreferenceCenterView: View { + @Environment(\.preferenceCenterDismissAction) + private var dismissAction: (() -> Void)? -/// Preference Center view style configuration -public struct PreferenceCenterViewStyleConfiguration { - /// The view's phase - public let phase: PreferenceCenterViewPhase - - /// The preference center theme - public let preferenceCenterTheme: PreferenceCenterTheme - - /// A block that can be called to refresh the view - public let refresh: () -> Void -} + @Environment(\.airshipPreferenceCenterTheme) + private var theme -/// Preference Center view style -public protocol PreferenceCenterViewStyle { - associatedtype Body: View - typealias Configuration = PreferenceCenterViewStyleConfiguration - func makeBody(configuration: Self.Configuration) -> Self.Body -} + private let preferenceCenterID: String -extension PreferenceCenterViewStyle -where Self == DefaultPreferenceCenterViewStyle { - /// Default style - public static var defaultStyle: Self { - return .init() + /// Default constructor + /// - Parameters: + /// - preferenceCenterID: The preference center ID + public init(preferenceCenterID: String) { + self.preferenceCenterID = preferenceCenterID } -} - -/// The default Preference Center view style -public struct DefaultPreferenceCenterViewStyle: PreferenceCenterViewStyle { - - static let subtitleAppearance = PreferenceCenterTheme.TextAppearance( - font: .subheadline - ) - - static let buttonLabelAppearance = PreferenceCenterTheme.TextAppearance( - color: .white - ) - - private func navigationBarTitle( - configuration: Configuration, - state: PreferenceCenterState? = nil - ) -> String { - - var title: String? - if let state = state { - title = state.config.display?.title?.nullIfEmpty() - } - let theme = configuration.preferenceCenterTheme - if let overrideConfigTitle = theme.viewController?.navigationBar?.overrideConfigTitle, overrideConfigTitle { - title = configuration.preferenceCenterTheme.viewController? - .navigationBar? - .title - } - return title ?? "ua_preference_center_title".preferenceCenterlocalizedString - } - @ViewBuilder - private func makeProgressView(configuration: Configuration) -> some View { - ProgressView() - .frame(alignment: .center) - .navigationTitle(navigationBarTitle(configuration: configuration)) + private func makeBackButton() -> some View { + Button(action: { + self.dismissAction?() + }) { + Image(systemName: "chevron.backward") + .scaleEffect(0.68) + .font(Font.title.weight(.medium)) + .foregroundColor(Color(UINavigationBar.appearance().tintColor ?? UIColor.systemBlue)) + } } @ViewBuilder - public func makeErrorView(configuration: Configuration) -> some View { - let theme = configuration.preferenceCenterTheme.preferenceCenter - let retry = theme?.retryButtonLabel ?? "ua_retry_button".preferenceCenterlocalizedString - let errorMessage = - theme?.retryMessage ?? "ua_preference_center_empty".preferenceCenterlocalizedString - - VStack { - Text(errorMessage) - .textAppearance(theme?.retryMessageAppearance) - .padding(16) - - Button( - action: { - configuration.refresh() - }, - label: { - Text(retry) - .textAppearance( - theme?.retryButtonLabelAppearance, - base: DefaultPreferenceCenterViewStyle - .buttonLabelAppearance - ) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule() - .fill( - theme?.retryButtonBackgroundColor - ?? Color.blue - ) - ) - .cornerRadius(8) - .frame(minWidth: 44) - } - ) - } - .navigationTitle(navigationBarTitle(configuration: configuration)) - } + public var body: some View { - public func makePreferenceCenterView( - configuration: Configuration, - state: PreferenceCenterState - ) -> some View { - let theme = configuration.preferenceCenterTheme - return ScrollView { - LazyVStack(alignment: .leading) { - if let subtitle = state.config.display?.subtitle { - Text(subtitle) - .textAppearance( - theme.preferenceCenter?.subtitleAppearance, - base: DefaultPreferenceCenterViewStyle - .subtitleAppearance - ) - .padding(.bottom, 16) + let content = PreferenceCenterList(preferenceCenterID: preferenceCenterID) + .airshipApplyIf(self.dismissAction != nil) { view in + view.toolbar { + ToolbarItem(placement: .navigationBarLeading) { + makeBackButton() + } } + } - ForEach(0.. some View { - - switch configuration.phase { - case .loading: - makeProgressView(configuration: configuration) - case .error(_): - makeErrorView(configuration: configuration) - case .loaded(let state): - makePreferenceCenterView(configuration: configuration, state: state) - } - } - - @ViewBuilder - func section( - _ section: PreferenceCenterConfig.Section, - state: PreferenceCenterState - ) -> some View { - switch section { - case .common(let section): - CommonSectionView(section: section, state: state) - case .labeledSectionBreak(let section): - LabeledSectionBreakView(section: section, state: state) + } else { + NavigationView { + content + } + .navigationViewStyle(.stack) } } } -struct AnyPreferenceCenterViewStyle: PreferenceCenterViewStyle { - @ViewBuilder - private var _makeBody: (Configuration) -> AnyView +private struct PreferenceCenterDismissActionKey: EnvironmentKey { + static let defaultValue: (() -> Void)? = nil +} - init(style: S) { - _makeBody = { configuration in - AnyView(style.makeBody(configuration: configuration)) - } - } - @ViewBuilder - func makeBody(configuration: Configuration) -> some View { - _makeBody(configuration) +extension EnvironmentValues { + var preferenceCenterDismissAction: (() -> Void)? { + get { self[PreferenceCenterDismissActionKey.self] } + set { self[PreferenceCenterDismissActionKey.self] = newValue } } } -struct PreferenceCenterViewStyleKey: EnvironmentKey { - static var defaultValue = AnyPreferenceCenterViewStyle(style: .defaultStyle) -} - -extension EnvironmentValues { - var airshipPreferenceCenterStyle: AnyPreferenceCenterViewStyle { - get { self[PreferenceCenterViewStyleKey.self] } - set { self[PreferenceCenterViewStyleKey.self] = newValue } +extension View { + func addPreferenceCenterDismissAction(action: (() -> Void)?) -> some View { + environment(\.preferenceCenterDismissAction, action) + } + + @ViewBuilder + func airshipApplyIf( + _ predicate: @autoclosure () -> Bool, + transform: (Self) -> Content + ) -> some View { + if predicate() { + transform(self) + } else { + self + } } } @@ -373,99 +272,3 @@ struct PreferenceCenterView_Previews: PreviewProvider { } } } - -/// Preference Center View -public struct PreferenceCenterView: View { - - @Environment(\.preferenceCenterDismissAction) - private var dismissAction: (() -> Void)? - - @Environment(\.airshipPreferenceCenterTheme) - private var theme - - private let preferenceCenterID: String - - /// Default constructor - /// - Parameters: - /// - preferenceCenterID: The preference center ID - public init(preferenceCenterID: String) { - self.preferenceCenterID = preferenceCenterID - } - - @ViewBuilder - private func makeBackButton() -> some View { - Button(action: { - self.dismissAction?() - }) { - Image(systemName: "chevron.backward") - .scaleEffect(0.68) - .font(Font.title.weight(.medium)) - .foregroundColor(Color(UINavigationBar.appearance().tintColor ?? UIColor.systemBlue)) - } - } - - @ViewBuilder - public var body: some View { - - let content = PreferenceCenterList(preferenceCenterID: preferenceCenterID) - .airshipApplyIf(self.dismissAction != nil) { view in - view.toolbar { - ToolbarItem(placement: .navigationBarLeading) { - makeBackButton() - } - } - } - - if #available(iOS 16.0, *) { - NavigationStack { - ZStack{ - content - } - } - } else { - NavigationView { - content - } - .navigationViewStyle(.stack) - } - } -} - - - -private struct PreferenceCenterDismissActionKey: EnvironmentKey { - static let defaultValue: (() -> Void)? = nil -} - - -extension EnvironmentValues { - var preferenceCenterDismissAction: (() -> Void)? { - get { self[PreferenceCenterDismissActionKey.self] } - set { self[PreferenceCenterDismissActionKey.self] = newValue } - } -} - - -extension View { - func addPreferenceCenterDismissAction(action: (() -> Void)?) -> some View { - environment(\.preferenceCenterDismissAction, action) - } - - @ViewBuilder - func airshipApplyIf( - _ predicate: @autoclosure () -> Bool, - transform: (Self) -> Content - ) -> some View { - if predicate() { - transform(self) - } else { - self - } - } -} - -extension String { - fileprivate func nullIfEmpty() -> String? { - return self.isEmpty ? nil : self - } -} diff --git a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewLoader.swift b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewLoader.swift index 09f305d70..ca4e0a0a0 100644 --- a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewLoader.swift +++ b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewLoader.swift @@ -65,8 +65,8 @@ final class PreferenceCenterViewLoader: ObservableObject { preferenceCenterID: preferenceCenterID ) - var channelSubscriptions: [String]? - var contactSubscriptions: [String: Set]? + var channelSubscriptions: [String] = [] + var contactSubscriptions: [String: Set] = [:] if config.containsChannelSubscriptions() { channelSubscriptions = try await Airship.channel @@ -78,11 +78,18 @@ final class PreferenceCenterViewLoader: ObservableObject { .fetchSubscriptionLists() .mapValues { Set($0) } } + + var channelUpdates: AsyncStream? = nil + + if config.containsContactManagement() { + channelUpdates = Airship.contact.contactChannelUpdates + } return PreferenceCenterState( config: config, - contactSubscriptions: contactSubscriptions ?? [:], - channelSubscriptions: Set(channelSubscriptions ?? []) + contactSubscriptions: contactSubscriptions, + channelSubscriptions: Set(channelSubscriptions), + channelUpdates: channelUpdates ) } } diff --git a/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewStyle.swift b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewStyle.swift new file mode 100644 index 000000000..044751ab9 --- /dev/null +++ b/Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewStyle.swift @@ -0,0 +1,204 @@ +/* Copyright Airship and Contributors */ + +import Foundation +import SwiftUI + +/// Preference Center view style configuration +public struct PreferenceCenterViewStyleConfiguration { + /// The view's phase + public let phase: PreferenceCenterViewPhase + + /// The preference center theme + public let preferenceCenterTheme: PreferenceCenterTheme + + /// A block that can be called to refresh the view + public let refresh: () -> Void +} + +/// Preference Center view style +public protocol PreferenceCenterViewStyle { + associatedtype Body: View + typealias Configuration = PreferenceCenterViewStyleConfiguration + func makeBody(configuration: Self.Configuration) -> Self.Body +} + +extension PreferenceCenterViewStyle +where Self == DefaultPreferenceCenterViewStyle { + /// Default style + public static var defaultStyle: Self { + return .init() + } +} + +/// The default Preference Center view style +public struct DefaultPreferenceCenterViewStyle: PreferenceCenterViewStyle { + + static let subtitleAppearance = PreferenceCenterTheme.TextAppearance( + font: .subheadline + ) + + static let buttonLabelAppearance = PreferenceCenterTheme.TextAppearance( + color: .white + ) + + private func navigationBarTitle( + configuration: Configuration, + state: PreferenceCenterState? = nil + ) -> String { + + var title: String? + if let state = state { + title = state.config.display?.title?.nullIfEmpty() + } + + let theme = configuration.preferenceCenterTheme + if let overrideConfigTitle = theme.viewController?.navigationBar?.overrideConfigTitle, overrideConfigTitle { + title = configuration.preferenceCenterTheme.viewController? + .navigationBar? + .title + } + return title ?? "ua_preference_center_title".preferenceCenterLocalizedString + } + + @ViewBuilder + private func makeProgressView(configuration: Configuration) -> some View { + ProgressView() + .frame(alignment: .center) + .navigationTitle(navigationBarTitle(configuration: configuration)) + } + + @ViewBuilder + public func makeErrorView(configuration: Configuration) -> some View { + let theme = configuration.preferenceCenterTheme.preferenceCenter + let retry = theme?.retryButtonLabel ?? "ua_retry_button".preferenceCenterLocalizedString + let errorMessage = + theme?.retryMessage ?? "ua_preference_center_empty".preferenceCenterLocalizedString + + VStack { + Text(errorMessage) + .textAppearance(theme?.retryMessageAppearance) + .padding(16) + + Button( + action: { + configuration.refresh() + }, + label: { + Text(retry) + .textAppearance( + theme?.retryButtonLabelAppearance, + base: DefaultPreferenceCenterViewStyle + .buttonLabelAppearance + ) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + Capsule() + .fill( + theme?.retryButtonBackgroundColor + ?? Color.blue + ) + ) + .cornerRadius(8) + .frame(minWidth: 44) + } + ) + } + .navigationTitle(navigationBarTitle(configuration: configuration)) + } + + public func makePreferenceCenterView( + configuration: Configuration, + state: PreferenceCenterState + ) -> some View { + let theme = configuration.preferenceCenterTheme + return ScrollView { + LazyVStack(alignment: .leading) { + if let subtitle = state.config.display?.subtitle { + Text(subtitle) + .textAppearance( + theme.preferenceCenter?.subtitleAppearance, + base: DefaultPreferenceCenterViewStyle + .subtitleAppearance + ) + .padding(.bottom, 16) + } + + ForEach(0.. some View { + + switch configuration.phase { + case .loading: + makeProgressView(configuration: configuration) + case .error(_): + makeErrorView(configuration: configuration) + case .loaded(let state): + makePreferenceCenterView(configuration: configuration, state: state) + } + } + + @ViewBuilder + func section( + _ section: PreferenceCenterConfig.Section, + state: PreferenceCenterState + ) -> some View { + switch section { + case .common(let section): + CommonSectionView( + section: section, + state: state + ) + case .labeledSectionBreak(let section): + LabeledSectionBreakView( + section: section, + state: state + ) + } + } +} + +struct AnyPreferenceCenterViewStyle: PreferenceCenterViewStyle { + @ViewBuilder + private var _makeBody: (Configuration) -> AnyView + + init(style: S) { + _makeBody = { configuration in + AnyView(style.makeBody(configuration: configuration)) + } + } + + @ViewBuilder + func makeBody(configuration: Configuration) -> some View { + _makeBody(configuration) + } +} + +struct PreferenceCenterViewStyleKey: EnvironmentKey { + static var defaultValue = AnyPreferenceCenterViewStyle(style: .defaultStyle) +} + +extension EnvironmentValues { + var airshipPreferenceCenterStyle: AnyPreferenceCenterViewStyle { + get { self[PreferenceCenterViewStyleKey.self] } + set { self[PreferenceCenterViewStyleKey.self] = newValue } + } +} + +extension String { + fileprivate func nullIfEmpty() -> String? { + return self.isEmpty ? nil : self + } +} diff --git a/Airship/AirshipPreferenceCenter/Tests/data/PreferenceCenterConfigTest.swift b/Airship/AirshipPreferenceCenter/Tests/data/PreferenceCenterConfigTest.swift index 06e7dc7de..20955e1c2 100644 --- a/Airship/AirshipPreferenceCenter/Tests/data/PreferenceCenterConfigTest.swift +++ b/Airship/AirshipPreferenceCenter/Tests/data/PreferenceCenterConfigTest.swift @@ -6,179 +6,227 @@ import XCTest @testable import AirshipPreferenceCenter class PreferenceCenterDecoderTest: XCTestCase { - func testForm() throws { - let form = """ + let testPayload: String = +""" +{ + "id": "cool-prefs", + "display": { + "name": "Cool Prefs", + "description": "Preferences but they're cool" + }, + "sections": [ + { + "id": "a2db6801-c766-44d7-b5d6-070ca64421b2", + "type": "section", + "items": [ { - "id": "preference_center_1", - "display": { - "name": "Notification Preferences" + "id": "a2db6801-c766-44d7-b5d6-070ca64421b3", + "type": "contact_management", + "platform": "email", + "display": { + "name": "Email Addresses", + "description": "Addresses associated with your account." + }, + "registration_options": { + "address_label": "Email address", + "resend": { + "interval": 10, + "message": "Pending verification", + "button": { + "text": "Resend", + "content_description": "Resend a verification message to this email address" + }, + "on_success": { + "name": "Verification resent", + "description": "Check your inbox for a new confirmation email.", + "button": { + "text": "Ok", + "content_description": "Close prompt" + } + } + }, + "error_messages": { + "invalid": "Please enter a valid email address.", + "default": "Uh oh, something went wrong." + } + }, + "add": { + "button": { + "text": "Add email", + "content_description": "Add a new email address" + }, + "view": { + "type": "prompt", + "display": { + "title": "Add an email address", + "description": "You will receive a confirmation email to verify your address.", + "footer": "Does anyone read our [Terms and Conditions](https://example.com) and [Privacy Policy](https://example.com)?" + }, + "submit_button": { + "text": "Send", + "content_description": "Send a message to this email address" + }, + "cancel_button": { + "text": "Cancel" + }, + "close_button": { + "content_description": "Close" + }, + "on_submit": { + "name": "Oh no, it worked.", + "description": "Hope you like emails.", + "button": { + "text": "Ok, dang" + } + } + } + }, + "remove": { + "button": { + "content_description": "Opt out and remove this email address" + }, + "view": { + "type": "prompt", + "display": { + "title": "Remove email address?", + "description": "I thought you liked emails." + }, + "submit_button": { + "text": "Yes", + "content_description": "Confirm opt out" + }, + "cancel_button": { + "text": "No", + "content_description": "Cancel opt out" + }, + "close_button": { + "content_description": "Close" + }, + "on_submit": { + "name": "Success", + "description": "Bye!", + "button": { + "text": "Ok", + "content_description": "Close prompt" + } + } + } + } + }, + { + "id": "a2db6801-c766-44d7-b5d6-070ca64421b4", + "type": "contact_management", + "platform": "sms", + "display": { + "name": "Mobile Numbers" + }, + "registration_options": { + "country_label": "Country", + "msisdn_label": "Phone number", + "resend": { + "interval": 10, + "message": "Pending verification", + "button": { + "text": "Resend", + "content_description": "Resend a verification message to this phone number" + } + }, + "senders": [ + { + "country_code": "+44", + "display_name": "United Kingdom", + "placeholder_text": "7010 111222", + "sender_id": "23450" + } + ], + "error_messages": { + "invalid": "Please enter a valid phone number.", + "default": "Uh oh, something went wrong." + } + }, + "add": { + "view": { + "type": "prompt", + "display": { + "title": "Add a phone number", + "description": "You will receive a text message with further details.", + "footer": "By opting in you give us the OK to hound you forever." + }, + "submit_button": { + "text": "Send", + "content_description": "Send a message to this phone number" + }, + "cancel_button": { + "text": "Cancel" + }, + "close_button": { + "content_description": "Close" + }, + "on_submit": { + "name": "Oh no, it worked.", + "description": "Hope you like text messages.", + "button": { + "text": "Ok, dang" + } + } }, - "options": { - "merge_channel_data_to_contact": true + "button": { + "text": "Add SMS", + "content_description": "Add a new phone number" + } + }, + "remove": { + "button": { + "content_description": "Opt out and remove this phone number" }, - "sections": [ - { - "type": "labeled_section_break", - "id": "LabeledSectionBreak", - "conditions": [ - { - "type": "notification_opt_in", - "when_status": "opt_out" - } - ], - "display": { - "name": "Labeled Section Break", - } - }, - { - "type": "section", - "id": "common", - "display": { - "name": "Section Title", - "description": "Section Subtitle" - }, - "items": [ - { - "type": "channel_subscription", - "id": "ChannelSubscription", - "subscription_id": "ChannelSubscription", - "display": { - "name": "Channel Subscription Title", - "description": "Channel Subscription Subtitle" - } - }, - { - "type": "contact_subscription", - "id": "ContactSubscription", - "subscription_id": "ContactSubscription", - "scopes": [ - "app", - "web" - ], - "display": { - "name": "Contact Subscription Title", - "description": "Contact Subscription Subtitle" - } - }, - { - "type": "contact_subscription_group", - "id": "ContactSubscriptionGroup", - "subscription_id": "ContactSubscriptionGroup", - "display": { - "name": "Contact Subscription Group Title", - "description": "Contact Subscription Group Subtitle" - }, - "components": [ - { - "scopes": [ - "web", - "app" - ], - "display": { - "name": "Web and App Component" - } - } - ] - } - ] + "view": { + "type": "prompt", + "display": { + "title": "Remove phone number?", + "description": "Your phone will buzz less." + }, + "submit_button": { + "text": "Yes", + "content_description": "Confirm opt out" + }, + "cancel_button": { + "text": "No", + "content_description": "Cancel opt out" + }, + "close_button": { + "content_description": "Close" + }, + "on_submit": { + "name": "Success", + "description": "Bye!", + "button": { + "text": "Ok", + "content_description": "Close prompt" } - ] + } + } + } } - """ - - let expected = PreferenceCenterConfig( - identifier: "preference_center_1", - sections: [ - .labeledSectionBreak( - PreferenceCenterConfig.LabeledSectionBreak( - identifier: "LabeledSectionBreak", - display: PreferenceCenterConfig.CommonDisplay( - title: "Labeled Section Break" - ), - conditions: [ - .notificationOptIn( - PreferenceCenterConfig - .NotificationOptInCondition( - optInStatus: .optedOut - ) - ) - ] - ) - ), - .common( - PreferenceCenterConfig.CommonSection( - identifier: "common", - items: [ - .channelSubscription( - PreferenceCenterConfig.ChannelSubscription( - identifier: "ChannelSubscription", - subscriptionID: "ChannelSubscription", - display: - PreferenceCenterConfig.CommonDisplay( - title: "Channel Subscription Title", - subtitle: - "Channel Subscription Subtitle" - ) - ) - ), - .contactSubscription( - PreferenceCenterConfig.ContactSubscription( - identifier: "ContactSubscription", - subscriptionID: "ContactSubscription", - scopes: [.app, .web], - display: - PreferenceCenterConfig.CommonDisplay( - title: "Contact Subscription Title", - subtitle: - "Contact Subscription Subtitle" - ) - ) - ), - .contactSubscriptionGroup( - PreferenceCenterConfig.ContactSubscriptionGroup( - identifier: "ContactSubscriptionGroup", - subscriptionID: "ContactSubscriptionGroup", - components: [ - PreferenceCenterConfig - .ContactSubscriptionGroup.Component( - scopes: [.web, .app], - display: - PreferenceCenterConfig - .CommonDisplay( - title: - "Web and App Component" - ) - ) - ], - display: - PreferenceCenterConfig.CommonDisplay( - title: - "Contact Subscription Group Title", - subtitle: - "Contact Subscription Group Subtitle" - ) - ) - ), - ], - display: PreferenceCenterConfig.CommonDisplay( - title: "Section Title", - subtitle: "Section Subtitle" - ) - ) - ), - ], - display: PreferenceCenterConfig.CommonDisplay( - title: "Notification Preferences" - ), - options: PreferenceCenterConfig.Options( - mergeChannelDataToContact: true - ) - ) - + ] + } + ] + } +""" let response = try! PreferenceCenterDecoder.decodeConfig( - data: form.data(using: .utf8)! + data: testPayload.data(using: .utf8)! ) - XCTAssertEqual(expected, response) + + XCTAssertNotNil(response) + } + + private func parseAndSortJSON(jsonString: String) -> String? { + guard let data = jsonString.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + else { return nil } + + let sortedData = try? JSONSerialization.data(withJSONObject: jsonObject, options: [.sortedKeys, .prettyPrinted]) + return sortedData.flatMap { String(data: $0, encoding: .utf8) } } } + diff --git a/Airship/AirshipPreferenceCenter/Tests/view/PreferenceCenterStateTest.swift b/Airship/AirshipPreferenceCenter/Tests/view/PreferenceCenterStateTest.swift index 406fd890d..e632e9e81 100644 --- a/Airship/AirshipPreferenceCenter/Tests/view/PreferenceCenterStateTest.swift +++ b/Airship/AirshipPreferenceCenter/Tests/view/PreferenceCenterStateTest.swift @@ -193,6 +193,12 @@ class TestPreferenceSubscriber: PreferenceSubscriber { { return channelEditsSubject.eraseToAnyPublisher() } + + private let channelAssociationSubject = PassthroughSubject<[AssociatedChannel], Never>() + var channelAssociationPublisher: AnyPublisher<[AssociatedChannel], Never> + { + return channelAssociationSubject.eraseToAnyPublisher() + } let contactEditsSubject = PassthroughSubject< ScopedSubscriptionListEdit, Never diff --git a/AirshipContentExtension.podspec b/AirshipContentExtension.podspec index b907c8cff..b7545c368 100644 --- a/AirshipContentExtension.podspec +++ b/AirshipContentExtension.podspec @@ -1,4 +1,4 @@ -AIRSHIP_VERSION="18.3.1" +AIRSHIP_VERSION="18.4.0" Pod::Spec.new do |s| s.version = AIRSHIP_VERSION diff --git a/AirshipDebug.podspec b/AirshipDebug.podspec index d27cd4910..9213768e5 100644 --- a/AirshipDebug.podspec +++ b/AirshipDebug.podspec @@ -1,4 +1,4 @@ -AIRSHIP_VERSION="18.3.1" +AIRSHIP_VERSION="18.4.0" Pod::Spec.new do |s| s.version = AIRSHIP_VERSION diff --git a/AirshipServiceExtension.podspec b/AirshipServiceExtension.podspec index 6333922f5..4610c1674 100644 --- a/AirshipServiceExtension.podspec +++ b/AirshipServiceExtension.podspec @@ -1,4 +1,4 @@ -AIRSHIP_VERSION="18.3.1" +AIRSHIP_VERSION="18.4.0" Pod::Spec.new do |s| s.version = AIRSHIP_VERSION diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d22e1555..01779c457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,16 @@ # iOS Changelog -[Migration Guides](https://github.com/urbanairship/ios-library/tree/main/Documentation/Migration) +## Version 18.4.0, June 14, 2024 +Minor release that adds contact management support to the preference center, support for anonymous channels, per-message in-app message theming, message center customization and logging improvements. Apps that use the message center or stories should update to this version. + +### Changes +- Added support for anonymous channels +- Added contact management support in preference centers +- Added improved theme support and per message theming for in-app messages +- Added public logging functions +- Fixed bug in stories page indicator +- Fixed message center list view background theming ## Version 18.3.1, May 27, 2024 Patch release with bug fix for message center customization. Apps that use the message center should update to this version. diff --git a/Thomas/Thomas/AppDelegate.swift b/Thomas/Thomas/AppDelegate.swift index be39a7b4a..df3ab8139 100644 --- a/Thomas/Thomas/AppDelegate.swift +++ b/Thomas/Thomas/AppDelegate.swift @@ -3,6 +3,7 @@ import AirshipCore import Foundation import SwiftUI +import AirshipAutomation class AppDelegate: NSObject, UIApplicationDelegate { @@ -43,6 +44,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { CustomViewExampleHelper.registerCameraView() CustomViewExampleHelper.registerBiometricLoginView() + InAppAutomation.shared.inAppMessaging.themeManager.htmlThemeExtender = { message, theme in + theme.maxWidth = 300 + theme.maxHeight = 300 + } + Task { // Set the icon badge to zero on startup (optional) try await Airship.push.resetBadge() diff --git a/Thomas/Thomas/Resources/Messages/HTML/fullscreen-airship.json b/Thomas/Thomas/Resources/Messages/HTML/fullscreen-airship.json index 2a6a0e85e..c99db07d9 100644 --- a/Thomas/Thomas/Resources/Messages/HTML/fullscreen-airship.json +++ b/Thomas/Thomas/Resources/Messages/HTML/fullscreen-airship.json @@ -5,10 +5,10 @@ "display": { "allow_fullscreen_display" : true, "aspect_lock" : true, - "background_color" : "#0000ffff", + "background_color" : "#ff00ffff", "border_radius" : 10, "dismiss_button_color" : "#ff00ff00", "require_connectivity" : true, - "url" : "https://a.thaigpt.com/user_webpages/Clearness.html" + "url" : "https://airship.com", } } diff --git a/Thomas/Thomas/Resources/Messages/HTML/sized-airship.json b/Thomas/Thomas/Resources/Messages/HTML/sized-airship.json index 9180aee85..d9ef41390 100644 --- a/Thomas/Thomas/Resources/Messages/HTML/sized-airship.json +++ b/Thomas/Thomas/Resources/Messages/HTML/sized-airship.json @@ -4,13 +4,10 @@ "display_type": "html", "display": { "allow_fullscreen_display" : false, - "aspect_lock" : true, "background_color" : "#ff00ffff", "border_radius" : 10, "dismiss_button_color" : "#ff00ff00", "require_connectivity" : true, "url" : "https://airship.com", - "width" : 300, - "height" : 400 } } diff --git a/Thomas/Thomas/Resources/Messages/Modal/header-body-media-joined.json b/Thomas/Thomas/Resources/Messages/Modal/header-body-media-joined.json index 8c57032ee..abd91cfc9 100644 --- a/Thomas/Thomas/Resources/Messages/Modal/header-body-media-joined.json +++ b/Thomas/Thomas/Resources/Messages/Modal/header-body-media-joined.json @@ -3,7 +3,7 @@ "name": "Jurassic Park", "display_type": "modal", "display": { - "allow_fullscreen_display": false, + "allow_fullscreen_display": true, "background_color": "#000000", "body": { "text": "Jurassic Park is a 1993 American science-fiction adventure film directed by Steven Spielberg and produced by Kathleen Kennedy and Gerald R. Molen. The first installment in the Jurassic Park franchise, it is based on the 1990 novel of the same name by Michael Crichton and a screenplay written by Crichton and David Koepp. The film is set on the fictional islet of Isla Nublar, located off Central America's Pacific Coast near Costa Rica, where a billionaire philanthropist and a small team of genetic scientists have created a wildlife park of cloned dinosaurs.", diff --git a/Thomas/Thomas/Resources/Messages/Modal/media-header-body-stacked.json b/Thomas/Thomas/Resources/Messages/Modal/media-header-body-stacked.json index 49f630a8a..f81248263 100644 --- a/Thomas/Thomas/Resources/Messages/Modal/media-header-body-stacked.json +++ b/Thomas/Thomas/Resources/Messages/Modal/media-header-body-stacked.json @@ -3,6 +3,7 @@ "name": "240515_CRM_CRMOther_Salvatore_Ferragamo_Afines - FINAL", "display_type": "modal", "display": { + "allow_fullscreen_display": true, "template": "media_header_body", "background_color": "#FFFFFF", "dismiss_button_color": "#222222", @@ -15,7 +16,7 @@ "font_family": ["sans-serif"] }, "media": { - "url": "https://dl.asnapieu.com/binary/public/f0EJWLPkS1q7ga45N9LitA/f51f406e-4759-4008-b003-a17430155668", + "url": "https://images.pexels.com/photos/207529/pexels-photo-207529.jpeg", "type": "image", "description": "Image" }, diff --git a/Thomas/Thomas/Resources/Scenes/Modal/story-video-and-gif.yml b/Thomas/Thomas/Resources/Scenes/Modal/story-video-and-gif.yml new file mode 100644 index 000000000..ed1be77aa --- /dev/null +++ b/Thomas/Thomas/Resources/Scenes/Modal/story-video-and-gif.yml @@ -0,0 +1,628 @@ +--- +presentation: + android: + disable_back_button: false + default_placement: + device: + lock_orientation: portrait + ignore_safe_area: true + position: + horizontal: center + vertical: center + shade_color: + default: + alpha: 0.2 + hex: "#000000" + type: hex + size: + height: 100% + max_height: 100% + max_width: 100% + min_height: 100% + min_width: 100% + width: 100% + dismiss_on_touch_outside: false + type: modal +version: 1 +view: + identifier: 7f67898a-5576-4f9d-af73-ff4d9568117a + type: pager_controller + view: + items: + - ignore_safe_area: true + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + disable_swipe: true + gestures: + - behavior: + behaviors: + - pager_previous + identifier: 7f67898a-5576-4f9d-af73-ff4d9568117a_tap_start + location: start + type: tap + - behavior: + behaviors: + - pager_next + identifier: 7f67898a-5576-4f9d-af73-ff4d9568117a_tap_end + location: end + type: tap + - behavior: + behaviors: + - dismiss + direction: up + identifier: 7f67898a-5576-4f9d-af73-ff4d9568117a_swipe_up + type: swipe + - behavior: + behaviors: + - dismiss + direction: down + identifier: 7f67898a-5576-4f9d-af73-ff4d9568117a_swipe_down + type: swipe + - identifier: 7f67898a-5576-4f9d-af73-ff4d9568117a_hold + press_behavior: + behaviors: + - pager_pause + release_behavior: + behaviors: + - pager_resume + type: hold + items: + - automated_actions: + - behaviors: + - pager_next + delay: 10 + identifier: "[pager_next]_e09ba183-67e5-422c-acb8-da868e392a7c" + identifier: e09ba183-67e5-422c-acb8-da868e392a7c + type: pager_item + view: + background_color: + default: + alpha: 1 + hex: "#969111" + type: hex + items: + - ignore_safe_area: true + margin: + end: 0 + start: 0 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + media_fit: center_crop + media_type: video + type: media + url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/18e56538-eb2b-4d7a-a69f-cd107d8a72d9 + video: + aspect_ratio: 0.5625 + autoplay: true + loop: true + muted: true + show_controls: false + - position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + items: + - margin: + bottom: 16 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + direction: vertical + items: + - identifier: layout_container + size: + height: 100% + width: 100% + view: + direction: vertical + items: + - margin: + bottom: 8 + end: 16 + start: 16 + top: 48 + size: + height: auto + width: 100% + view: + text: VIDEO longer than Story + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - margin: + bottom: 8 + end: 16 + start: 16 + top: 8 + size: + height: auto + width: 100% + view: + text: '22 seconds video ' + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - margin: + bottom: 8 + end: 16 + start: 16 + top: 8 + size: + height: auto + width: 100% + view: + text: 10 seconds story + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - size: + height: 100% + width: 100% + view: + direction: horizontal + items: [] + type: linear_layout + type: linear_layout + type: linear_layout + type: container + type: container + - automated_actions: + - behaviors: + - pager_next + delay: 10 + identifier: "[pager_next]_afb9eb28-a67f-40c2-a4fe-7b28fd3707e0" + identifier: afb9eb28-a67f-40c2-a4fe-7b28fd3707e0 + type: pager_item + view: + background_color: + default: + alpha: 1 + hex: "#969111" + type: hex + items: + - ignore_safe_area: true + margin: + end: 0 + start: 0 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + media_fit: center_crop + media_type: video + type: media + url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/e17527d2-3471-47b2-8733-7afbe3272b50 + video: + aspect_ratio: 0.5625 + autoplay: true + loop: true + muted: true + show_controls: false + - position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + items: + - margin: + bottom: 16 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + direction: vertical + items: + - identifier: layout_container + size: + height: 100% + width: 100% + view: + direction: vertical + items: + - margin: + bottom: 8 + end: 16 + start: 16 + top: 48 + size: + height: auto + width: 100% + view: + text: Video shorter than Story + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + selectors: + - color: + alpha: 1 + hex: "#FFFFFF" + type: hex + dark_mode: true + platform: ios + - color: + alpha: 1 + hex: "#FFFFFF" + type: hex + dark_mode: true + platform: android + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - margin: + bottom: 8 + end: 16 + start: 16 + top: 8 + size: + height: auto + width: 100% + view: + text: 6 seconds video + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + selectors: + - color: + alpha: 1 + hex: "#FFFFFF" + type: hex + dark_mode: true + platform: ios + - color: + alpha: 1 + hex: "#FFFFFF" + type: hex + dark_mode: true + platform: android + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - margin: + bottom: 8 + end: 16 + start: 16 + top: 8 + size: + height: auto + width: 100% + view: + text: 10 seconds story + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + selectors: + - color: + alpha: 1 + hex: "#FFFFFF" + type: hex + dark_mode: true + platform: ios + - color: + alpha: 1 + hex: "#FFFFFF" + type: hex + dark_mode: true + platform: android + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - size: + height: 100% + width: 100% + view: + direction: horizontal + items: [] + type: linear_layout + type: linear_layout + type: linear_layout + type: container + type: container + - automated_actions: + - behaviors: + - pager_next + delay: 10 + identifier: "[pager_next]_e09ba183-67e5-422c-acb8-da868e392a7c" + identifier: e09ba183-67e5-422c-acb8-da868e392a7c + type: pager_item + view: + background_color: + default: + alpha: 1 + hex: "#969111" + type: hex + items: + - ignore_safe_area: true + margin: + end: 0 + start: 0 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + media_fit: center_crop + media_type: youtube + type: media + url: https://www.youtube.com/embed/xUOQZeN8A7o + video: + aspect_ratio: 0.5625 + autoplay: true + loop: true + muted: true + show_controls: false + - position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + items: + - margin: + bottom: 16 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + direction: vertical + items: + - identifier: layout_container + size: + height: 100% + width: 100% + view: + direction: vertical + items: + - margin: + bottom: 8 + end: 16 + start: 16 + top: 48 + size: + height: auto + width: 100% + view: + text: Youtube VIDEO + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - margin: + bottom: 8 + end: 16 + start: 16 + top: 8 + size: + height: auto + width: 100% + view: + text: 'youtube video ' + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - margin: + bottom: 8 + end: 16 + start: 16 + top: 8 + size: + height: auto + width: 100% + view: + text: 10 seconds story + text_appearance: + alignment: center + color: + default: + alpha: 1 + hex: "#000000" + type: hex + font_families: + - sans-serif + font_size: 40 + styles: + - bold + type: label + - size: + height: 100% + width: 100% + view: + direction: horizontal + items: [ ] + type: linear_layout + type: linear_layout + type: linear_layout + type: container + type: container + - automated_actions: + - behaviors: + - pager_next + delay: 10 + identifier: pager_next_627ad448-a18c-432e-976e-d0ab10a67fdb + identifier: 627ad448-a18c-432e-976e-d0ab10a67fdb + type: pager_item + view: + background_color: + default: + alpha: 1 + hex: "#969111" + type: hex + items: + - ignore_safe_area: true + margin: + end: 0 + start: 0 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + media_fit: center_inside + media_type: image + type: media + url: https://hangar-dl.urbanairship.com/binary/public/VWDwdOFjRTKLRxCeXTVP6g/e9a5493a-c5bb-48ab-9557-48cee046ea74 + type: container + - automated_actions: + - behaviors: + - pager_next + delay: 10 + identifier: pager_next_627ad448-a18c-432e-976e-d0ab10a67fdb + identifier: 627ad448-a18c-432e-976e-d0ab10a67fdb + type: pager_item + view: + background_color: + default: + alpha: 1 + hex: "#969111" + type: hex + items: + - ignore_safe_area: true + margin: + end: 0 + start: 0 + position: + horizontal: center + vertical: center + size: + height: 100% + width: 100% + view: + media_fit: center_inside + media_type: image + type: media + url: https://hangar-dl.urbanairship.com/binary/public/VWDwdOFjRTKLRxCeXTVP6g/25b3bea5-e233-4d25-8d96-c65e6a859cee + type: container + type: pager + - margin: + bottom: 0 + end: 16 + start: 16 + top: 8 + position: + horizontal: center + vertical: top + size: + height: 2 + width: 100% + view: + source: + type: pager + style: + direction: horizontal + progress_color: + default: + alpha: 1 + hex: "#AAAAAA" + type: hex + sizing: equal + spacing: 4 + track_color: + default: + alpha: 0.5 + hex: "#AAAAAA" + type: hex + type: linear_progress + type: story_indicator + type: container diff --git a/Thomas/UAInAppMessageBannerStyle.plist b/Thomas/UAInAppMessageBannerStyle.plist index 326ab7e66..36dddd62c 100644 --- a/Thomas/UAInAppMessageBannerStyle.plist +++ b/Thomas/UAInAppMessageBannerStyle.plist @@ -13,8 +13,6 @@ trailing 4 - maxWidth - 26 tapOpacity 0.11 headerStyle diff --git a/UIKit-Sample/Sample-UIKit.xcodeproj/project.pbxproj b/UIKit-Sample/Sample-UIKit.xcodeproj/project.pbxproj new file mode 100644 index 000000000..fecf393ca --- /dev/null +++ b/UIKit-Sample/Sample-UIKit.xcodeproj/project.pbxproj @@ -0,0 +1,432 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 994F638E2BE9729F004AF281 /* AirshipAutomation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 994F638D2BE9729F004AF281 /* AirshipAutomation.framework */; }; + 994F638F2BE9729F004AF281 /* AirshipAutomation.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 994F638D2BE9729F004AF281 /* AirshipAutomation.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 994F63912BE972AD004AF281 /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 994F63902BE972AD004AF281 /* AirshipCore.framework */; }; + 994F63922BE972AD004AF281 /* AirshipCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 994F63902BE972AD004AF281 /* AirshipCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 994F63942BE972B1004AF281 /* AirshipBasement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 994F63932BE972B1004AF281 /* AirshipBasement.framework */; }; + 994F63952BE972B1004AF281 /* AirshipBasement.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 994F63932BE972B1004AF281 /* AirshipBasement.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 99AAA7A12BE31BBA007FC46F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99AAA7A02BE31BBA007FC46F /* AppDelegate.swift */; }; + 99AAA7A32BE31BBA007FC46F /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99AAA7A22BE31BBA007FC46F /* SceneDelegate.swift */; }; + 99AAA7A52BE31BBA007FC46F /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99AAA7A42BE31BBA007FC46F /* ViewController.swift */; }; + 99AAA7A82BE31BBA007FC46F /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 99AAA7A72BE31BBA007FC46F /* Base */; }; + 99AAA7AA2BE31BBB007FC46F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 99AAA7A92BE31BBB007FC46F /* Assets.xcassets */; }; + 99AAA7AD2BE31BBB007FC46F /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 99AAA7AC2BE31BBB007FC46F /* Base */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 99AAA8302BE31E25007FC46F /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 994F63952BE972B1004AF281 /* AirshipBasement.framework in Embed Frameworks */, + 994F63922BE972AD004AF281 /* AirshipCore.framework in Embed Frameworks */, + 994F638F2BE9729F004AF281 /* AirshipAutomation.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 994F638D2BE9729F004AF281 /* AirshipAutomation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipAutomation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 994F63902BE972AD004AF281 /* AirshipCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 994F63932BE972B1004AF281 /* AirshipBasement.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipBasement.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 99AAA79D2BE31BBA007FC46F /* Sample-UIKit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Sample-UIKit.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 99AAA7A02BE31BBA007FC46F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 99AAA7A22BE31BBA007FC46F /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 99AAA7A42BE31BBA007FC46F /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; + 99AAA7A72BE31BBA007FC46F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 99AAA7A92BE31BBB007FC46F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 99AAA7AC2BE31BBB007FC46F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 99AAA7AE2BE31BBB007FC46F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 99AAA82C2BE31DC4007FC46F /* Sample-UIKit.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Sample-UIKit.entitlements"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 99AAA79A2BE31BBA007FC46F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 994F63942BE972B1004AF281 /* AirshipBasement.framework in Frameworks */, + 994F63912BE972AD004AF281 /* AirshipCore.framework in Frameworks */, + 994F638E2BE9729F004AF281 /* AirshipAutomation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 99AAA7942BE31BBA007FC46F = { + isa = PBXGroup; + children = ( + 99AAA79F2BE31BBA007FC46F /* Sample-UIKit */, + 99AAA79E2BE31BBA007FC46F /* Products */, + 99AAA82D2BE31E25007FC46F /* Frameworks */, + ); + sourceTree = ""; + }; + 99AAA79E2BE31BBA007FC46F /* Products */ = { + isa = PBXGroup; + children = ( + 99AAA79D2BE31BBA007FC46F /* Sample-UIKit.app */, + ); + name = Products; + sourceTree = ""; + }; + 99AAA79F2BE31BBA007FC46F /* Sample-UIKit */ = { + isa = PBXGroup; + children = ( + 99AAA82C2BE31DC4007FC46F /* Sample-UIKit.entitlements */, + 99AAA7A02BE31BBA007FC46F /* AppDelegate.swift */, + 99AAA7A22BE31BBA007FC46F /* SceneDelegate.swift */, + 99AAA7A42BE31BBA007FC46F /* ViewController.swift */, + 99AAA7A62BE31BBA007FC46F /* Main.storyboard */, + 99AAA7A92BE31BBB007FC46F /* Assets.xcassets */, + 99AAA7AB2BE31BBB007FC46F /* LaunchScreen.storyboard */, + 99AAA7AE2BE31BBB007FC46F /* Info.plist */, + ); + path = "Sample-UIKit"; + sourceTree = ""; + }; + 99AAA82D2BE31E25007FC46F /* Frameworks */ = { + isa = PBXGroup; + children = ( + 994F63932BE972B1004AF281 /* AirshipBasement.framework */, + 994F63902BE972AD004AF281 /* AirshipCore.framework */, + 994F638D2BE9729F004AF281 /* AirshipAutomation.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 99AAA79C2BE31BBA007FC46F /* Sample-UIKit */ = { + isa = PBXNativeTarget; + buildConfigurationList = 99AAA7B12BE31BBB007FC46F /* Build configuration list for PBXNativeTarget "Sample-UIKit" */; + buildPhases = ( + 99AAA7992BE31BBA007FC46F /* Sources */, + 99AAA79A2BE31BBA007FC46F /* Frameworks */, + 99AAA79B2BE31BBA007FC46F /* Resources */, + 99AAA8302BE31E25007FC46F /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Sample-UIKit"; + packageProductDependencies = ( + ); + productName = "Sample-UIKit"; + productReference = 99AAA79D2BE31BBA007FC46F /* Sample-UIKit.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 99AAA7952BE31BBA007FC46F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1530; + LastUpgradeCheck = 1530; + TargetAttributes = { + 99AAA79C2BE31BBA007FC46F = { + CreatedOnToolsVersion = 15.3; + }; + }; + }; + buildConfigurationList = 99AAA7982BE31BBA007FC46F /* Build configuration list for PBXProject "Sample-UIKit" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 99AAA7942BE31BBA007FC46F; + packageReferences = ( + 99AAA9302BE57AFC007FC46F /* XCRemoteSwiftPackageReference "ios-library-dev" */, + ); + productRefGroup = 99AAA79E2BE31BBA007FC46F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 99AAA79C2BE31BBA007FC46F /* Sample-UIKit */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 99AAA79B2BE31BBA007FC46F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 99AAA7AA2BE31BBB007FC46F /* Assets.xcassets in Resources */, + 99AAA7AD2BE31BBB007FC46F /* Base in Resources */, + 99AAA7A82BE31BBA007FC46F /* Base in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 99AAA7992BE31BBA007FC46F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 99AAA7A52BE31BBA007FC46F /* ViewController.swift in Sources */, + 99AAA7A12BE31BBA007FC46F /* AppDelegate.swift in Sources */, + 99AAA7A32BE31BBA007FC46F /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 99AAA7A62BE31BBA007FC46F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 99AAA7A72BE31BBA007FC46F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 99AAA7AB2BE31BBB007FC46F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 99AAA7AC2BE31BBB007FC46F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 99AAA7AF2BE31BBB007FC46F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 99AAA7B02BE31BBB007FC46F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.4; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 99AAA7B22BE31BBB007FC46F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Sample-UIKit/Sample-UIKit.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PGJV57GD94; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Sample-UIKit/Info.plist"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + NEW_SETTING = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 99AAA7B32BE31BBB007FC46F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Sample-UIKit/Sample-UIKit.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PGJV57GD94; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Sample-UIKit/Info.plist"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + NEW_SETTING = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 99AAA7982BE31BBA007FC46F /* Build configuration list for PBXProject "Sample-UIKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99AAA7AF2BE31BBB007FC46F /* Debug */, + 99AAA7B02BE31BBB007FC46F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 99AAA7B12BE31BBB007FC46F /* Build configuration list for PBXNativeTarget "Sample-UIKit" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 99AAA7B22BE31BBB007FC46F /* Debug */, + 99AAA7B32BE31BBB007FC46F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 99AAA9302BE57AFC007FC46F /* XCRemoteSwiftPackageReference "ios-library-dev" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "git@github.com:urbanairship/ios-library-dev.git"; + requirement = { + kind = exactVersion; + version = 18.1.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + }; + rootObject = 99AAA7952BE31BBA007FC46F /* Project object */; +} diff --git a/watchOSSample/watchOSSampleTests/watchOSSampleTests.swift b/watchOSSample/watchOSSampleTests/watchOSSampleTests.swift index c0b664b08..bb07738fb 100644 --- a/watchOSSample/watchOSSampleTests/watchOSSampleTests.swift +++ b/watchOSSample/watchOSSampleTests/watchOSSampleTests.swift @@ -21,12 +21,4 @@ class watchOSSampleTests: XCTestCase { // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - }