Repository: urbanairship/ios-library Branch: main Commit: 60e0385d882f Files: 1418 Total size: 10.7 MB Directory structure: gitextract_2xh8r30o/ ├── .github/ │ ├── CODEOWNERS │ ├── CONTRIBUTING.md │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── check-cert.yml │ ├── check_framework_size.yml │ ├── ci.yml │ ├── deploy_docC.yml │ ├── merge.yml │ ├── pr-review.yml │ └── release.yml ├── .gitignore ├── .gitmodules ├── .ruby-version ├── .swift-format.json ├── .swiftpm/ │ └── xcode/ │ └── package.xcworkspace/ │ └── contents.xcworkspacedata ├── .whitesource ├── Airship/ │ ├── Airship.xcodeproj/ │ │ ├── project.pbxproj │ │ ├── project.xcworkspace/ │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata/ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ ├── AirshipAutomation.xcscheme │ │ ├── AirshipBasement.xcscheme │ │ ├── AirshipCore.xcscheme │ │ ├── AirshipDebug.xcscheme │ │ ├── AirshipFeatureFlags.xcscheme │ │ ├── AirshipMessageCenter.xcscheme │ │ ├── AirshipObjectiveC.xcscheme │ │ └── AirshipPreferenceCenter.xcscheme │ ├── AirshipAutomation/ │ │ ├── Info.plist │ │ ├── Resources/ │ │ │ ├── AirshipAutomation.xcdatamodeld/ │ │ │ │ ├── .xccurrentversion │ │ │ │ ├── AirshipAutomation 2.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── AirshipAutomation 3.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ └── AirshipAutomation.xcdatamodel/ │ │ │ │ └── contents │ │ │ ├── UAAutomation.xcdatamodeld/ │ │ │ │ ├── .xccurrentversion │ │ │ │ ├── UAAutomation 10.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 11.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 12.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 13.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 2.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 3.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 4.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 5.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 6.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 7.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 8.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAAutomation 9.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ └── UAAutomation.xcdatamodel/ │ │ │ │ └── contents │ │ │ └── UAFrequencyLimits.xcdatamodeld/ │ │ │ └── UAFrequencyLimits.xcdatamodel/ │ │ │ └── contents │ │ ├── Source/ │ │ │ ├── ActionAutomation/ │ │ │ │ ├── ActionAutomationExecutor.swift │ │ │ │ └── ActionAutomationPreparer.swift │ │ │ ├── Actions/ │ │ │ │ ├── CancelSchedulesAction.swift │ │ │ │ ├── LandingPageAction.swift │ │ │ │ └── ScheduleAction.swift │ │ │ ├── AirshipAutomationResources.swift │ │ │ ├── AudienceCheck/ │ │ │ │ ├── AdditionalAudienceCheckerApiClient.swift │ │ │ │ └── AdditionalAudienceCheckerResolver.swift │ │ │ ├── Automation/ │ │ │ │ ├── ApplicationMetrics.swift │ │ │ │ ├── AutomationAudience.swift │ │ │ │ ├── AutomationCompoundAudience.swift │ │ │ │ ├── AutomationDelay.swift │ │ │ │ ├── AutomationSchedule.swift │ │ │ │ ├── AutomationTrigger.swift │ │ │ │ ├── DeferredAutomationData.swift │ │ │ │ ├── Engine/ │ │ │ │ │ ├── AutomationDelayProcessor.swift │ │ │ │ │ ├── AutomationEngine.swift │ │ │ │ │ ├── AutomationEventFeed.swift │ │ │ │ │ ├── AutomationEventsHistory.swift │ │ │ │ │ ├── AutomationExecutor.swift │ │ │ │ │ ├── AutomationPreparer.swift │ │ │ │ │ ├── AutomationScheduleData.swift │ │ │ │ │ ├── AutomationScheduleState.swift │ │ │ │ │ ├── AutomationStore.swift │ │ │ │ │ ├── ExecutionWindowProcessor.swift │ │ │ │ │ ├── LegacyAutomationStore.swift │ │ │ │ │ ├── PreparedSchedule.swift │ │ │ │ │ ├── ScheduleExecuteResult.swift │ │ │ │ │ ├── SchedulePrepareResult.swift │ │ │ │ │ ├── ScheduleReadyResult.swift │ │ │ │ │ ├── TriggerProcessor/ │ │ │ │ │ │ ├── AutomationTriggerProcessor.swift │ │ │ │ │ │ ├── PreparedTrigger.swift │ │ │ │ │ │ └── TriggerData.swift │ │ │ │ │ └── TriggeringInfo.swift │ │ │ │ └── ExecutionWindow.swift │ │ │ ├── AutomationSDKModule.swift │ │ │ ├── InAppAutomation.swift │ │ │ ├── InAppAutomationComponent.swift │ │ │ ├── InAppAutomationUpdateStatus.swift │ │ │ ├── InAppMessage/ │ │ │ │ ├── Analytics/ │ │ │ │ │ ├── InAppDisplayImpressionRuleProvider.swift │ │ │ │ │ ├── InAppMessageAnalytics.swift │ │ │ │ │ └── InAppMessageAnalyticsFactory.swift │ │ │ │ ├── Assets/ │ │ │ │ │ ├── AirshipCachedAssets.swift │ │ │ │ │ ├── AssetCacheManager.swift │ │ │ │ │ ├── DefaultAssetDownloader.swift │ │ │ │ │ └── DefaultAssetFileManager.swift │ │ │ │ ├── Display Adapter/ │ │ │ │ │ ├── AirshipLayoutDisplayAdapter.swift │ │ │ │ │ ├── CustomDisplayAdapter.swift │ │ │ │ │ ├── CustomDisplayAdapterWrapper.swift │ │ │ │ │ ├── DisplayAdapter.swift │ │ │ │ │ ├── DisplayAdapterFactory.swift │ │ │ │ │ └── InAppMessageDisplayListener.swift │ │ │ │ ├── Display Coordinators/ │ │ │ │ │ ├── DefaultDisplayCoordinator.swift │ │ │ │ │ ├── DisplayCoordinator.swift │ │ │ │ │ ├── DisplayCoordinatorManager.swift │ │ │ │ │ └── ImmediateDisplayCoordinator.swift │ │ │ │ ├── InAppActionRunner.swift │ │ │ │ ├── InAppMessage.swift │ │ │ │ ├── InAppMessageAutomationExecutor.swift │ │ │ │ ├── InAppMessageAutomationPreparer.swift │ │ │ │ ├── InAppMessageColor.swift │ │ │ │ ├── InAppMessageDisplayContent.swift │ │ │ │ ├── InAppMessageDisplayDelegate.swift │ │ │ │ ├── InAppMessageEnvironment.swift │ │ │ │ ├── InAppMessageSceneDelegate.swift │ │ │ │ ├── InAppMessageSceneManager.swift │ │ │ │ ├── InAppMessageValidation.swift │ │ │ │ ├── InAppMessageViewDelegate.swift │ │ │ │ ├── InAppMessaging.swift │ │ │ │ ├── Info/ │ │ │ │ │ ├── InAppMessageButtonInfo.swift │ │ │ │ │ ├── InAppMessageButtonLayoutType.swift │ │ │ │ │ ├── InAppMessageMediaInfo.swift │ │ │ │ │ └── InAppMessageTextInfo.swift │ │ │ │ ├── Legacy/ │ │ │ │ │ ├── LegacyInAppAnalytics.swift │ │ │ │ │ ├── LegacyInAppMessage.swift │ │ │ │ │ └── LegacyInAppMessaging.swift │ │ │ │ └── View/ │ │ │ │ ├── BeveledLoadingView.swift │ │ │ │ ├── ButtonGroup.swift │ │ │ │ ├── CloseButton.swift │ │ │ │ ├── FullscreenView.swift │ │ │ │ ├── HTMLView.swift │ │ │ │ ├── InAppMessageBannerView.swift │ │ │ │ ├── InAppMessageExtensions.swift │ │ │ │ ├── InAppMessageHostingController.swift │ │ │ │ ├── InAppMessageModalView.swift │ │ │ │ ├── InAppMessageNativeBridgeExtension.swift │ │ │ │ ├── InAppMessageRootView.swift │ │ │ │ ├── InAppMessageViewUtils.swift │ │ │ │ ├── InAppMessageWebView.swift │ │ │ │ ├── MediaView.swift │ │ │ │ ├── TextView.swift │ │ │ │ └── Theme/ │ │ │ │ ├── InAppMessageTheme.swift │ │ │ │ ├── InAppMessageThemeAdditionalPadding.swift │ │ │ │ ├── InAppMessageThemeBanner.swift │ │ │ │ ├── InAppMessageThemeButton.swift │ │ │ │ ├── InAppMessageThemeFullscreen.swift │ │ │ │ ├── InAppMessageThemeHTML.swift │ │ │ │ ├── InAppMessageThemeManager.swift │ │ │ │ ├── InAppMessageThemeMedia.swift │ │ │ │ ├── InAppMessageThemeModal.swift │ │ │ │ ├── InAppMessageThemeShadow.swift │ │ │ │ ├── InAppMessageThemeText.swift │ │ │ │ └── ThemeExtensions.swift │ │ │ ├── Limits/ │ │ │ │ ├── FrequencyChecker.swift │ │ │ │ ├── FrequencyConstraint.swift │ │ │ │ ├── FrequencyLimitManager.swift │ │ │ │ ├── FrequencyLimitStore.swift │ │ │ │ └── Occurrence.swift │ │ │ ├── RemoteData/ │ │ │ │ ├── AutomationRemoteDataAccess.swift │ │ │ │ ├── AutomationRemoteDataSubscriber.swift │ │ │ │ ├── AutomationSourceInfoStore.swift │ │ │ │ └── DeferredScheduleResult.swift │ │ │ └── Utils/ │ │ │ ├── ActiveTimer.swift │ │ │ ├── AirshipAsyncSemaphore.swift │ │ │ ├── AutomationActionRunner.swift │ │ │ ├── RetryingQueue.swift │ │ │ └── ScheduleConditionsChangedNotifier.swift │ │ └── Tests/ │ │ ├── Action Automation/ │ │ │ ├── ActionAutomationExecutorTest.swift │ │ │ └── ActionAutomationPreparerTest.swift │ │ ├── Actions/ │ │ │ ├── CancelSchedulesActionTest.swift │ │ │ ├── LandingPageActionTest.swift │ │ │ └── ScheduleActionTest.swift │ │ ├── Automation/ │ │ │ ├── ApplicationMetricsTest.swift │ │ │ ├── AudienceCheck/ │ │ │ │ └── AdditionalAudienceCheckerResolverTest.swift │ │ │ ├── AutomationScheduleDataTest.swift │ │ │ ├── AutomationScheduleTest.swift │ │ │ └── Engine/ │ │ │ ├── AutomationDelayProcessorTest.swift │ │ │ ├── AutomationEngineTest.swift │ │ │ ├── AutomationEventFeedTest.swift │ │ │ ├── AutomationEventsHistoryTest.swift │ │ │ ├── AutomationExecutorTest.swift │ │ │ ├── AutomationPreparerTest.swift │ │ │ ├── AutomationStoreTest.swift │ │ │ ├── AutomationTriggerProcessorTest.swift │ │ │ ├── ExecutionWindowProcessorTest.swift │ │ │ ├── PreparedScheduleInfoTest.swift │ │ │ └── PreparedTriggerTest.swift │ │ ├── ExecutionWindowTest.swift │ │ ├── InAppMessage/ │ │ │ ├── Analytics/ │ │ │ │ ├── DefaultInAppDisplayImpressionRuleProviderTest.swift │ │ │ │ └── InAppMessageAnalyticsTest.swift │ │ │ ├── Assets/ │ │ │ │ ├── AssetCacheManagerTest.swift │ │ │ │ ├── DefaultAssetDownloaderTest.swift │ │ │ │ └── DefaultAssetFileManagerTest.swift │ │ │ ├── DefaultInAppActionRunnerTest.swift │ │ │ ├── Display Adapter/ │ │ │ │ ├── AirshipLayoutDisplayAdapterTest.swift │ │ │ │ ├── CustomDisplayAdapterWrapperTest.swift │ │ │ │ ├── DisplayAdapterFactoryTest.swift │ │ │ │ └── InAppMessageDisplayListenerTest.swift │ │ │ ├── Display Coordinators/ │ │ │ │ ├── DefaultDisplayCoordinatorTest.swift │ │ │ │ ├── DisplayCoordinatorManagerTest.swift │ │ │ │ └── ImmediateDisplayCoordinatorTest.swift │ │ │ ├── InAppMessageAutomationExecutorTest.swift │ │ │ ├── InAppMessageAutomationPreparerTest.swift │ │ │ ├── InAppMessageContentValidationTest.swift │ │ │ ├── InAppMessageTest.swift │ │ │ ├── InAppMessageThemeTest.swift │ │ │ └── View/ │ │ │ └── InAppMessageNativeBridgeExtensionTest.swift │ │ ├── InAppMessaging/ │ │ │ ├── Invalid-UAInAppMessageBannerStyle.plist │ │ │ ├── Invalid-UAInAppMessageFullScreenStyle.plist │ │ │ ├── Invalid-UAInAppMessageModalStyle.plist │ │ │ ├── Valid-UAInAppMessageBannerStyle.plist │ │ │ ├── Valid-UAInAppMessageFullScreenStyle.plist │ │ │ ├── Valid-UAInAppMessageHTMLStyle.plist │ │ │ └── Valid-UAInAppMessageModalStyle.plist │ │ ├── Legacy/ │ │ │ ├── LegacyInAppAnalyticsTest.swift │ │ │ ├── LegacyInAppMessageTest.swift │ │ │ └── LegacyInAppMessagingTest.swift │ │ ├── Limits/ │ │ │ └── FrequencyLimitManagerTest.swift │ │ ├── RemoteData/ │ │ │ ├── AutomationRemoteDataAccessTest.swift │ │ │ ├── AutomationRemoteDataSubscriberTest.swift │ │ │ └── AutomationSourceInfoStoreTest.swift │ │ ├── Test Utils/ │ │ │ ├── TestActionRunner.swift │ │ │ ├── TestActiveTimer.swift │ │ │ ├── TestAutomationEngine.swift │ │ │ ├── TestCachedAssets.swift │ │ │ ├── TestDisplayAdapter.swift │ │ │ ├── TestDisplayCoordinator.swift │ │ │ ├── TestFrequencyLimitsManager.swift │ │ │ ├── TestInAppMessageAnalytics.swift │ │ │ ├── TestInAppMessageAutomationExecutor.swift │ │ │ └── TestRemoteDataAccess.swift │ │ └── Utils/ │ │ ├── ActiveTimerTest.swift │ │ ├── AirshipAsyncSemaphoreTest.swift │ │ └── RetryingQueueTests.swift │ ├── AirshipBasement/ │ │ ├── Info.plist │ │ └── Source/ │ │ ├── AirshipLogHandler.swift │ │ ├── AirshipLogPrivacyLevel.swift │ │ ├── AirshipLogger.swift │ │ ├── DefaultLogHandler.swift │ │ └── LogLevel.swift │ ├── AirshipConfig.xcconfig │ ├── AirshipCore/ │ │ ├── Info.plist │ │ ├── Resources/ │ │ │ ├── PrivacyInfo.xcprivacy │ │ │ ├── UAEvents.xcdatamodeld/ │ │ │ │ └── UAEvents.xcdatamodel/ │ │ │ │ └── contents │ │ │ ├── UAMeteredUsage.xcdatamodeld/ │ │ │ │ └── UAMeteredUsage.xcdatamodel/ │ │ │ │ └── contents │ │ │ ├── UANativeBridge │ │ │ ├── UANotificationCategories.plist │ │ │ ├── UARemoteData.xcdatamodeld/ │ │ │ │ ├── .xccurrentversion │ │ │ │ ├── UARemoteData 2.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UARemoteData 3.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UARemoteData 4.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ └── UARemoteData.xcdatamodel/ │ │ │ │ └── contents │ │ │ ├── UARemoteDataMappingV1toV4.xcmappingmodel/ │ │ │ │ └── xcmapping.xml │ │ │ ├── UARemoteDataMappingV2toV4.xcmappingmodel/ │ │ │ │ └── xcmapping.xml │ │ │ ├── UARemoteDataMappingV3toV4.xcmappingmodel/ │ │ │ │ └── xcmapping.xml │ │ │ ├── UAirshipCache.xcdatamodeld/ │ │ │ │ └── UAAirshipCache.xcdatamodel/ │ │ │ │ └── contents │ │ │ ├── af.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── am.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── ar.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── bg.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── ca.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── cs.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── da.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── de.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── el.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── en.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── es-419.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── es.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── et.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── fa.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── fi.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── fr-CA.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── fr.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── hi.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── hr.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── hu.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── id.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── it.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── iw.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── ja.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── ko.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── lt.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── lv.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── ms.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── nl.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── no.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── pl.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── pt-PT.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── pt.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── ro.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── ru.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── sk.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── sl.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── sr.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── sv.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── sw.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── th.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── tr.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── uk.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── vi.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── zh-HK.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── zh-Hans.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ ├── zh-Hant.lproj/ │ │ │ │ └── UrbanAirship.strings │ │ │ └── zu.lproj/ │ │ │ └── UrbanAirship.strings │ │ ├── Source/ │ │ │ ├── APNSEnvironment.swift │ │ │ ├── APNSRegistrar.swift │ │ │ ├── APNSRegistrationResult.swift │ │ │ ├── AccountEventTemplate.swift │ │ │ ├── ActionArguments.swift │ │ │ ├── ActionRegistry.swift │ │ │ ├── ActionResult.swift │ │ │ ├── ActionRunner.swift │ │ │ ├── ActivityViewController.swift │ │ │ ├── AddCustomEventAction.swift │ │ │ ├── AddTagsAction.swift │ │ │ ├── Airship.swift │ │ │ ├── AirshipAction.swift │ │ │ ├── AirshipActorValue.swift │ │ │ ├── AirshipAnalytics.swift │ │ │ ├── AirshipAnalyticsFeed.swift │ │ │ ├── AirshipAppCredentials.swift │ │ │ ├── AirshipApptimizeIntegration.swift │ │ │ ├── AirshipAsyncChannel.swift │ │ │ ├── AirshipAsyncImage.swift │ │ │ ├── AirshipAuthorizedNotificationSettings.swift │ │ │ ├── AirshipBase64.swift │ │ │ ├── AirshipButton.swift │ │ │ ├── AirshipCache.swift │ │ │ ├── AirshipCancellable.swift │ │ │ ├── AirshipChannel.swift │ │ │ ├── AirshipCheckboxToggleStyle.swift │ │ │ ├── AirshipColor.swift │ │ │ ├── AirshipComponent.swift │ │ │ ├── AirshipConfig.swift │ │ │ ├── AirshipContact.swift │ │ │ ├── AirshipCoreDataPredicate.swift │ │ │ ├── AirshipCoreResources.swift │ │ │ ├── AirshipDate.swift │ │ │ ├── AirshipDateFormatter.swift │ │ │ ├── AirshipDevice.swift │ │ │ ├── AirshipDeviceAudienceResult.swift │ │ │ ├── AirshipDeviceID.swift │ │ │ ├── AirshipDisplayTarget.swift │ │ │ ├── AirshipEmbeddedInfo.swift │ │ │ ├── AirshipEmbeddedObserver.swift │ │ │ ├── AirshipEmbeddedSize.swift │ │ │ ├── AirshipEmbeddedView.swift │ │ │ ├── AirshipEmbeddedViewManager.swift │ │ │ ├── AirshipErrors.swift │ │ │ ├── AirshipEvent.swift │ │ │ ├── AirshipEventData.swift │ │ │ ├── AirshipEventType.swift │ │ │ ├── AirshipEvents.swift │ │ │ ├── AirshipFont.swift │ │ │ ├── AirshipImageLoader.swift │ │ │ ├── AirshipImageProvider.swift │ │ │ ├── AirshipInputValidator.swift │ │ │ ├── AirshipInstance.swift │ │ │ ├── AirshipIvyVersionMatcher.swift │ │ │ ├── AirshipJSON.swift │ │ │ ├── AirshipJSONUtils.swift │ │ │ ├── AirshipKeychainAccess.swift │ │ │ ├── AirshipLayout.swift │ │ │ ├── AirshipLocalizationUtils.swift │ │ │ ├── AirshipLock.swift │ │ │ ├── AirshipMeteredUsage.swift │ │ │ ├── AirshipMeteredUsageEvent.swift │ │ │ ├── AirshipNativePlatform.swift │ │ │ ├── AirshipNetworkChecker.swift │ │ │ ├── AirshipNotificationCenter.swift │ │ │ ├── AirshipNotificationStatus.swift │ │ │ ├── AirshipPasteboard.swift │ │ │ ├── AirshipPrivacyManager.swift │ │ │ ├── AirshipProgressView.swift │ │ │ ├── AirshipPush.swift │ │ │ ├── AirshipPushableComponent.swift │ │ │ ├── AirshipRequest.swift │ │ │ ├── AirshipRequestSession.swift │ │ │ ├── AirshipResources.swift │ │ │ ├── AirshipResponse.swift │ │ │ ├── AirshipSDKExtension.swift │ │ │ ├── AirshipSDKModule.swift │ │ │ ├── AirshipSceneController.swift │ │ │ ├── AirshipSceneManager.swift │ │ │ ├── AirshipSimpleLayoutView.swift │ │ │ ├── AirshipSimpleLayoutViewModel.swift │ │ │ ├── AirshipStateOverrides.swift │ │ │ ├── AirshipSwitchToggleStyle.swift │ │ │ ├── AirshipSwizzler.swift │ │ │ ├── AirshipTaskSleeper.swift │ │ │ ├── AirshipTimeCriteria.swift │ │ │ ├── AirshipTimerProtocol.swift │ │ │ ├── AirshipToggle.swift │ │ │ ├── AirshipUnsafeSendableWrapper.swift │ │ │ ├── AirshipUtils.swift │ │ │ ├── AirshipVersion.swift │ │ │ ├── AirshipViewSizeReader.swift │ │ │ ├── AirshipViewUtils.swift │ │ │ ├── AirshipWeakValueHolder.swift │ │ │ ├── AirshipWebview.swift │ │ │ ├── AirshipWindowFactory.swift │ │ │ ├── AirshipWorkManager.swift │ │ │ ├── AirshipWorkManagerProtocol.swift │ │ │ ├── AirshipWorkRequest.swift │ │ │ ├── AirshipWorkResult.swift │ │ │ ├── AirsihpTriggerContext.swift │ │ │ ├── AnonContactData.swift │ │ │ ├── AppIntegration.swift │ │ │ ├── AppRemoteDataProviderDelegate.swift │ │ │ ├── AppStateTracker.swift │ │ │ ├── AppStateTrackerAdapter.swift │ │ │ ├── ApplicationState.swift │ │ │ ├── ArishipCustomViewManager.swift │ │ │ ├── AssociatedIdentifiers.swift │ │ │ ├── AsyncSerialQueue.swift │ │ │ ├── AsyncStream.swift │ │ │ ├── Atomic.swift │ │ │ ├── AttributePendingMutations.swift │ │ │ ├── AttributeUpdate.swift │ │ │ ├── Attributes.swift │ │ │ ├── AttributesEditor.swift │ │ │ ├── AudienceDeviceInfoProvider.swift │ │ │ ├── AudienceHashSelector.swift │ │ │ ├── AudienceOverridesProvider.swift │ │ │ ├── AudienceUtils.swift │ │ │ ├── AuthToken.swift │ │ │ ├── AutoIntegration.swift │ │ │ ├── BackgroundColorViewModifier.swift │ │ │ ├── Badger.swift │ │ │ ├── BannerView.swift │ │ │ ├── BaseCachingRemoteDataProvider.swift │ │ │ ├── BasementImport.swift │ │ │ ├── BasicToggleLayout.swift │ │ │ ├── BlockAction.swift │ │ │ ├── BundleExtensions.swift │ │ │ ├── ButtonLayout.swift │ │ │ ├── ButtonState.swift │ │ │ ├── CachedList.swift │ │ │ ├── CachedValue.swift │ │ │ ├── CachingSMSValidatorAPIClient.swift │ │ │ ├── CancellableValueHolder.swift │ │ │ ├── ChallengeResolver.swift │ │ │ ├── ChannelAPIClient.swift │ │ │ ├── ChannelAudienceManager.swift │ │ │ ├── ChannelAuthTokenAPIClient.swift │ │ │ ├── ChannelAuthTokenProvider.swift │ │ │ ├── ChannelBulkUpdateAPIClient.swift │ │ │ ├── ChannelCapture.swift │ │ │ ├── ChannelRegistrar.swift │ │ │ ├── ChannelRegistrationPayload.swift │ │ │ ├── ChannelScope.swift │ │ │ ├── ChannelSubscriptionListProvider.swift │ │ │ ├── ChannelType.swift │ │ │ ├── Checkbox.swift │ │ │ ├── CheckboxController.swift │ │ │ ├── CheckboxState.swift │ │ │ ├── CheckboxToggleLayout.swift │ │ │ ├── CircularRegion.swift │ │ │ ├── CloudSite.swift │ │ │ ├── CompoundDeviceAudienceSelector.swift │ │ │ ├── ContactAPIClient.swift │ │ │ ├── ContactChannel.swift │ │ │ ├── ContactChannelsAPIClient.swift │ │ │ ├── ContactChannelsProvider.swift │ │ │ ├── ContactConflictEvent.swift │ │ │ ├── ContactManager.swift │ │ │ ├── ContactManagerProtocol.swift │ │ │ ├── ContactOperation.swift │ │ │ ├── ContactRemoteDataProviderDelegate.swift │ │ │ ├── ContactSubscriptionListClient.swift │ │ │ ├── Container.swift │ │ │ ├── CustomEvent.swift │ │ │ ├── CustomView.swift │ │ │ ├── DeepLinkAction.swift │ │ │ ├── DeepLinkDelegate.swift │ │ │ ├── DefaultAirshipAnalytics.swift │ │ │ ├── DefaultAirshipChannel.swift │ │ │ ├── DefaultAirshipContact.swift │ │ │ ├── DefaultAirshipPush.swift │ │ │ ├── DefaultAppIntegrationDelegate.swift │ │ │ ├── DeferredAPIClient.swift │ │ │ ├── DeferredResolver.swift │ │ │ ├── DeviceAudienceChecker.swift │ │ │ ├── DeviceAudienceSelector.swift │ │ │ ├── DeviceTagSelector.swift │ │ │ ├── Dispatcher.swift │ │ │ ├── EmailRegistrationOptions.swift │ │ │ ├── EmbeddedView.swift │ │ │ ├── EmbeddedViewSelector.swift │ │ │ ├── EmptyAction.swift │ │ │ ├── EmptyView.swift │ │ │ ├── EnableBehaviorModifiers.swift │ │ │ ├── EnableFeatureAction.swift │ │ │ ├── EnvironmentValues.swift │ │ │ ├── EventAPIClient.swift │ │ │ ├── EventHandlerViewModifier.swift │ │ │ ├── EventManager.swift │ │ │ ├── EventStore.swift │ │ │ ├── EventUploadScheduler.swift │ │ │ ├── EventUploadTuningInfo.swift │ │ │ ├── EventUtils.swift │ │ │ ├── Experiment.swift │ │ │ ├── ExperimentDataProvider.swift │ │ │ ├── ExperimentManager.swift │ │ │ ├── ExternalURLProcessor.swift │ │ │ ├── FarmHashFingerprint64.swift │ │ │ ├── FetchDeviceInfoAction.swift │ │ │ ├── FontViewModifier.swift │ │ │ ├── FormController.swift │ │ │ ├── FormInputViewModifier.swift │ │ │ ├── HashChecker.swift │ │ │ ├── IconView.swift │ │ │ ├── Icons.swift │ │ │ ├── Image.swift │ │ │ ├── ImageButton.swift │ │ │ ├── JSONMatcher.swift │ │ │ ├── JSONPredicate.swift │ │ │ ├── JSONValueMatcher.swift │ │ │ ├── JSONValueMatcherPredicates.swift │ │ │ ├── JSONValueTransformer.swift │ │ │ ├── JavaScriptCommand.swift │ │ │ ├── JavaScriptCommandDelegate.swift │ │ │ ├── JavaScriptEnvironment.swift │ │ │ ├── Label.swift │ │ │ ├── LabelButton.swift │ │ │ ├── LayoutState.swift │ │ │ ├── LinearLayout.swift │ │ │ ├── LiveActivity.swift │ │ │ ├── LiveActivityRegistrationStatus.swift │ │ │ ├── LiveActivityRegistrationStatusUpdates.swift │ │ │ ├── LiveActivityRegistry.swift │ │ │ ├── LiveActivityRestorer.swift │ │ │ ├── LiveActivityUpdate.swift │ │ │ ├── LocaleManager.swift │ │ │ ├── Media.swift │ │ │ ├── MediaEventTemplate.swift │ │ │ ├── MessageCriteria.swift │ │ │ ├── MessageDisplayHistory.swift │ │ │ ├── MeteredUsageAPIClient.swift │ │ │ ├── MeteredUsageStore.swift │ │ │ ├── ModalView.swift │ │ │ ├── ModifyAttributesAction.swift │ │ │ ├── ModifyTagsAction.swift │ │ │ ├── ModuleLoader.swift │ │ │ ├── NativeBridge.swift │ │ │ ├── NativeBridgeActionHandler.swift │ │ │ ├── NativeBridgeActionRunner.swift │ │ │ ├── NativeBridgeDelegate.swift │ │ │ ├── NativeBridgeExtensionDelegate.swift │ │ │ ├── NativeVideoPlayer.swift │ │ │ ├── NotificationCategories.swift │ │ │ ├── NotificationPermissionDelegate.swift │ │ │ ├── NotificationRegistrar.swift │ │ │ ├── NotificationRegistrationResult.swift │ │ │ ├── OpenExternalURLAction.swift │ │ │ ├── OpenRegistrationOptions.swift │ │ │ ├── Pager.swift │ │ │ ├── PagerController.swift │ │ │ ├── PagerGestureMap.swift │ │ │ ├── PagerIndicator.swift │ │ │ ├── PagerState.swift │ │ │ ├── PagerSwipeDirection.swift │ │ │ ├── PagerUtils.swift │ │ │ ├── PasteboardAction.swift │ │ │ ├── Permission.swift │ │ │ ├── PermissionDelegate.swift │ │ │ ├── PermissionPrompter.swift │ │ │ ├── PermissionStatus.swift │ │ │ ├── PermissionsManager.swift │ │ │ ├── PreferenceDataStore.swift │ │ │ ├── PromptPermissionAction.swift │ │ │ ├── ProximityRegion.swift │ │ │ ├── PushNotificationDelegate.swift │ │ │ ├── RadioInput.swift │ │ │ ├── RadioInputController.swift │ │ │ ├── RadioInputState.swift │ │ │ ├── RadioInputToggleLayout.swift │ │ │ ├── RateAppAction.swift │ │ │ ├── RegionEvent.swift │ │ │ ├── RegistrationDelegate.swift │ │ │ ├── RemoteConfig.swift │ │ │ ├── RemoteConfigCache.swift │ │ │ ├── RemoteConfigManager.swift │ │ │ ├── RemoteData.swift │ │ │ ├── RemoteDataAPIClient.swift │ │ │ ├── RemoteDataInfo.swift │ │ │ ├── RemoteDataPayload.swift │ │ │ ├── RemoteDataProtocol.swift │ │ │ ├── RemoteDataProvider.swift │ │ │ ├── RemoteDataProviderDelegate.swift │ │ │ ├── RemoteDataProviderProtocol.swift │ │ │ ├── RemoteDataSource.swift │ │ │ ├── RemoteDataSourceStatus.swift │ │ │ ├── RemoteDataStore.swift │ │ │ ├── RemoteDataStorePayload.swift │ │ │ ├── RemoteDataURLFactory.swift │ │ │ ├── RemoveTagsAction.swift │ │ │ ├── RetailEventTemplate.swift │ │ │ ├── RootView.swift │ │ │ ├── RuntimeConfig.swift │ │ │ ├── SMSRegistrationOptions.swift │ │ │ ├── SMSValidatorAPIClient.swift │ │ │ ├── ScopedSubscriptionListEdit.swift │ │ │ ├── ScopedSubscriptionListEditor.swift │ │ │ ├── ScopedSubscriptionListUpdate.swift │ │ │ ├── Score.swift │ │ │ ├── ScoreController.swift │ │ │ ├── ScoreState.swift │ │ │ ├── ScoreToggleLayout.swift │ │ │ ├── ScrollLayout.swift │ │ │ ├── SearchEventTemplate.swift │ │ │ ├── SerialQueue.swift │ │ │ ├── SessionEvent.swift │ │ │ ├── SessionEventFactory.swift │ │ │ ├── SessionState.swift │ │ │ ├── SessionTracker.swift │ │ │ ├── Shapes.swift │ │ │ ├── ShareAction.swift │ │ │ ├── SmsLocalePicker.swift │ │ │ ├── StackImageButton.swift │ │ │ ├── StateController.swift │ │ │ ├── StateSubscriptionsModifier.swift │ │ │ ├── StoryIndicator.swift │ │ │ ├── SubjectExtension.swift │ │ │ ├── SubscriptionListAPIClient.swift │ │ │ ├── SubscriptionListAction.swift │ │ │ ├── SubscriptionListEdit.swift │ │ │ ├── SubscriptionListEditor.swift │ │ │ ├── SubscriptionListProvider.swift │ │ │ ├── SubscriptionListUpdate.swift │ │ │ ├── TagActionMutation.swift │ │ │ ├── TagEditor.swift │ │ │ ├── TagGroupMutations.swift │ │ │ ├── TagGroupUpdate.swift │ │ │ ├── TagGroupsEditor.swift │ │ │ ├── TagsActionArgs.swift │ │ │ ├── TextInput.swift │ │ │ ├── Thomas.swift │ │ │ ├── ThomasAccessibilityAction.swift │ │ │ ├── ThomasAccessibleInfo.swift │ │ │ ├── ThomasActionsPayload.swift │ │ │ ├── ThomasAssociatedLabelResolver.swift │ │ │ ├── ThomasAsyncImage.swift │ │ │ ├── ThomasAttributeName.swift │ │ │ ├── ThomasAttributeValue.swift │ │ │ ├── ThomasAutomatedAccessibilityAction.swift │ │ │ ├── ThomasAutomatedAction.swift │ │ │ ├── ThomasBorder.swift │ │ │ ├── ThomasButtonClickBehavior.swift │ │ │ ├── ThomasButtonTapEffect.swift │ │ │ ├── ThomasColor.swift │ │ │ ├── ThomasConstants.swift │ │ │ ├── ThomasConstrainedSize.swift │ │ │ ├── ThomasDelegate.swift │ │ │ ├── ThomasDirection.swift │ │ │ ├── ThomasDisplayListener.swift │ │ │ ├── ThomasEmailRegistrationOptions.swift │ │ │ ├── ThomasEnableBehavior.swift │ │ │ ├── ThomasEnvironment.swift │ │ │ ├── ThomasEvent.swift │ │ │ ├── ThomasEventHandler.swift │ │ │ ├── ThomasFormDataCollector.swift │ │ │ ├── ThomasFormField.swift │ │ │ ├── ThomasFormFieldProcessor.swift │ │ │ ├── ThomasFormPayloadGenerator.swift │ │ │ ├── ThomasFormResult.swift │ │ │ ├── ThomasFormState.swift │ │ │ ├── ThomasFormStatus.swift │ │ │ ├── ThomasFormSubmitBehavior.swift │ │ │ ├── ThomasFormValidationMode.swift │ │ │ ├── ThomasIcon.swift │ │ │ ├── ThomasLayoutButtonTapEvent.swift │ │ │ ├── ThomasLayoutContext.swift │ │ │ ├── ThomasLayoutDisplayEvent.swift │ │ │ ├── ThomasLayoutEvent.swift │ │ │ ├── ThomasLayoutEventContext.swift │ │ │ ├── ThomasLayoutEventMessageID.swift │ │ │ ├── ThomasLayoutEventRecorder.swift │ │ │ ├── ThomasLayoutEventSource.swift │ │ │ ├── ThomasLayoutFormDisplayEvent.swift │ │ │ ├── ThomasLayoutFormResultEvent.swift │ │ │ ├── ThomasLayoutGestureEvent.swift │ │ │ ├── ThomasLayoutPageActionEvent.swift │ │ │ ├── ThomasLayoutPageSwipeEvent.swift │ │ │ ├── ThomasLayoutPageViewEvent.swift │ │ │ ├── ThomasLayoutPagerCompletedEvent.swift │ │ │ ├── ThomasLayoutPagerSummaryEvent.swift │ │ │ ├── ThomasLayoutPermissionResultEvent.swift │ │ │ ├── ThomasLayoutResolutionEvent.swift │ │ │ ├── ThomasMargin.swift │ │ │ ├── ThomasMarkdownOptions.swift │ │ │ ├── ThomasMediaFit.swift │ │ │ ├── ThomasOrientation.swift │ │ │ ├── ThomasPagerControllerBranching.swift │ │ │ ├── ThomasPagerTracker.swift │ │ │ ├── ThomasPlatform.swift │ │ │ ├── ThomasPosition.swift │ │ │ ├── ThomasPresentationInfo.swift │ │ │ ├── ThomasPropertyOverride.swift │ │ │ ├── ThomasSerializable.swift │ │ │ ├── ThomasShadow.swift │ │ │ ├── ThomasShapeInfo.swift │ │ │ ├── ThomasSize.swift │ │ │ ├── ThomasSizeConstraint.swift │ │ │ ├── ThomasSmsLocale.swift │ │ │ ├── ThomasState.swift │ │ │ ├── ThomasStateAction.swift │ │ │ ├── ThomasStateStorage.swift │ │ │ ├── ThomasStateTrigger.swift │ │ │ ├── ThomasTextAppearance.swift │ │ │ ├── ThomasToggleStyleInfo.swift │ │ │ ├── ThomasValidationInfo.swift │ │ │ ├── ThomasViewController.swift │ │ │ ├── ThomasViewInfo.swift │ │ │ ├── ThomasViewedPageInfo.swift │ │ │ ├── ThomasVisibilityInfo.swift │ │ │ ├── ThomasWindowSize.swift │ │ │ ├── ToggleLayout.swift │ │ │ ├── TouchViewModifier.swift │ │ │ ├── UAAppIntegrationDelegate.swift │ │ │ ├── UACoreData.swift │ │ │ ├── UARemoteDataMapping.swift │ │ │ ├── UNNotificationRegistrar.swift │ │ │ ├── URLAllowList.swift │ │ │ ├── UrlInfo.swift │ │ │ ├── ValidatableHelper.swift │ │ │ ├── VideoController.swift │ │ │ ├── VideoGroupState.swift │ │ │ ├── VideoMediaNativeView.swift │ │ │ ├── VideoMediaWebView.swift │ │ │ ├── VideoState.swift │ │ │ ├── ViewConstraints.swift │ │ │ ├── ViewExtensions.swift │ │ │ ├── ViewFactory.swift │ │ │ ├── VisibilityViewModifier.swift │ │ │ ├── WorkBackgroundTasks.swift │ │ │ ├── WorkConditionsMonitor.swift │ │ │ ├── WorkRateLimiterActor.swift │ │ │ ├── Worker.swift │ │ │ └── WrappingLayout.swift │ │ └── Tests/ │ │ ├── APNSEnvironmentTest.swift │ │ ├── AccountEventTemplateTest.swift │ │ ├── ActionArgumentsTest.swift │ │ ├── ActionRegistryTest.swift │ │ ├── AddCustomEventActionTest.swift │ │ ├── AddTagsActionTest.swift │ │ ├── AirshipAnalyticFeedTest.swift │ │ ├── AirshipAsyncChannelTest.swift │ │ ├── AirshipBase64Test.swift │ │ ├── AirshipBaseTest.swift │ │ ├── AirshipCacheTest.swift │ │ ├── AirshipColorTests.swift │ │ ├── AirshipConfigTest.swift │ │ ├── AirshipContactTest.swift │ │ ├── AirshipDateFormatterTest.swift │ │ ├── AirshipDeviceIDTest.swift │ │ ├── AirshipEventsTest.swift │ │ ├── AirshipHTTPResponseTest.swift │ │ ├── AirshipIvyVersionMatcherTest.swift │ │ ├── AirshipJSONTest.swift │ │ ├── AirshipJSONUtilsTest.swift │ │ ├── AirshipLocaleManagerTest.swift │ │ ├── AirshipLocalizationUtilsTest.swift │ │ ├── AirshipMeteredUsageTest.swift │ │ ├── AirshipPrivacyManagerTest.swift │ │ ├── AirshipPushTest.swift │ │ ├── AirshipTest.swift │ │ ├── AirshipURLAllowListTest.swift │ │ ├── AirshipUtilsTest.swift │ │ ├── AishipFontTests.swift │ │ ├── Analytics/ │ │ │ ├── Events/ │ │ │ │ ├── ThomasLayoutButtonTapEventTest.swift │ │ │ │ ├── ThomasLayoutDisplayEventTest.swift │ │ │ │ ├── ThomasLayoutEventTestUtils.swift │ │ │ │ ├── ThomasLayoutFormDisplayEventTest.swift │ │ │ │ ├── ThomasLayoutFormResultEventTest.swift │ │ │ │ ├── ThomasLayoutGestureEventTest.swift │ │ │ │ ├── ThomasLayoutPageActionEventTest.swift │ │ │ │ ├── ThomasLayoutPageSwipeEventAction.swift │ │ │ │ ├── ThomasLayoutPageViewEventTest.swift │ │ │ │ ├── ThomasLayoutPagerCompletedEventTest.swift │ │ │ │ ├── ThomasLayoutPagerSummaryEventTest.swift │ │ │ │ ├── ThomasLayoutPermissionResultEventTest.swift │ │ │ │ └── ThomasLayoutResolutionEventTest.swift │ │ │ ├── ThomasDisplayListenerTest.swift │ │ │ ├── ThomasLayoutEventContextTest.swift │ │ │ ├── ThomasLayoutEventMessageIDTest.swift │ │ │ └── ThomasLayoutEventRecorderTest.swift │ │ ├── AnalyticsTest.swift │ │ ├── AppIntegrationTests.swift │ │ ├── AppRemoteDataProviderDelegateTest.swift │ │ ├── AppStateTrackerTest.swift │ │ ├── AssociatedIdentifiersTest.swift │ │ ├── AttributeEditorTest.swift │ │ ├── AttributeUpdateTest.swift │ │ ├── AudienceHashSelectorTest.swift │ │ ├── AudienceUtilsTest.swift │ │ ├── CachedListTest.swift │ │ ├── CachedValueTest.swift │ │ ├── ChallengeResolverTest.swift │ │ ├── ChannelAPIClientTest.swift │ │ ├── ChannelAudienceManagerTest.swift │ │ ├── ChannelAuthTokenAPIClientTest.swift │ │ ├── ChannelAuthTokenProviderTest.swift │ │ ├── ChannelBulkUpdateAPIClientTest.swift │ │ ├── ChannelCaptureTest.swift │ │ ├── ChannelRegistrarTest.swift │ │ ├── ChannelRegistrationPayloadTest.swift │ │ ├── ChannelTest.swift │ │ ├── CircularRegionTest.swift │ │ ├── CompoundDeviceAudienceSelectorTest.swift │ │ ├── ContactAPIClientTest.swift │ │ ├── ContactChannelsProviderTest.swift │ │ ├── ContactManagerTest.swift │ │ ├── ContactOperationTest.swift │ │ ├── ContactRemoteDataProviderTest.swift │ │ ├── ContactSubscriptionListAPIClientTest.swift │ │ ├── CustomEventTest.swift │ │ ├── CustomNotificationCategories.plist │ │ ├── DeepLinkActionTest.swift │ │ ├── DeepLinkHandlerTest.swift │ │ ├── DefaultAirshipRequestSessionTest.swift │ │ ├── DefaultAppIntegrationDelegateTest.swift │ │ ├── DefaultTaskSleeperTest.swift │ │ ├── DeferredAPIClientTest.swift │ │ ├── DeferredResolverTest.swift │ │ ├── DeviceAudienceSelectorTest.swift │ │ ├── DeviceTagSelectorTest.swift │ │ ├── EnableFeatureActionTest.swift │ │ ├── Environment/ │ │ │ ├── ThomasEnvironmentTest.swift │ │ │ ├── ThomasFormDataCollectorTest.swift │ │ │ ├── ThomasFormFieldProcessorTest.swift │ │ │ ├── ThomasFormFieldTest.swift │ │ │ ├── ThomasFormPayloadGeneratorTest.swift │ │ │ ├── ThomasFormStateTest.swift │ │ │ ├── ThomasPagerTrackerTest.swift │ │ │ └── ThomasStateTest.swift │ │ ├── EventAPIClientTest.swift │ │ ├── EventManagerTest.swift │ │ ├── EventSchedulerTest.swift │ │ ├── EventStoreTest.swift │ │ ├── EventTestUtils.swift │ │ ├── ExperimentManagerTest.swift │ │ ├── ExperimentTest.swift │ │ ├── FarmHashFingerprint64Test.swift │ │ ├── FetchDeviceInfoActionTest.swift │ │ ├── HashCheckerTest.swift │ │ ├── Info.plist │ │ ├── Input Validation/ │ │ │ ├── AirshipInputValidationTest.swift │ │ │ ├── CachingSMSValidatorAPIClientTest.swift │ │ │ ├── SMSValidatorAPIClientTest.swift │ │ │ └── TestSMSValidatorAPIClient.swift │ │ ├── JSONPredicateTest.swift │ │ ├── JavaScriptCommandTest.swift │ │ ├── JsonMatcherTest.swift │ │ ├── JsonValueMatcherTest.swift │ │ ├── LayoutModelsTest.swift │ │ ├── LiveActivityRegistryTest.swift │ │ ├── MediaEventTemplateTest.swift │ │ ├── MeteredUsageApiClientTest.swift │ │ ├── ModifyAttributesActionTest.swift │ │ ├── ModifyTagsActionTest.swift │ │ ├── NativeBridgeActionHandlerTest.swift │ │ ├── NotificationCategoriesTest.swift │ │ ├── OpenExternalURLActionTest.swift │ │ ├── PagerControllerTest.swift │ │ ├── PasteboardActionTest.swift │ │ ├── PermissionsManagerTests.swift │ │ ├── PreferenceDataStoreTest.swift │ │ ├── PromptPermissionActionTest.swift │ │ ├── ProximityRegionTest.swift │ │ ├── RateAppActionTest.swift │ │ ├── RegionEventTest.swift │ │ ├── RemoteConfigManagerTest.swift │ │ ├── RemoteConfigTest.swift │ │ ├── RemoteDataAPIClientTest.swift │ │ ├── RemoteDataProviderTest.swift │ │ ├── RemoteDataStoreTest.swift │ │ ├── RemoteDataTest.swift │ │ ├── RemoteDataTestUtils.swift │ │ ├── RemoteDataURLFactoryTest.swift │ │ ├── RemoveTagsActionTest.swift │ │ ├── RetailEventTemplateTest.swift │ │ ├── RuntimeConfig.swift │ │ ├── RuntimeConfigTest.swift │ │ ├── SearchEventTemplateTest.swift │ │ ├── SessionTrackerTest.swift │ │ ├── ShareActionTest.swift │ │ ├── SubscriptionListAPIClientTest.swift │ │ ├── SubscriptionListActionTest.swift │ │ ├── Support/ │ │ │ ├── AirshipConfig-Valid-Legacy.plist │ │ │ ├── AirshipConfig-Valid.plist │ │ │ ├── TestAppStateTracker.swift │ │ │ ├── airship.der │ │ │ ├── development-embedded.mobileprovision │ │ │ ├── production-embedded.mobileprovision │ │ │ └── testMCColorsCatalog.xcassets/ │ │ │ ├── Contents.json │ │ │ └── seapunkTestColor.colorset/ │ │ │ └── Contents.json │ │ ├── TagEditorTest.swift │ │ ├── TagGroupsEditorTest.swift │ │ ├── TestAirshipInstance.swift │ │ ├── TestAirshipRequestSession.swift │ │ ├── TestAnalytics.swift │ │ ├── TestAudienceChecker.swift │ │ ├── TestCache.swift │ │ ├── TestChannel.swift │ │ ├── TestChannelAudienceManager.swift │ │ ├── TestChannelAuthTokenAPIClient.swift │ │ ├── TestChannelBulkUpdateAPIClient.swift │ │ ├── TestChannelRegistrar.swift │ │ ├── TestContact.swift │ │ ├── TestContactAPIClient.swift │ │ ├── TestContactSubscriptionListAPIClient.swift │ │ ├── TestDate.swift │ │ ├── TestDeferredResolver.swift │ │ ├── TestDispatcher.swift │ │ ├── TestExperimentDataProvider.swift │ │ ├── TestKeychainAccess.swift │ │ ├── TestLocaleManager.swift │ │ ├── TestNetworkMonitor.swift │ │ ├── TestPermissionPrompter.swift │ │ ├── TestPrivacyManager.swift │ │ ├── TestPush.swift │ │ ├── TestRemoteData.swift │ │ ├── TestRemoteDataAPIClient.swift │ │ ├── TestSubscriptionListAPIClient.swift │ │ ├── TestThomasLayoutEvent.swift │ │ ├── TestURLAllowList.swift │ │ ├── TestURLOpener.swift │ │ ├── TestWorkManager.swift │ │ ├── TestWorkRateLimiterActor.swift │ │ ├── ThomasPresentationModelCodingTest.swift │ │ ├── ThomasValidationTests.swift │ │ ├── ThomasViewModelTest.swift │ │ ├── Types/ │ │ │ ├── PagerDisableSwipeSelectorTest.swift │ │ │ └── ThomasEmailRegistrationOptionsTest.swift │ │ ├── VideoMediaWebViewTests.swift │ │ └── WorkManager/ │ │ └── WorkRateLimiterTests.swift │ ├── AirshipDebug/ │ │ ├── Info.plist │ │ ├── Resources/ │ │ │ ├── AirshipDebugEventData.xcdatamodeld/ │ │ │ │ └── AirshipEventData.xcdatamodel/ │ │ │ │ └── contents │ │ │ └── AirshipDebugPushData.xcdatamodeld/ │ │ │ └── AirshipDebugPushData.xcdatamodel/ │ │ │ └── contents │ │ └── Source/ │ │ ├── AirshipDebugManager.swift │ │ ├── AirshipDebugResources.swift │ │ ├── DebugComponent.swift │ │ ├── DebugSDKModule.swift │ │ ├── Events/ │ │ │ ├── AirshipEvent.swift │ │ │ ├── EventData.swift │ │ │ └── EventDataManager.swift │ │ ├── Push/ │ │ │ ├── PushData+CoreDataClass.swift │ │ │ ├── PushData+CoreDataProperties.swift │ │ │ ├── PushDataManager.swift │ │ │ └── PushNotification.swift │ │ ├── ShakeUtils.swift │ │ └── View/ │ │ ├── AirshipDebugContentView.swift │ │ ├── AirshipDebugRoute.swift │ │ ├── AirshipDebugView.swift │ │ ├── AirshipoDebugTriggers.swift │ │ ├── Analytics/ │ │ │ ├── AirshipDebugAddEventView.swift │ │ │ ├── AirshipDebugAnalyticIdentifierEditorView.swift │ │ │ ├── AirshipDebugAnalyticsView.swift │ │ │ ├── AirshipDebugEventDetailsView.swift │ │ │ └── AirshipDebugEventsView.swift │ │ ├── AppInfo/ │ │ │ └── AirshipDebugAppInfoView.swift │ │ ├── Automations/ │ │ │ ├── AirshipDebugAutomationsView.swift │ │ │ ├── AirshipDebugExperimentsView.swift │ │ │ └── AirshipDebugInAppExperiencesView.swift │ │ ├── Channel/ │ │ │ ├── AirshipDebugChannelSubscriptionsView.swift │ │ │ ├── AirshipDebugChannelTagView.swift │ │ │ └── AirshipDebugChannelView.swift │ │ ├── Common/ │ │ │ ├── AirshipDebugAddPropertyView.swift │ │ │ ├── AirshipDebugAddStringPropertyView.swift │ │ │ ├── AirshipDebugAttributesEditorView.swift │ │ │ ├── AirshipDebugAudienceSubject.swift │ │ │ ├── AirshipDebugExtensions.swift │ │ │ ├── AirshipDebugTagGroupsEditorView.swift │ │ │ ├── AirshipJSONDetailsView.swift │ │ │ ├── AirshipJSONView.swift │ │ │ ├── AirshipToast.swift │ │ │ └── Extensions.swift │ │ ├── Contact/ │ │ │ ├── AirshipDebugAddEmailChannelView.swift │ │ │ ├── AirshipDebugAddOpenChannelView.swift │ │ │ ├── AirshipDebugAddSMSChannelView.swift │ │ │ ├── AirshipDebugContactSubscriptionEditorView.swift │ │ │ ├── AirshipDebugContactView.swift │ │ │ └── AirshipDebugNamedUserView.swift │ │ ├── FeatureFlags/ │ │ │ ├── AirshipDebugFeatureFlagDetailsView.swift │ │ │ └── AirshipDebugFeatureFlagView.swift │ │ ├── PreferenceCenter/ │ │ │ ├── AirshipDebugPreferencCenterItemView.swift │ │ │ └── AirshipDebugPreferenceCenterView.swift │ │ ├── PrivacyManager/ │ │ │ └── AirshipDebugPrivacyManagerView.swift │ │ ├── Push/ │ │ │ ├── AirshipDebugPushDetailsView.swift │ │ │ ├── AirshipDebugPushView.swift │ │ │ └── AirshipDebugReceivedPushView.swift │ │ └── TvOSComponents/ │ │ ├── TVDatePicker.swift │ │ └── TVSlider.swift │ ├── AirshipFeatureFlags/ │ │ ├── Info.plist │ │ ├── Source/ │ │ │ ├── AirshipFeatureFlagsSDKModule.swift │ │ │ ├── DeferredFlagResolver.swift │ │ │ ├── FeatureFlag.swift │ │ │ ├── FeatureFlagAnalytics.swift │ │ │ ├── FeatureFlagComponent.swift │ │ │ ├── FeatureFlagManager.swift │ │ │ ├── FeatureFlagManagerProtocol.swift │ │ │ ├── FeatureFlagPayload.swift │ │ │ ├── FeatureFlagResultCache.swift │ │ │ ├── FeatureFlagUpdateStatus.swift │ │ │ └── FeatureFlagsRemoteDataAccess.swift │ │ └── Tests/ │ │ ├── FeatureFlagAnalyticsTest.swift │ │ ├── FeatureFlagDeferredResolverTest.swift │ │ ├── FeatureFlagInfoTest.swift │ │ ├── FeatureFlagManagerTest.swift │ │ ├── FeatureFlagRemoteDataAccessTest.swift │ │ ├── FeatureFlagResultCacheTest.swift │ │ └── FeatureFlagVariablesTest.swift │ ├── AirshipMessageCenter/ │ │ ├── Info.plist │ │ ├── Resources/ │ │ │ ├── TestAssets.xcassets/ │ │ │ │ ├── Contents.json │ │ │ │ └── testNamedColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── UAInbox.xcdatamodeld/ │ │ │ │ ├── .xccurrentversion │ │ │ │ ├── UAInbox 2.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAInbox 3.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ ├── UAInbox 4.xcdatamodel/ │ │ │ │ │ └── contents │ │ │ │ └── UAInbox.xcdatamodel/ │ │ │ │ └── contents │ │ │ ├── UAInboxDataMappingV1toV4.xcmappingmodel/ │ │ │ │ └── xcmapping.xml │ │ │ ├── UAInboxDataMappingV2toV4.xcmappingmodel/ │ │ │ │ └── xcmapping.xml │ │ │ └── UAInboxDataMappingV3toV4.xcmappingmodel/ │ │ │ └── xcmapping.xml │ │ ├── Source/ │ │ │ ├── AirshipMessageCenterResources.swift │ │ │ ├── MessageCenter.swift │ │ │ ├── MessageCenterAPIClient.swift │ │ │ ├── MessageCenterAction.swift │ │ │ ├── MessageCenterComponent.swift │ │ │ ├── MessageCenterList.swift │ │ │ ├── MessageCenterNativeBridgeExtension.swift │ │ │ ├── MessageCenterPredicate.swift │ │ │ ├── MessageCenterSDKModule.swift │ │ │ ├── MessageCenterStore.swift │ │ │ ├── MessageViewAnalytics.swift │ │ │ ├── Model/ │ │ │ │ ├── InboxMessageData.swift │ │ │ │ ├── MessageCenterMessage.swift │ │ │ │ ├── MessageCenterUser.swift │ │ │ │ └── UAInboxDataMapping.swift │ │ │ ├── StateStore/ │ │ │ │ └── NativeLayoutPersistentDataStore.swift │ │ │ ├── Theme/ │ │ │ │ ├── MessageCenterNavigationAppearance.swift │ │ │ │ ├── MessageCenterTheme.swift │ │ │ │ └── MessageCenterThemeLoader.swift │ │ │ ├── ViewModel/ │ │ │ │ └── MessageCenterListItemViewModel.swift │ │ │ └── Views/ │ │ │ ├── MessageCenter/ │ │ │ │ ├── MessageCenterContent.swift │ │ │ │ ├── MessageCenterController.swift │ │ │ │ ├── MessageCenterNavigationStack.swift │ │ │ │ ├── MessageCenterSplitNavigationView.swift │ │ │ │ ├── MessageCenterUIKitAppearance.swift │ │ │ │ └── MessageCenterView.swift │ │ │ ├── MessageCenterViewController.swift │ │ │ ├── MessageList/ │ │ │ │ ├── MessageCenterListItemView.swift │ │ │ │ ├── MessageCenterListView.swift │ │ │ │ ├── MessageCenterListViewModel.swift │ │ │ │ └── MessageCenterListViewWithNavigation.swift │ │ │ ├── MessageView/ │ │ │ │ ├── MessageCenterMessageError.swift │ │ │ │ ├── MessageCenterMessageView.swift │ │ │ │ ├── MessageCenterMessageViewModel.swift │ │ │ │ ├── MessageCenterMessageViewWithNavigation.swift │ │ │ │ ├── MessageCenterThomasView.swift │ │ │ │ └── MessageCenterWebView.swift │ │ │ └── Shared/ │ │ │ └── MessageCenterBackButton.swift │ │ └── Tests/ │ │ ├── MessageCenterAPIClientTest.swift │ │ ├── MessageCenterListTests.swift │ │ ├── MessageCenterMessageTest.swift │ │ ├── MessageCenterStoreTest.swift │ │ ├── MessageCenterThemeLoaderTest.swift │ │ └── MessageViewAnalyticsTest.swift │ ├── AirshipObjectiveC/ │ │ └── Source/ │ │ ├── Analytics/ │ │ │ ├── Events/ │ │ │ │ ├── Templates/ │ │ │ │ │ ├── UAAccountEventTemplate.swift │ │ │ │ │ ├── UAMediaEventTemplate.swift │ │ │ │ │ ├── UARetailEventTemplate.swift │ │ │ │ │ └── UASearchEventTemplate.swift │ │ │ │ └── UACustomEvent.swift │ │ │ ├── UAAnalytics.swift │ │ │ └── UAAssociatedIdentifiers.swift │ │ ├── Automation/ │ │ │ ├── UAInAppAutomation.swift │ │ │ └── UAInAppMessaging.swift │ │ ├── Channel/ │ │ │ ├── UAAttributesEditor.swift │ │ │ ├── UAChannel.swift │ │ │ ├── UATagEditor.swift │ │ │ └── UATagGroupsEditor.swift │ │ ├── Contact/ │ │ │ └── UAContact.swift │ │ ├── Embedded/ │ │ │ └── UAEmbeddedViewController.swift │ │ ├── MessageCenter/ │ │ │ ├── UAMessageCenter.swift │ │ │ ├── UAMessageCenterList.swift │ │ │ ├── UAMessageCenterMessage.swift │ │ │ ├── UAMessageCenterNativeBridge.swift │ │ │ ├── UAMessageCenterTheme.swift │ │ │ ├── UAMessageCenterUser.swift │ │ │ └── UAMessageCenterViewController.swift │ │ ├── PreferenceCenter/ │ │ │ ├── UAPreferenceCenter.swift │ │ │ └── UAPreferenceCenterViewController.swift │ │ ├── PrivacyManager/ │ │ │ └── UAPrivacyManager.swift │ │ ├── Push/ │ │ │ ├── UAAuthorizedNotificationSettings.swift │ │ │ ├── UANotificationCategories.swift │ │ │ ├── UAPush.swift │ │ │ ├── UAPushNotificationDelegate.swift │ │ │ ├── UAPushNotificationStatus.swift │ │ │ └── UARegistrationDelegate.swift │ │ ├── Subscription Lists/ │ │ │ ├── UAScopedSubscriptionListEditor.swift │ │ │ └── UASubscriptionListEditor.swift │ │ ├── UAAppIntegration.swift │ │ ├── UAConfig.swift │ │ ├── UADeepLinkDelegate.swift │ │ ├── UAFeature.swift │ │ ├── UAPermission.swift │ │ ├── UAPermissionsManager.swift │ │ └── UAirship.swift │ └── AirshipPreferenceCenter/ │ ├── Info.plist │ ├── Source/ │ │ ├── AirshipPreferenceCenterResources.swift │ │ ├── PreferenceCenter.swift │ │ ├── PreferenceCenterComponent.swift │ │ ├── PreferenceCenterSDKModule.swift │ │ ├── data/ │ │ │ ├── PreferenceCenterConfig+ContactManagement.swift │ │ │ ├── PreferenceCenterConfig.swift │ │ │ ├── PreferenceCenterDecoder.swift │ │ │ └── PreferenceCenterResponse.swift │ │ ├── theme/ │ │ │ ├── PreferenceCenterTheme.swift │ │ │ └── PreferenceCenterThemeLoader.swift │ │ └── view/ │ │ ├── ChannelSubscriptionView.swift │ │ ├── CommonSectionView.swift │ │ ├── ConditionsMonitor.swift │ │ ├── ConditionsViewModifier.swift │ │ ├── Contact management/ │ │ │ ├── AddChannelPromptView.swift │ │ │ ├── AddChannelPromptViewModel.swift │ │ │ ├── ChannelListView.swift │ │ │ ├── ChannelListViewCell.swift │ │ │ ├── Component Views/ │ │ │ │ ├── BackgroundShape.swift │ │ │ │ ├── ChannelTextField.swift │ │ │ │ ├── EmptySectionLabel.swift │ │ │ │ ├── ErrorLabel.swift │ │ │ │ └── PreferenceCloseButton.swift │ │ │ ├── ContactManagementView.swift │ │ │ └── FooterView.swift │ │ ├── ContactSubscriptionGroupView.swift │ │ ├── ContactSubscriptionView.swift │ │ ├── LabeledSectionBreakView.swift │ │ ├── PreferenceCenterAlertView.swift │ │ ├── PreferenceCenterContent.swift │ │ ├── PreferenceCenterContentLoader.swift │ │ ├── PreferenceCenterContentStyle.swift │ │ ├── PreferenceCenterState.swift │ │ ├── PreferenceCenterUtils.swift │ │ ├── PreferenceCenterView.swift │ │ ├── PreferenceCenterViewControllerFactory.swift │ │ └── PreferenceCenterViewExtensions.swift │ └── Tests/ │ ├── Info.plist │ ├── PreferenceCenterTest.swift │ ├── data/ │ │ └── PreferenceCenterConfigTest.swift │ ├── test data/ │ │ ├── TestLegacyTheme.plist │ │ ├── TestTheme.plist │ │ ├── TestThemeEmpty.plist │ │ └── TestThemeInvalid.plist │ ├── theme/ │ │ └── PreferenceThemeLoaderTest.swift │ └── view/ │ └── PreferenceCenterStateTest.swift ├── Airship.podspec ├── Airship.xcworkspace/ │ ├── contents.xcworkspacedata │ └── xcshareddata/ │ ├── IDEWorkspaceChecks.plist │ ├── WorkspaceSettings.xcsettings │ └── swiftpm/ │ └── Package.resolved ├── AirshipExtensions/ │ ├── AirshipExtensions.xcodeproj/ │ │ ├── project.pbxproj │ │ └── xcshareddata/ │ │ └── xcschemes/ │ │ └── AirshipNotificationServiceExtension.xcscheme │ └── AirshipNotificationServiceExtension/ │ ├── Info.plist │ ├── Source/ │ │ ├── AirshipExtensionConfig.swift │ │ ├── AirshipExtensionLogger.swift │ │ ├── AirshipNotificationMutationProvider.swift │ │ ├── ChallengeResolver.swift │ │ ├── MediaAttachmentPayload.swift │ │ └── UANotificationServiceExtension.swift │ └── Tests/ │ ├── Info.plist │ ├── MediaAttachmentPayloadTest.swift │ └── UANotificationServiceExtensionTests.swift ├── AirshipServiceExtension.podspec ├── CHANGELOG.md ├── DevApp/ │ ├── AirshipConfig.plist.sample │ ├── Dev App/ │ │ ├── AirshipConfig.plist.sample │ │ ├── AppRouter.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ └── HomeHeroImage.imageset/ │ │ │ └── Contents.json │ │ ├── DevApp.entitlements │ │ ├── MainApp.swift │ │ ├── Setup/ │ │ │ ├── AirshipInitializer.swift │ │ │ ├── DeepLinkHandler.swift │ │ │ ├── LiveActivityHandler.swift │ │ │ └── PushNotificationHandler.swift │ │ ├── Thomas/ │ │ │ ├── CustomView/ │ │ │ │ └── Examples/ │ │ │ │ ├── AdView.swift │ │ │ │ ├── BiometricLoginView.swift │ │ │ │ ├── CameraView.swift │ │ │ │ ├── MapRouteView.swift │ │ │ │ └── Weather/ │ │ │ │ ├── WeatherView.swift │ │ │ │ └── WeatherViewModel.swift │ │ │ ├── Embedded Playground View/ │ │ │ │ ├── EmbeddedPlaygroundMenuView.swift │ │ │ │ ├── EmbeddedPlaygroundPicker.swift │ │ │ │ ├── EmbeddedPlaygroundView.swift │ │ │ │ ├── KeyView.swift │ │ │ │ └── PlaceholderToggleView.swift │ │ │ ├── JurassicPark.otf │ │ │ ├── Layouts.swift │ │ │ ├── LayoutsList.swift │ │ │ ├── Resources/ │ │ │ │ ├── Messages/ │ │ │ │ │ ├── Banner/ │ │ │ │ │ │ ├── media-left-jurassic-park-bottom.json │ │ │ │ │ │ ├── media-left.json │ │ │ │ │ │ ├── media-right-jurassic-park-bottom.json │ │ │ │ │ │ ├── media-right-jurassic-park-text-alignment-1.json │ │ │ │ │ │ ├── media-right-jurassic-park-text-alignment-2.json │ │ │ │ │ │ ├── media-right-jurassic-park-top.json │ │ │ │ │ │ ├── media-right.json │ │ │ │ │ │ └── small-banner.json │ │ │ │ │ ├── Fullscreen/ │ │ │ │ │ │ ├── header-body-media-joined.json │ │ │ │ │ │ ├── header-body-separate.json │ │ │ │ │ │ └── media-header-body-stacked.json │ │ │ │ │ ├── HTML/ │ │ │ │ │ │ ├── fullscreen-airship.json │ │ │ │ │ │ ├── sized-airship.json │ │ │ │ │ │ ├── sized-too-tall-airship.json │ │ │ │ │ │ └── sized-too-wide-airship.json │ │ │ │ │ └── Modal/ │ │ │ │ │ ├── a11y-test.json │ │ │ │ │ ├── accessibility-modal.json │ │ │ │ │ ├── header-body-media-joined.json │ │ │ │ │ ├── header-body-media-stacked.json │ │ │ │ │ ├── header-media-body-joined.json │ │ │ │ │ ├── header-media-body-stacked.json │ │ │ │ │ ├── media-header-body-joined.json │ │ │ │ │ ├── media-header-body-separate-tall-image.json │ │ │ │ │ ├── media-header-body-separate-wide-image.json │ │ │ │ │ ├── media-header-body-separate.json │ │ │ │ │ ├── media-header-body-stacked.json │ │ │ │ │ ├── swipe-gesture-test.json │ │ │ │ │ └── youtube-video-modal.json │ │ │ │ ├── Scenes/ │ │ │ │ │ ├── Banner/ │ │ │ │ │ │ ├── banner-bottom.yml │ │ │ │ │ │ ├── banner-safe-area-bottom.yml │ │ │ │ │ │ ├── banner-safe-area-top.yml │ │ │ │ │ │ ├── banner-top-old.yml │ │ │ │ │ │ └── banner-top.yml │ │ │ │ │ ├── Embedded/ │ │ │ │ │ │ ├── 100pct x 100pct.yml │ │ │ │ │ │ ├── 100pct x auto.yml │ │ │ │ │ │ ├── 50pct x 50pct.yml │ │ │ │ │ │ ├── 75pct x 200pt.yml │ │ │ │ │ │ ├── auto x 200.yml │ │ │ │ │ │ ├── gif_and_vid.yml │ │ │ │ │ │ ├── home_image.yml │ │ │ │ │ │ ├── home_rating.yml │ │ │ │ │ │ └── home_special_offer.yml │ │ │ │ │ └── Modal/ │ │ │ │ │ ├── 1-pager-different-path.yml │ │ │ │ │ ├── 1-pager-pranching-test.yml │ │ │ │ │ ├── 1qa.yml │ │ │ │ │ ├── MOBILE_4621.yml │ │ │ │ │ ├── _a11y_focus.json │ │ │ │ │ ├── _vimeo.ml │ │ │ │ │ ├── a-gif-and-youtube.yml │ │ │ │ │ ├── a-landscape-video.yml │ │ │ │ │ ├── aaa-modal-bg-image.yaml │ │ │ │ │ ├── aaa-modal.json │ │ │ │ │ ├── accessibility-action-story.yml │ │ │ │ │ ├── accessibility-actions-modal-pager-fullsize.yml │ │ │ │ │ ├── accessibility-test-modal.json │ │ │ │ │ ├── airship-quiz-1.yml │ │ │ │ │ ├── async_view.yaml │ │ │ │ │ ├── auto_height_modal.yml │ │ │ │ │ ├── button_layout.yml │ │ │ │ │ ├── container.yml │ │ │ │ │ ├── containerception.yml │ │ │ │ │ ├── custom-fonts.yml │ │ │ │ │ ├── form-input-branching-child-form.yaml │ │ │ │ │ ├── form-input-branching-on-demand.yml │ │ │ │ │ ├── form_immediate_phone.yaml │ │ │ │ │ ├── form_immediate_validation_email.yaml │ │ │ │ │ ├── form_on_demand_validation_email.yaml │ │ │ │ │ ├── form_single_page_on_demand.yml │ │ │ │ │ ├── forms.yml │ │ │ │ │ ├── image-pager-test.yml │ │ │ │ │ ├── image_cropping.yml │ │ │ │ │ ├── image_resizing.yml │ │ │ │ │ ├── image_scaling.yml │ │ │ │ │ ├── is-accessibility-alert-form.yml │ │ │ │ │ ├── linear_layout_scroll.yml │ │ │ │ │ ├── markdown.yml │ │ │ │ │ ├── martin.json │ │ │ │ │ ├── mobile-4599.yml │ │ │ │ │ ├── mobile-5409-2.json │ │ │ │ │ ├── mobile-5409.json │ │ │ │ │ ├── mobile-5553-shape-corners.yml │ │ │ │ │ ├── modal-buttons.yml │ │ │ │ │ ├── modal-checkboxes-radios.yml │ │ │ │ │ ├── modal-constrained.yml │ │ │ │ │ ├── modal-custom-biometric-login.yml │ │ │ │ │ ├── modal-custom-interstitial-banner-ad.yaml │ │ │ │ │ ├── modal-custom-weather-and-map.yaml │ │ │ │ │ ├── modal-day-night-colors.yml │ │ │ │ │ ├── modal-disable-back-button.yml │ │ │ │ │ ├── modal-labels.yml │ │ │ │ │ ├── modal-linear-layout-horizontal.yml │ │ │ │ │ ├── modal-linear-layout-vertical.yml │ │ │ │ │ ├── modal-linear-layout-webview-emoji.yml │ │ │ │ │ ├── modal-linear-layout.yml │ │ │ │ │ ├── modal-media-header-body-stacked-buttons.yml │ │ │ │ │ ├── modal-pager-fullsize.yml │ │ │ │ │ ├── modal-pager-with-title-and-button.yml │ │ │ │ │ ├── modal-placement-selectors.yml │ │ │ │ │ ├── modal-responsive.yml │ │ │ │ │ ├── modal-score.yml │ │ │ │ │ ├── modal-transparancy.yml │ │ │ │ │ ├── modal-video-cropping.yml │ │ │ │ │ ├── modal-webview-full-size.yml │ │ │ │ │ ├── modal-webview-with-buttons.yml │ │ │ │ │ ├── model-custom-camera-view.yml │ │ │ │ │ ├── multi-video-players.yml │ │ │ │ │ ├── nps.yml │ │ │ │ │ ├── pager-behaviors.yml │ │ │ │ │ ├── portrait-video.yml │ │ │ │ │ ├── qa-advseg-2998.yml │ │ │ │ │ ├── qa-advseg-2999.yml │ │ │ │ │ ├── safe-areas-linear-layout.yml │ │ │ │ │ ├── safe-areas-pager.yml │ │ │ │ │ ├── scene-scroll.yml │ │ │ │ │ ├── story-one-screen.yml │ │ │ │ │ ├── story-video-and-gif.yml │ │ │ │ │ ├── story.yml │ │ │ │ │ ├── tap-handler-visibility.yml │ │ │ │ │ ├── textInput │ │ │ │ │ ├── toggle-branching-simple-quiz.yml │ │ │ │ │ ├── toggle-layout-types.yml │ │ │ │ │ ├── toggle-rating-numbers.yaml │ │ │ │ │ ├── toggle-rating-stars-small.yaml │ │ │ │ │ ├── toggleLayout.yml │ │ │ │ │ ├── tour-example.yml │ │ │ │ │ ├── tour-no-safe-areas.yml │ │ │ │ │ ├── tour-safe-areas.yml │ │ │ │ │ ├── unsafe_areas.yml │ │ │ │ │ ├── video-controls.yml │ │ │ │ │ ├── video-qa.yaml │ │ │ │ │ ├── video-story-multipage.yml │ │ │ │ │ ├── video-story.yml │ │ │ │ │ ├── video-test.yml │ │ │ │ │ ├── video-youtube.yml │ │ │ │ │ ├── video_cropping.yml │ │ │ │ │ ├── view_overrides.yml │ │ │ │ │ ├── wide-image-pager-test.yml │ │ │ │ │ ├── wrapping-modal-score.yml │ │ │ │ │ ├── wrapping-multi-page.yml │ │ │ │ │ ├── wrapping-nps-test-2.yml │ │ │ │ │ ├── wrapping-nps-test-3.yml │ │ │ │ │ ├── wrapping-nps-test-4.yml │ │ │ │ │ ├── wrapping-nps-test-5.yml │ │ │ │ │ └── wrapping-nps-test.yml │ │ │ │ └── SharedAssets.xcassets/ │ │ │ │ ├── 23GrandeAccentColor.colorset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── 23GrandeAllFashion.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── 23GrandeAppIcon.appiconset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── 23GrandeArrival1.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── 23GrandeArrival2.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ ├── 23GrandeHomeBanner.imageset/ │ │ │ │ │ └── Contents.json │ │ │ │ └── Contents.json │ │ │ ├── ThomasLayoutListView.swift │ │ │ └── ThomasLayoutViewModel.swift │ │ ├── Toast.swift │ │ └── View/ │ │ ├── AppView.swift │ │ ├── HomeView.swift │ │ ├── NamedUserView.swift │ │ └── ToastView.swift │ ├── Dev-App-Info.plist │ ├── DevApp App Clip/ │ │ ├── Airship_Sample_App_Clip.entitlements │ │ └── Info.plist │ ├── DevApp Service Extension/ │ │ ├── Info.plist │ │ └── NotificationService.swift │ ├── DevApp.xcodeproj/ │ │ ├── project.pbxproj │ │ └── project.xcworkspace/ │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata/ │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm/ │ │ └── Package.resolved │ ├── LiveActivity/ │ │ ├── Assets.xcassets/ │ │ │ ├── 23Grande.imageset/ │ │ │ │ └── Contents.json │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ ├── Contents.json │ │ │ └── WidgetBackground.colorset/ │ │ │ └── Contents.json │ │ ├── DeliveryActivityWidget.swift │ │ ├── DeliveryAttributes.swift │ │ ├── Info.plist │ │ ├── LiveActivity.intentdefinition │ │ └── Widgets.swift │ └── Sample Plist/ │ └── AirshipConfig.plist.sample ├── DevApp watchOS/ │ ├── DevApp watchOS/ │ │ ├── AppDelegate.swift │ │ ├── Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── HomeView.swift │ │ ├── MainApp.swift │ │ ├── NamedUserView.swift │ │ ├── Preview Content/ │ │ │ └── Preview Assets.xcassets/ │ │ │ ├── AccentColor.colorset/ │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset/ │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── PushNotificationPayload.apns │ └── DevApp watchOS.xcodeproj/ │ ├── project.pbxproj │ └── project.xcworkspace/ │ └── contents.xcworkspacedata ├── Documentation/ │ ├── .jazzy.json │ ├── Migration/ │ │ ├── README.md │ │ ├── migration-guide-16-17.md │ │ ├── migration-guide-17-18.md │ │ ├── migration-guide-18-19.md │ │ └── migration-guide-19-20.md │ ├── abstracts/ │ │ └── Guides.md │ └── readme-for-jazzy.md ├── Gemfile ├── LICENSE ├── Makefile ├── Package.swift ├── README.md └── scripts/ ├── airship_version.sh ├── build_docCs.sh ├── build_sample.sh ├── build_sample_watchos.sh ├── build_xcframeworks.sh ├── check_size.sh ├── check_version.sh ├── check_xcbeautify.sh ├── gemini-review-prompt.md ├── get_xcode_path.sh ├── package.sh ├── package_xcframeworks.sh ├── pr-review.mjs ├── run_xcodebuild.sh └── update_version.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ # https://help.github.com/en/articles/about-code-owners * @urbanairship/mobile ================================================ FILE: .github/CONTRIBUTING.md ================================================ ## Contributing Code We accept pull requests! If you would like to submit a pull request, please fill out and submit our [Contributor License Agreement](https://docs.google.com/forms/d/e/1FAIpQLScErfiz-fXSPpVZ9r8Di2Tr2xDFxt5MgzUel0__9vqUgvko7Q/viewform). One of our engineers will verify receipt of the agreement before approving your pull request. ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ ❗For how-to inquiries involving Airship functionality or use cases, please contact (support)[https://support.airship.com/]. # Preliminary Info ### What Airship dependencies are you using? ### What are the versions of any relevant development tools you are using? # Report ### What unexpected behavior are you seeing? ### What is the expected behavior? ### What are the steps to reproduce the unexpected behavior? ### Do you have logging for the issue? ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ### What do these changes do? ### Why are these changes necessary? ### How did you verify these changes? #### Verification Screenshots: ### Anything else a reviewer should know? ================================================ FILE: .github/workflows/check-cert.yml ================================================ name: Check Certificate Expiration on: workflow_dispatch: jobs: check-cert: runs-on: macos-latest steps: - name: Check Certificate Details env: CERT_BASE64: ${{ secrets.CERTIFICATEXC }} CERT_PASS: ${{ secrets.CERTIFICATEXC_PASS }} run: | # Decode the certificate from base64 echo "$CERT_BASE64" | base64 --decode > cert.p12 # Extract certificate info without importing to keychain # This will show expiration date and other details openssl pkcs12 -in cert.p12 -passin pass:"$CERT_PASS" -nokeys -clcerts | openssl x509 -noout -subject -issuer -dates -fingerprint # Also show the certificate name as it appears for codesigning openssl pkcs12 -in cert.p12 -passin pass:"$CERT_PASS" -nokeys -clcerts | openssl x509 -noout -subject | sed 's/subject=//' # Clean up rm cert.p12 ================================================ FILE: .github/workflows/check_framework_size.yml ================================================ name: Check Framework Size on: workflow_dispatch: env: BUNDLE_PATH: vendor/bundle jobs: framework-size: runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Restore Size uses: actions/cache@v4 with: path: build/previous-size.txt key: size-cache-${{ github.head_ref }} restore-keys: | size-cache- - name: Install Apple Certificate uses: apple-actions/import-codesign-certs@v3 with: p12-file-base64: ${{ secrets.CERTIFICATEXC }} p12-password: ${{ secrets.CERTIFICATEXC_PASS }} - name: Framework size run: make compare-framework-size - name: Save Size uses: actions/cache@v4 with: path: build/previous-size.txt key: size-cache-${{ github.head_ref }}-${{ github.run_id }} ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI Pull Request on: [pull_request] env: BUNDLE_PATH: vendor/bundle concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: run-tests-core: runs-on: macos-15-xlarge timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Test run: make test-core run-tests-message-center: runs-on: macos-15-xlarge timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Test run: make test-message-center run-tests-preference-center: runs-on: macos-15-xlarge timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Test run: make test-preference-center run-tests-feature-flags: runs-on: macos-15-xlarge timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Test run: make test-feature-flags run-tests-automation: runs-on: macos-15-xlarge timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Test run: make test-automation run-tests-extensions: runs-on: macos-15-xlarge timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Test run: make test-service-extension build-airship-objectiveC: runs-on: macos-15-xlarge timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Build AirshipObjectiveC target run: make build-airship-objectiveC build-samples: runs-on: macos-15-xlarge timeout-minutes: 15 steps: - uses: actions/checkout@v4 - name: Build samples run: make build-samples # run-tests-watchos: # runs-on: macos-15-xlarge # steps: # - uses: actions/checkout@v4 # - uses: ruby/setup-ruby@v1 # with: # bundler-cache: true # - name: Install xcodegen # run: brew install xcodegen # - name: Install Apple Certificate # uses: apple-actions/import-codesign-certs@v1 # with: # p12-file-base64: ${{ secrets.CERTIFICATE_P12_BASE64 }} # p12-password: ${{ secrets.CERTIFICATE_P12_PASSWORD }} # - name: Install the provisioning profile # env: # PROVISIONING_APP_BASE64: ${{ secrets.PROVISIONING_PROFILE_APP_BASE64 }} # PROVISIONING_EXT_BASE64: ${{ secrets.PROVISIONING_PROFILE_EXT_BASE64 }} # run: | # PP_APP_PATH=$RUNNER_TEMP/wkapp_prof.mobileprovision # PP_EXT_PATH=$RUNNER_TEMP/wkext_prof.mobileprovision # echo -n "$PROVISIONING_APP_BASE64" | base64 --decode > $PP_APP_PATH # echo -n "$PROVISIONING_EXT_BASE64" | base64 --decode > $PP_EXT_PATH # mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles # cp $PP_APP_PATH ~/Library/MobileDevice/Provisioning\ Profiles # cp $PP_EXT_PATH ~/Library/MobileDevice/Provisioning\ Profiles # - name: Test # run: make build-sample-watchos pod-lib-lint-watchos: runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Pod lint run: make pod-lint-watchos pod-lib-lint-tvos: runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Pod lint run: make pod-lint-tvos pod-lib-lint-ios: runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Pod lint run: make pod-lint-ios pod-lib-lint-extensions: runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Pod lint run: make pod-lint-extensions ================================================ FILE: .github/workflows/deploy_docC.yml ================================================ name: Deploy docC to Pages on: push: tags: - "[0-9]+.[0-9]+.[0-9]+**" # Allows you to run this workflow manually from the Actions tab workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: write pages: write id-token: write # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: "pages" cancel-in-progress: false jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Extract version id: extract_version run: | set -euo pipefail VERSION="${GITHUB_REF#refs/tags/}" MAJOR_VERSION=$(echo "$VERSION" | cut -d. -f1) echo "VERSION=$VERSION" >> $GITHUB_OUTPUT echo "VERSION_DIR=v$MAJOR_VERSION" >> $GITHUB_OUTPUT echo "VERSION_DIR=v$MAJOR_VERSION" >> $GITHUB_ENV - name: Check Version run: | set -euo pipefail bash ./scripts/check_version.sh "${{ steps.extract_version.outputs.VERSION }}" - name: Build DocC env: VERSION_DIR: ${{ steps.extract_version.outputs.VERSION_DIR }} run: | set -euo pipefail make build-docC version="$VERSION_DIR" - name: Deploy to GitHub Pages env: VERSION_DIR: ${{ steps.extract_version.outputs.VERSION_DIR }} uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_branch: gh-pages publish_dir: ./docs destination_dir: $VERSION_DIR keep_files: true enable_jekyll: false ================================================ FILE: .github/workflows/merge.yml ================================================ name: CI Merge on: push: branches: - main - next env: BUNDLE_PATH: vendor/bundle concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: build-sdk: runs-on: macos-15-xlarge steps: - name: Install Apple Certificate uses: apple-actions/import-codesign-certs@v3 with: p12-file-base64: ${{ secrets.CERTIFICATEXC }} p12-password: ${{ secrets.CERTIFICATEXC_PASS }} - uses: actions/checkout@v4 - name: Build SDK run: make build-xcframeworks-no-sign finished: runs-on: ubuntu-latest needs: [build-sdk] steps: - name: Slack Notification uses: lazy-actions/slatify@master if: ${{ failure() }} with: type: ${{ job.status }} job_name: "Merge things busted!" url: ${{ secrets.SLACK_WEBHOOK }} ================================================ FILE: .github/workflows/pr-review.yml ================================================ name: Gemini PR Review on: pull_request: types: [opened, edited, synchronize] concurrency: pr-${{ github.event.pull_request.number }} jobs: review: runs-on: ubuntu-latest permissions: pull-requests: write contents: read steps: - name: ⬇️ Checkout uses: actions/checkout@v4 - name: 🪄 Save PR diff env: GH_TOKEN: ${{ secrets.MOBILE_REVIEW_PAT }} run: gh pr diff ${{ github.event.pull_request.number }} > diff.patch - name: 🛠️ Set up Node 20 uses: actions/setup-node@v4 with: node-version: 20 - name: 🤖 Run Gemini review script continue-on-error: true env: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} MODEL_ID: gemini-2.5-pro-preview-05-06 GITHUB_TOKEN: ${{ secrets.MOBILE_REVIEW_PAT }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} DEBUG: true run: node scripts/pr-review.mjs ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - "[0-9]+.[0-9]+.[0-9]+**" env: BUNDLE_PATH: vendor/bundle jobs: check-version: if: github.repository == 'urbanairship/ios-library' runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Get the version id: get_version run: | set -euo pipefail VERSION="${GITHUB_REF#refs/tags/}" echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Check Version run: | set -euo pipefail bash ./scripts/check_version.sh "${{ steps.get_version.outputs.VERSION }}" - name: Slack Notification uses: lazy-actions/slatify@master with: type: ${{ job.status }} job_name: "iOS SDK Release Started :apple_og:" url: ${{ secrets.SLACK_WEBHOOK }} build-package: needs: check-version runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Install Coreutils run: brew install coreutils - name: Install Apple Certificate uses: apple-actions/import-codesign-certs@v3 with: p12-file-base64: ${{ secrets.CERTIFICATEXC }} p12-password: ${{ secrets.CERTIFICATEXC_PASS }} - name: Build SDK run: make build-package - name: Upload zip distribution uses: actions/upload-artifact@v4 with: name: airship path: ./build/Airship.zip - name: Upload zip distribution (Carthage) uses: actions/upload-artifact@v4 with: name: airship-carthage path: ./build/Airship.xcframeworks.zip - name: Upload .NET xcframeworks uses: actions/upload-artifact@v4 with: name: airship-dotnet path: ./build/Airship.dotnet.xcframeworks.zip build-samples: needs: check-version runs-on: macos-15-xlarge steps: - uses: actions/checkout@v4 - name: Build samples run: make build-samples # Test jobs, all depend on check-version run-tests-core: needs: check-version runs-on: macos-15-xlarge timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install xcodegen run: brew install xcodegen - name: Test Core run: make test-core run-tests-preference-center: needs: check-version runs-on: macos-15-xlarge timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install xcodegen run: brew install xcodegen - name: Test Preference Center run: make test-preference-center run-tests-message-center: needs: check-version runs-on: macos-15-xlarge timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install xcodegen run: brew install xcodegen - name: Test Message Center run: make test-message-center run-tests-automation: needs: check-version runs-on: macos-15-xlarge timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install xcodegen run: brew install xcodegen - name: Test Automation run: make test-automation run-tests-feature-flags: needs: check-version runs-on: macos-15-xlarge timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install xcodegen run: brew install xcodegen - name: Test Feature Flags run: make test-feature-flags run-tests-service-extension: needs: check-version runs-on: macos-15-xlarge timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install xcodegen run: brew install xcodegen - name: Test Service Extension run: make test-service-extension deploy-github: permissions: contents: write runs-on: macos-15-xlarge needs: - run-tests-core - run-tests-preference-center - run-tests-message-center - run-tests-automation - run-tests-feature-flags - run-tests-service-extension - build-package - build-samples steps: - uses: actions/checkout@v4 - name: Get the version id: get_version run: | set -euo pipefail VERSION="${GITHUB_REF#refs/tags/}" echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Get the release notes id: get_release_notes run: | set -euo pipefail VERSION="${{ steps.get_version.outputs.VERSION }}" # Match line starting with '## Version [VERSION]' # but allow for the date suffix that follows. NOTES=$(awk -v ver="$VERSION" ' $0 ~ "^## Version " ver "($|[ ]-)" {flag=1; next} $0 ~ "^## Version " {flag=0} flag ' CHANGELOG.md) # Strip potential leading/trailing whitespace/newlines NOTES="$(echo "$NOTES" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" if [ -z "$NOTES" ]; then echo "::error::Could not find notes for Version $VERSION in CHANGELOG.md" exit 1 fi DELIMITER="EOF_$(uuidgen)" { echo "NOTES<<$DELIMITER" echo "$NOTES" echo "$DELIMITER" } >> "$GITHUB_OUTPUT" - name: Download zip distribution uses: actions/download-artifact@v4 with: name: airship path: ./build - name: Download Carthage zip distribution uses: actions/download-artifact@v4 with: name: airship-carthage path: ./build - name: Download .NET xcframeworks uses: actions/download-artifact@v4 with: name: airship-dotnet path: ./build - name: Create GitHub Release id: create_release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.get_version.outputs.VERSION }} release_name: ${{ steps.get_version.outputs.VERSION }} body: ${{ steps.get_release_notes.outputs.NOTES }} draft: false prerelease: false files: | ./build/Airship.zip ./build/Airship.xcframeworks.zip ./build/Airship.dotnet.xcframeworks.zip - name: Kickoff prebuilt repo env: GITHUB_TOKEN: ${{ secrets.IOS_DEPLOY_PREBUILT_PAT }} run: gh --repo urbanairship/ios-library-prebuilt workflow run release.yml deploy-pods: runs-on: macos-15-xlarge needs: - run-tests-core - run-tests-preference-center - run-tests-message-center - run-tests-automation - run-tests-feature-flags - run-tests-service-extension - build-package - build-samples steps: - uses: actions/checkout@v4 - name: Get the version id: get_version run: | set -euo pipefail VERSION="${GITHUB_REF#refs/tags/}" echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Publish Pods env: COCOAPODS_TRUNK_TOKEN: ${{ secrets.COCOAPODS_TRUNK_TOKEN }} run: make pod-publish - name: Slack Notification uses: lazy-actions/slatify@master if: always() with: type: ${{ job.status }} job_name: "Publish the Pods ${{ steps.get_version.outputs.VERSION }} :tidepod:" url: ${{ secrets.SLACK_WEBHOOK }} finished: runs-on: ubuntu-latest needs: [deploy-github, deploy-pods] steps: - name: Get the version id: get_version run: | set -euo pipefail VERSION="${GITHUB_REF#refs/tags/}" echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Slack Notification uses: lazy-actions/slatify@master if: always() with: type: ${{ job.status }} job_name: ":raised_hands: iOS SDK Released! :raised_hands:" url: ${{ secrets.SLACK_WEBHOOK }} ================================================ FILE: .gitignore ================================================ .DS_Store *.orig .hg* *.mode1v3 *.pbxuser *.perspective* distribution_binaries/ distribution_package/ docs/html/ docs/docset/ Debug/ Release/ Distribution/ build/ UANativeBridge.c xcuserdata/ AirshipConfig.plist AirshipDevelopment.plist fabfile.py* *.build_output # Undo files for MacVim *.un~ # CoverStory tool script cover.py OCMockLibrary *.swp Deploy/output test-output/ Pods/ Carthage/ Airship.framework.zip DerivedData **/.claude/settings.local.json ================================================ FILE: .gitmodules ================================================ ================================================ FILE: .ruby-version ================================================ 3.2.0 ================================================ FILE: .swift-format.json ================================================ { "fileScopedDeclarationPrivacy" : { "accessLevel" : "private" }, "indentation" : { "spaces" : 4 }, "indentConditionalCompilationBlocks" : false, "indentSwitchCaseLabels" : false, "lineBreakAroundMultilineExpressionChainComponents" : true, "lineBreakBeforeControlFlowKeywords" : false, "lineBreakBeforeEachArgument" : true, "lineBreakBeforeEachGenericRequirement" : true, "lineLength" : 80, "maximumBlankLines" : 1, "prioritizeKeepingFunctionOutputTogether" : false, "respectsExistingLineBreaks" : true, "rules" : { "AllPublicDeclarationsHaveDocumentation" : true, "AlwaysUseLowerCamelCase" : true, "AmbiguousTrailingClosureOverload" : true, "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, "FileScopedDeclarationPrivacy" : true, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, "NeverForceUnwrap" : true, "NeverUseForceTry" : true, "NeverUseImplicitlyUnwrappedOptionals" : true, "NoAccessLevelOnExtensionDeclaration" : true, "NoBlockComments" : true, "NoCasesWithOnlyFallthrough" : true, "NoEmptyTrailingClosureParentheses" : true, "NoLabelsInCasePatterns" : true, "NoLeadingUnderscores" : false, "NoParensAroundConditions" : true, "NoVoidReturnOnFunctionSignature" : true, "OneCasePerLine" : true, "OneVariableDeclarationPerLine" : true, "OnlyOneTrailingClosureArgument" : true, "OrderedImports" : true, "ReturnVoidInsteadOfEmptyTuple" : true, "UseEarlyExits" : true, "UseLetInEveryBoundCaseVariable" : true, "UseShorthandTypeNames" : true, "UseSingleLinePropertyGetter" : true, "UseSynthesizedInitializer" : true, "UseTripleSlashForDocumentationComments" : true, "UseWhereClausesInForLoops" : false, "ValidateDocumentationComments" : false }, "tabWidth" : 8, "version" : 1 } ================================================ FILE: .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: .whitesource ================================================ { "scanSettings": { "configMode": "AUTO", "configExternalURL": "", "projectToken": "", "baseBranches": [] }, "checkRunSettings": { "vulnerableCheckRunConclusionLevel": "success", "displayMode": "diff" }, "issueSettings": { "minSeverityLevel": "NONE" } } ================================================ FILE: Airship/Airship.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 70; objects = { /* Begin PBXAggregateTarget section */ 6EAAE85D28C2AD3A003CAE53 /* AirshipRelease */ = { isa = PBXAggregateTarget; buildConfigurationList = 6EAAE85E28C2AD3A003CAE53 /* Build configuration list for PBXAggregateTarget "AirshipRelease" */; buildPhases = ( ); dependencies = ( 6E128B9D2D305C4600733024 /* PBXTargetDependency */, 6E5917892B28E93A0084BBBF /* PBXTargetDependency */, 6E6B493E2A787D0A00AF98D8 /* PBXTargetDependency */, 6EAAE87728C2AD80003CAE53 /* PBXTargetDependency */, 6EAAE87928C2AD80003CAE53 /* PBXTargetDependency */, 6EAAE87B28C2AD80003CAE53 /* PBXTargetDependency */, 6EAAE87D28C2AD80003CAE53 /* PBXTargetDependency */, 6EAAE87F28C2AD80003CAE53 /* PBXTargetDependency */, ); name = AirshipRelease; productName = AirshipRelease; }; 6ECCAD252CF55BC700423D86 /* AirshipRelease tvOS */ = { isa = PBXAggregateTarget; buildConfigurationList = 6ECCAD342CF55BC700423D86 /* Build configuration list for PBXAggregateTarget "AirshipRelease tvOS" */; buildPhases = ( ); dependencies = ( 6ECCAD282CF55BC700423D86 /* PBXTargetDependency */, 6ECCAD2A2CF55BC700423D86 /* PBXTargetDependency */, 6ECCAD2C2CF55BC700423D86 /* PBXTargetDependency */, 6ECCAD322CF55BC700423D86 /* PBXTargetDependency */, ); name = "AirshipRelease tvOS"; productName = AirshipRelease; }; /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ 27051CD72EE75E3300C770D5 /* AutomationEventsHistoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27051CD62EE75E3300C770D5 /* AutomationEventsHistoryTest.swift */; }; 27077E4C2EE7531C0027A282 /* AutomationEventsHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27077E4B2EE7531C0027A282 /* AutomationEventsHistory.swift */; }; 271B38652DB2866200495D9F /* TagActionMutation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 271B38642DB2866200495D9F /* TagActionMutation.swift */; }; 27264FB32E81B064000B6FA3 /* AirshipSceneController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27264FB22E81B064000B6FA3 /* AirshipSceneController.swift */; }; 2726505B2E81B80E000B6FA3 /* PagerControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2726505A2E81B80E000B6FA3 /* PagerControllerTest.swift */; }; 2753F6422F6C5BB50073882C /* MessageCenterMessageError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2753F6412F6C5BB50073882C /* MessageCenterMessageError.swift */; }; 275D32AC2EF957F200B75760 /* AirshipSimpleLayoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275D32AA2EF955AD00B75760 /* AirshipSimpleLayoutView.swift */; }; 275D32AD2EF957F200B75761 /* AirshipSimpleLayoutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 275D32AB2EF955AD00B75761 /* AirshipSimpleLayoutViewModel.swift */; }; 2797B4192F47687800A7F848 /* NativeLayoutPersistentDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2797B4182F47687800A7F848 /* NativeLayoutPersistentDataStore.swift */; }; 27AFE70F2E733F4400767044 /* ModifyTagsAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27AFE70E2E733F4400767044 /* ModifyTagsAction.swift */; }; 27AFE7112E73477200767044 /* ModifyTagsActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27AFE7102E73477200767044 /* ModifyTagsActionTest.swift */; }; 27CCF77D2F1656150018058F /* MessageViewAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CCF77C2F1656150018058F /* MessageViewAnalytics.swift */; }; 27CCF77F2F16DA500018058F /* MessageViewAnalyticsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CCF77E2F16DA500018058F /* MessageViewAnalyticsTest.swift */; }; 27CCF8D32F2382750018058F /* ThomasStateStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CCF8D22F2382750018058F /* ThomasStateStorage.swift */; }; 27E4194A2EF59F9800D5C1A6 /* MessageCenterThomasView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E419492EF59F9800D5C1A6 /* MessageCenterThomasView.swift */; }; 27F1E1332F0E7AA400E317DB /* ThomasLayoutEventContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAFB2B58AA4F002FEA75 /* ThomasLayoutEventContext.swift */; }; 27F1E1342F0E7C9C00E317DB /* ThomasLayoutEventRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AABA2B58A946002FEA75 /* ThomasLayoutEventRecorder.swift */; }; 27F1E1352F0E7D2C00E317DB /* ThomasLayoutEventMessageID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAB62B58A945002FEA75 /* ThomasLayoutEventMessageID.swift */; }; 27F1E1362F0E7D7B00E317DB /* ThomasLayoutEventSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAFA2B58AA4F002FEA75 /* ThomasLayoutEventSource.swift */; }; 27F1E18C2F0E828D00E317DB /* ThomasLayoutEventMessageIDTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AADD2B58A9D2002FEA75 /* ThomasLayoutEventMessageIDTest.swift */; }; 27F1E18D2F0E828D00E317DB /* ThomasLayoutEventRecorderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAD72B58A9D1002FEA75 /* ThomasLayoutEventRecorderTest.swift */; }; 27F1E18E2F0E828D00E317DB /* ThomasLayoutEventContextTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AADF2B58A9D2002FEA75 /* ThomasLayoutEventContextTest.swift */; }; 27F1E18F2F0E82C700E317DB /* ThomasLayoutResolutionEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AADB2B58A9D1002FEA75 /* ThomasLayoutResolutionEventTest.swift */; }; 27F1E1902F0E82C700E317DB /* ThomasLayoutEventTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AADA2B58A9D1002FEA75 /* ThomasLayoutEventTestUtils.swift */; }; 27F1E1912F0E82C700E317DB /* ThomasLayoutFormResultEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAE02B58A9D2002FEA75 /* ThomasLayoutFormResultEventTest.swift */; }; 27F1E1922F0E82C700E317DB /* ThomasLayoutPageSwipeEventAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAE72B58A9D4002FEA75 /* ThomasLayoutPageSwipeEventAction.swift */; }; 27F1E1932F0E82C700E317DB /* ThomasLayoutDisplayEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAD62B58A9D1002FEA75 /* ThomasLayoutDisplayEventTest.swift */; }; 27F1E1942F0E82C700E317DB /* ThomasLayoutButtonTapEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AADE2B58A9D2002FEA75 /* ThomasLayoutButtonTapEventTest.swift */; }; 27F1E1952F0E82C700E317DB /* ThomasLayoutPagerSummaryEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAE12B58A9D2002FEA75 /* ThomasLayoutPagerSummaryEventTest.swift */; }; 27F1E1962F0E82C700E317DB /* ThomasLayoutPageActionEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AADC2B58A9D2002FEA75 /* ThomasLayoutPageActionEventTest.swift */; }; 27F1E1972F0E82C700E317DB /* ThomasLayoutGestureEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAE42B58A9D3002FEA75 /* ThomasLayoutGestureEventTest.swift */; }; 27F1E1992F0E82C700E317DB /* ThomasLayoutFormDisplayEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAE32B58A9D3002FEA75 /* ThomasLayoutFormDisplayEventTest.swift */; }; 27F1E19A2F0E82C700E317DB /* ThomasLayoutPermissionResultEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAD92B58A9D1002FEA75 /* ThomasLayoutPermissionResultEventTest.swift */; }; 27F1E19B2F0E82C700E317DB /* ThomasLayoutPageViewEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAE22B58A9D2002FEA75 /* ThomasLayoutPageViewEventTest.swift */; }; 27F1E19C2F0E82C700E317DB /* ThomasLayoutPagerCompletedEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAD82B58A9D1002FEA75 /* ThomasLayoutPagerCompletedEventTest.swift */; }; 27F1E19D2F0E836000E317DB /* InAppMessageAnalyticsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAE52B58A9D3002FEA75 /* InAppMessageAnalyticsTest.swift */; }; 27F1E19E2F0E846B00E317DB /* TestThomasLayoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAD42B58A977002FEA75 /* TestThomasLayoutEvent.swift */; }; 27F1E19F2F0E848B00E317DB /* TestThomasLayoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAD42B58A977002FEA75 /* TestThomasLayoutEvent.swift */; }; 27F1E1A02F0E84C300E317DB /* ThomasLayoutEventTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AADA2B58A9D1002FEA75 /* ThomasLayoutEventTestUtils.swift */; }; 27F1E2012F0E910B00E317DB /* ThomasLayoutButtonTapEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1F32F0E910B00E317DB /* ThomasLayoutButtonTapEvent.swift */; }; 27F1E2022F0E910B00E317DB /* ThomasLayoutDisplayEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1F42F0E910B00E317DB /* ThomasLayoutDisplayEvent.swift */; }; 27F1E2032F0E910B00E317DB /* ThomasLayoutEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1F52F0E910B00E317DB /* ThomasLayoutEvent.swift */; }; 27F1E2042F0E910B00E317DB /* ThomasLayoutFormDisplayEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1F62F0E910B00E317DB /* ThomasLayoutFormDisplayEvent.swift */; }; 27F1E2052F0E910B00E317DB /* ThomasLayoutFormResultEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1F72F0E910B00E317DB /* ThomasLayoutFormResultEvent.swift */; }; 27F1E2062F0E910B00E317DB /* ThomasLayoutGestureEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1F82F0E910B00E317DB /* ThomasLayoutGestureEvent.swift */; }; 27F1E2072F0E910B00E317DB /* ThomasLayoutPageActionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1F92F0E910B00E317DB /* ThomasLayoutPageActionEvent.swift */; }; 27F1E2082F0E910B00E317DB /* ThomasLayoutPagerCompletedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1FA2F0E910B00E317DB /* ThomasLayoutPagerCompletedEvent.swift */; }; 27F1E2092F0E910B00E317DB /* ThomasLayoutPagerSummaryEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1FB2F0E910B00E317DB /* ThomasLayoutPagerSummaryEvent.swift */; }; 27F1E20A2F0E910B00E317DB /* ThomasLayoutPageSwipeEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1FC2F0E910B00E317DB /* ThomasLayoutPageSwipeEvent.swift */; }; 27F1E20B2F0E910B00E317DB /* ThomasLayoutPageViewEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1FD2F0E910B00E317DB /* ThomasLayoutPageViewEvent.swift */; }; 27F1E20C2F0E910B00E317DB /* ThomasLayoutPermissionResultEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1FE2F0E910B00E317DB /* ThomasLayoutPermissionResultEvent.swift */; }; 27F1E20D2F0E910B00E317DB /* ThomasLayoutResolutionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F1E1FF2F0E910B00E317DB /* ThomasLayoutResolutionEvent.swift */; }; 27F1E2112F0FF5FE00E317DB /* ThomasDisplayListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AB172B59E80E002FEA75 /* ThomasDisplayListener.swift */; }; 27F1E2122F0FF6EB00E317DB /* ThomasDisplayListenerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BBC2B5B290200A6489B /* ThomasDisplayListenerTest.swift */; }; 320AD3A629E7FA2000D66106 /* PagerGestureMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 320AD3A529E7FA2000D66106 /* PagerGestureMap.swift */; }; 3215CA9D2739349800B7D97E /* 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 /* AirshipAutomationResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231126929D5E4F600CF0D86 /* AirshipAutomationResources.swift */; }; 3231128229D5E67200CF0D86 /* FrequencyLimitStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231127B29D5E67200CF0D86 /* FrequencyLimitStore.swift */; }; 3231128329D5E67200CF0D86 /* FrequencyLimitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231127C29D5E67200CF0D86 /* FrequencyLimitManager.swift */; }; 3231128429D5E67200CF0D86 /* Occurrence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231127D29D5E67200CF0D86 /* Occurrence.swift */; }; 3231128729D5E67200CF0D86 /* FrequencyConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231128029D5E67200CF0D86 /* FrequencyConstraint.swift */; }; 3231128829D5E67200CF0D86 /* FrequencyChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231128129D5E67200CF0D86 /* FrequencyChecker.swift */; }; 3231128C29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 3231128A29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodeld */; }; 3231129129D5E6D900CF0D86 /* FrequencyLimitManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3231128F29D5E6C600CF0D86 /* FrequencyLimitManagerTest.swift */; }; 3237D5F22B865D990055932B /* JSONValueTransformer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3237D5F12B865D990055932B /* JSONValueTransformer.swift */; }; 3243EC632D93109C00B43B25 /* AirshipSwitchToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3243EC622D93109C00B43B25 /* AirshipSwitchToggleStyle.swift */; }; 3243EC642D93109C00B43B25 /* AirshipCheckboxToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3243EC612D93109C00B43B25 /* AirshipCheckboxToggleStyle.swift */; }; 324D3BFF273E6B4500058EE4 /* BannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324D3BFE273E6B4500058EE4 /* BannerView.swift */; }; 32515869272AFB2E00DF8B44 /* Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32515866272AFB2E00DF8B44 /* Media.swift */; }; 3251586B272AFB2E00DF8B44 /* VideoMediaWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32515868272AFB2E00DF8B44 /* VideoMediaWebView.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 */; }; 325D53FA29648818003421B4 /* TestAirshipRequestSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A467F28EF4FAF00A25617 /* TestAirshipRequestSession.swift */; }; 325D53FB2964885B003421B4 /* AirshipBaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3299EF212949EC3E00251E70 /* AirshipBaseTest.swift */; }; 3261A7F6243CD7F900ADBF6B /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3261A7F4243CD73100ADBF6B /* CoreTelephony.framework */; platformFilter = maccatalyst; }; 3299EF172948CBC100251E70 /* RemoteDataAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3299EF162948CBC100251E70 /* RemoteDataAPIClientTest.swift */; }; 3299EF222949EC3E00251E70 /* AirshipBaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3299EF212949EC3E00251E70 /* AirshipBaseTest.swift */; }; 3299EF26294B222F00251E70 /* RemoteDataStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3299EF25294B222F00251E70 /* RemoteDataStoreTest.swift */; }; 329DFCCF2B7E4DDA0039C8C0 /* UARemoteDataMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 329DFCCE2B7E4DDA0039C8C0 /* UARemoteDataMapping.swift */; }; 329DFCD52B7E59700039C8C0 /* UAInboxDataMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 329DFCD42B7E59700039C8C0 /* UAInboxDataMapping.swift */; }; 329DFCFF2B7FB8810039C8C0 /* UARemoteDataMappingV3toV4.xcmappingmodel in Resources */ = {isa = PBXBuildFile; fileRef = 329DFCCA2B7E4DA10039C8C0 /* UARemoteDataMappingV3toV4.xcmappingmodel */; }; 32B513562B9F53A500BBE780 /* MessageCenterPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B513552B9F53A500BBE780 /* MessageCenterPredicate.swift */; }; 32B5BE3B28F8A7EB00F2254B /* MessageCenterController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE3A28F8A7EB00F2254B /* MessageCenterController.swift */; }; 32B5BE3E28F8A8C000F2254B /* MessageCenterListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE2728F8A7D600F2254B /* MessageCenterListItemView.swift */; }; 32B5BE3F28F8A8C200F2254B /* MessageCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE2828F8A7D600F2254B /* MessageCenterView.swift */; }; 32B5BE4028F8A8C500F2254B /* MessageCenterMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE2928F8A7D600F2254B /* MessageCenterMessageView.swift */; }; 32B5BE4128F8A8C700F2254B /* MessageCenterListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE2A28F8A7D600F2254B /* MessageCenterListView.swift */; }; 32B5BE4228F8A8CA00F2254B /* MessageCenterListItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE2B28F8A7D600F2254B /* MessageCenterListItemViewModel.swift */; }; 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 */; }; 32D6E87B2727F7060077C784 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D6E87A2727F7060077C784 /* Image.swift */; }; 32E339E32A334A2000CD3BE5 /* AddCustomEventActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32E339E22A334A2000CD3BE5 /* AddCustomEventActionTest.swift */; }; 32F293D5295AFD94004A7D9C /* ActionArgumentsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F293D4295AFD94004A7D9C /* ActionArgumentsTest.swift */; }; 32F615A728F708980015696D /* MessageCenterListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F615A628F708980015696D /* MessageCenterListTests.swift */; }; 32F615A828F708AD0015696D /* TestChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E48426A60CDF0038CFDD /* TestChannel.swift */; }; 32F68CDC28F02A7100F7F52A /* MessageCenterStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F68CDA28F02A6B00F7F52A /* MessageCenterStoreTest.swift */; }; 32F68CEE28F07C2C00F7F52A /* AirshipMessageCenterResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F68CE828F07C2B00F7F52A /* AirshipMessageCenterResources.swift */; }; 32F68CEF28F07C2C00F7F52A /* MessageCenterSDKModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F68CE928F07C2C00F7F52A /* MessageCenterSDKModule.swift */; }; 32F68CF328F07C2C00F7F52A /* MessageCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F68CED28F07C2C00F7F52A /* MessageCenter.swift */; }; 32F68CF528F07C4900F7F52A /* MessageCenterList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F68CF428F07C4800F7F52A /* MessageCenterList.swift */; }; 32F97AC129E5986B00FED65F /* StoryIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F97AC029E5986B00FED65F /* StoryIndicator.swift */; }; 32FD4C782D8079910056D141 /* BasicToggleLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32FD4C772D8079910056D141 /* BasicToggleLayout.swift */; }; 3C39D3092384C8BE003C50D4 /* AirshipMessageCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CA0E423237E4A7B00EE76CF /* AirshipMessageCenter.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3C63556026CDD4F8006E9916 /* AirshipPush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C63555F26CDD4F8006E9916 /* AirshipPush.swift */; }; 3C693E4E25141CAC00EBFB88 /* AirshipDebugEventData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 3CA0E222237CCBA600EE76CF /* AirshipDebugEventData.xcdatamodeld */; }; 3C693E4F25141CAC00EBFB88 /* AirshipDebugPushData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 83A674F623AA7AA4005C0C8F /* AirshipDebugPushData.xcdatamodeld */; }; 3CA0E2A0237CCE2600EE76CF /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 494DD9571B0EB677009C134E /* AirshipCore.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 3CA0E2BF237CD05F00EE76CF /* AirshipEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA0E241237CCBA600EE76CF /* AirshipEvent.swift */; }; 3CA0E2CA237CD05F00EE76CF /* EventDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA0E24D237CCBA600EE76CF /* EventDataManager.swift */; }; 3CA0E2CD237CD05F00EE76CF /* AirshipDebugManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA0E233237CCBA600EE76CF /* AirshipDebugManager.swift */; }; 3CA0E479237E4E3000EE76CF /* UAInbox.xcdatamodeld in Resources */ = {isa = PBXBuildFile; fileRef = 3CA0E304237E396100EE76CF /* UAInbox.xcdatamodeld */; }; 3CA84AAE26DE255200A59685 /* EventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA84AA626DE255200A59685 /* EventStore.swift */; }; 3CA84AB826DE257200A59685 /* DefaultAirshipAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA84AB626DE257200A59685 /* DefaultAirshipAnalytics.swift */; }; 3CA84ABA26DE257200A59685 /* AirshipAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CA84AB726DE257200A59685 /* AirshipAnalytics.swift */; }; 3CB37A1E251151A400E60392 /* AirshipDebugResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CB37A1D251151A400E60392 /* AirshipDebugResources.swift */; }; 3CC8AA0626BB3C7900405614 /* DefaultAirshipPush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC8AA0526BB3C7900405614 /* DefaultAirshipPush.swift */; }; 3CC95B1F268E785900FE2ACD /* NotificationCategories.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC95B1E268E785900FE2ACD /* NotificationCategories.swift */; }; 3CC95B2B2696549B00FE2ACD /* AirshipPushableComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC95B2A2696549B00FE2ACD /* AirshipPushableComponent.swift */; }; 45A8ADF023134B38004AD8CA /* testMCColorsCatalog.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 45A8ADD123133E51004AD8CA /* testMCColorsCatalog.xcassets */; }; 6014AD672C1B5F540072DCF0 /* ChallengeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */; }; 6014AD6C2C2032730072DCF0 /* ChallengeResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD6A2C2032360072DCF0 /* ChallengeResolverTest.swift */; }; 6014AD752C20410B0072DCF0 /* airship.der in Resources */ = {isa = PBXBuildFile; fileRef = 6014AD742C20410A0072DCF0 /* airship.der */; }; 6018AF572B29C20A008E528B /* SearchEventTemplateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6018AF562B29C20A008E528B /* SearchEventTemplateTest.swift */; }; 602AD0D52D7242B300C7D566 /* ThomasSmsLocale.swift in Sources */ = {isa = PBXBuildFile; fileRef = 602AD0D42D7242B300C7D566 /* ThomasSmsLocale.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 */; }; 605073902B347B6400209B51 /* ThomasPresentationModelCodingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6050738F2B347B6400209B51 /* ThomasPresentationModelCodingTest.swift */; }; 6058771D2AC73C940021628E /* AirshipMeteredUsageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6058771C2AC73C7E0021628E /* AirshipMeteredUsageTest.swift */; }; 605877202ACAC8700021628E /* MeteredUsageApiClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6058771E2ACAC86A0021628E /* MeteredUsageApiClientTest.swift */; }; 60653FC22CBD2CD4009CD9A7 /* PushData+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60653FC02CBD2CD4009CD9A7 /* PushData+CoreDataClass.swift */; }; 60653FC32CBD2CD4009CD9A7 /* PushData+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60653FC12CBD2CD4009CD9A7 /* PushData+CoreDataProperties.swift */; }; 6068E0062B2A190300349E82 /* CustomEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6068E0052B2A190300349E82 /* CustomEventTest.swift */; }; 6068E0082B2A2A6700349E82 /* AccountEventTemplateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6068E0072B2A2A6700349E82 /* AccountEventTemplateTest.swift */; }; 6068E0322B2B785A00349E82 /* MediaEventTemplateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6068E0312B2B785A00349E82 /* MediaEventTemplateTest.swift */; }; 6068E0342B2B7CA100349E82 /* RetailEventTemplateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6068E0332B2B7CA100349E82 /* RetailEventTemplateTest.swift */; }; 6068E03B2B2CBCF200349E82 /* ActiveTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6068E03A2B2CBCF200349E82 /* ActiveTimer.swift */; }; 607951222A1CD1A50086578F /* ExperimentManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6079511F2A1CD19F0086578F /* ExperimentManagerTest.swift */; }; 6087DB882B278F7600449BA8 /* JsonValueMatcherTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6087DB872B278F7600449BA8 /* JsonValueMatcherTest.swift */; }; 608B16E62C2C1138005298FA /* SubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */; }; 608B16E82C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */; }; 608B16F12C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */; }; 60956D862CBE7CFA00950172 /* AirshipLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8E1C9A26447B3800B11791 /* AirshipLock.swift */; }; 609843562D6F518900690371 /* SmsLocalePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609843552D6F518900690371 /* SmsLocalePicker.swift */; }; 60A292112CB7C5C30096F5EB /* TestDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1422683B8FA00A2CBD0 /* TestDate.swift */; }; 60A364ED2C3479BF00B05E26 /* ExecutionWindowTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A364EC2C3479BF00B05E26 /* ExecutionWindowTest.swift */; }; 60A5CC082B28DC500017EDB2 /* NotificationCategoriesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A5CC072B28DC500017EDB2 /* NotificationCategoriesTest.swift */; }; 60A5CC0C2B29AE890017EDB2 /* ProximityRegionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A5CC0B2B29AE890017EDB2 /* ProximityRegionTest.swift */; }; 60A5CC0E2B29B1B80017EDB2 /* CircularRegionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A5CC0D2B29B1B80017EDB2 /* CircularRegionTest.swift */; }; 60A5CC102B29B4100017EDB2 /* RegionEventTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60A5CC0F2B29B4100017EDB2 /* RegionEventTest.swift */; }; 60C1DB0F2A8B743C00A1D3DA /* AirshipEmbeddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C1DB0B2A8B743B00A1D3DA /* AirshipEmbeddedView.swift */; }; 60C1DB102A8B743C00A1D3DA /* AirshipEmbeddedViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C1DB0C2A8B743B00A1D3DA /* AirshipEmbeddedViewManager.swift */; }; 60C1DB112A8B743C00A1D3DA /* AirshipEmbeddedObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C1DB0D2A8B743B00A1D3DA /* AirshipEmbeddedObserver.swift */; }; 60C1DB122A8B743C00A1D3DA /* EmbeddedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C1DB0E2A8B743C00A1D3DA /* EmbeddedView.swift */; }; 60CE9BDE2D0B6A0900A8B625 /* ThomasPagerControllerBranching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60CE9BDD2D0B6A0900A8B625 /* ThomasPagerControllerBranching.swift */; }; 60D1D9B82B68FB6400EBE0A4 /* PreparedTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D1D9B72B68FB6400EBE0A4 /* PreparedTrigger.swift */; }; 60D1D9BB2B6A53F000EBE0A4 /* PreparedTriggerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D1D9BA2B6A53F000EBE0A4 /* PreparedTriggerTest.swift */; }; 60D1D9BD2B6AB2D100EBE0A4 /* AutomationTriggerProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D1D9BC2B6AB2D100EBE0A4 /* AutomationTriggerProcessorTest.swift */; }; 60D2B3352D9F0FCF00B0752D /* PagerDisableSwipeSelectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D2B3342D9F0FCF00B0752D /* PagerDisableSwipeSelectorTest.swift */; }; 60D3BCC42A1529D800E07524 /* ExperimentDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D3BCC32A1529D800E07524 /* ExperimentDataProvider.swift */; }; 60D3BCC62A152A0D00E07524 /* ExperimentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D3BCC52A152A0D00E07524 /* ExperimentManager.swift */; }; 60D3BCCC2A153C0700E07524 /* Experiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D3BCCB2A153C0700E07524 /* Experiment.swift */; }; 60D3BCCE2A15471C00E07524 /* AudienceHashSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D3BCCD2A15471C00E07524 /* AudienceHashSelector.swift */; }; 60D3BCD02A154D9400E07524 /* MessageCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60D3BCCF2A154D9400E07524 /* MessageCriteria.swift */; }; 60E09FDB2B2780DB005A16EA /* JsonMatcherTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E09FDA2B2780DB005A16EA /* JsonMatcherTest.swift */; }; 60EACF542B7BF2EA00CAFDBB /* AirshipApptimizeIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60EACF532B7BF2EA00CAFDBB /* AirshipApptimizeIntegration.swift */; }; 60F8E75C2B8F3D4B00460EDF /* CancelSchedulesAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E75B2B8F3D4B00460EDF /* CancelSchedulesAction.swift */; }; 60F8E75E2B8FA12800460EDF /* CancelSchedulesActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E75D2B8FA12800460EDF /* CancelSchedulesActionTest.swift */; }; 60F8E7602B8FAF5400460EDF /* ScheduleAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E75F2B8FAF5400460EDF /* ScheduleAction.swift */; }; 60F8E7622B8FB2CC00460EDF /* ScheduleActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7612B8FB2CC00460EDF /* ScheduleActionTest.swift */; }; 60FCA3052B4F1110005C9232 /* LegacyInAppMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60FCA3042B4F1110005C9232 /* LegacyInAppMessaging.swift */; }; 60FCA3072B4F1C73005C9232 /* LegacyInAppMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60FCA3062B4F1C73005C9232 /* LegacyInAppMessage.swift */; }; 60FCA30A2B51364A005C9232 /* LegacyInAppMessageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60FCA3092B51364A005C9232 /* LegacyInAppMessageTest.swift */; }; 60FCA30C2B51492A005C9232 /* LegacyInAppMessagingTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60FCA30B2B51492A005C9232 /* LegacyInAppMessagingTest.swift */; }; 60FCA30D2B5534DB005C9232 /* TestAirshipInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BE1526E29BC90005D20D /* TestAirshipInstance.swift */; }; 60FCA30E2B5535F4005C9232 /* TestAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BE1726E2C5940005D20D /* TestAnalytics.swift */; }; 60FCA3252B5EF3A8005C9232 /* AutomationEventFeedTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60FCA3242B5EF3A8005C9232 /* AutomationEventFeedTest.swift */; }; 6329102E2DD8103200B13C6C /* NativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6329102D2DD8103200B13C6C /* NativeVideoPlayer.swift */; }; 632913FA2DE547A500B13C6C /* VideoMediaNativeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 632913F92DE547A500B13C6C /* VideoMediaNativeView.swift */; }; 6E0031AB2D08CC920004F53E /* AirshipAuthorizedNotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0031AA2D08CC8A0004F53E /* AirshipAuthorizedNotificationSettings.swift */; }; 6E0104FF2DDF9B26009D651F /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0104FE2DDF9B26009D651F /* IconView.swift */; }; 6E0105012DDFA5E9009D651F /* ScoreController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0105002DDFA5E6009D651F /* ScoreController.swift */; }; 6E0105032DDFA719009D651F /* ScoreToggleLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0105022DDFA719009D651F /* ScoreToggleLayout.swift */; }; 6E0105052DDFA735009D651F /* ScoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0105042DDFA735009D651F /* ScoreState.swift */; }; 6E032A502B210E6000404630 /* RemoteConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E032A4F2B210E6000404630 /* RemoteConfigTest.swift */; }; 6E062D03271656DE001A74A1 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E062D02271656DE001A74A1 /* Container.swift */; }; 6E062D05271656F8001A74A1 /* LinearLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E062D04271656F8001A74A1 /* LinearLayout.swift */; }; 6E062D0727165709001A74A1 /* Label.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E062D0627165709001A74A1 /* Label.swift */; }; 6E062D092716571F001A74A1 /* LabelButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E062D082716571F001A74A1 /* LabelButton.swift */; }; 6E062D0D2718B505001A74A1 /* ViewConstraints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E062D0C2718B505001A74A1 /* ViewConstraints.swift */; }; 6E07688829F9D28A0014E2A9 /* AirshipNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07688729F9D28A0014E2A9 /* AirshipNotificationCenter.swift */; }; 6E07688C29F9F0830014E2A9 /* AirshipLocaleManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07688B29F9F0830014E2A9 /* AirshipLocaleManagerTest.swift */; }; 6E07689229FB39440014E2A9 /* AirshipUnsafeSendableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07689129FB39440014E2A9 /* AirshipUnsafeSendableWrapper.swift */; }; 6E07B5F82D925ED60087EC47 /* TestPrivacyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07B5F72D925ED30087EC47 /* TestPrivacyManager.swift */; }; 6E07B5F92D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07B5F72D925ED30087EC47 /* TestPrivacyManager.swift */; }; 6E07B5FA2D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07B5F72D925ED30087EC47 /* TestPrivacyManager.swift */; }; 6E07B5FB2D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07B5F72D925ED30087EC47 /* TestPrivacyManager.swift */; }; 6E07B5FC2D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E07B5F72D925ED30087EC47 /* TestPrivacyManager.swift */; }; 6E0B8732294A9C130064B7BD /* AirshipAutomation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E0B872A294A9C120064B7BD /* AirshipAutomation.framework */; }; 6E0B8744294A9C950064B7BD /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 494DD9571B0EB677009C134E /* AirshipCore.framework */; platformFilters = (ios, maccatalyst, macos, tvos, xros, ); }; 6E0B8760294CE0BF0064B7BD /* FarmHashFingerprint64Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0B875F294CE0BF0064B7BD /* FarmHashFingerprint64Test.swift */; }; 6E0B8762294CE0DC0064B7BD /* FarmHashFingerprint64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0B8761294CE0DC0064B7BD /* FarmHashFingerprint64.swift */; }; 6E0F4BE22B32190400673CA4 /* AutomationSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F4BE12B32190400673CA4 /* AutomationSchedule.swift */; }; 6E0F4BE52B32645600673CA4 /* AutomationTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F4BE42B32645600673CA4 /* AutomationTrigger.swift */; }; 6E0F4BE72B32646000673CA4 /* AutomationDelay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F4BE62B32646000673CA4 /* AutomationDelay.swift */; }; 6E0F4BE92B3264A400673CA4 /* DeferredAutomationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F4BE82B3264A400673CA4 /* DeferredAutomationData.swift */; }; 6E0F557F2AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */; }; 6E10A1482C2B825200ED9556 /* DefaultTaskSleeperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E10A1472C2B825200ED9556 /* DefaultTaskSleeperTest.swift */; }; 6E1185C62C3328A10071334E /* ExecutionWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1185C52C3328A10071334E /* ExecutionWindow.swift */; }; 6E12539129A81ACE0009EE58 /* AirshipCoreDataPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E12539029A81ACE0009EE58 /* AirshipCoreDataPredicate.swift */; }; 6E146C502F5214D900320A36 /* AirshipDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E146C4F2F5214D900320A36 /* AirshipDevice.swift */; }; 6E146D682F523DB900320A36 /* AirshipFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E146D672F523DB700320A36 /* AirshipFont.swift */; }; 6E146D6A2F5241BC00320A36 /* AirshipColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E146D692F5241BA00320A36 /* AirshipColor.swift */; }; 6E146EDD2F52537000320A36 /* AishipFontTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E146EDC2F52536C00320A36 /* AishipFontTests.swift */; }; 6E146FF32F525E7300320A36 /* AirshipPasteboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E146FF22F525E7300320A36 /* AirshipPasteboard.swift */; }; 6E1472D52F526DCD00320A36 /* AirshipNativePlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1472D42F526DC600320A36 /* AirshipNativePlatform.swift */; }; 6E1473EA2F527C4D00320A36 /* TestURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECB62812A36A45F0095C85C /* TestURLOpener.swift */; }; 6E1476CC2F5643A100320A36 /* MessageCenterNavigationAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1476CB2F56439A00320A36 /* MessageCenterNavigationAppearance.swift */; }; 6E14C9A128B5E4AF00A55E65 /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E14C9A028B5E4AF00A55E65 /* PushNotification.swift */; }; 6E1528172B4DC3C000DF1377 /* ActionAutomationExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528162B4DC3C000DF1377 /* ActionAutomationExecutor.swift */; }; 6E1528192B4DC3D000DF1377 /* InAppMessageAutomationPreparer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528182B4DC3D000DF1377 /* InAppMessageAutomationPreparer.swift */; }; 6E15281B2B4DC3DF00DF1377 /* InAppMessageAutomationExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15281A2B4DC3DF00DF1377 /* InAppMessageAutomationExecutor.swift */; }; 6E15281D2B4DC43100DF1377 /* ActionAutomationPreparer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15281C2B4DC43100DF1377 /* ActionAutomationPreparer.swift */; }; 6E1528202B4DC59C00DF1377 /* InAppMessageSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15281F2B4DC59C00DF1377 /* InAppMessageSceneDelegate.swift */; }; 6E1528222B4DC5C000DF1377 /* InAppMessageDisplayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528212B4DC5C000DF1377 /* InAppMessageDisplayDelegate.swift */; }; 6E1528242B4DC60200DF1377 /* DisplayCoordinatorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528232B4DC60200DF1377 /* DisplayCoordinatorManager.swift */; }; 6E1528262B4DC64B00DF1377 /* DisplayAdapterFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528252B4DC64B00DF1377 /* DisplayAdapterFactory.swift */; }; 6E1528282B4DCFCB00DF1377 /* AirshipActorValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528272B4DCFCB00DF1377 /* AirshipActorValue.swift */; }; 6E15282C2B4DE81E00DF1377 /* AutomationSDKModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15282B2B4DE81E00DF1377 /* AutomationSDKModule.swift */; }; 6E15282F2B4DED7A00DF1377 /* InAppMessageAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15282E2B4DED7A00DF1377 /* InAppMessageAnalytics.swift */; }; 6E1528312B4DED8900DF1377 /* InAppMessageAnalyticsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528302B4DED8900DF1377 /* InAppMessageAnalyticsFactory.swift */; }; 6E1528332B4DF2E600DF1377 /* ScheduleConditionsChangedNotifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528322B4DF2E600DF1377 /* ScheduleConditionsChangedNotifier.swift */; }; 6E1528352B4E11DB00DF1377 /* CustomDisplayAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528342B4E11DB00DF1377 /* CustomDisplayAdapter.swift */; }; 6E1528372B4E11E800DF1377 /* CustomDisplayAdapterWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528362B4E11E800DF1377 /* CustomDisplayAdapterWrapper.swift */; }; 6E1528392B4E13D400DF1377 /* AirshipLayoutDisplayAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528382B4E13D400DF1377 /* AirshipLayoutDisplayAdapter.swift */; }; 6E1528402B4F153900DF1377 /* TestDisplayAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15283F2B4F153900DF1377 /* TestDisplayAdapter.swift */; }; 6E1528422B4F156200DF1377 /* TestDisplayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1528412B4F156200DF1377 /* TestDisplayCoordinator.swift */; }; 6E152BCA2743235800788402 /* Icons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E152BC92743235800788402 /* Icons.swift */; }; 6E1589502AFEF19F00954A04 /* SessionTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15894F2AFEF19F00954A04 /* SessionTracker.swift */; }; 6E1589542AFF021D00954A04 /* SessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1589532AFF021D00954A04 /* SessionState.swift */; }; 6E1589582AFF023400954A04 /* SessionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1589572AFF023400954A04 /* SessionEvent.swift */; }; 6E15B6DB26CC749F0099C92D /* RemoteConfigManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B6D826CC749F0099C92D /* RemoteConfigManager.swift */; }; 6E15B6F426CD85C40099C92D /* RuntimeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B6F326CD85C40099C92D /* RuntimeConfig.swift */; }; 6E15B6FA26CDCA6A0099C92D /* RuntimeConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B6F926CDCA6A0099C92D /* RuntimeConfigTest.swift */; }; 6E15B70326CDE40E0099C92D /* RemoteConfigManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B70226CDE40E0099C92D /* RemoteConfigManagerTest.swift */; }; 6E15B70526CE07180099C92D /* TestRemoteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B70426CE07180099C92D /* TestRemoteData.swift */; }; 6E15B71426CEB4190099C92D /* RemoteDataStorePayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B70A26CEB4190099C92D /* RemoteDataStorePayload.swift */; }; 6E15B71826CEB4190099C92D /* RemoteDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B70C26CEB4190099C92D /* RemoteDataStore.swift */; }; 6E15B72326CEC7030099C92D /* RemoteDataPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B72026CEC7030099C92D /* RemoteDataPayload.swift */; }; 6E15B72A26CEDBA50099C92D /* RemoteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B72926CEDBA50099C92D /* RemoteData.swift */; }; 6E15B72D26CF13BC0099C92D /* RemoteDataProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B72C26CF13BC0099C92D /* RemoteDataProviderDelegate.swift */; }; 6E15B73026CF4F6B0099C92D /* TestRemoteDataAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B72F26CF4F6B0099C92D /* TestRemoteDataAPIClient.swift */; }; 6E16208A2B311219009240B2 /* DisplayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1620892B311219009240B2 /* DisplayCoordinator.swift */; }; 6E16208D2B3116AE009240B2 /* ImmediateDisplayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E16208C2B3116AE009240B2 /* ImmediateDisplayCoordinator.swift */; }; 6E16208F2B3116BA009240B2 /* DefaultDisplayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E16208E2B3116BA009240B2 /* DefaultDisplayCoordinator.swift */; }; 6E1620932B3118D9009240B2 /* ImmediateDisplayCoordinatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1620912B3118D5009240B2 /* ImmediateDisplayCoordinatorTest.swift */; }; 6E1620952B311D8A009240B2 /* DefaultDisplayCoordinatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1620942B311D8A009240B2 /* DefaultDisplayCoordinatorTest.swift */; }; 6E1767F629B923D100D65F60 /* ChannelAuthTokenProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1767F329B923D100D65F60 /* ChannelAuthTokenProviderTest.swift */; }; 6E1767F729B923D100D65F60 /* ChannelAuthTokenAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1767F429B923D100D65F60 /* ChannelAuthTokenAPIClientTest.swift */; }; 6E1767F829B923D100D65F60 /* TestChannelAuthTokenAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1767F529B923D100D65F60 /* TestChannelAuthTokenAPIClient.swift */; }; 6E1767FA29B92F1700D65F60 /* AirshipUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1767F929B92F1700D65F60 /* AirshipUtilsTest.swift */; }; 6E1802F92C5C2DEC00198D0D /* AirshipAnalyticFeedTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1802F82C5C2DEC00198D0D /* AirshipAnalyticFeedTest.swift */; }; 6E1892B1268CE8FE00417887 /* AirshipLock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8E1C9A26447B3800B11791 /* AirshipLock.swift */; }; 6E1892C8268D15C300417887 /* PreferenceCenterTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1892C7268D15C300417887 /* PreferenceCenterTest.swift */; }; 6E1892D5268E3D8500417887 /* PreferenceCenterDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1892D4268E3D8500417887 /* PreferenceCenterDecoder.swift */; }; 6E1892D7268E3F1800417887 /* PreferenceCenterConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1892D6268E3F1800417887 /* PreferenceCenterConfigTest.swift */; }; 6E1892DE2694F1C100417887 /* ChannelRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1892DD2694F1C100417887 /* ChannelRegistrar.swift */; }; 6E1A15062D6EA3A50056418B /* ThomasFormState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A15052D6EA3A50056418B /* ThomasFormState.swift */; }; 6E1A19222D6F875A0056418B /* ThomasFormValidationMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A19212D6F87550056418B /* ThomasFormValidationMode.swift */; }; 6E1A19242D6F8BDD0056418B /* AirshipInputValidationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A19232D6F8BD50056418B /* AirshipInputValidationTest.swift */; }; 6E1A1BB32D6F9D0F0056418B /* ThomasFormFieldProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A1BB22D6F9D090056418B /* ThomasFormFieldProcessorTest.swift */; }; 6E1A1D852D70F3700056418B /* ThomasState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A1D842D70F36D0056418B /* ThomasState.swift */; }; 6E1A9BAB2B5AE38A00A6489B /* InAppMessageDisplayListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BAA2B5AE38A00A6489B /* InAppMessageDisplayListener.swift */; }; 6E1A9BB02B5B0C4C00A6489B /* AutomationActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BAF2B5B0C4C00A6489B /* AutomationActionRunner.swift */; }; 6E1A9BB22B5B172F00A6489B /* TestActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BB12B5B172F00A6489B /* TestActionRunner.swift */; }; 6E1A9BB72B5B1D9E00A6489B /* TestActiveTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BB62B5B1D9E00A6489B /* TestActiveTimer.swift */; }; 6E1A9BB92B5B20A500A6489B /* InAppMessageDisplayListenerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BB82B5B20A500A6489B /* InAppMessageDisplayListenerTest.swift */; }; 6E1A9BBB2B5B20D700A6489B /* TestInAppMessageAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BBA2B5B20D700A6489B /* TestInAppMessageAnalytics.swift */; }; 6E1A9BBF2B5EE19000A6489B /* PreparedSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BBE2B5EE19000A6489B /* PreparedSchedule.swift */; }; 6E1A9BC12B5EE1CF00A6489B /* SchedulePrepareResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BC02B5EE1CF00A6489B /* SchedulePrepareResult.swift */; }; 6E1A9BC32B5EE1DE00A6489B /* ScheduleReadyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BC22B5EE1DE00A6489B /* ScheduleReadyResult.swift */; }; 6E1A9BC52B5EE1EE00A6489B /* ScheduleExecuteResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BC42B5EE1EE00A6489B /* ScheduleExecuteResult.swift */; }; 6E1A9BC72B5EE32E00A6489B /* AutomationTriggerProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BC62B5EE32E00A6489B /* AutomationTriggerProcessor.swift */; }; 6E1A9BC92B5EE34600A6489B /* AutomationEventFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BC82B5EE34600A6489B /* AutomationEventFeed.swift */; }; 6E1A9BD12B5EE84600A6489B /* AutomationScheduleData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BD02B5EE84600A6489B /* AutomationScheduleData.swift */; }; 6E1A9BD32B5EE8A400A6489B /* AutomationScheduleState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BD22B5EE8A400A6489B /* AutomationScheduleState.swift */; }; 6E1A9BD52B5EE97000A6489B /* TriggeringInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BD42B5EE97000A6489B /* TriggeringInfo.swift */; }; 6E1A9BF72B606CF200A6489B /* AutomationDelayProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1A9BF62B606CF200A6489B /* AutomationDelayProcessor.swift */; }; 6E1B7B132B714FFC00695561 /* LandingPageAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1B7B122B714FFC00695561 /* LandingPageAction.swift */; }; 6E1B7B162B715FFE00695561 /* LandingPageActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1B7B152B715FFE00695561 /* LandingPageActionTest.swift */; }; 6E1BACDB2719ED7D0038399E /* ScrollLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1BACDA2719ED7D0038399E /* ScrollLayout.swift */; }; 6E1BACDD2719FC0A0038399E /* ViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1BACDC2719FC0A0038399E /* ViewFactory.swift */; }; 6E1C9C3A271E90EB009EF9EF /* LayoutModelsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1C9C39271E90EB009EF9EF /* LayoutModelsTest.swift */; }; 6E1C9C4B271F7878009EF9EF /* BackgroundColorViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1C9C4A271F7878009EF9EF /* BackgroundColorViewModifier.swift */; }; 6E1CBD812BA3A30300519D9C /* AirshipEmbeddedInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1CBD802BA3A30300519D9C /* AirshipEmbeddedInfo.swift */; }; 6E1CBDE32BA51ED100519D9C /* InAppDisplayImpressionRuleProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1CBDE22BA51ED100519D9C /* InAppDisplayImpressionRuleProvider.swift */; }; 6E1CBDFF2BAA1DF200519D9C /* DefaultInAppDisplayImpressionRuleProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1CBDFE2BAA1DF200519D9C /* DefaultInAppDisplayImpressionRuleProviderTest.swift */; }; 6E1CBE2D2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 6E1CBE2A2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodeld */; }; 6E1D8AD126CC5D490049DACB /* RemoteConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E1D8AB226CC5D490049DACB /* RemoteConfig.swift */; }; 6E1D8AD826CC66BE0049DACB /* 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 */; }; 6E1F6E842BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel in Resources */ = {isa = PBXBuildFile; fileRef = 6E1F6E832BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel */; }; 6E1F6E882BE683E600CFC7A7 /* UARemoteDataMappingV1toV4.xcmappingmodel in Resources */ = {isa = PBXBuildFile; fileRef = 6E1F6E872BE683E600CFC7A7 /* UARemoteDataMappingV1toV4.xcmappingmodel */; }; 6E213B182BC60AF100BF24AE /* AirshipWeakValueHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E213B172BC60AF100BF24AE /* AirshipWeakValueHolder.swift */; }; 6E213B1E2BC7054500BF24AE /* InAppActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E213B1D2BC7054500BF24AE /* InAppActionRunner.swift */; }; 6E21852B237D32B30084933A /* EventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E21852A237D32B30084933A /* EventData.swift */; }; 6E2486DF28945D3900657CE4 /* PreferenceCenterState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486DE28945D3900657CE4 /* PreferenceCenterState.swift */; }; 6E2486EC2894901E00657CE4 /* ConditionsViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486EB2894901E00657CE4 /* ConditionsViewModifier.swift */; }; 6E2486F22898341400657CE4 /* ConditionsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486F02898341400657CE4 /* ConditionsMonitor.swift */; }; 6E2486F728984D0D00657CE4 /* PreferenceCenterTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486F628984D0D00657CE4 /* PreferenceCenterTheme.swift */; }; 6E2486FD2899C06100657CE4 /* PreferenceCenterContentLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2486FC2899C06100657CE4 /* PreferenceCenterContentLoader.swift */; }; 6E25DD052D515F33009CF1A4 /* ThomasEmailRegistrationOptionsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FA28B2D515C5A005893E2 /* ThomasEmailRegistrationOptionsTest.swift */; }; 6E2811682BE406A50040D928 /* FeatureFlagDeferredResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7EACD22AF4220E00DA286B /* FeatureFlagDeferredResolverTest.swift */; }; 6E28116C2BE40E860040D928 /* FeatureFlagVariablesTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E28116B2BE40E860040D928 /* FeatureFlagVariablesTest.swift */; }; 6E29474B2AD47E15009EC6DD /* AirshipPreferenceCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 847BFFF4267CD739007CD249 /* AirshipPreferenceCenter.framework */; }; 6E29474D2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E29474C2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift */; }; 6E2947512AD5DB5A009EC6DD /* LiveActivityRegistrationStatusUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2947502AD5DB5A009EC6DD /* LiveActivityRegistrationStatusUpdates.swift */; }; 6E299FD528D13D00001305A7 /* DefaultAirshipRequestSessionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E299FD428D13D00001305A7 /* DefaultAirshipRequestSessionTest.swift */; }; 6E299FD728D13E54001305A7 /* AirshipRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E299FD628D13E54001305A7 /* AirshipRequest.swift */; }; 6E299FDB28D14208001305A7 /* AirshipResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E299FDA28D14208001305A7 /* AirshipResponse.swift */; }; 6E299FDF28D14258001305A7 /* AirshipRequestSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E299FDE28D14258001305A7 /* AirshipRequestSession.swift */; }; 6E2D6AEE26B083DB00B7C226 /* ChannelAudienceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2D6AED26B083DB00B7C226 /* ChannelAudienceManager.swift */; }; 6E2D6AF226B0B64E00B7C226 /* SubscriptionListAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2D6AF126B0B64E00B7C226 /* SubscriptionListAPIClientTest.swift */; }; 6E2D6AF426B0C3C500B7C226 /* ChannelAudienceManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2D6AF326B0C3C500B7C226 /* ChannelAudienceManagerTest.swift */; }; 6E2D6AF626B0C6CA00B7C226 /* TestSubscriptionListAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2D6AF526B0C6CA00B7C226 /* TestSubscriptionListAPIClient.swift */; }; 6E2E3CA22B32723C00B8515B /* InAppMessageNativeBridgeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2E3CA12B32723C00B8515B /* InAppMessageNativeBridgeExtension.swift */; }; 6E2E3CA62B327A6C00B8515B /* InAppMessageNativeBridgeExtensionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2E3CA42B32726400B8515B /* InAppMessageNativeBridgeExtensionTest.swift */; }; 6E2F5A742A60833700CABD3D /* FeatureFlagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5A732A60833700CABD3D /* FeatureFlagManager.swift */; }; 6E2F5A762A60871E00CABD3D /* FeatureFlagPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5A752A60871E00CABD3D /* FeatureFlagPayload.swift */; }; 6E2F5A862A65F00200CABD3D /* RemoteDataSourceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5A852A65F00200CABD3D /* RemoteDataSourceStatus.swift */; }; 6E2F5A8A2A66088100CABD3D /* AudienceDeviceInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5A892A66088100CABD3D /* AudienceDeviceInfoProvider.swift */; }; 6E2F5A8E2A66FE8900CABD3D /* AirshipTimeCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5A8D2A66FE8900CABD3D /* AirshipTimeCriteria.swift */; }; 6E2F5A932A67316C00CABD3D /* AirshipFeatureFlags.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A62058692A5841330041FBF9 /* AirshipFeatureFlags.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 6E2F5AB12A67434B00CABD3D /* FeatureFlag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5AB02A67434B00CABD3D /* FeatureFlag.swift */; }; 6E2F5AB22A67589400CABD3D /* TestDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1422683B8FA00A2CBD0 /* TestDate.swift */; }; 6E2F5AB32A6758ED00CABD3D /* TestNetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED171268448EC00A2CBD0 /* TestNetworkMonitor.swift */; }; 6E2F5AB52A67599400CABD3D /* TestRemoteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B70426CE07180099C92D /* TestRemoteData.swift */; }; 6E2F5AB72A675ADC00CABD3D /* TestAudienceChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5AB62A675ADC00CABD3D /* TestAudienceChecker.swift */; }; 6E2F5AB82A675ADC00CABD3D /* TestAudienceChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5AB62A675ADC00CABD3D /* TestAudienceChecker.swift */; }; 6E2F5ABA2A675D3600CABD3D /* FeatureFlagsRemoteDataAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5AB92A675D3600CABD3D /* FeatureFlagsRemoteDataAccess.swift */; }; 6E2FA2892D51519B005893E2 /* ThomasEmailRegistrationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2FA2882D515189005893E2 /* ThomasEmailRegistrationOptions.swift */; }; 6E34C4B12C7D4B6400B00506 /* ExecutionWindowProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E34C4B02C7D4B6400B00506 /* ExecutionWindowProcessor.swift */; }; 6E34C4B32C7D4C6600B00506 /* ExecutionWindowProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E34C4B22C7D4C6600B00506 /* ExecutionWindowProcessorTest.swift */; }; 6E382C21276D3E990091A351 /* ThomasValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E382C20276D3E990091A351 /* ThomasValidationTests.swift */; }; 6E3B230F28A318CD0005D46E /* PreferenceCenterThemeLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B230E28A318CD0005D46E /* PreferenceCenterThemeLoader.swift */; }; 6E3B231328A32EC30005D46E /* PreferenceCenterViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B231228A32EC30005D46E /* PreferenceCenterViewExtensions.swift */; }; 6E3B32CC27559D8B00B89C7B /* FormInputViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B32CB27559D8B00B89C7B /* FormInputViewModifier.swift */; }; 6E3B32CF2755D8C700B89C7B /* LayoutState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3B32CE2755D8C700B89C7B /* LayoutState.swift */; }; 6E3CA5412ECB9B7900210C32 /* AirshipDisplayTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3CA5402ECB9B7400210C32 /* AirshipDisplayTarget.swift */; }; 6E4007142A153AB20013C2DE /* AppRemoteDataProviderDelegateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4007132A153AB20013C2DE /* AppRemoteDataProviderDelegateTest.swift */; }; 6E4007162A153ABE0013C2DE /* ContactRemoteDataProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4007152A153ABE0013C2DE /* ContactRemoteDataProviderTest.swift */; }; 6E4007182A153AFE0013C2DE /* RemoteDataURLFactoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4007172A153AFE0013C2DE /* RemoteDataURLFactoryTest.swift */; }; 6E40868C2B8931C900435E2C /* AirshipViewSizeReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E40868B2B8931C900435E2C /* AirshipViewSizeReader.swift */; }; 6E40868E2B8D036600435E2C /* AirshipEmbeddedSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E40868D2B8D036600435E2C /* AirshipEmbeddedSize.swift */; }; 6E411B782538C4E600FEE4E8 /* UANativeBridge in Resources */ = {isa = PBXBuildFile; fileRef = 6E411B6C2538C4E500FEE4E8 /* UANativeBridge */; }; 6E411B7C2538C4E600FEE4E8 /* UANotificationCategories.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6E411B6D2538C4E600FEE4E8 /* UANotificationCategories.plist */; }; 6E411C782538C60900FEE4E8 /* UrbanAirship.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6E411C742538C60900FEE4E8 /* UrbanAirship.strings */; }; 6E411CAB2538C6A600FEE4E8 /* UARemoteData.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 6E411CA42538C6A500FEE4E8 /* UARemoteData.xcdatamodeld */; }; 6E411CAF2538C6A600FEE4E8 /* UAEvents.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 6E411CA72538C6A500FEE4E8 /* UAEvents.xcdatamodeld */; }; 6E43202226EA814F009228AB /* CoreTelephony.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3261A7F4243CD73100ADBF6B /* CoreTelephony.framework */; platformFilter = maccatalyst; }; 6E43219226EA89B6009228AB /* NativeBridgeDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E43218F26EA89B6009228AB /* NativeBridgeDelegate.swift */; }; 6E4325C32B7A9D9A00A9B000 /* AirshipPrivacyManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325C22B7A9D9A00A9B000 /* AirshipPrivacyManagerTest.swift */; }; 6E4325C52B7AC3F700A9B000 /* TestPush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325C42B7AC3F700A9B000 /* TestPush.swift */; }; 6E4325C62B7AC40D00A9B000 /* TestPush.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325C42B7AC3F700A9B000 /* TestPush.swift */; }; 6E4325CE2B7AD5A200A9B000 /* AirshipComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325CD2B7AD5A200A9B000 /* AirshipComponent.swift */; }; 6E4325D32B7AD96800A9B000 /* AirshipTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325D22B7AD96800A9B000 /* AirshipTest.swift */; }; 6E4325E92B7AEB1F00A9B000 /* AirshipEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325E82B7AEB1F00A9B000 /* AirshipEvent.swift */; }; 6E4325F22B7B1EDA00A9B000 /* SessionEventFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325F12B7B1EDA00A9B000 /* SessionEventFactory.swift */; }; 6E4325F62B7B2F5800A9B000 /* FeatureFlagAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325F52B7B2F5800A9B000 /* FeatureFlagAnalytics.swift */; }; 6E4325F82B7C08A600A9B000 /* AirshipAnalyticsFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4325F72B7C08A600A9B000 /* AirshipAnalyticsFeed.swift */; }; 6E4326012B7C327C00A9B000 /* AirshipEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4326002B7C327C00A9B000 /* AirshipEvents.swift */; }; 6E4326072B7C364300A9B000 /* AssociatedIdentifiersTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4326042B7C361F00A9B000 /* AssociatedIdentifiersTest.swift */; }; 6E4326092B7C396F00A9B000 /* TestAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BE1726E2C5940005D20D /* TestAnalytics.swift */; }; 6E4339EF2DFA03A3000A7741 /* JSONValueMatcherPredicates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4339EE2DFA039B000A7741 /* JSONValueMatcherPredicates.swift */; }; 6E4339F12DFA099F000A7741 /* AirshipIvyVersionMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4339F02DFA099C000A7741 /* AirshipIvyVersionMatcher.swift */; }; 6E44626729E6813A00CB2B56 /* AsyncSerialQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E44626629E6813A00CB2B56 /* AsyncSerialQueue.swift */; }; 6E46A273272B19760089CDE3 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E46A272272B19760089CDE3 /* ViewExtensions.swift */; }; 6E46A27C272B63680089CDE3 /* ThomasEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E46A27B272B63680089CDE3 /* ThomasEnvironment.swift */; }; 6E46A27F272B68660089CDE3 /* ThomasDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E46A27E272B68660089CDE3 /* ThomasDelegate.swift */; }; 6E475BFE2F5A3709003D8E42 /* VideoGroupState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E475BFD2F5A3709003D8E42 /* VideoGroupState.swift */; }; 6E475CBA2F5B3E45003D8E42 /* VideoMediaWebViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E475CB92F5B3E45003D8E42 /* VideoMediaWebViewTests.swift */; }; 6E49D7B228401D2E00C7BB9D /* Permission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7A628401D2D00C7BB9D /* Permission.swift */; }; 6E49D7B428401D2E00C7BB9D /* PermissionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7A728401D2D00C7BB9D /* PermissionDelegate.swift */; }; 6E49D7B628401D2E00C7BB9D /* NotificationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7A828401D2D00C7BB9D /* NotificationRegistrar.swift */; }; 6E49D7B828401D2E00C7BB9D /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7A928401D2D00C7BB9D /* Atomic.swift */; }; 6E49D7BA28401D2E00C7BB9D /* NotificationPermissionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7AA28401D2D00C7BB9D /* NotificationPermissionDelegate.swift */; }; 6E49D7BC28401D2E00C7BB9D /* PermissionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7AB28401D2D00C7BB9D /* PermissionsManager.swift */; }; 6E49D7BE28401D2E00C7BB9D /* RegistrationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7AC28401D2D00C7BB9D /* RegistrationDelegate.swift */; }; 6E49D7C028401D2E00C7BB9D /* UNNotificationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7AD28401D2D00C7BB9D /* UNNotificationRegistrar.swift */; }; 6E49D7C228401D2E00C7BB9D /* PermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7AE28401D2D00C7BB9D /* PermissionStatus.swift */; }; 6E49D7C428401D2E00C7BB9D /* PushNotificationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7AF28401D2E00C7BB9D /* PushNotificationDelegate.swift */; }; 6E49D7C628401D2E00C7BB9D /* Badger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7B028401D2E00C7BB9D /* Badger.swift */; }; 6E49D7C828401D2E00C7BB9D /* APNSRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7B128401D2E00C7BB9D /* APNSRegistrar.swift */; }; 6E49D7CD284028C600C7BB9D /* PermissionsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E49D7CC284028C600C7BB9D /* PermissionsManagerTests.swift */; }; 6E4A466128EF447C00A25617 /* MessageCenterUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A466028EF447C00A25617 /* MessageCenterUser.swift */; }; 6E4A466528EF448600A25617 /* MessageCenterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A466228EF448600A25617 /* MessageCenterStore.swift */; }; 6E4A466628EF448600A25617 /* MessageCenterAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A466328EF448600A25617 /* MessageCenterAPIClient.swift */; }; 6E4A466728EF448600A25617 /* MessageCenterMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A466428EF448600A25617 /* MessageCenterMessage.swift */; }; 6E4A467228EF44F600A25617 /* AirshipMessageCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CA0E423237E4A7B00EE76CF /* AirshipMessageCenter.framework */; }; 6E4A467928EF453400A25617 /* MessageCenterAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A467828EF453400A25617 /* MessageCenterAPIClientTest.swift */; }; 6E4A468028EF4FAF00A25617 /* TestAirshipRequestSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A467F28EF4FAF00A25617 /* TestAirshipRequestSession.swift */; }; 6E4A468128EF4FAF00A25617 /* TestAirshipRequestSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A467F28EF4FAF00A25617 /* TestAirshipRequestSession.swift */; }; 6E4A469F28F4A7DF00A25617 /* MessageCenterAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A469E28F4A7DF00A25617 /* MessageCenterAction.swift */; }; 6E4A46A128F4AEDF00A25617 /* MessageCenterNativeBridgeExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A46A028F4AEDF00A25617 /* MessageCenterNativeBridgeExtension.swift */; }; 6E4A4FDA2A30358F0049FEFC /* TagsActionArgs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A4FD92A30358F0049FEFC /* TagsActionArgs.swift */; }; 6E4A4FDE2A3132850049FEFC /* AirshipSDKModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A4FDD2A3132850049FEFC /* AirshipSDKModule.swift */; }; 6E4AEE0C2B6B24D7008AEAC1 /* AirshipAutomation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E0B872A294A9C120064B7BD /* AirshipAutomation.framework */; }; 6E4AEE272B6B2E0A008AEAC1 /* alternate-airship.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 6E4AEE222B6B2E09008AEAC1 /* alternate-airship.jpg */; }; 6E4AEE282B6B2E0A008AEAC1 /* DefaultAssetFileManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4AEE232B6B2E09008AEAC1 /* DefaultAssetFileManagerTest.swift */; }; 6E4AEE292B6B2E0A008AEAC1 /* airship.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 6E4AEE242B6B2E0A008AEAC1 /* airship.jpg */; }; 6E4AEE2A2B6B2E0A008AEAC1 /* AssetCacheManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4AEE252B6B2E0A008AEAC1 /* AssetCacheManagerTest.swift */; }; 6E4AEE2B2B6B2E0A008AEAC1 /* DefaultAssetDownloaderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4AEE262B6B2E0A008AEAC1 /* DefaultAssetDownloaderTest.swift */; }; 6E4AEE2C2B6B302D008AEAC1 /* ActionAutomationPreparerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15283D2B4F0B8200DF1377 /* ActionAutomationPreparerTest.swift */; }; 6E4AEE302B6B3041008AEAC1 /* InAppMessageThemeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D79C2B4F9E830099B6F3 /* InAppMessageThemeTest.swift */; }; 6E4AEE312B6B3A6A008AEAC1 /* Valid-UAInAppMessageModalStyle.plist in Resources */ = {isa = PBXBuildFile; fileRef = 459D40542092474300C40E2D /* Valid-UAInAppMessageModalStyle.plist */; }; 6E4AEE322B6B3A6A008AEAC1 /* Valid-UAInAppMessageBannerStyle.plist in Resources */ = {isa = PBXBuildFile; fileRef = 459D40562092474A00C40E2D /* Valid-UAInAppMessageBannerStyle.plist */; }; 6E4AEE332B6B3A6A008AEAC1 /* Valid-UAInAppMessageFullScreenStyle.plist in Resources */ = {isa = PBXBuildFile; fileRef = 459D4049208FE64D00C40E2D /* Valid-UAInAppMessageFullScreenStyle.plist */; }; 6E4AEE342B6B3A6A008AEAC1 /* Invalid-UAInAppMessageBannerStyle.plist in Resources */ = {isa = PBXBuildFile; fileRef = 459D40582092475500C40E2D /* Invalid-UAInAppMessageBannerStyle.plist */; }; 6E4AEE352B6B3A6A008AEAC1 /* Valid-UAInAppMessageHTMLStyle.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6EE6529222A7E3B800F7D54D /* Valid-UAInAppMessageHTMLStyle.plist */; }; 6E4AEE362B6B3A6A008AEAC1 /* Invalid-UAInAppMessageModalStyle.plist in Resources */ = {isa = PBXBuildFile; fileRef = 459D405A2092475C00C40E2D /* Invalid-UAInAppMessageModalStyle.plist */; }; 6E4AEE372B6B3A6A008AEAC1 /* Invalid-UAInAppMessageFullScreenStyle.plist in Resources */ = {isa = PBXBuildFile; fileRef = 459D404B208FE6BA00C40E2D /* Invalid-UAInAppMessageFullScreenStyle.plist */; }; 6E4AEE572B6B4358008AEAC1 /* UAAutomation.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 6E4AEE492B6B4358008AEAC1 /* UAAutomation.xcdatamodeld */; }; 6E4AEE642B6B44EA008AEAC1 /* LegacyAutomationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4AEE622B6B44EA008AEAC1 /* LegacyAutomationStore.swift */; }; 6E4AEE652B6B44EA008AEAC1 /* AutomationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4AEE632B6B44EA008AEAC1 /* AutomationStore.swift */; }; 6E4AEEBC2B6D6380008AEAC1 /* TriggerData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4AEEBB2B6D6380008AEAC1 /* TriggerData.swift */; }; 6E4D20722E6B761200A8D641 /* MessageCenterListViewWithNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D20712E6B760C00A8D641 /* MessageCenterListViewWithNavigation.swift */; }; 6E4D224A2E6F814000A8D641 /* MessageCenterMessageViewWithNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D22492E6F813700A8D641 /* MessageCenterMessageViewWithNavigation.swift */; }; 6E4D224C2E6F968A00A8D641 /* MessageCenterNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D224B2E6F968000A8D641 /* MessageCenterNavigationStack.swift */; }; 6E4D224E2E6F96B800A8D641 /* MessageCenterSplitNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D224D2E6F96B100A8D641 /* MessageCenterSplitNavigationView.swift */; }; 6E4D22502E6F9CF200A8D641 /* MessageCenterContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D224F2E6F9CD700A8D641 /* MessageCenterContent.swift */; }; 6E4D22522E6FA2F700A8D641 /* MessageCenterBackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D22512E6FA2F300A8D641 /* MessageCenterBackButton.swift */; }; 6E4D22542E6FA5ED00A8D641 /* MessageCenterWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D22532E6FA5EA00A8D641 /* MessageCenterWebView.swift */; }; 6E4D225D2E70ADE100A8D641 /* MessageCenterMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D225C2E70ADDB00A8D641 /* MessageCenterMessageViewModel.swift */; }; 6E4D225F2E70AFE800A8D641 /* MessageCenterListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4D225E2E70AFE300A8D641 /* MessageCenterListViewModel.swift */; }; 6E4E2E2829CEB222002E7682 /* ContactManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4E2E2729CEB222002E7682 /* ContactManagerProtocol.swift */; }; 6E4E5B3B26E7F91600198175 /* AirshipLocalizationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4E5B3926E7F91600198175 /* AirshipLocalizationUtils.swift */; }; 6E4E5B3D26E7F91600198175 /* Attributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4E5B3A26E7F91600198175 /* Attributes.swift */; }; 6E5213E32DCA7A3B00CF64B9 /* ThomasEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5213E22DCA7A3800CF64B9 /* ThomasEvent.swift */; }; 6E5214672DCAB03900CF64B9 /* ThomasFormResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5214662DCAB03600CF64B9 /* ThomasFormResult.swift */; }; 6E5214692DCABFCE00CF64B9 /* ThomasLayoutContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5214682DCABFCA00CF64B9 /* ThomasLayoutContext.swift */; }; 6E52146B2DCBF9BD00CF64B9 /* AirshipTimerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E52146A2DCBF9BA00CF64B9 /* AirshipTimerProtocol.swift */; }; 6E52146D2DCBFABE00CF64B9 /* ThomasPagerTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E52146C2DCBFAB900CF64B9 /* ThomasPagerTracker.swift */; }; 6E52146F2DCC075500CF64B9 /* ThomasPagerTrackerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E52146E2DCC075100CF64B9 /* ThomasPagerTrackerTest.swift */; }; 6E5215222DCEA12A00CF64B9 /* ThomasViewedPageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5215212DCEA10F00CF64B9 /* ThomasViewedPageInfo.swift */; }; 6E524C732C126F5F002CA094 /* AirshipEventType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524C722C126F5F002CA094 /* AirshipEventType.swift */; }; 6E524D022C1A2CAE002CA094 /* InAppMessageThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524D012C1A2CAE002CA094 /* InAppMessageThemeManager.swift */; }; 6E524D042C1A454E002CA094 /* InAppMessageThemeShadow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524D032C1A454E002CA094 /* InAppMessageThemeShadow.swift */; }; 6E55A4D72E1DB4F700B07DF8 /* ThomasAssociatedLabelResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E55A4D62E1DB4CB00B07DF8 /* ThomasAssociatedLabelResolver.swift */; }; 6E57CE3228DB8BDA00287601 /* LiveActivityUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E57CE3128DB8BDA00287601 /* LiveActivityUpdate.swift */; }; 6E57CE3728DBBD9A00287601 /* LiveActivityRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E57CE3628DBBD9A00287601 /* LiveActivityRegistry.swift */; }; 6E590E6E29A94CA90036DFAB /* AppStateTrackerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E590E6D29A94CA90036DFAB /* AppStateTrackerTest.swift */; }; 6E5A64C42AAB7D5C00574085 /* AirshipMeteredUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5A64C32AAB7D5C00574085 /* AirshipMeteredUsage.swift */; }; 6E5A64C82AABBE7100574085 /* MeteredUsageAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5A64C72AABBE7100574085 /* MeteredUsageAPIClient.swift */; }; 6E5A64D02AABBEAF00574085 /* AirshipMeteredUsageEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5A64CF2AABBEAF00574085 /* AirshipMeteredUsageEvent.swift */; }; 6E5A64D42AABBED600574085 /* MeteredUsageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5A64D32AABBED600574085 /* MeteredUsageStore.swift */; }; 6E5A64D92AABC5A400574085 /* UAMeteredUsage.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 6E5A64D72AABC5A400574085 /* UAMeteredUsage.xcdatamodeld */; }; 6E5ADF822D7682A300A03799 /* StateSubscriptionsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5ADF812D7682A200A03799 /* StateSubscriptionsModifier.swift */; }; 6E5ADF842D7682D600A03799 /* ThomasStateTrigger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5ADF832D7682D300A03799 /* ThomasStateTrigger.swift */; }; 6E5B1A052AFF090B0019CA61 /* SessionTrackerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5B1A042AFF090B0019CA61 /* SessionTrackerTest.swift */; }; 6E60EF6A29DF542B003F7A8D /* AnonContactData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E60EF6929DF542B003F7A8D /* AnonContactData.swift */; }; 6E6363E229DCD0CF009C358A /* ContactSubscriptionListClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6363E129DCD0CF009C358A /* ContactSubscriptionListClient.swift */; }; 6E6363E629DCE9A2009C358A /* ContactSubscriptionListAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6363E529DCE9A2009C358A /* ContactSubscriptionListAPIClientTest.swift */; }; 6E6363E829DCEB84009C358A /* ContactManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6363E729DCEB84009C358A /* ContactManagerTest.swift */; }; 6E6363EA29DCECA1009C358A /* TestContactSubscriptionListAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6363E929DCECA1009C358A /* TestContactSubscriptionListAPIClient.swift */; }; 6E6363EC29DDF84B009C358A /* SerialQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6363EB29DDF84B009C358A /* SerialQueue.swift */; }; 6E64C88027331ABA000EB887 /* PreferenceDataStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E64C87F27331ABA000EB887 /* PreferenceDataStoreTest.swift */; }; 6E65244C2A4FD4270019F353 /* DeviceTagSelectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E65244B2A4FD4270019F353 /* DeviceTagSelectorTest.swift */; }; 6E65244E2A4FD69F0019F353 /* DeviceAudienceSelectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E65244D2A4FD69F0019F353 /* DeviceAudienceSelectorTest.swift */; }; 6E6524502A4FD8D30019F353 /* JSONPredicateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E65244F2A4FD8D30019F353 /* JSONPredicateTest.swift */; }; 6E6541E02758976D009676CA /* AirshipProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6541DF2758976D009676CA /* AirshipProgressView.swift */; }; 6E65FB602C753CB400D9F341 /* EmbeddedViewSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E65FB5F2C753CB400D9F341 /* EmbeddedViewSelector.swift */; }; 6E664BA126C43F5400A2C8E5 /* ActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BA026C43F5400A2C8E5 /* ActivityViewController.swift */; }; 6E664BA726C4417400A2C8E5 /* ShareAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BA426C4417400A2C8E5 /* ShareAction.swift */; }; 6E664BCA26C4852B00A2C8E5 /* AddCustomEventAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BC926C4852B00A2C8E5 /* AddCustomEventAction.swift */; }; 6E664BD026C4916600A2C8E5 /* AddTagsAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BCF26C4916600A2C8E5 /* AddTagsAction.swift */; }; 6E664BD326C4917000A2C8E5 /* RemoveTagsAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BD226C4917000A2C8E5 /* RemoveTagsAction.swift */; }; 6E664BD926C4CD8700A2C8E5 /* PasteboardAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BD526C4CD8700A2C8E5 /* PasteboardAction.swift */; }; 6E664BDB26C4CD8700A2C8E5 /* EnableFeatureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BD626C4CD8700A2C8E5 /* EnableFeatureAction.swift */; }; 6E664BDD26C4CD8700A2C8E5 /* OpenExternalURLAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BD726C4CD8700A2C8E5 /* OpenExternalURLAction.swift */; }; 6E664BDF26C4CD8700A2C8E5 /* FetchDeviceInfoAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BD826C4CD8700A2C8E5 /* FetchDeviceInfoAction.swift */; }; 6E664BE526C5817B00A2C8E5 /* TestContact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BE426C5817B00A2C8E5 /* TestContact.swift */; }; 6E664BE726C5B21600A2C8E5 /* ModifyAttributesAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BE626C5B21600A2C8E5 /* ModifyAttributesAction.swift */; }; 6E664BEA26C6DB7600A2C8E5 /* AirshipUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E664BE926C6DB7500A2C8E5 /* AirshipUtils.swift */; }; 6E66BA7F2D14B61A0083A9FD /* WrappingLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E66BA7E2D14B61A0083A9FD /* WrappingLayout.swift */; }; 6E66DDA62E95A68300D44555 /* WorkRateLimiterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E66DDA52E95A67C00D44555 /* WorkRateLimiterTests.swift */; }; 6E68028B2B850DDE00F4591F /* ApplicationMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1512683DBC300A2CBD0 /* ApplicationMetrics.swift */; }; 6E68028C2B85149900F4591F /* ApplicationMetricsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E60EF6529DF4BB5003F7A8D /* ApplicationMetricsTest.swift */; }; 6E68028E2B852F6A00F4591F /* AutomationScheduleDataTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E68028D2B852F6A00F4591F /* AutomationScheduleDataTest.swift */; }; 6E6802902B8671E700F4591F /* InAppAutomationComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E68028F2B8671E700F4591F /* InAppAutomationComponent.swift */; }; 6E6802922B86732200F4591F /* PreferenceCenterComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6802912B86732200F4591F /* PreferenceCenterComponent.swift */; }; 6E6802942B8673F900F4591F /* FeatureFlagComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6802932B8673F900F4591F /* FeatureFlagComponent.swift */; }; 6E6802962B86749900F4591F /* MessageCenterComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6802952B86749900F4591F /* MessageCenterComponent.swift */; }; 6E6802982B8675A200F4591F /* DebugComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6802972B8675A200F4591F /* DebugComponent.swift */; }; 6E68203228EDE3E200A4F90B /* LiveActivityRestorer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E68203128EDE3E200A4F90B /* LiveActivityRestorer.swift */; }; 6E692AFD29E0CB2F00D96CCC /* JavaScriptCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E692AFC29E0CB2F00D96CCC /* JavaScriptCommand.swift */; }; 6E692AFF29E0CB4100D96CCC /* JavaScriptCommandDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E692AFE29E0CB4100D96CCC /* JavaScriptCommandDelegate.swift */; }; 6E692B0329E0CBB500D96CCC /* NativeBridgeExtensionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E692B0229E0CBB500D96CCC /* NativeBridgeExtensionDelegate.swift */; }; 6E698DEC26790AC300654DB2 /* PreferenceDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698DE826790AC300654DB2 /* PreferenceDataStore.swift */; }; 6E698DF226790AC300654DB2 /* AirshipPrivacyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698DEB26790AC300654DB2 /* AirshipPrivacyManager.swift */; }; 6E698E03267A799500654DB2 /* AirshipErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E02267A799500654DB2 /* AirshipErrors.swift */; }; 6E698E09267A7DD900654DB2 /* RemoteDataAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E08267A7DD900654DB2 /* RemoteDataAPIClient.swift */; }; 6E698E0C267A88D600654DB2 /* EventAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E0B267A88D600654DB2 /* EventAPIClient.swift */; }; 6E698E3D267BEDC300654DB2 /* DefaultAirshipContact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E3A267BEDC300654DB2 /* DefaultAirshipContact.swift */; }; 6E698E3F267BEDC300654DB2 /* AttributesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E3B267BEDC300654DB2 /* AttributesEditor.swift */; }; 6E698E41267BEDC300654DB2 /* TagGroupsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E3C267BEDC300654DB2 /* TagGroupsEditor.swift */; }; 6E698E57267BF63B00654DB2 /* AppStateTrackerAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E52267BF63A00654DB2 /* AppStateTrackerAdapter.swift */; }; 6E698E59267BF63B00654DB2 /* AppStateTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E53267BF63A00654DB2 /* AppStateTracker.swift */; }; 6E698E5F267BF63B00654DB2 /* ApplicationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E56267BF63B00654DB2 /* ApplicationState.swift */; }; 6E698E62267C03C700654DB2 /* TestAppStateTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E61267C03C700654DB2 /* TestAppStateTracker.swift */; }; 6E6A848D2B6854FC006FFB35 /* AutomationDelayProcessorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6A848C2B6854FC006FFB35 /* AutomationDelayProcessorTest.swift */; }; 6E6A84932B68A57E006FFB35 /* AutomationStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6A84912B68A571006FFB35 /* AutomationStoreTest.swift */; }; 6E6B2DBE2B33B768008BF788 /* AutomationScheduleTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6B2DBD2B33B768008BF788 /* AutomationScheduleTest.swift */; }; 6E6BD2422AE995DA00B9DFC9 /* DeferredResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2412AE995DA00B9DFC9 /* DeferredResolver.swift */; }; 6E6BD2462AEAFE7E00B9DFC9 /* DeferredAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2452AEAFE7E00B9DFC9 /* DeferredAPIClient.swift */; }; 6E6BD24A2AEAFEB700B9DFC9 /* AirsihpTriggerContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2492AEAFEB700B9DFC9 /* AirsihpTriggerContext.swift */; }; 6E6BD24E2AEAFEC500B9DFC9 /* AirshipStateOverrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD24D2AEAFEC500B9DFC9 /* AirshipStateOverrides.swift */; }; 6E6BD2582AEC598C00B9DFC9 /* DeferredAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2572AEC598C00B9DFC9 /* DeferredAPIClientTest.swift */; }; 6E6BD25A2AEC626B00B9DFC9 /* DeferredResolverTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2592AEC626B00B9DFC9 /* DeferredResolverTest.swift */; }; 6E6BD26D2AF1AC5700B9DFC9 /* AirshipCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD26C2AF1AC5700B9DFC9 /* AirshipCache.swift */; }; 6E6BD2722AF1B05500B9DFC9 /* UAirshipCache.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2702AF1B05500B9DFC9 /* UAirshipCache.xcdatamodeld */; }; 6E6BD2762AF1C1D800B9DFC9 /* DeferredFlagResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2752AF1C1D800B9DFC9 /* DeferredFlagResolver.swift */; }; 6E6BD2782AF2B97300B9DFC9 /* AirshipTaskSleeper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6BD2772AF2B97300B9DFC9 /* AirshipTaskSleeper.swift */; }; 6E6C3F7F27A20C3C007F55C7 /* ChannelScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6C3F7E27A20C3C007F55C7 /* ChannelScope.swift */; }; 6E6C3F8A27A266C0007F55C7 /* CachedValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6C3F8927A266C0007F55C7 /* CachedValue.swift */; }; 6E6C3F8D27A26992007F55C7 /* CachedValueTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6C3F8C27A26992007F55C7 /* CachedValueTest.swift */; }; 6E6C3F9A27A47DB4007F55C7 /* PreferenceCenterConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6C3F9927A47DB4007F55C7 /* PreferenceCenterConfig.swift */; }; 6E6C3F9E27A4C3D4007F55C7 /* AirshipJSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6C3F9D27A4C3D4007F55C7 /* AirshipJSON.swift */; }; 6E6C84462A5C8CFE00DD83A2 /* AirshipConfigTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6C84452A5C8CFD00DD83A2 /* AirshipConfigTest.swift */; }; 6E6CC38623A3F9B4003D583C /* PushDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6CC38023A3F9B4003D583C /* PushDataManager.swift */; }; 6E6ED13B2683A58D00A2CBD0 /* Dispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1352683A58D00A2CBD0 /* Dispatcher.swift */; }; 6E6ED1402683A9F200A2CBD0 /* AirshipDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED13F2683A9F200A2CBD0 /* AirshipDate.swift */; }; 6E6ED1432683B8FA00A2CBD0 /* TestDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1422683B8FA00A2CBD0 /* TestDate.swift */; }; 6E6ED1452683BC7F00A2CBD0 /* TestDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1442683BC7F00A2CBD0 /* TestDispatcher.swift */; }; 6E6ED1492683D8E200A2CBD0 /* LocaleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1482683D8E200A2CBD0 /* LocaleManager.swift */; }; 6E6ED15B2683DBC300A2CBD0 /* AirshipBase64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED14D2683DBC200A2CBD0 /* AirshipBase64.swift */; }; 6E6ED15F2683DBC300A2CBD0 /* AirshipCoreResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED14F2683DBC200A2CBD0 /* AirshipCoreResources.swift */; }; 6E6ED1612683DBC300A2CBD0 /* AirshipVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1502683DBC300A2CBD0 /* AirshipVersion.swift */; }; 6E6ED1672683DBC300A2CBD0 /* UACoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1532683DBC300A2CBD0 /* UACoreData.swift */; }; 6E6ED1692683DBC300A2CBD0 /* AirshipNetworkChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED1542683DBC300A2CBD0 /* AirshipNetworkChecker.swift */; }; 6E6ED172268448EC00A2CBD0 /* TestNetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED171268448EC00A2CBD0 /* TestNetworkMonitor.swift */; }; 6E6EF9E7270625C400D30C35 /* AirshipEventsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6EF9E6270625C400D30C35 /* AirshipEventsTest.swift */; }; 6E71129D2880DACB004942E4 /* EventHandlerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7112962880DACB004942E4 /* EventHandlerViewModifier.swift */; }; 6E7112A12880DACB004942E4 /* EnableBehaviorModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7112982880DACB004942E4 /* EnableBehaviorModifiers.swift */; }; 6E7112A32880DACB004942E4 /* VisibilityViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7112992880DACB004942E4 /* VisibilityViewModifier.swift */; }; 6E7112A52880DACB004942E4 /* StateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E71129A2880DACB004942E4 /* StateController.swift */; }; 6E739D6626B9BDC100BC6F6D /* ChannelBulkUpdateAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E739D6526B9BDC100BC6F6D /* ChannelBulkUpdateAPIClient.swift */; }; 6E739D6B26B9DFFB00BC6F6D /* AttributePendingMutations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E739D6A26B9DFFB00BC6F6D /* AttributePendingMutations.swift */; }; 6E739D6E26B9F58700BC6F6D /* TagGroupMutations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E739D6D26B9F58700BC6F6D /* TagGroupMutations.swift */; }; 6E739D7F26BAFCB800BC6F6D /* TestChannelBulkUpdateAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E739D7E26BAFCB800BC6F6D /* TestChannelBulkUpdateAPIClient.swift */; }; 6E739D8226BB33A200BC6F6D /* ChannelBulkUpdateAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E739D8126BB33A200BC6F6D /* ChannelBulkUpdateAPIClientTest.swift */; }; 6E75F50529C4EAF600E3585A /* AudienceOverridesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75F50429C4EAF600E3585A /* AudienceOverridesProvider.swift */; }; 6E77CD4A2D8A225E0057A52C /* SMSValidatorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E77CD492D8A225E0057A52C /* SMSValidatorAPIClient.swift */; }; 6E77CD4B2D8A225E0057A52C /* AirshipInputValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E77CD472D8A225E0057A52C /* AirshipInputValidator.swift */; }; 6E77CE472D8A28B90057A52C /* CachingSMSValidatorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E77CE462D8A28B10057A52C /* CachingSMSValidatorAPIClient.swift */; }; 6E78848F29B9643C00ACAE45 /* AirshipContact.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E78848E29B9643C00ACAE45 /* AirshipContact.swift */; }; 6E7DB38328ECDC41002725F6 /* LiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7DB38228ECDC41002725F6 /* LiveActivity.swift */; }; 6E7DB38E28ECFCED002725F6 /* AirshipJSONTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7DB38D28ECFCED002725F6 /* AirshipJSONTest.swift */; }; 6E7DB39228ED0D7C002725F6 /* LiveActivityRegistryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7DB38A28ECDDD9002725F6 /* LiveActivityRegistryTest.swift */; }; 6E7E770D2DDFD0D80042086D /* AirshipAsyncSemaphore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7E770C2DDFD0D80042086D /* AirshipAsyncSemaphore.swift */; }; 6E7E770F2DDFD10A0042086D /* AirshipAsyncSemaphoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7E770E2DDFD1040042086D /* AirshipAsyncSemaphoreTest.swift */; }; 6E7EACD12AF4192400DA286B /* AirshipCacheTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7EACD02AF4192400DA286B /* AirshipCacheTest.swift */; }; 6E82482229A6D9DF00136EA0 /* CancellableValueHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E82482129A6D9DF00136EA0 /* CancellableValueHolder.swift */; }; 6E82483829A6E1BE00136EA0 /* AirshipCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E82483729A6E1BE00136EA0 /* AirshipCancellable.swift */; }; 6E8746492D8A3C71002469D7 /* TestSMSValidatorAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8746482D8A3C64002469D7 /* TestSMSValidatorAPIClient.swift */; }; 6E87BD6426D594870005D20D /* ChannelRegistrationPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BD6326D594870005D20D /* ChannelRegistrationPayload.swift */; }; 6E87BD6726D6A39A0005D20D /* AirshipConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BD6626D6A39A0005D20D /* AirshipConfig.swift */; }; 6E87BD8326D757CA0005D20D /* CloudSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BD8226D757CA0005D20D /* CloudSite.swift */; }; 6E87BD8D26D815780005D20D /* AppIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BD8C26D815780005D20D /* AppIntegration.swift */; }; 6E87BD9226D963B60005D20D /* DefaultAppIntegrationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BD9126D963B60005D20D /* DefaultAppIntegrationDelegate.swift */; }; 6E87BD9D26DD78CC0005D20D /* DefaultAppIntegrationDelegateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BD9C26DD78CC0005D20D /* DefaultAppIntegrationDelegateTest.swift */; }; 6E87BD9F26DDDB250005D20D /* AppIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BD9E26DDDB250005D20D /* AppIntegrationTests.swift */; }; 6E87BDBD26E01FF40005D20D /* ModuleLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BDBC26E01FF40005D20D /* ModuleLoader.swift */; }; 6E87BE0126E283850005D20D /* Airship.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BDFD26E283840005D20D /* Airship.swift */; }; 6E87BE0726E283850005D20D /* DeepLinkDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BE0026E283850005D20D /* DeepLinkDelegate.swift */; }; 6E87BE1326E28F570005D20D /* AirshipInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BE1226E28F570005D20D /* AirshipInstance.swift */; }; 6E87BE1626E29BC90005D20D /* TestAirshipInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BE1526E29BC90005D20D /* TestAirshipInstance.swift */; }; 6E87BE1B26E2C59D0005D20D /* TestAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BE1726E2C5940005D20D /* TestAnalytics.swift */; }; 6E88739A2763D8AB00AC248A /* AirshipImageProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8873992763D8AB00AC248A /* AirshipImageProvider.swift */; }; 6E887CD1272C5E8400E83363 /* CheckboxState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E887CD0272C5E8400E83363 /* CheckboxState.swift */; }; 6E887CD3272C5F5000E83363 /* Checkbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E887CD2272C5F5000E83363 /* Checkbox.swift */; }; 6E887CD5272C5F5A00E83363 /* CheckboxController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E887CD4272C5F5A00E83363 /* CheckboxController.swift */; }; 6E892F2E2E7A193E00FB0EC4 /* PreferenceCenterContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E892F2D2E7A193200FB0EC4 /* PreferenceCenterContent.swift */; }; 6E8932982E7B666000FB0EC4 /* APNSRegistrationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8932972E7B665200FB0EC4 /* APNSRegistrationResult.swift */; }; 6E89329A2E7B66C300FB0EC4 /* NotificationRegistrationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8932992E7B66B600FB0EC4 /* NotificationRegistrationResult.swift */; }; 6E8B4BF12888606D00AA336E /* ChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8B4BEF2888606400AA336E /* ChannelTest.swift */; }; 6E8BDA172B62EC9F00711DB8 /* AutomationRemoteDataSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8BDA162B62EC9F00711DB8 /* AutomationRemoteDataSubscriber.swift */; }; 6E8BDEFE2A67938200F816D9 /* FeatureFlagInfoTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8BDEFC2A67937E00F816D9 /* FeatureFlagInfoTest.swift */; }; 6E8BDF012A679E5000F816D9 /* FeatureFlagRemoteDataAccessTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8BDEFF2A679CD100F816D9 /* FeatureFlagRemoteDataAccessTest.swift */; }; 6E8BDF022A684FC700F816D9 /* TestRemoteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B70426CE07180099C92D /* TestRemoteData.swift */; }; 6E8CE762284137D600CF4B11 /* AirshipPushTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E8CE761284137D600CF4B11 /* AirshipPushTest.swift */; }; 6E916C572DB30DA200C676FA /* AirshipWindowFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E916C562DB30D9E00C676FA /* AirshipWindowFactory.swift */; }; 6E91B43C26868A6300DDB1A8 /* CircularRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91B43B26868A6300DDB1A8 /* CircularRegion.swift */; }; 6E91B44026868C3400DDB1A8 /* RegionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91B43E26868C3400DDB1A8 /* RegionEvent.swift */; }; 6E91B44226868C3400DDB1A8 /* ProximityRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91B43F26868C3400DDB1A8 /* ProximityRegion.swift */; }; 6E91B4452686911B00DDB1A8 /* EventUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91B4442686911B00DDB1A8 /* EventUtils.swift */; }; 6E91B4692689327D00DDB1A8 /* CustomEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91B4662689327D00DDB1A8 /* CustomEvent.swift */; }; 6E91E43A28EF423400B6F25E /* WorkBackgroundTasks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E42F28EF423300B6F25E /* WorkBackgroundTasks.swift */; }; 6E91E43D28EF423400B6F25E /* WorkConditionsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E43028EF423300B6F25E /* WorkConditionsMonitor.swift */; }; 6E91E44028EF423400B6F25E /* Worker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E43128EF423300B6F25E /* Worker.swift */; }; 6E91E44328EF423400B6F25E /* WorkRateLimiterActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E43228EF423300B6F25E /* WorkRateLimiterActor.swift */; }; 6E91E44628EF423400B6F25E /* AirshipWorkManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E43328EF423300B6F25E /* AirshipWorkManagerProtocol.swift */; }; 6E91E44C28EF423400B6F25E /* AirshipWorkRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E43528EF423300B6F25E /* AirshipWorkRequest.swift */; }; 6E91E45228EF423400B6F25E /* AirshipWorkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E43728EF423300B6F25E /* AirshipWorkManager.swift */; }; 6E91E45828EF423400B6F25E /* AirshipWorkResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E91E43928EF423400B6F25E /* AirshipWorkResult.swift */; }; 6E92EC8A284933750038802D /* PromptPermissionAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92EC89284933750038802D /* PromptPermissionAction.swift */; }; 6E92EC8D2849378E0038802D /* PermissionPrompter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92EC8C2849378E0038802D /* PermissionPrompter.swift */; }; 6E92EC90284954B10038802D /* ButtonState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92EC8F284954B10038802D /* ButtonState.swift */; }; 6E92ECA1284A79AB0038802D /* PromptPermissionActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92ECA0284A79AB0038802D /* PromptPermissionActionTest.swift */; }; 6E92ECA5284A7A5C0038802D /* TestPermissionPrompter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92ECA2284A7A2A0038802D /* TestPermissionPrompter.swift */; }; 6E92ECA7284AC1120038802D /* EnableFeatureActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92ECA6284AC1120038802D /* EnableFeatureActionTest.swift */; }; 6E92ECAC284EA7DB0038802D /* AnalyticsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92ECAB284EA7DA0038802D /* AnalyticsTest.swift */; }; 6E92ECB1284ECE590038802D /* CachedList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92ECB0284ECE590038802D /* CachedList.swift */; }; 6E92ECB6284ED7330038802D /* CachedListTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E92ECB3284ED6F10038802D /* CachedListTest.swift */; }; 6E938DBC2AC39A0500F691D9 /* FeatureFlagAnalyticsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E938DBB2AC39A0500F691D9 /* FeatureFlagAnalyticsTest.swift */; }; 6E94760F29BA8FA30025F364 /* ContactManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E94760E29BA8FA30025F364 /* ContactManager.swift */; }; 6E94761529BBC0240025F364 /* AirshipButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E94761429BBC0230025F364 /* AirshipButton.swift */; }; 6E952920268A6C1500398B54 /* SearchEventTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E95291F268A6C1500398B54 /* SearchEventTemplate.swift */; }; 6E952923268B812000398B54 /* AccountEventTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E952922268B812000398B54 /* AccountEventTemplate.swift */; }; 6E952926268B8F6600398B54 /* AssociatedIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E952925268B8F6500398B54 /* AssociatedIdentifiers.swift */; }; 6E95292C268B98A200398B54 /* MediaEventTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E95292B268B98A200398B54 /* MediaEventTemplate.swift */; }; 6E95292F268BBD7D00398B54 /* RetailEventTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E95292E268BBD7D00398B54 /* RetailEventTemplate.swift */; }; 6E96ECF2293EB7900053CC91 /* AirshipEventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ECF1293EB7900053CC91 /* AirshipEventData.swift */; }; 6E96ECF6293FCE080053CC91 /* EventUploadScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ECF5293FCE080053CC91 /* EventUploadScheduler.swift */; }; 6E96ECFA293FDDD90053CC91 /* AirshipSDKExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ECF9293FDDD90053CC91 /* AirshipSDKExtension.swift */; }; 6E96ED02294115210053CC91 /* AsyncStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED01294115210053CC91 /* AsyncStream.swift */; }; 6E96ED0A294135500053CC91 /* EventManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED09294135500053CC91 /* EventManager.swift */; }; 6E96ED0E29416E820053CC91 /* EventManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED0D29416E820053CC91 /* EventManagerTest.swift */; }; 6E96ED1029416E8F0053CC91 /* EventAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED0F29416E8F0053CC91 /* EventAPIClientTest.swift */; }; 6E96ED1229416E990053CC91 /* EventSchedulerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED1129416E990053CC91 /* EventSchedulerTest.swift */; }; 6E96ED1429417A600053CC91 /* EventStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED1329417A600053CC91 /* EventStoreTest.swift */; }; 6E96ED16294197D90053CC91 /* EventUploadTuningInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED15294197D90053CC91 /* EventUploadTuningInfo.swift */; }; 6E96ED1A2941A0EC0053CC91 /* EventTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E96ED192941A0EC0053CC91 /* EventTestUtils.swift */; }; 6E9752562A5F79E200E67B1A /* ExperimentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9752552A5F79E200E67B1A /* ExperimentTest.swift */; }; 6E97D6AD2D84B1660001CF7F /* ThomasFormStateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E97D6AC2D84B1610001CF7F /* ThomasFormStateTest.swift */; }; 6E97D6AF2D84B17D0001CF7F /* ThomasFormDataCollectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E97D6AE2D84B1780001CF7F /* ThomasFormDataCollectorTest.swift */; }; 6E97D6B12D84B18E0001CF7F /* ThomasFormDataCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E97D6B02D84B1890001CF7F /* ThomasFormDataCollector.swift */; }; 6E97D6B42D84B1D70001CF7F /* ThomasStateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E97D6B32D84B1D10001CF7F /* ThomasStateTest.swift */; }; 6E97D6B62D84B2350001CF7F /* ThomasFormFieldTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E97D6B52D84B2330001CF7F /* ThomasFormFieldTest.swift */; }; 6E986EE42B448D3C00FBE6A0 /* AutomationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E986EE32B448D3C00FBE6A0 /* AutomationEngine.swift */; }; 6E986EF92B44D41E00FBE6A0 /* InAppAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E986EF82B44D41E00FBE6A0 /* InAppAutomation.swift */; }; 6E986EFB2B44D48C00FBE6A0 /* InAppMessaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E986EFA2B44D48C00FBE6A0 /* InAppMessaging.swift */; }; 6E986F062B47319E00FBE6A0 /* DeferredScheduleResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E986F052B47319E00FBE6A0 /* DeferredScheduleResult.swift */; }; 6E986F0E2B473EC700FBE6A0 /* AutomationRemoteDataAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E986F0D2B473EC700FBE6A0 /* AutomationRemoteDataAccess.swift */; }; 6E9B4874288F0CE000C905B1 /* RateAppAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B4873288F0CE000C905B1 /* RateAppAction.swift */; }; 6E9B4878288F360C00C905B1 /* RateAppActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B4877288F360C00C905B1 /* RateAppActionTest.swift */; }; 6E9B488B2891962000C905B1 /* PreferenceCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B488A2891962000C905B1 /* PreferenceCenterView.swift */; }; 6E9B488D2891B43F00C905B1 /* LabeledSectionBreakView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B488C2891B43F00C905B1 /* LabeledSectionBreakView.swift */; }; 6E9B488F2891B57300C905B1 /* CommonSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B488E2891B57300C905B1 /* CommonSectionView.swift */; }; 6E9B48912891B68C00C905B1 /* PreferenceCenterAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B48902891B68B00C905B1 /* PreferenceCenterAlertView.swift */; }; 6E9B48932891B6A700C905B1 /* ChannelSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B48922891B6A700C905B1 /* ChannelSubscriptionView.swift */; }; 6E9B48952891B6B400C905B1 /* ContactSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B48942891B6B400C905B1 /* ContactSubscriptionView.swift */; }; 6E9B48972891B6BF00C905B1 /* ContactSubscriptionGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9B48962891B6BF00C905B1 /* ContactSubscriptionGroupView.swift */; }; 6E9C2B7D2D014438000089A9 /* APNSEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2B7C2D014426000089A9 /* APNSEnvironment.swift */; }; 6E9C2BD02D02321D000089A9 /* RuntimeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2BCF2D023216000089A9 /* RuntimeConfig.swift */; }; 6E9C2BD32D02683D000089A9 /* RuntimeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2BCF2D023216000089A9 /* RuntimeConfig.swift */; }; 6E9C2BD42D02683D000089A9 /* RuntimeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2BCF2D023216000089A9 /* RuntimeConfig.swift */; }; 6E9C2BD52D02683D000089A9 /* RuntimeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2BCF2D023216000089A9 /* RuntimeConfig.swift */; }; 6E9C2BD62D02683D000089A9 /* RuntimeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2BCF2D023216000089A9 /* RuntimeConfig.swift */; }; 6E9C2BD72D0269AE000089A9 /* TestAirshipRequestSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A467F28EF4FAF00A25617 /* TestAirshipRequestSession.swift */; }; 6E9C2BD82D0269AE000089A9 /* TestAirshipRequestSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4A467F28EF4FAF00A25617 /* TestAirshipRequestSession.swift */; }; 6E9C2BDA2D027B5F000089A9 /* AirshipAppCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2BD92D027B5A000089A9 /* AirshipAppCredentials.swift */; }; 6E9C2BDC2D028034000089A9 /* APNSEnvironmentTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9C2BDB2D028030000089A9 /* APNSEnvironmentTest.swift */; }; 6E9D529826C195F7004EA16B /* ActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9D529726C195F7004EA16B /* ActionRunner.swift */; }; 6E9D529B26C1A77C004EA16B /* ActionRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E9D529A26C1A77C004EA16B /* ActionRegistry.swift */; }; 6EA5202327D1364E003011CA /* AirshipDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EA5202227D1364E003011CA /* AirshipDateFormatter.swift */; }; 6EAA61492D5297A7006602F7 /* SubjectExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAA61482D5297A2006602F7 /* SubjectExtension.swift */; }; 6EAC295A27580063006DFA63 /* ChannelRegistrarTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAC295927580063006DFA63 /* ChannelRegistrarTest.swift */; }; 6EAD3AF82F45305E00FF274E /* AirshipSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAD3AF72F45305B00FF274E /* AirshipSwizzler.swift */; }; 6EAD3AFA2F4530BE00FF274E /* AutoIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAD3AF92F4530AD00FF274E /* AutoIntegration.swift */; }; 6EAD3AFC2F4530F600FF274E /* UAAppIntegrationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAD3AFB2F4530E400FF274E /* UAAppIntegrationDelegate.swift */; }; 6EAD7CE526B216DB00B88EA7 /* DeepLinkAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAD7CE426B216DB00B88EA7 /* DeepLinkAction.swift */; }; 6EB11C872697ACBF00DC698F /* ContactOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB11C862697ACBF00DC698F /* ContactOperation.swift */; }; 6EB11C892697AF5600DC698F /* TagGroupUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB11C882697AF5600DC698F /* TagGroupUpdate.swift */; }; 6EB11C8B2697AFC700DC698F /* AttributeUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB11C8A2697AFC700DC698F /* AttributeUpdate.swift */; }; 6EB11C8D2698C50F00DC698F /* AudienceUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB11C8C2698C50F00DC698F /* AudienceUtils.swift */; }; 6EB1B3F326EAA4D6000421B9 /* ChannelAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698E11267A98AB00654DB2 /* ChannelAPIClient.swift */; }; 6EB214D22E7DBA61001A5660 /* FeatureFlagManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB214D12E7DBA5E001A5660 /* FeatureFlagManagerProtocol.swift */; }; 6EB21A682E81BB6E001A5660 /* AirshipDebugAddEmailChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A4C2E81BB6E001A5660 /* AirshipDebugAddEmailChannelView.swift */; }; 6EB21A692E81BB6E001A5660 /* AirshipDebugChannelTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A412E81BB6E001A5660 /* AirshipDebugChannelTagView.swift */; }; 6EB21A6A2E81BB6E001A5660 /* AirshipDebugAutomationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A3E2E81BB6E001A5660 /* AirshipDebugAutomationsView.swift */; }; 6EB21A6B2E81BB6E001A5660 /* AirshipDebugFeatureFlagDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A532E81BB6E001A5660 /* AirshipDebugFeatureFlagDetailsView.swift */; }; 6EB21A6C2E81BB6E001A5660 /* AirshipDebugExperimentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A3C2E81BB6E001A5660 /* AirshipDebugExperimentsView.swift */; }; 6EB21A6D2E81BB6E001A5660 /* AirshipDebugExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A452E81BB6E001A5660 /* AirshipDebugExtensions.swift */; }; 6EB21A6E2E81BB6E001A5660 /* AirshipDebugAddEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A342E81BB6E001A5660 /* AirshipDebugAddEventView.swift */; }; 6EB21A6F2E81BB6E001A5660 /* AirshipDebugPushDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A5D2E81BB6E001A5660 /* AirshipDebugPushDetailsView.swift */; }; 6EB21A702E81BB6E001A5660 /* AirshipDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A662E81BB6E001A5660 /* AirshipDebugView.swift */; }; 6EB21A712E81BB6E001A5660 /* AirshipDebugAddSMSChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A4E2E81BB6E001A5660 /* AirshipDebugAddSMSChannelView.swift */; }; 6EB21A722E81BB6E001A5660 /* AirshipJSONDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A472E81BB6E001A5660 /* AirshipJSONDetailsView.swift */; }; 6EB21A732E81BB6E001A5660 /* AirshipDebugAnalyticIdentifierEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A352E81BB6E001A5660 /* AirshipDebugAnalyticIdentifierEditorView.swift */; }; 6EB21A742E81BB6E001A5660 /* AirshipDebugContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A642E81BB6E001A5660 /* AirshipDebugContentView.swift */; }; 6EB21A752E81BB6E001A5660 /* AirshipDebugRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A652E81BB6E001A5660 /* AirshipDebugRoute.swift */; }; 6EB21A772E81BB6E001A5660 /* AirshipDebugPreferencCenterItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A572E81BB6E001A5660 /* AirshipDebugPreferencCenterItemView.swift */; }; 6EB21A792E81BB6E001A5660 /* AirshipDebugAttributesEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A442E81BB6E001A5660 /* AirshipDebugAttributesEditorView.swift */; }; 6EB21A7A2E81BB6E001A5660 /* AirshipToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A492E81BB6E001A5660 /* AirshipToast.swift */; }; 6EB21A7B2E81BB6E001A5660 /* AirshipDebugFeatureFlagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A552E81BB6E001A5660 /* AirshipDebugFeatureFlagView.swift */; }; 6EB21A7C2E81BB6E001A5660 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A4A2E81BB6E001A5660 /* Extensions.swift */; }; 6EB21A7D2E81BB6E001A5660 /* AirshipDebugTagGroupsEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A462E81BB6E001A5660 /* AirshipDebugTagGroupsEditorView.swift */; }; 6EB21A7E2E81BB6E001A5660 /* AirshipDebugAppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A3A2E81BB6E001A5660 /* AirshipDebugAppInfoView.swift */; }; 6EB21A7F2E81BB6E001A5660 /* AirshipDebugEventDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A372E81BB6E001A5660 /* AirshipDebugEventDetailsView.swift */; }; 6EB21A802E81BB6E001A5660 /* AirshipDebugPrivacyManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A5B2E81BB6E001A5660 /* AirshipDebugPrivacyManagerView.swift */; }; 6EB21A812E81BB6E001A5660 /* AirshipDebugEventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A382E81BB6E001A5660 /* AirshipDebugEventsView.swift */; }; 6EB21A822E81BB6E001A5660 /* AirshipDebugContactSubscriptionEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A4F2E81BB6E001A5660 /* AirshipDebugContactSubscriptionEditorView.swift */; }; 6EB21A832E81BB6E001A5660 /* AirshipDebugPreferenceCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A582E81BB6E001A5660 /* AirshipDebugPreferenceCenterView.swift */; }; 6EB21A842E81BB6E001A5660 /* AirshipDebugAnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A362E81BB6E001A5660 /* AirshipDebugAnalyticsView.swift */; }; 6EB21A852E81BB6E001A5660 /* AirshipDebugReceivedPushView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A5F2E81BB6E001A5660 /* AirshipDebugReceivedPushView.swift */; }; 6EB21A862E81BB6E001A5660 /* AirshipDebugPushView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A5E2E81BB6E001A5660 /* AirshipDebugPushView.swift */; }; 6EB21A872E81BB6E001A5660 /* AirshipDebugNamedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A512E81BB6E001A5660 /* AirshipDebugNamedUserView.swift */; }; 6EB21A882E81BB6E001A5660 /* AirshipJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A482E81BB6E001A5660 /* AirshipJSONView.swift */; }; 6EB21A892E81BB6E001A5660 /* AirshipDebugChannelSubscriptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A402E81BB6E001A5660 /* AirshipDebugChannelSubscriptionsView.swift */; }; 6EB21A8A2E81BB6E001A5660 /* AirshipDebugInAppExperiencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A3D2E81BB6E001A5660 /* AirshipDebugInAppExperiencesView.swift */; }; 6EB21A8B2E81BB6E001A5660 /* TVSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A622E81BB6E001A5660 /* TVSlider.swift */; }; 6EB21A8C2E81BB6E001A5660 /* TVDatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A612E81BB6E001A5660 /* TVDatePicker.swift */; }; 6EB21A8D2E81BB6E001A5660 /* AirshipDebugChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A422E81BB6E001A5660 /* AirshipDebugChannelView.swift */; }; 6EB21A8E2E81BB6E001A5660 /* AirshipDebugContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A502E81BB6E001A5660 /* AirshipDebugContactView.swift */; }; 6EB21A8F2E81BB6E001A5660 /* AirshipDebugAddOpenChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A4D2E81BB6E001A5660 /* AirshipDebugAddOpenChannelView.swift */; }; 6EB21A912E81BFC1001A5660 /* AirshipDebugAddPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A902E81BFB9001A5660 /* AirshipDebugAddPropertyView.swift */; }; 6EB21A932E81C7AB001A5660 /* AirshipDebugAddStringPropertyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A922E81C7A2001A5660 /* AirshipDebugAddStringPropertyView.swift */; }; 6EB21AFC2E8216A4001A5660 /* AirshipoDebugTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21AFB2E82169F001A5660 /* AirshipoDebugTriggers.swift */; }; 6EB21B5F2E82FE9F001A5660 /* AirshipDebugAudienceSubject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21B5E2E82FE98001A5660 /* AirshipDebugAudienceSubject.swift */; }; 6EB3FCEF2ABCFA680018594E /* RemoteDataProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB3FCEE2ABCFA680018594E /* RemoteDataProtocol.swift */; }; 6EB5156E28A42B5800870C5A /* AirshipPreferenceCenterResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB5156D28A42B5800870C5A /* AirshipPreferenceCenterResources.swift */; }; 6EB5157128A4608C00870C5A /* PreferenceCenterViewControllerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB5157028A4608C00870C5A /* PreferenceCenterViewControllerFactory.swift */; }; 6EB5158128A47BD700870C5A /* SubscriptionListEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB5158028A47BD700870C5A /* SubscriptionListEdit.swift */; }; 6EB5158328A47C7100870C5A /* ScopedSubscriptionListEdit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB5158228A47C7100870C5A /* ScopedSubscriptionListEdit.swift */; }; 6EB5159228A5B1B400870C5A /* TestLegacyTheme.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6EB5159128A5B1B400870C5A /* TestLegacyTheme.plist */; }; 6EB5159428A5B8E900870C5A /* TestTheme.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6EB5159328A5B8E900870C5A /* TestTheme.plist */; }; 6EB5159528A5B96300870C5A /* PreferenceThemeLoaderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB5158E28A5B15C00870C5A /* PreferenceThemeLoaderTest.swift */; }; 6EB5159728A5C54400870C5A /* TestThemeEmpty.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6EB5159628A5C54400870C5A /* TestThemeEmpty.plist */; }; 6EB5159928A5C61D00870C5A /* TestThemeInvalid.plist in Resources */ = {isa = PBXBuildFile; fileRef = 6EB5159828A5C61D00870C5A /* TestThemeInvalid.plist */; }; 6EB515A328A5F1C600870C5A /* PreferenceCenterStateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB515A228A5F1C600870C5A /* PreferenceCenterStateTest.swift */; }; 6EB839472BC83B9D006611C4 /* DefaultInAppActionRunnerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB839452BC83B96006611C4 /* DefaultInAppActionRunnerTest.swift */; }; 6EB839492BC8898E006611C4 /* AirshipAsyncChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB839482BC8898E006611C4 /* AirshipAsyncChannel.swift */; }; 6EB8394C2BC8AB51006611C4 /* TestWorkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63A567528F449D8004B8951 /* TestWorkManager.swift */; }; 6EB8394E2BC8B1F4006611C4 /* AirshipAsyncChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB8394D2BC8B1F4006611C4 /* AirshipAsyncChannelTest.swift */; }; 6EBD12052DA73FDA00F678AB /* ValidatableHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EBD12042DA73FD300F678AB /* ValidatableHelper.swift */; }; 6EBFA9AD2D15DA73002BA3E9 /* HashChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EBFA9AC2D15DA70002BA3E9 /* HashChecker.swift */; }; 6EBFA9AF2D15E04D002BA3E9 /* AirshipDeviceAudienceResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EBFA9AE2D15E04B002BA3E9 /* AirshipDeviceAudienceResult.swift */; }; 6EBFA9B12D15F499002BA3E9 /* HashCheckerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EBFA9B02D15F491002BA3E9 /* HashCheckerTest.swift */; }; 6EC0CA4F2B48987700333A87 /* AutomationRemoteDataAccessTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA4E2B48987700333A87 /* AutomationRemoteDataAccessTest.swift */; }; 6EC0CA502B4899CC00333A87 /* TestRemoteData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E15B70426CE07180099C92D /* TestRemoteData.swift */; }; 6EC0CA512B4899DB00333A87 /* TestNetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E6ED171268448EC00A2CBD0 /* TestNetworkMonitor.swift */; }; 6EC0CA532B48A2C300333A87 /* AutomationAudience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA522B48A2C300333A87 /* AutomationAudience.swift */; }; 6EC0CA562B48B05600333A87 /* ActionAutomationExecutorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA542B48B05000333A87 /* ActionAutomationExecutorTest.swift */; }; 6EC0CA5C2B48C2F500333A87 /* AutomationPreparer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA5B2B48C2F500333A87 /* AutomationPreparer.swift */; }; 6EC0CA682B49287100333A87 /* AutomationExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA672B49287100333A87 /* AutomationExecutor.swift */; }; 6EC0CA6B2B4B698000333A87 /* AutomationExecutorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA6A2B4B698000333A87 /* AutomationExecutorTest.swift */; }; 6EC0CA6D2B4B879800333A87 /* AutomationPreparerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA6C2B4B879800333A87 /* AutomationPreparerTest.swift */; }; 6EC0CA6F2B4B893500333A87 /* TestDeferredResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA6E2B4B893500333A87 /* TestDeferredResolver.swift */; }; 6EC0CA702B4B895A00333A87 /* TestDeferredResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA6E2B4B893500333A87 /* TestDeferredResolver.swift */; }; 6EC0CA722B4B897B00333A87 /* TestExperimentDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA712B4B897B00333A87 /* TestExperimentDataProvider.swift */; }; 6EC0CA732B4B897B00333A87 /* TestExperimentDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA712B4B897B00333A87 /* TestExperimentDataProvider.swift */; }; 6EC0CA762B4B8A3A00333A87 /* TestRemoteDataAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA752B4B8A3A00333A87 /* TestRemoteDataAccess.swift */; }; 6EC0CA782B4B8A4700333A87 /* TestFrequencyLimitsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA772B4B8A4700333A87 /* TestFrequencyLimitsManager.swift */; }; 6EC0CA792B4B8C2B00333A87 /* TestAudienceChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E2F5AB62A675ADC00CABD3D /* TestAudienceChecker.swift */; }; 6EC0CA812B4C812A00333A87 /* DisplayAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC0CA802B4C812A00333A87 /* DisplayAdapter.swift */; }; 6EC755992A4E115400851ABB /* DeviceAudienceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC755982A4E115400851ABB /* DeviceAudienceSelector.swift */; }; 6EC7559B2A4E129000851ABB /* DeviceTagSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7559A2A4E129000851ABB /* DeviceTagSelector.swift */; }; 6EC7559F2A4E5AB200851ABB /* DeviceAudienceChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7559E2A4E5AB200851ABB /* DeviceAudienceChecker.swift */; }; 6EC755AF2A4FCD8800851ABB /* AudienceHashSelectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC755AE2A4FCD8800851ABB /* AudienceHashSelectorTest.swift */; }; 6EC7E46E269E2A4C0038CFDD /* AttributeEditorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E46D269E2A4C0038CFDD /* AttributeEditorTest.swift */; }; 6EC7E470269E33290038CFDD /* TagGroupsEditorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E46F269E33290038CFDD /* TagGroupsEditorTest.swift */; }; 6EC7E472269E51030038CFDD /* AttributeUpdateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E471269E51030038CFDD /* AttributeUpdateTest.swift */; }; 6EC7E474269E52600038CFDD /* ContactOperationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E473269E52600038CFDD /* ContactOperationTest.swift */; }; 6EC7E47626A5EE910038CFDD /* AudienceUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E47526A5EE910038CFDD /* AudienceUtilsTest.swift */; }; 6EC7E47826A604080038CFDD /* AirshipContactTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E47726A604080038CFDD /* AirshipContactTest.swift */; }; 6EC7E48226A60C060038CFDD /* AirshipChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E48126A60C060038CFDD /* AirshipChannel.swift */; }; 6EC7E48526A60CDF0038CFDD /* TestChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E48426A60CDF0038CFDD /* TestChannel.swift */; }; 6EC7E48726A60DD60038CFDD /* TestContactAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E48626A60DD60038CFDD /* TestContactAPIClient.swift */; }; 6EC7E48D26A738C80038CFDD /* ContactConflictEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC7E48C26A738C70038CFDD /* ContactConflictEvent.swift */; }; 6EC815AF2F2BBFD500E1C0C6 /* BundleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC815AE2F2BBFD100E1C0C6 /* BundleExtensions.swift */; }; 6EC81D032F2D445500E1C0C6 /* UAInboxDataMappingV1toV4.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6EC81D022F2D445500E1C0C6 /* UAInboxDataMappingV1toV4.xcmappingmodel */; }; 6EC81D052F2D448B00E1C0C6 /* UAInboxDataMappingV2toV4.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6EC81D042F2D448B00E1C0C6 /* UAInboxDataMappingV2toV4.xcmappingmodel */; }; 6EC81D082F2D44D700E1C0C6 /* UAInboxDataMappingV3toV4.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 6EC81D072F2D44D700E1C0C6 /* UAInboxDataMappingV3toV4.xcmappingmodel */; }; 6EC824A02F33A4DD00E1C0C6 /* MessageDisplayHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC8249F2F33A4DD00E1C0C6 /* MessageDisplayHistory.swift */; }; 6EC824A22F33A5EC00E1C0C6 /* MessageCenterThemeLoaderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC824A12F33A5EC00E1C0C6 /* MessageCenterThemeLoaderTest.swift */; }; 6EC824A42F33A5F600E1C0C6 /* MessageCenterMessageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC824A32F33A5F600E1C0C6 /* MessageCenterMessageTest.swift */; }; 6EC9214E2D82144A000A3A59 /* ThomasFormField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC9214D2D82144A000A3A59 /* ThomasFormField.swift */; }; 6EC922E12D832BB8000A3A59 /* ThomasFormFieldProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC922E02D832BAF000A3A59 /* ThomasFormFieldProcessor.swift */; }; 6EC922E32D838DFF000A3A59 /* ThomasFormPayloadGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EC922E22D838DFA000A3A59 /* ThomasFormPayloadGenerator.swift */; }; 6ECB627C2A369F5B0095C85C /* OpenExternalURLActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECB627B2A369F5B0095C85C /* OpenExternalURLActionTest.swift */; }; 6ECB627E2A36A0770095C85C /* ExternalURLProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECB627D2A36A0770095C85C /* ExternalURLProcessor.swift */; }; 6ECB62822A36A45F0095C85C /* TestURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECB62812A36A45F0095C85C /* TestURLOpener.swift */; }; 6ECB62842A36A7510095C85C /* DeepLinkActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECB62832A36A7510095C85C /* DeepLinkActionTest.swift */; }; 6ECB62862A36C1EE0095C85C /* NativeBridgeActionHandlerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECB62852A36C1EE0095C85C /* NativeBridgeActionHandlerTest.swift */; }; 6ECD4F6D2DD7A7060060EE72 /* RadioInputToggleLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECD4F6C2DD7A7060060EE72 /* RadioInputToggleLayout.swift */; }; 6ECD4F6F2DD7A7090060EE72 /* CheckboxToggleLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECD4F6E2DD7A7090060EE72 /* CheckboxToggleLayout.swift */; }; 6ECD4F712DD7A7CD0060EE72 /* ToggleLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECD4F702DD7A7C90060EE72 /* ToggleLayout.swift */; }; 6ECDDE6C29B7EEE9009D79DB /* AuthToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECDDE6B29B7EEE9009D79DB /* AuthToken.swift */; }; 6ECDDE7429B80462009D79DB /* ChannelAuthTokenProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECDDE7329B80462009D79DB /* ChannelAuthTokenProvider.swift */; }; 6ECDDE7929B804FB009D79DB /* ChannelAuthTokenAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ECDDE7829B804FB009D79DB /* ChannelAuthTokenAPIClient.swift */; }; 6ED040EB278B5D7C00FCF773 /* ThomasFormPayloadGeneratorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED040EA278B5D7C00FCF773 /* ThomasFormPayloadGeneratorTest.swift */; }; 6ED2F5252B7EE648000AFC80 /* AirshipBase64Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F5242B7EE648000AFC80 /* AirshipBase64Test.swift */; }; 6ED2F5272B7EE82B000AFC80 /* AirshipJSONUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F5262B7EE82B000AFC80 /* AirshipJSONUtilsTest.swift */; }; 6ED2F5292B7FC59F000AFC80 /* AirshipColorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F5282B7FC59F000AFC80 /* AirshipColorTests.swift */; }; 6ED2F52B2B7FC5C8000AFC80 /* AirshipIvyVersionMatcherTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F52A2B7FC5C8000AFC80 /* AirshipIvyVersionMatcherTest.swift */; }; 6ED2F52D2B7FD403000AFC80 /* JavaScriptCommandTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F52C2B7FD403000AFC80 /* JavaScriptCommandTest.swift */; }; 6ED2F52F2B7FD49B000AFC80 /* AirshipURLAllowListTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F52E2B7FD49B000AFC80 /* AirshipURLAllowListTest.swift */; }; 6ED2F5312B7FF819000AFC80 /* AirshipViewUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F5302B7FF819000AFC80 /* AirshipViewUtils.swift */; }; 6ED2F5382B7FFCEB000AFC80 /* AirshipDateFormatterTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED2F5342B7FFCD7000AFC80 /* AirshipDateFormatterTest.swift */; }; 6ED2F5392B7FFF68000AFC80 /* AirshipSceneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D1B3272B44F08900447840 /* AirshipSceneManager.swift */; }; 6ED562A02EA9434B00C20B55 /* StackImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED5629F2EA9434900C20B55 /* StackImageButton.swift */; }; 6ED6ECA426ADCA6F00973364 /* BlockAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED6ECA326ADCA6F00973364 /* BlockAction.swift */; }; 6ED6ECA726AE05B700973364 /* EmptyAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED6ECA626AE05B700973364 /* EmptyAction.swift */; }; 6ED735DA26C73DC5003B0A7D /* DefaultAirshipChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED735D926C73DC5003B0A7D /* DefaultAirshipChannel.swift */; }; 6ED735DD26C7401D003B0A7D /* TagEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED735DC26C7401D003B0A7D /* TagEditor.swift */; }; 6ED735E026C74321003B0A7D /* TagEditorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED735DF26C74321003B0A7D /* TagEditorTest.swift */; }; 6ED735E226CAE2D7003B0A7D /* TestChannelRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED735E126CAE2D7003B0A7D /* TestChannelRegistrar.swift */; }; 6ED735E426CAE8AA003B0A7D /* TestChannelAudienceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED735E326CAE8AA003B0A7D /* TestChannelAudienceManager.swift */; }; 6ED735E626CAEABC003B0A7D /* TestLocaleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED735E526CAEABC003B0A7D /* TestLocaleManager.swift */; }; 6ED7BE602D13D9E300B6A124 /* TestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7BE5F2D13D9E300B6A124 /* TestCache.swift */; }; 6ED7BE612D13D9E300B6A124 /* TestCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7BE5F2D13D9E300B6A124 /* TestCache.swift */; }; 6ED7BE632D13D9FE00B6A124 /* FeatureFlagResultCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7BE622D13D9FE00B6A124 /* FeatureFlagResultCache.swift */; }; 6ED7BE652D13DA0400B6A124 /* FeatureFlagResultCacheTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED7BE642D13DA0400B6A124 /* FeatureFlagResultCacheTest.swift */; }; 6ED80793273CA0C800D1F455 /* EnvironmentValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED80792273CA0C800D1F455 /* EnvironmentValues.swift */; }; 6ED8079A273DA56000D1F455 /* ThomasViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED80799273DA56000D1F455 /* ThomasViewController.swift */; }; 6ED838AB2D0CE9D6009CBB0C /* CompoundDeviceAudienceSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED838AA2D0CE9D6009CBB0C /* CompoundDeviceAudienceSelector.swift */; }; 6ED838AD2D0CEF4A009CBB0C /* CompoundDeviceAudienceSelectorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED838AC2D0CEF4A009CBB0C /* CompoundDeviceAudienceSelectorTest.swift */; }; 6ED838D02D0D118B009CBB0C /* AutomationCompoundAudience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ED838CF2D0D118B009CBB0C /* AutomationCompoundAudience.swift */; }; 6EDAFB262CB463C5000BD4AA /* ButtonLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDAFB252CB463C1000BD4AA /* ButtonLayout.swift */; }; 6EDE293F2A9802BF00235738 /* NativeBridgeActionRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDE293E2A9802BF00235738 /* NativeBridgeActionRunner.swift */; }; 6EDE5F192B9BD7E700E33D04 /* InboxMessageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDE5F182B9BD7E700E33D04 /* InboxMessageData.swift */; }; 6EDE5F4F2BA248FF00E33D04 /* TouchViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDE5F4E2BA248FF00E33D04 /* TouchViewModifier.swift */; }; 6EDE5FC22BADDD96003ADF55 /* PreparedScheduleInfoTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDE5FC12BADDD96003ADF55 /* PreparedScheduleInfoTest.swift */; }; 6EDF1D932B292FB100E23BC4 /* InAppMessageTextInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1D922B292FB000E23BC4 /* InAppMessageTextInfo.swift */; }; 6EDF1D962B2A25B400E23BC4 /* InAppMessageButtonInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1D952B2A25B400E23BC4 /* InAppMessageButtonInfo.swift */; }; 6EDF1D982B2A25C800E23BC4 /* InAppMessageMediaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1D972B2A25C800E23BC4 /* InAppMessageMediaInfo.swift */; }; 6EDF1D9C2B2A287A00E23BC4 /* InAppMessageButtonLayoutType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1D9B2B2A287A00E23BC4 /* InAppMessageButtonLayoutType.swift */; }; 6EDF1D9E2B2A2A5900E23BC4 /* InAppMessageDisplayContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1D9D2B2A2A5900E23BC4 /* InAppMessageDisplayContent.swift */; }; 6EDF1DA42B2A2C6F00E23BC4 /* InAppMessageColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1DA32B2A2C6F00E23BC4 /* InAppMessageColor.swift */; }; 6EDF1DA62B2A300100E23BC4 /* InAppMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1DA52B2A300100E23BC4 /* InAppMessage.swift */; }; 6EDF1DAD2B2A73FC00E23BC4 /* InAppMessageTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1DAA2B2A6D9900E23BC4 /* InAppMessageTest.swift */; }; 6EDF1DB82B2BB2B800E23BC4 /* RetryingQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDF1DB72B2BB2B800E23BC4 /* RetryingQueue.swift */; }; 6EDFBBC32F5780BC0043D9EF /* BasementImport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDFBBC22F5780BA0043D9EF /* BasementImport.swift */; }; 6EDFBBC42F5780EA0043D9EF /* AirshipLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF1933B2838062B005F192A /* AirshipLogHandler.swift */; }; 6EDFBBC52F5780EA0043D9EF /* AirshipLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E698DEA26790AC300654DB2 /* AirshipLogger.swift */; }; 6EDFBBC62F5780EA0043D9EF /* AirshipLogPrivacyLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E524CC22C180A39002CA094 /* AirshipLogPrivacyLevel.swift */; }; 6EDFBBC72F5780EA0043D9EF /* DefaultLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF1933D28380644005F192A /* DefaultLogHandler.swift */; }; 6EDFBBC82F5780EA0043D9EF /* LogLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E87BDFE26E283840005D20D /* LogLevel.swift */; }; 6EE49BDD2A09AD3600AB1CF4 /* AirshipNotificationStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49BDC2A09AD3600AB1CF4 /* AirshipNotificationStatus.swift */; }; 6EE49BE12A0AADC900AB1CF4 /* AppRemoteDataProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49BE02A0AADC900AB1CF4 /* AppRemoteDataProviderDelegate.swift */; }; 6EE49C082A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49C072A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift */; }; 6EE49C0C2A0C141800AB1CF4 /* RemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49C0B2A0C141800AB1CF4 /* RemoteDataSource.swift */; }; 6EE49C102A0C142F00AB1CF4 /* RemoteDataInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49C0F2A0C142F00AB1CF4 /* RemoteDataInfo.swift */; }; 6EE49C182A0C3CC600AB1CF4 /* ContactRemoteDataProviderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49C172A0C3CC600AB1CF4 /* ContactRemoteDataProviderDelegate.swift */; }; 6EE49C1D2A0D9D8000AB1CF4 /* RemoteDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49C1C2A0D9D8000AB1CF4 /* RemoteDataProvider.swift */; }; 6EE49C222A13E32B00AB1CF4 /* RemoteDataProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49C212A13E32B00AB1CF4 /* RemoteDataProviderProtocol.swift */; }; 6EE49C262A1446B100AB1CF4 /* RemoteDataTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE49C252A1446B100AB1CF4 /* RemoteDataTestUtils.swift */; }; 6EE6AA132B4F3009002FEA75 /* InAppMessageAutomationPreparerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA112B4F3003002FEA75 /* InAppMessageAutomationPreparerTest.swift */; }; 6EE6AA162B4F302D002FEA75 /* InAppMessageAutomationExecutorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA142B4F302A002FEA75 /* InAppMessageAutomationExecutorTest.swift */; }; 6EE6AA1B2B4F3062002FEA75 /* DisplayCoordinatorManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA1A2B4F3062002FEA75 /* DisplayCoordinatorManagerTest.swift */; }; 6EE6AA1C2B4F3066002FEA75 /* DisplayAdapterFactoryTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA182B4F304B002FEA75 /* DisplayAdapterFactoryTest.swift */; }; 6EE6AA1E2B4F31B1002FEA75 /* TestCachedAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA1D2B4F31B1002FEA75 /* TestCachedAssets.swift */; }; 6EE6AA202B4F5246002FEA75 /* InAppMessageSceneManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA1F2B4F5246002FEA75 /* InAppMessageSceneManager.swift */; }; 6EE6AA282B50C91E002FEA75 /* AutomationRemoteDataSubscriberTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA272B50C91E002FEA75 /* AutomationRemoteDataSubscriberTest.swift */; }; 6EE6AA2A2B50C976002FEA75 /* TestAutomationEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA292B50C976002FEA75 /* TestAutomationEngine.swift */; }; 6EE6AA2C2B51DB1E002FEA75 /* AutomationSourceInfoStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA2B2B51DB1E002FEA75 /* AutomationSourceInfoStore.swift */; }; 6EE6AA382B572897002FEA75 /* AutomationSourceInfoStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AA372B572897002FEA75 /* AutomationSourceInfoStoreTest.swift */; }; 6EE6AAFF2B58AB66002FEA75 /* LegacyInAppAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AAFE2B58AB66002FEA75 /* LegacyInAppAnalytics.swift */; }; 6EE6AB022B58B6E9002FEA75 /* LegacyInAppAnalyticsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AB002B58B5AE002FEA75 /* LegacyInAppAnalyticsTest.swift */; }; 6EE6AB062B59C231002FEA75 /* AirshipLayoutDisplayAdapterTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AB052B59C231002FEA75 /* AirshipLayoutDisplayAdapterTest.swift */; }; 6EE6AB092B59C236002FEA75 /* CustomDisplayAdapterWrapperTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE6AB032B59C21A002FEA75 /* CustomDisplayAdapterWrapperTest.swift */; }; 6EE7725B238F179300E79944 /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 494DD9571B0EB677009C134E /* AirshipCore.framework */; settings = {ATTRIBUTES = (Required, ); }; }; 6EED67652CDEE7900087CDCB /* ThomasViewInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67632CDEE75D0087CDCB /* ThomasViewInfo.swift */; }; 6EED676E2CDEE7EB0087CDCB /* ThomasPresentationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED676C2CDEE7E50087CDCB /* ThomasPresentationInfo.swift */; }; 6EED67722CDEE8390087CDCB /* ThomasOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67702CDEE8370087CDCB /* ThomasOrientation.swift */; }; 6EED67752CDEE8460087CDCB /* ThomasWindowSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67742CDEE8460087CDCB /* ThomasWindowSize.swift */; }; 6EED677A2CDEE87D0087CDCB /* ThomasShadow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67782CDEE8790087CDCB /* ThomasShadow.swift */; }; 6EED677D2CDEE9040087CDCB /* ThomasSerializable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED677C2CDEE8FE0087CDCB /* ThomasSerializable.swift */; }; 6EED67972CDEEA2E0087CDCB /* ThomasShapeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67952CDEEA2B0087CDCB /* ThomasShapeInfo.swift */; }; 6EED679C2CDEEA380087CDCB /* ThomasToggleStyleInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67992CDEEA380087CDCB /* ThomasToggleStyleInfo.swift */; }; 6EED67A02CDEEAAB0087CDCB /* AirshipLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED679D2CDEEAA90087CDCB /* AirshipLayout.swift */; }; 6EED67A22CE1A47C0087CDCB /* ThomasColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67A12CE1A4780087CDCB /* ThomasColor.swift */; }; 6EED67A72CE1A5000087CDCB /* ThomasBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67A52CE1A4FC0087CDCB /* ThomasBorder.swift */; }; 6EED67AB2CE1A5D00087CDCB /* ThomasMarkdownOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67A92CE1A5CC0087CDCB /* ThomasMarkdownOptions.swift */; }; 6EED67AE2CE1A6B90087CDCB /* ThomasPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67AD2CE1A6B70087CDCB /* ThomasPosition.swift */; }; 6EED67B32CE1A8330087CDCB /* ThomasEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67B12CE1A8300087CDCB /* ThomasEventHandler.swift */; }; 6EED67B62CE1B4420087CDCB /* ThomasTextAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67B52CE1B43C0087CDCB /* ThomasTextAppearance.swift */; }; 6EED67BA2CE1B4E90087CDCB /* ThomasPlatform.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67B92CE1B4E60087CDCB /* ThomasPlatform.swift */; }; 6EED67C02CE1B5160087CDCB /* ThomasActionsPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67BD2CE1B5120087CDCB /* ThomasActionsPayload.swift */; }; 6EED67C32CE1B5890087CDCB /* ThomasIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67C12CE1B5850087CDCB /* ThomasIcon.swift */; }; 6EED67C82CE1B6020087CDCB /* ThomasMediaFit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67C52CE1B5FF0087CDCB /* ThomasMediaFit.swift */; }; 6EED67E32CE268680087CDCB /* ThomasButtonTapEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67E22CE268630087CDCB /* ThomasButtonTapEffect.swift */; }; 6EED67E82CE268BF0087CDCB /* ThomasButtonClickBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67E62CE268BB0087CDCB /* ThomasButtonClickBehavior.swift */; }; 6EED67EB2CE269970087CDCB /* ThomasDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67EA2CE269930087CDCB /* ThomasDirection.swift */; }; 6EED67F02CE26CA40087CDCB /* ThomasSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67EF2CE26CA10087CDCB /* ThomasSize.swift */; }; 6EED67F62CE26CB80087CDCB /* ThomasConstrainedSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67F32CE26CB50087CDCB /* ThomasConstrainedSize.swift */; }; 6EED67F82CE26CE40087CDCB /* ThomasSizeConstraint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67F72CE26CE20087CDCB /* ThomasSizeConstraint.swift */; }; 6EED67FC2CE26DB60087CDCB /* ThomasAttributeName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67FB2CE26DAF0087CDCB /* ThomasAttributeName.swift */; }; 6EED68012CE26DCD0087CDCB /* ThomasAttributeValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED67FF2CE26DCA0087CDCB /* ThomasAttributeValue.swift */; }; 6EED68042CE26E1A0087CDCB /* ThomasMargin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68032CE26E180087CDCB /* ThomasMargin.swift */; }; 6EED680A2CE26E550087CDCB /* ThomasAutomatedAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68072CE26E510087CDCB /* ThomasAutomatedAction.swift */; }; 6EED680D2CE2707F0087CDCB /* ThomasStateAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED680C2CE2707E0087CDCB /* ThomasStateAction.swift */; }; 6EED68122CE271E50087CDCB /* ThomasEnableBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68102CE271E10087CDCB /* ThomasEnableBehavior.swift */; }; 6EED68162CE271F80087CDCB /* ThomasFormSubmitBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68142CE271F30087CDCB /* ThomasFormSubmitBehavior.swift */; }; 6EED68192CE272790087CDCB /* ThomasAutomatedAccessibilityAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68182CE272710087CDCB /* ThomasAutomatedAccessibilityAction.swift */; }; 6EED681D2CE274300087CDCB /* ThomasAccessibilityAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED681C2CE274290087CDCB /* ThomasAccessibilityAction.swift */; }; 6EED68222CE2806D0087CDCB /* ThomasAccessibleInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68202CE2806B0087CDCB /* ThomasAccessibleInfo.swift */; }; 6EED68292CE28C9A0087CDCB /* ThomasVisibilityInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68282CE28C960087CDCB /* ThomasVisibilityInfo.swift */; }; 6EED682D2CE28CC10087CDCB /* ThomasValidationInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED682C2CE28CBF0087CDCB /* ThomasValidationInfo.swift */; }; 6EED68332CE28FAC0087CDCB /* ThomasConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68302CE28FA70087CDCB /* ThomasConstants.swift */; }; 6EED68E72CE3ECCB0087CDCB /* ThomasPropertyOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EED68E42CE3ECC50087CDCB /* ThomasPropertyOverride.swift */; }; 6EEE8BA2290B3EDE00230528 /* AirshipKeychainAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EEE8BA1290B3EDE00230528 /* AirshipKeychainAccess.swift */; }; 6EF02DF02714EB500008B6C9 /* Thomas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF02DEF2714EB500008B6C9 /* Thomas.swift */; }; 6EF13FFD2A16F390009A125D /* AirshipBaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3299EF212949EC3E00251E70 /* AirshipBaseTest.swift */; }; 6EF13FFE2A16F391009A125D /* AirshipBaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3299EF212949EC3E00251E70 /* AirshipBaseTest.swift */; }; 6EF1401B2A2671ED009A125D /* AirshipDeviceID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF1401A2A2671ED009A125D /* AirshipDeviceID.swift */; }; 6EF1401F2A268CE6009A125D /* TestKeychainAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF1401E2A268CE6009A125D /* TestKeychainAccess.swift */; }; 6EF140212A269074009A125D /* AirshipDeviceIDTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF140202A269074009A125D /* AirshipDeviceIDTest.swift */; }; 6EF1E9282CD005E4005EAA07 /* PagerUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF1E9252CD005E2005EAA07 /* PagerUtils.swift */; }; 6EF1E92A2CD0069B005EAA07 /* PagerSwipeDirection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF1E9292CD00698005EAA07 /* PagerSwipeDirection.swift */; }; 6EF27DD927306C9100548DA3 /* AirshipToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF27DD827306C9100548DA3 /* AirshipToggle.swift */; }; 6EF27DE32730E6F900548DA3 /* RadioInputController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF27DE22730E6F900548DA3 /* RadioInputController.swift */; }; 6EF27DE62730E77300548DA3 /* RadioInputState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF27DE52730E77300548DA3 /* RadioInputState.swift */; }; 6EF27DE92730E85700548DA3 /* RadioInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF27DE82730E85700548DA3 /* RadioInput.swift */; }; 6EF553E32B7EE40B00901A22 /* AirshipLocalizationUtilsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF553E22B7EE40B00901A22 /* AirshipLocalizationUtilsTest.swift */; }; 6EF66D8D276461DA00ABCB76 /* UrlInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF66D8C276461DA00ABCB76 /* UrlInfo.swift */; }; 6EF66D912769B69C00ABCB76 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EF66D902769B69C00ABCB76 /* RootView.swift */; }; 6EFAFB78295525C3008AD187 /* ChannelAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFAFB77295525C3008AD187 /* ChannelAPIClientTest.swift */; }; 6EFAFB7A295525CD008AD187 /* ChannelCaptureTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFAFB79295525CD008AD187 /* ChannelCaptureTest.swift */; }; 6EFAFB7C295525DF008AD187 /* ChannelRegistrationPayloadTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFAFB7B295525DF008AD187 /* ChannelRegistrationPayloadTest.swift */; }; 6EFAFB8229555174008AD187 /* FetchDeviceInfoActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFAFB8129555174008AD187 /* FetchDeviceInfoActionTest.swift */; }; 6EFAFB8429561F23008AD187 /* ModifyAttributesActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFAFB8329561F23008AD187 /* ModifyAttributesActionTest.swift */; }; 6EFAFB8A29562474008AD187 /* AddTagsActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFAFB8929562474008AD187 /* AddTagsActionTest.swift */; }; 6EFAFB8C29562866008AD187 /* RemoveTagsActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFAFB8B29562866008AD187 /* RemoveTagsActionTest.swift */; }; 6EFB7B342A14A0F400133115 /* RemoteDataProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFB7B322A14A0EC00133115 /* RemoteDataProviderTest.swift */; }; 6EFD6D4B27272333005B26F1 /* EmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D4A27272333005B26F1 /* EmptyView.swift */; }; 6EFD6D5C27273257005B26F1 /* Shapes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D5B27273257005B26F1 /* Shapes.swift */; }; 6EFD6D6E27290C0B005B26F1 /* FormController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D6D27290C0B005B26F1 /* FormController.swift */; }; 6EFD6D7127290C16005B26F1 /* TextInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D7027290C16005B26F1 /* TextInput.swift */; }; 6EFD6D82272A53AE005B26F1 /* PagerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D81272A53AE005B26F1 /* PagerState.swift */; }; 6EFD6D8B272A53FB005B26F1 /* PagerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D86272A53FB005B26F1 /* PagerController.swift */; }; 6EFE7E3F2A97ED660064AC31 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6EFE7E3E2A97ED600064AC31 /* PrivacyInfo.xcprivacy */; }; 8401769426C5671100373AF7 /* JSONMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401769326C5671100373AF7 /* JSONMatcher.swift */; }; 8401769826C5722400373AF7 /* JSONPredicate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401769726C5722400373AF7 /* JSONPredicate.swift */; }; 8401769A26C5725800373AF7 /* AirshipJSONUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401769926C5725800373AF7 /* AirshipJSONUtils.swift */; }; 8401769C26C5729E00373AF7 /* JSONValueMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8401769B26C5729E00373AF7 /* JSONValueMatcher.swift */; }; 841E7D12268617C800EA0317 /* PreferenceCenterResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841E7D11268617C800EA0317 /* PreferenceCenterResponse.swift */; }; 84483A68267CF0C000D0DA7D /* PreferenceCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84483A67267CF0C000D0DA7D /* PreferenceCenter.swift */; }; 847B000B267CD85E007CD249 /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 494DD9571B0EB677009C134E /* AirshipCore.framework */; }; 847B0013267CE558007CD249 /* PreferenceCenterSDKModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 847B0012267CE558007CD249 /* PreferenceCenterSDKModule.swift */; }; 847BFFFD267CD73A007CD249 /* AirshipPreferenceCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 847BFFF4267CD739007CD249 /* AirshipPreferenceCenter.framework */; }; 9908E60E2B000DBA00DB3E2E /* CustomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9908E60D2B000DBA00DB3E2E /* CustomView.swift */; }; 9908E6122B0189F800DB3E2E /* ArishipCustomViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9908E6112B0189F800DB3E2E /* ArishipCustomViewManager.swift */; }; 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 */; }; 99104DF32BA6689A0040C0FD /* PreferenceCloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99104DF22BA6689A0040C0FD /* PreferenceCloseButton.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 */; }; 993F91FB2CA37874001B1C2E /* FooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 993F91FA2CA37874001B1C2E /* FooterView.swift */; }; 99560C1E2BAE2FFA00F28BDC /* ChannelTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99560C1D2BAE2FFA00F28BDC /* ChannelTextField.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 */; }; 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 */; }; 99C3CC792BCF3E5B00B5BED5 /* SMSValidatorAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C3CC772BCF3DF700B5BED5 /* SMSValidatorAPIClientTest.swift */; }; 99C3CC7E2BCF40B200B5BED5 /* CachingSMSValidatorAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C3CC7C2BCF401B00B5BED5 /* CachingSMSValidatorAPIClientTest.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 */; }; 99E0BD0D2B4DD4AB00465B37 /* FullscreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E0BD0C2B4DD4AB00465B37 /* FullscreenView.swift */; }; 99E0BD0F2B4DD71A00465B37 /* InAppMessageHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E0BD0E2B4DD71A00465B37 /* InAppMessageHostingController.swift */; }; 99E433932C9A0362006436B9 /* PagerIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D85272A53FA005B26F1 /* PagerIndicator.swift */; }; 99E433942C9A03D9006436B9 /* Pager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EFD6D84272A53FA005B26F1 /* Pager.swift */; }; 99E433982C9A044C006436B9 /* AirshipResources.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E433972C9A044C006436B9 /* AirshipResources.swift */; }; 99E6EF6A2B8E36BA0006326A /* InAppMessageValidation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E6EF692B8E36BA0006326A /* InAppMessageValidation.swift */; }; 99E6EF6D2B8E3C250006326A /* InAppMessageContentValidationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E6EF6B2B8E3AF60006326A /* InAppMessageContentValidationTest.swift */; }; 99E8D7972B4F17260099B6F3 /* CloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7962B4F17260099B6F3 /* CloseButton.swift */; }; 99E8D7992B4F19BA0099B6F3 /* TextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E8D7982B4F19BA0099B6F3 /* TextView.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 /* 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 /* PreferenceCenterContentStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99F4FE5A2BC36A6700754F0F /* PreferenceCenterContentStyle.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 */; }; 99FD20A52DEFC35900242551 /* MessageCenterUIKitAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FD20A42DEFC35900242551 /* MessageCenterUIKitAppearance.swift */; }; A1B2C3D4E5F60002VIDEOST /* VideoState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60001VIDEOST /* VideoState.swift */; }; A1B2C3D4E5F60004VIDEOCR /* VideoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60003VIDEOCR /* VideoController.swift */; }; A61517B226A9C4C3008A41C4 /* SubscriptionListEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61517B126A9C4C3008A41C4 /* SubscriptionListEditor.swift */; }; A61517B526AEEAAB008A41C4 /* SubscriptionListUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61517B426AEEAAB008A41C4 /* SubscriptionListUpdate.swift */; }; A61517C426B009D6008A41C4 /* SubscriptionListAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61517C026B009D6008A41C4 /* SubscriptionListAPIClient.swift */; }; A61F3A752A5DA58500EE94CC /* FeatureFlagManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A61F3A732A5D9D6800EE94CC /* FeatureFlagManagerTest.swift */; }; A61F3A782A5DBA1800EE94CC /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 494DD9571B0EB677009C134E /* AirshipCore.framework */; platformFilters = (ios, maccatalyst, macos, tvos, xros, ); }; A62058712A5841330041FBF9 /* AirshipFeatureFlags.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A62058692A5841330041FBF9 /* AirshipFeatureFlags.framework */; }; A62058812A5842200041FBF9 /* AirshipFeatureFlagsSDKModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62058802A5842200041FBF9 /* AirshipFeatureFlagsSDKModule.swift */; }; A629F7DA295B514C00671647 /* PasteboardActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A629F7D9295B514C00671647 /* PasteboardActionTest.swift */; }; A62C3354299FD509004DB0DA /* ShareActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62C3353299FD509004DB0DA /* ShareActionTest.swift */; }; A63A567628F449D8004B8951 /* TestWorkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63A567528F449D8004B8951 /* TestWorkManager.swift */; }; A63A567828F457FE004B8951 /* TestWorkRateLimiterActor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A63A567728F457FE004B8951 /* TestWorkRateLimiterActor.swift */; }; A658DE0C2727020200007672 /* ImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = A658DE0A2727020100007672 /* ImageButton.swift */; }; A658DE192728498900007672 /* AirshipWebview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A658DE182728498900007672 /* AirshipWebview.swift */; }; A658DE2B272AFB0400007672 /* AirshipImageLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A658DE2A272AFB0400007672 /* AirshipImageLoader.swift */; }; A67EC249279B1A40009089E1 /* ScopedSubscriptionListEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A67EC248279B1A40009089E1 /* ScopedSubscriptionListEditor.swift */; }; A67EC24B279B1C34009089E1 /* ScopedSubscriptionListUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A67EC24A279B1C34009089E1 /* ScopedSubscriptionListUpdate.swift */; }; A67F87D2268DECCE00EF5F43 /* ContactAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A67F87D1268DECCE00EF5F43 /* ContactAPIClient.swift */; }; A6849387273290520021675E /* Score.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6849386273290520021675E /* Score.swift */; }; A684939D273436370021675E /* FontViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A684939C273436370021675E /* FontViewModifier.swift */; }; A69C987F27E247B20063A101 /* SubscriptionListAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A69C987E27E247B20063A101 /* SubscriptionListAction.swift */; }; A6A5530A26D548AF002B20F6 /* NativeBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A5530926D548AF002B20F6 /* NativeBridge.swift */; }; A6A5531026D548D6002B20F6 /* JavaScriptEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A5530F26D548D6002B20F6 /* JavaScriptEnvironment.swift */; }; A6A5531326D548FF002B20F6 /* NativeBridgeActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6A5531226D548FF002B20F6 /* NativeBridgeActionHandler.swift */; }; A6AC44832B923ACB00769ED2 /* TestInAppMessageAutomationExecutor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC44822B923ACB00769ED2 /* TestInAppMessageAutomationExecutor.swift */; }; A6AF8D2D27E8D4910068C7EE /* SubscriptionListActionTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AF8D2C27E8D4910068C7EE /* SubscriptionListActionTest.swift */; }; A6CDD8D0269491BE0040A673 /* ContactAPIClientTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6CDD8CF269491BE0040A673 /* ContactAPIClientTest.swift */; }; A6D6D48F2A0253AA0072A5CA /* ActionArguments.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D6D48E2A0253AA0072A5CA /* ActionArguments.swift */; }; A6D6D49D2A0260780072A5CA /* AirshipAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D6D49C2A0260780072A5CA /* AirshipAction.swift */; }; A6D6D49F2A02608C0072A5CA /* ActionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D6D49E2A02608C0072A5CA /* ActionResult.swift */; }; A6E9AD982D4D12D00091BBAF /* FeatureFlagUpdateStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E9AD972D4D12C60091BBAF /* FeatureFlagUpdateStatus.swift */; }; A6E9ADED2D4D204B0091BBAF /* InAppAutomationUpdateStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6E9ADEC2D4D20300091BBAF /* InAppAutomationUpdateStatus.swift */; }; A6F0B1912B83CD9B002D10A4 /* AutomationEngineTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6F0B18F2B837E36002D10A4 /* AutomationEngineTest.swift */; }; C00ED4CF26C729390040C5D0 /* URLAllowList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C00ED4CE26C729390040C5D0 /* URLAllowList.swift */; }; C02D0B6626C1A3E200F673E6 /* ChannelCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = C02D0B6526C1A3E200F673E6 /* ChannelCapture.swift */; }; C088383726E0244C00D40838 /* TestURLAllowList.swift in Sources */ = {isa = PBXBuildFile; fileRef = C088383526E0244C00D40838 /* TestURLAllowList.swift */; }; CC64F0591D8B77E3009CEF27 /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 494DD9571B0EB677009C134E /* AirshipCore.framework */; }; CC64F0CE1D8B781C009CEF27 /* CustomNotificationCategories.plist in Resources */ = {isa = PBXBuildFile; fileRef = CC64F0611D8B781C009CEF27 /* CustomNotificationCategories.plist */; }; CC64F14D1D8B7954009CEF27 /* AirshipConfig-Valid-Legacy.plist in Resources */ = {isa = PBXBuildFile; fileRef = CC64F1451D8B7954009CEF27 /* AirshipConfig-Valid-Legacy.plist */; }; CC64F14F1D8B7954009CEF27 /* AirshipConfig-Valid.plist in Resources */ = {isa = PBXBuildFile; fileRef = CC64F1471D8B7954009CEF27 /* AirshipConfig-Valid.plist */; }; CC64F1511D8B7954009CEF27 /* development-embedded.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = CC64F1491D8B7954009CEF27 /* development-embedded.mobileprovision */; }; CC64F1521D8B7954009CEF27 /* production-embedded.mobileprovision in Resources */ = {isa = PBXBuildFile; fileRef = CC64F14A1D8B7954009CEF27 /* production-embedded.mobileprovision */; }; DFD2464E2473404C000FD565 /* DebugSDKModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFD2464D2473404C000FD565 /* DebugSDKModule.swift */; }; E976486F27A46CC50024518D /* ChannelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E976486E27A46CC50024518D /* ChannelType.swift */; }; E99605A127A071EA00365AE4 /* EmailRegistrationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99605A027A071EA00365AE4 /* EmailRegistrationOptions.swift */; }; E99605A427A075B800365AE4 /* SMSRegistrationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99605A327A075B800365AE4 /* SMSRegistrationOptions.swift */; }; E99605A727A075C600365AE4 /* OpenRegistrationOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99605A627A075C600365AE4 /* OpenRegistrationOptions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 3C39D3072384C8B6003C50D4 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 3CA0E346237E4A7B00EE76CF; remoteInfo = AirshipMessageCenter; }; 3CA0E2D9237CD59100EE76CF /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 3CA0E348237E4A7B00EE76CF /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 6E0B8733294A9C130064B7BD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E0B8729294A9C120064B7BD; remoteInfo = AirshipAutomationSwift; }; 6E0B8742294A9C780064B7BD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 6E107F032B30B887007AFC4D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 6E128B9C2D305C4600733024 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = A641E1462BDBBDB400DE6FAA; remoteInfo = AirshipObjectiveC; }; 6E2947492AD47E0C009EC6DD /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 847BFFF3267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; 6E2F5A912A67314A00CABD3D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; 6E2F5A952A67316C00CABD3D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; 6E43218A26EA891F009228AB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E431F6B26EA814F009228AB; remoteInfo = AirshipBasement; }; 6E4A467328EF44F600A25617 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 3CA0E346237E4A7B00EE76CF; remoteInfo = AirshipMessageCenter; }; 6E4AEE0A2B6B24D1008AEAC1 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E0B8729294A9C120064B7BD; remoteInfo = AirshipAutomationSwift; }; 6E5917882B28E93A0084BBBF /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E0B8729294A9C120064B7BD; remoteInfo = AirshipAutomationSwift; }; 6E6B493D2A787D0A00AF98D8 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; 6EAAE87628C2AD80003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E431F6B26EA814F009228AB; remoteInfo = AirshipBasement; }; 6EAAE87828C2AD80003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 6EAAE87A28C2AD80003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 3CA0E298237CCE2600EE76CF; remoteInfo = AirshipDebug; }; 6EAAE87C28C2AD80003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 3CA0E346237E4A7B00EE76CF; remoteInfo = AirshipMessageCenter; }; 6EAAE87E28C2AD80003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 847BFFF3267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; 6ECCAD292CF55BC700423D86 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; 6ECCAD2B2CF55BC700423D86 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E431F6B26EA814F009228AB; remoteInfo = AirshipBasement; }; 6ECCAD2D2CF55BC700423D86 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 6ECCAD332CF55BC700423D86 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 847BFFF3267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; 847B000D267CD85E007CD249 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 847BFFFE267CD73A007CD249 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 847BFFF3267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; A60235352CCB9E3C00CF412B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E431F6B26EA814F009228AB; remoteInfo = AirshipBasement; }; A61F3A762A5DBA0E00EE94CC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; A61F3A7A2A5DBA1800EE94CC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; A62058722A5841330041FBF9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; A641E1562BDBF5FF00DE6FAA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 6E0B8729294A9C120064B7BD; remoteInfo = AirshipAutomation; }; A641E1582BDBF5FF00DE6FAA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; A641E15A2BDBF5FF00DE6FAA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; A641E15C2BDBF5FF00DE6FAA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 3CA0E346237E4A7B00EE76CF; remoteInfo = AirshipMessageCenter; }; A641E15E2BDBF5FF00DE6FAA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 847BFFF3267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; CC64F05A1D8B77E3009CEF27 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 494DD94E1B0EB677009C134E /* Project object */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipKit; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ 03525B87280DE48100320AA9 /* af */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = af; path = af.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B88280DE49B00320AA9 /* am */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = am; path = am.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B89280DE4E200320AA9 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B8A280DE4ED00320AA9 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B8B280DE4F800320AA9 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B8C280DE50D00320AA9 /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B8D280DE54900320AA9 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B8E280DE55900320AA9 /* fr-CA */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "fr-CA"; path = "fr-CA.lproj/UrbanAirship.strings"; sourceTree = ""; }; 03525B8F280DE58800320AA9 /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B90280DE5A200320AA9 /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lt; path = lt.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B91280DE5B800320AA9 /* lv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = lv; path = lv.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B92280DE5CE00320AA9 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B93280DE5DD00320AA9 /* sr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sr; path = sr.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B94280DE5EA00320AA9 /* sw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sw; path = sw.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B95280DE5F400320AA9 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/UrbanAirship.strings; sourceTree = ""; }; 03525B96280DE60200320AA9 /* zh-HK */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-HK"; path = "zh-HK.lproj/UrbanAirship.strings"; sourceTree = ""; }; 03525B97280DE61200320AA9 /* zu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zu; path = zu.lproj/UrbanAirship.strings; sourceTree = ""; }; 27051CD62EE75E3300C770D5 /* AutomationEventsHistoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationEventsHistoryTest.swift; sourceTree = ""; }; 27077E4B2EE7531C0027A282 /* AutomationEventsHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationEventsHistory.swift; sourceTree = ""; }; 271B38642DB2866200495D9F /* TagActionMutation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagActionMutation.swift; sourceTree = ""; }; 27264FB22E81B064000B6FA3 /* AirshipSceneController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSceneController.swift; sourceTree = ""; }; 2726505A2E81B80E000B6FA3 /* PagerControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerControllerTest.swift; sourceTree = ""; }; 2753F6412F6C5BB50073882C /* MessageCenterMessageError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterMessageError.swift; sourceTree = ""; }; 275D32AA2EF955AD00B75760 /* AirshipSimpleLayoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSimpleLayoutView.swift; sourceTree = ""; }; 275D32AB2EF955AD00B75761 /* AirshipSimpleLayoutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSimpleLayoutViewModel.swift; sourceTree = ""; }; 2797B4182F47687800A7F848 /* NativeLayoutPersistentDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLayoutPersistentDataStore.swift; sourceTree = ""; }; 27AFE70E2E733F4400767044 /* ModifyTagsAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyTagsAction.swift; sourceTree = ""; }; 27AFE7102E73477200767044 /* ModifyTagsActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyTagsActionTest.swift; sourceTree = ""; }; 27CCF77C2F1656150018058F /* MessageViewAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewAnalytics.swift; sourceTree = ""; }; 27CCF77E2F16DA500018058F /* MessageViewAnalyticsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewAnalyticsTest.swift; sourceTree = ""; }; 27CCF8D22F2382750018058F /* ThomasStateStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasStateStorage.swift; sourceTree = ""; }; 27E419482EF484DB00D5C1A6 /* UAInbox 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAInbox 4.xcdatamodel"; sourceTree = ""; }; 27E419492EF59F9800D5C1A6 /* MessageCenterThomasView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterThomasView.swift; sourceTree = ""; }; 27F1E1F32F0E910B00E317DB /* ThomasLayoutButtonTapEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutButtonTapEvent.swift; sourceTree = ""; }; 27F1E1F42F0E910B00E317DB /* ThomasLayoutDisplayEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutDisplayEvent.swift; sourceTree = ""; }; 27F1E1F52F0E910B00E317DB /* ThomasLayoutEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEvent.swift; sourceTree = ""; }; 27F1E1F62F0E910B00E317DB /* ThomasLayoutFormDisplayEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutFormDisplayEvent.swift; sourceTree = ""; }; 27F1E1F72F0E910B00E317DB /* ThomasLayoutFormResultEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutFormResultEvent.swift; sourceTree = ""; }; 27F1E1F82F0E910B00E317DB /* ThomasLayoutGestureEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutGestureEvent.swift; sourceTree = ""; }; 27F1E1F92F0E910B00E317DB /* ThomasLayoutPageActionEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPageActionEvent.swift; sourceTree = ""; }; 27F1E1FA2F0E910B00E317DB /* ThomasLayoutPagerCompletedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPagerCompletedEvent.swift; sourceTree = ""; }; 27F1E1FB2F0E910B00E317DB /* ThomasLayoutPagerSummaryEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPagerSummaryEvent.swift; sourceTree = ""; }; 27F1E1FC2F0E910B00E317DB /* ThomasLayoutPageSwipeEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPageSwipeEvent.swift; sourceTree = ""; }; 27F1E1FD2F0E910B00E317DB /* ThomasLayoutPageViewEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPageViewEvent.swift; sourceTree = ""; }; 27F1E1FE2F0E910B00E317DB /* ThomasLayoutPermissionResultEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPermissionResultEvent.swift; sourceTree = ""; }; 27F1E1FF2F0E910B00E317DB /* ThomasLayoutResolutionEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutResolutionEvent.swift; 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 /* AirshipAutomationResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAutomationResources.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 = ""; }; 3231127D29D5E67200CF0D86 /* Occurrence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Occurrence.swift; sourceTree = ""; }; 3231128029D5E67200CF0D86 /* FrequencyConstraint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrequencyConstraint.swift; sourceTree = ""; }; 3231128129D5E67200CF0D86 /* FrequencyChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrequencyChecker.swift; sourceTree = ""; }; 3231128B29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = UAFrequencyLimits.xcdatamodel; sourceTree = ""; }; 3231128F29D5E6C600CF0D86 /* FrequencyLimitManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrequencyLimitManagerTest.swift; sourceTree = ""; }; 3237D5F12B865D990055932B /* JSONValueTransformer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONValueTransformer.swift; sourceTree = ""; }; 3243EC612D93109C00B43B25 /* AirshipCheckboxToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipCheckboxToggleStyle.swift; sourceTree = ""; }; 3243EC622D93109C00B43B25 /* AirshipSwitchToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSwitchToggleStyle.swift; sourceTree = ""; }; 324D3BFE273E6B4500058EE4 /* BannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerView.swift; sourceTree = ""; }; 325108C72B7A596F0028F508 /* UAInbox 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAInbox 3.xcdatamodel"; sourceTree = ""; }; 325108C82B7A5A220028F508 /* UARemoteData 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UARemoteData 4.xcdatamodel"; sourceTree = ""; }; 32515866272AFB2E00DF8B44 /* Media.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Media.swift; sourceTree = ""; }; 32515868272AFB2E00DF8B44 /* VideoMediaWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoMediaWebView.swift; sourceTree = ""; }; 325D53D9295C7979003421B4 /* ActionRegistryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRegistryTest.swift; sourceTree = ""; }; 3261A7F4243CD73100ADBF6B /* CoreTelephony.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreTelephony.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/System/Library/Frameworks/CoreTelephony.framework; sourceTree = DEVELOPER_DIR; }; 3299EF162948CBC100251E70 /* RemoteDataAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataAPIClientTest.swift; sourceTree = ""; }; 3299EF212949EC3E00251E70 /* AirshipBaseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipBaseTest.swift; sourceTree = ""; }; 3299EF25294B222F00251E70 /* RemoteDataStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataStoreTest.swift; sourceTree = ""; }; 329DFCCA2B7E4DA10039C8C0 /* UARemoteDataMappingV3toV4.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = UARemoteDataMappingV3toV4.xcmappingmodel; sourceTree = ""; }; 329DFCCE2B7E4DDA0039C8C0 /* UARemoteDataMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UARemoteDataMapping.swift; sourceTree = ""; }; 329DFCD42B7E59700039C8C0 /* UAInboxDataMapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAInboxDataMapping.swift; sourceTree = ""; }; 32B513552B9F53A500BBE780 /* MessageCenterPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterPredicate.swift; sourceTree = ""; }; 32B5BE2728F8A7D600F2254B /* MessageCenterListItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterListItemView.swift; sourceTree = ""; }; 32B5BE2828F8A7D600F2254B /* MessageCenterView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterView.swift; sourceTree = ""; }; 32B5BE2928F8A7D600F2254B /* MessageCenterMessageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterMessageView.swift; sourceTree = ""; }; 32B5BE2A28F8A7D600F2254B /* MessageCenterListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterListView.swift; sourceTree = ""; }; 32B5BE2B28F8A7D600F2254B /* MessageCenterListItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterListItemViewModel.swift; sourceTree = ""; }; 32B5BE3A28F8A7EB00F2254B /* MessageCenterController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterController.swift; sourceTree = ""; }; 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 = ""; }; 32F68CDA28F02A6B00F7F52A /* MessageCenterStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterStoreTest.swift; sourceTree = ""; }; 32F68CE828F07C2B00F7F52A /* AirshipMessageCenterResources.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipMessageCenterResources.swift; sourceTree = ""; }; 32F68CE928F07C2C00F7F52A /* MessageCenterSDKModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterSDKModule.swift; sourceTree = ""; }; 32F68CED28F07C2C00F7F52A /* MessageCenter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenter.swift; sourceTree = ""; }; 32F68CF428F07C4800F7F52A /* MessageCenterList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterList.swift; sourceTree = ""; }; 32F97AC029E5986B00FED65F /* StoryIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryIndicator.swift; sourceTree = ""; }; 32FD4C772D8079910056D141 /* BasicToggleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicToggleLayout.swift; sourceTree = ""; }; 3C63555F26CDD4F8006E9916 /* AirshipPush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPush.swift; sourceTree = ""; }; 3CA0E223237CCBA600EE76CF /* AirshipEventData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AirshipEventData.xcdatamodel; sourceTree = ""; }; 3CA0E233237CCBA600EE76CF /* AirshipDebugManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipDebugManager.swift; sourceTree = ""; }; 3CA0E241237CCBA600EE76CF /* AirshipEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipEvent.swift; sourceTree = ""; }; 3CA0E24D237CCBA600EE76CF /* EventDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventDataManager.swift; sourceTree = ""; }; 3CA0E24F237CCBA600EE76CF /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3CA0E2AC237CCE2600EE76CF /* AirshipDebug.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipDebug.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3CA0E305237E396100EE76CF /* UAInbox.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = UAInbox.xcdatamodel; sourceTree = ""; }; 3CA0E423237E4A7B00EE76CF /* AirshipMessageCenter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipMessageCenter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3CA0E47F237E505200EE76CF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3CA84AA626DE255200A59685 /* EventStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventStore.swift; sourceTree = ""; }; 3CA84AB626DE257200A59685 /* DefaultAirshipAnalytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultAirshipAnalytics.swift; sourceTree = ""; }; 3CA84AB726DE257200A59685 /* AirshipAnalytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipAnalytics.swift; sourceTree = ""; }; 3CB37A1D251151A400E60392 /* AirshipDebugResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugResources.swift; sourceTree = ""; }; 3CC8AA0526BB3C7900405614 /* DefaultAirshipPush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAirshipPush.swift; sourceTree = ""; }; 3CC95B1E268E785900FE2ACD /* NotificationCategories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategories.swift; sourceTree = ""; }; 3CC95B2A2696549B00FE2ACD /* AirshipPushableComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPushableComponent.swift; sourceTree = ""; }; 459D4049208FE64D00C40E2D /* Valid-UAInAppMessageFullScreenStyle.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Valid-UAInAppMessageFullScreenStyle.plist"; sourceTree = ""; }; 459D404B208FE6BA00C40E2D /* Invalid-UAInAppMessageFullScreenStyle.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Invalid-UAInAppMessageFullScreenStyle.plist"; sourceTree = ""; }; 459D40542092474300C40E2D /* Valid-UAInAppMessageModalStyle.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Valid-UAInAppMessageModalStyle.plist"; sourceTree = ""; }; 459D40562092474A00C40E2D /* Valid-UAInAppMessageBannerStyle.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Valid-UAInAppMessageBannerStyle.plist"; sourceTree = ""; }; 459D40582092475500C40E2D /* Invalid-UAInAppMessageBannerStyle.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Invalid-UAInAppMessageBannerStyle.plist"; sourceTree = ""; }; 459D405A2092475C00C40E2D /* Invalid-UAInAppMessageModalStyle.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "Invalid-UAInAppMessageModalStyle.plist"; sourceTree = ""; }; 45A8ADD123133E51004AD8CA /* testMCColorsCatalog.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = testMCColorsCatalog.xcassets; sourceTree = ""; }; 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 = ""; }; 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeResolver.swift; sourceTree = ""; }; 6014AD6A2C2032360072DCF0 /* ChallengeResolverTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChallengeResolverTest.swift; sourceTree = ""; }; 6014AD742C20410A0072DCF0 /* airship.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = airship.der; sourceTree = ""; }; 6018AF562B29C20A008E528B /* SearchEventTemplateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEventTemplateTest.swift; sourceTree = ""; }; 602AD0D42D7242B300C7D566 /* ThomasSmsLocale.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasSmsLocale.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 = ""; }; 6058771C2AC73C7E0021628E /* AirshipMeteredUsageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipMeteredUsageTest.swift; sourceTree = ""; }; 6058771E2ACAC86A0021628E /* MeteredUsageApiClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeteredUsageApiClientTest.swift; sourceTree = ""; }; 60653FC02CBD2CD4009CD9A7 /* PushData+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PushData+CoreDataClass.swift"; sourceTree = ""; }; 60653FC12CBD2CD4009CD9A7 /* PushData+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PushData+CoreDataProperties.swift"; sourceTree = ""; }; 6068E0052B2A190300349E82 /* CustomEventTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventTest.swift; sourceTree = ""; }; 6068E0072B2A2A6700349E82 /* AccountEventTemplateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountEventTemplateTest.swift; sourceTree = ""; }; 6068E0312B2B785A00349E82 /* MediaEventTemplateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaEventTemplateTest.swift; sourceTree = ""; }; 6068E0332B2B7CA100349E82 /* RetailEventTemplateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetailEventTemplateTest.swift; sourceTree = ""; }; 6068E03A2B2CBCF200349E82 /* ActiveTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveTimer.swift; sourceTree = ""; }; 6079511F2A1CD19F0086578F /* ExperimentManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExperimentManagerTest.swift; sourceTree = ""; }; 6087DB872B278F7600449BA8 /* JsonValueMatcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonValueMatcherTest.swift; sourceTree = ""; }; 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListProvider.swift; sourceTree = ""; }; 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSubscriptionListProvider.swift; sourceTree = ""; }; 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BaseCachingRemoteDataProvider.swift; sourceTree = ""; }; 609843552D6F518900690371 /* SmsLocalePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmsLocalePicker.swift; sourceTree = ""; }; 60A364EC2C3479BF00B05E26 /* ExecutionWindowTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionWindowTest.swift; sourceTree = ""; }; 60A5CC072B28DC500017EDB2 /* NotificationCategoriesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCategoriesTest.swift; sourceTree = ""; }; 60A5CC0B2B29AE890017EDB2 /* ProximityRegionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProximityRegionTest.swift; sourceTree = ""; }; 60A5CC0D2B29B1B80017EDB2 /* CircularRegionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularRegionTest.swift; sourceTree = ""; }; 60A5CC0F2B29B4100017EDB2 /* RegionEventTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionEventTest.swift; sourceTree = ""; }; 60C1DB0B2A8B743B00A1D3DA /* AirshipEmbeddedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipEmbeddedView.swift; sourceTree = ""; }; 60C1DB0C2A8B743B00A1D3DA /* AirshipEmbeddedViewManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipEmbeddedViewManager.swift; sourceTree = ""; }; 60C1DB0D2A8B743B00A1D3DA /* AirshipEmbeddedObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipEmbeddedObserver.swift; sourceTree = ""; }; 60C1DB0E2A8B743C00A1D3DA /* EmbeddedView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmbeddedView.swift; sourceTree = ""; }; 60CE9BDD2D0B6A0900A8B625 /* ThomasPagerControllerBranching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPagerControllerBranching.swift; sourceTree = ""; }; 60D1D9B72B68FB6400EBE0A4 /* PreparedTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedTrigger.swift; sourceTree = ""; }; 60D1D9BA2B6A53F000EBE0A4 /* PreparedTriggerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedTriggerTest.swift; sourceTree = ""; }; 60D1D9BC2B6AB2D100EBE0A4 /* AutomationTriggerProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationTriggerProcessorTest.swift; sourceTree = ""; }; 60D2B3342D9F0FCF00B0752D /* PagerDisableSwipeSelectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerDisableSwipeSelectorTest.swift; sourceTree = ""; }; 60D3BCC32A1529D800E07524 /* ExperimentDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentDataProvider.swift; sourceTree = ""; }; 60D3BCC52A152A0D00E07524 /* ExperimentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentManager.swift; sourceTree = ""; }; 60D3BCCB2A153C0700E07524 /* Experiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Experiment.swift; sourceTree = ""; }; 60D3BCCD2A15471C00E07524 /* AudienceHashSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudienceHashSelector.swift; sourceTree = ""; }; 60D3BCCF2A154D9400E07524 /* MessageCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCriteria.swift; sourceTree = ""; }; 60E09FDA2B2780DB005A16EA /* JsonMatcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonMatcherTest.swift; sourceTree = ""; }; 60EACF532B7BF2EA00CAFDBB /* AirshipApptimizeIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipApptimizeIntegration.swift; sourceTree = ""; }; 60F8E75B2B8F3D4B00460EDF /* CancelSchedulesAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelSchedulesAction.swift; sourceTree = ""; }; 60F8E75D2B8FA12800460EDF /* CancelSchedulesActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelSchedulesActionTest.swift; sourceTree = ""; }; 60F8E75F2B8FAF5400460EDF /* ScheduleAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleAction.swift; sourceTree = ""; }; 60F8E7612B8FB2CC00460EDF /* ScheduleActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleActionTest.swift; sourceTree = ""; }; 60FCA3042B4F1110005C9232 /* LegacyInAppMessaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyInAppMessaging.swift; sourceTree = ""; }; 60FCA3062B4F1C73005C9232 /* LegacyInAppMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyInAppMessage.swift; sourceTree = ""; }; 60FCA3092B51364A005C9232 /* LegacyInAppMessageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyInAppMessageTest.swift; sourceTree = ""; }; 60FCA30B2B51492A005C9232 /* LegacyInAppMessagingTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyInAppMessagingTest.swift; sourceTree = ""; }; 60FCA3242B5EF3A8005C9232 /* AutomationEventFeedTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationEventFeedTest.swift; sourceTree = ""; }; 6329102D2DD8103200B13C6C /* NativeVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeVideoPlayer.swift; sourceTree = ""; }; 632913F92DE547A500B13C6C /* VideoMediaNativeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoMediaNativeView.swift; sourceTree = ""; }; 6E0031AA2D08CC8A0004F53E /* AirshipAuthorizedNotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAuthorizedNotificationSettings.swift; sourceTree = ""; }; 6E0104FE2DDF9B26009D651F /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; 6E0105002DDFA5E6009D651F /* ScoreController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreController.swift; sourceTree = ""; }; 6E0105022DDFA719009D651F /* ScoreToggleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreToggleLayout.swift; sourceTree = ""; }; 6E0105042DDFA735009D651F /* ScoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreState.swift; sourceTree = ""; }; 6E032A4F2B210E6000404630 /* RemoteConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigTest.swift; sourceTree = ""; }; 6E062D02271656DE001A74A1 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; 6E062D04271656F8001A74A1 /* LinearLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinearLayout.swift; sourceTree = ""; }; 6E062D0627165709001A74A1 /* Label.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Label.swift; sourceTree = ""; }; 6E062D082716571F001A74A1 /* LabelButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelButton.swift; sourceTree = ""; }; 6E062D0C2718B505001A74A1 /* ViewConstraints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewConstraints.swift; sourceTree = ""; }; 6E07688729F9D28A0014E2A9 /* AirshipNotificationCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipNotificationCenter.swift; sourceTree = ""; }; 6E07688B29F9F0830014E2A9 /* AirshipLocaleManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipLocaleManagerTest.swift; sourceTree = ""; }; 6E07689129FB39440014E2A9 /* AirshipUnsafeSendableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipUnsafeSendableWrapper.swift; sourceTree = ""; }; 6E07B5F72D925ED30087EC47 /* TestPrivacyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestPrivacyManager.swift; sourceTree = ""; }; 6E0B872A294A9C120064B7BD /* AirshipAutomation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipAutomation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6E0B8731294A9C130064B7BD /* AirshipAutomationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AirshipAutomationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6E0B8749294A9CCA0064B7BD /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6E0B875F294CE0BF0064B7BD /* FarmHashFingerprint64Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FarmHashFingerprint64Test.swift; sourceTree = ""; }; 6E0B8761294CE0DC0064B7BD /* FarmHashFingerprint64.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FarmHashFingerprint64.swift; sourceTree = ""; }; 6E0F4BE12B32190400673CA4 /* AutomationSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSchedule.swift; sourceTree = ""; }; 6E0F4BE42B32645600673CA4 /* AutomationTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationTrigger.swift; sourceTree = ""; }; 6E0F4BE62B32646000673CA4 /* AutomationDelay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationDelay.swift; sourceTree = ""; }; 6E0F4BE82B3264A400673CA4 /* DeferredAutomationData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredAutomationData.swift; sourceTree = ""; }; 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasAsyncImage.swift; sourceTree = ""; }; 6E10A1472C2B825200ED9556 /* DefaultTaskSleeperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTaskSleeperTest.swift; sourceTree = ""; }; 6E1185C52C3328A10071334E /* ExecutionWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionWindow.swift; sourceTree = ""; }; 6E12539029A81ACE0009EE58 /* AirshipCoreDataPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipCoreDataPredicate.swift; sourceTree = ""; }; 6E146C4F2F5214D900320A36 /* AirshipDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDevice.swift; sourceTree = ""; }; 6E146D672F523DB700320A36 /* AirshipFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipFont.swift; sourceTree = ""; }; 6E146D692F5241BA00320A36 /* AirshipColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipColor.swift; sourceTree = ""; }; 6E146EDC2F52536C00320A36 /* AishipFontTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AishipFontTests.swift; sourceTree = ""; }; 6E146FF22F525E7300320A36 /* AirshipPasteboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPasteboard.swift; sourceTree = ""; }; 6E1472D42F526DC600320A36 /* AirshipNativePlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipNativePlatform.swift; sourceTree = ""; }; 6E1476CB2F56439A00320A36 /* MessageCenterNavigationAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterNavigationAppearance.swift; sourceTree = ""; }; 6E14C9A028B5E4AF00A55E65 /* PushNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotification.swift; sourceTree = ""; }; 6E1528162B4DC3C000DF1377 /* ActionAutomationExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionAutomationExecutor.swift; sourceTree = ""; }; 6E1528182B4DC3D000DF1377 /* InAppMessageAutomationPreparer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageAutomationPreparer.swift; sourceTree = ""; }; 6E15281A2B4DC3DF00DF1377 /* InAppMessageAutomationExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageAutomationExecutor.swift; sourceTree = ""; }; 6E15281C2B4DC43100DF1377 /* ActionAutomationPreparer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionAutomationPreparer.swift; sourceTree = ""; }; 6E15281F2B4DC59C00DF1377 /* InAppMessageSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageSceneDelegate.swift; sourceTree = ""; }; 6E1528212B4DC5C000DF1377 /* InAppMessageDisplayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageDisplayDelegate.swift; sourceTree = ""; }; 6E1528232B4DC60200DF1377 /* DisplayCoordinatorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinatorManager.swift; sourceTree = ""; }; 6E1528252B4DC64B00DF1377 /* DisplayAdapterFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayAdapterFactory.swift; sourceTree = ""; }; 6E1528272B4DCFCB00DF1377 /* AirshipActorValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipActorValue.swift; sourceTree = ""; }; 6E15282B2B4DE81E00DF1377 /* AutomationSDKModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSDKModule.swift; sourceTree = ""; }; 6E15282E2B4DED7A00DF1377 /* InAppMessageAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageAnalytics.swift; sourceTree = ""; }; 6E1528302B4DED8900DF1377 /* InAppMessageAnalyticsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageAnalyticsFactory.swift; sourceTree = ""; }; 6E1528322B4DF2E600DF1377 /* ScheduleConditionsChangedNotifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleConditionsChangedNotifier.swift; sourceTree = ""; }; 6E1528342B4E11DB00DF1377 /* CustomDisplayAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisplayAdapter.swift; sourceTree = ""; }; 6E1528362B4E11E800DF1377 /* CustomDisplayAdapterWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisplayAdapterWrapper.swift; sourceTree = ""; }; 6E1528382B4E13D400DF1377 /* AirshipLayoutDisplayAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipLayoutDisplayAdapter.swift; sourceTree = ""; }; 6E15283D2B4F0B8200DF1377 /* ActionAutomationPreparerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionAutomationPreparerTest.swift; sourceTree = ""; }; 6E15283F2B4F153900DF1377 /* TestDisplayAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDisplayAdapter.swift; sourceTree = ""; }; 6E1528412B4F156200DF1377 /* TestDisplayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDisplayCoordinator.swift; sourceTree = ""; }; 6E152BC92743235800788402 /* Icons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Icons.swift; sourceTree = ""; }; 6E15894F2AFEF19F00954A04 /* SessionTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTracker.swift; sourceTree = ""; }; 6E1589532AFF021D00954A04 /* SessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionState.swift; sourceTree = ""; }; 6E1589572AFF023400954A04 /* SessionEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEvent.swift; sourceTree = ""; }; 6E15B6D826CC749F0099C92D /* RemoteConfigManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteConfigManager.swift; sourceTree = ""; }; 6E15B6F326CD85C40099C92D /* RuntimeConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeConfig.swift; sourceTree = ""; }; 6E15B6F926CDCA6A0099C92D /* RuntimeConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeConfigTest.swift; sourceTree = ""; }; 6E15B70226CDE40E0099C92D /* RemoteConfigManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteConfigManagerTest.swift; sourceTree = ""; }; 6E15B70426CE07180099C92D /* TestRemoteData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRemoteData.swift; sourceTree = ""; }; 6E15B70A26CEB4190099C92D /* RemoteDataStorePayload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDataStorePayload.swift; sourceTree = ""; }; 6E15B70C26CEB4190099C92D /* RemoteDataStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDataStore.swift; sourceTree = ""; }; 6E15B72026CEC7030099C92D /* RemoteDataPayload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDataPayload.swift; sourceTree = ""; }; 6E15B72926CEDBA50099C92D /* RemoteData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteData.swift; sourceTree = ""; }; 6E15B72C26CF13BC0099C92D /* RemoteDataProviderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataProviderDelegate.swift; sourceTree = ""; }; 6E15B72F26CF4F6B0099C92D /* TestRemoteDataAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRemoteDataAPIClient.swift; sourceTree = ""; }; 6E1620892B311219009240B2 /* DisplayCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayCoordinator.swift; sourceTree = ""; }; 6E16208C2B3116AE009240B2 /* ImmediateDisplayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmediateDisplayCoordinator.swift; sourceTree = ""; }; 6E16208E2B3116BA009240B2 /* DefaultDisplayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDisplayCoordinator.swift; sourceTree = ""; }; 6E1620912B3118D5009240B2 /* ImmediateDisplayCoordinatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmediateDisplayCoordinatorTest.swift; sourceTree = ""; }; 6E1620942B311D8A009240B2 /* DefaultDisplayCoordinatorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDisplayCoordinatorTest.swift; sourceTree = ""; }; 6E1767F329B923D100D65F60 /* ChannelAuthTokenProviderTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelAuthTokenProviderTest.swift; sourceTree = ""; }; 6E1767F429B923D100D65F60 /* ChannelAuthTokenAPIClientTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelAuthTokenAPIClientTest.swift; sourceTree = ""; }; 6E1767F529B923D100D65F60 /* TestChannelAuthTokenAPIClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestChannelAuthTokenAPIClient.swift; sourceTree = ""; }; 6E1767F929B92F1700D65F60 /* AirshipUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipUtilsTest.swift; sourceTree = ""; }; 6E1802F82C5C2DEC00198D0D /* AirshipAnalyticFeedTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAnalyticFeedTest.swift; sourceTree = ""; }; 6E1892C7268D15C300417887 /* PreferenceCenterTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterTest.swift; sourceTree = ""; }; 6E1892C9268D16E200417887 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6E1892D4268E3D8500417887 /* PreferenceCenterDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterDecoder.swift; sourceTree = ""; }; 6E1892D6268E3F1800417887 /* PreferenceCenterConfigTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PreferenceCenterConfigTest.swift; path = AirshipPreferenceCenter/Tests/data/PreferenceCenterConfigTest.swift; sourceTree = SOURCE_ROOT; }; 6E1892DD2694F1C100417887 /* ChannelRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRegistrar.swift; sourceTree = ""; }; 6E1A15052D6EA3A50056418B /* ThomasFormState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormState.swift; sourceTree = ""; }; 6E1A19212D6F87550056418B /* ThomasFormValidationMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormValidationMode.swift; sourceTree = ""; }; 6E1A19232D6F8BD50056418B /* AirshipInputValidationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipInputValidationTest.swift; sourceTree = ""; }; 6E1A1BB22D6F9D090056418B /* ThomasFormFieldProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormFieldProcessorTest.swift; sourceTree = ""; }; 6E1A1D842D70F36D0056418B /* ThomasState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasState.swift; sourceTree = ""; }; 6E1A9BAA2B5AE38A00A6489B /* InAppMessageDisplayListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageDisplayListener.swift; sourceTree = ""; }; 6E1A9BAF2B5B0C4C00A6489B /* AutomationActionRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationActionRunner.swift; sourceTree = ""; }; 6E1A9BB12B5B172F00A6489B /* TestActionRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestActionRunner.swift; sourceTree = ""; }; 6E1A9BB62B5B1D9E00A6489B /* TestActiveTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestActiveTimer.swift; sourceTree = ""; }; 6E1A9BB82B5B20A500A6489B /* InAppMessageDisplayListenerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageDisplayListenerTest.swift; sourceTree = ""; }; 6E1A9BBA2B5B20D700A6489B /* TestInAppMessageAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInAppMessageAnalytics.swift; sourceTree = ""; }; 6E1A9BBC2B5B290200A6489B /* ThomasDisplayListenerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasDisplayListenerTest.swift; sourceTree = ""; }; 6E1A9BBE2B5EE19000A6489B /* PreparedSchedule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedSchedule.swift; sourceTree = ""; }; 6E1A9BC02B5EE1CF00A6489B /* SchedulePrepareResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulePrepareResult.swift; sourceTree = ""; }; 6E1A9BC22B5EE1DE00A6489B /* ScheduleReadyResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleReadyResult.swift; sourceTree = ""; }; 6E1A9BC42B5EE1EE00A6489B /* ScheduleExecuteResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleExecuteResult.swift; sourceTree = ""; }; 6E1A9BC62B5EE32E00A6489B /* AutomationTriggerProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationTriggerProcessor.swift; sourceTree = ""; }; 6E1A9BC82B5EE34600A6489B /* AutomationEventFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationEventFeed.swift; sourceTree = ""; }; 6E1A9BD02B5EE84600A6489B /* AutomationScheduleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationScheduleData.swift; sourceTree = ""; }; 6E1A9BD22B5EE8A400A6489B /* AutomationScheduleState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationScheduleState.swift; sourceTree = ""; }; 6E1A9BD42B5EE97000A6489B /* TriggeringInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggeringInfo.swift; sourceTree = ""; }; 6E1A9BF62B606CF200A6489B /* AutomationDelayProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationDelayProcessor.swift; sourceTree = ""; }; 6E1B7B122B714FFC00695561 /* LandingPageAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingPageAction.swift; sourceTree = ""; }; 6E1B7B152B715FFE00695561 /* LandingPageActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandingPageActionTest.swift; sourceTree = ""; }; 6E1BACDA2719ED7D0038399E /* ScrollLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollLayout.swift; sourceTree = ""; }; 6E1BACDC2719FC0A0038399E /* ViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewFactory.swift; sourceTree = ""; }; 6E1C9C39271E90EB009EF9EF /* LayoutModelsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutModelsTest.swift; sourceTree = ""; }; 6E1C9C4A271F7878009EF9EF /* BackgroundColorViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundColorViewModifier.swift; sourceTree = ""; }; 6E1CBD802BA3A30300519D9C /* AirshipEmbeddedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipEmbeddedInfo.swift; sourceTree = ""; }; 6E1CBDB52BA4CF0C00519D9C /* MessageDisplayHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDisplayHistory.swift; sourceTree = ""; }; 6E1CBDE22BA51ED100519D9C /* InAppDisplayImpressionRuleProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppDisplayImpressionRuleProvider.swift; sourceTree = ""; }; 6E1CBDFE2BAA1DF200519D9C /* DefaultInAppDisplayImpressionRuleProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultInAppDisplayImpressionRuleProviderTest.swift; sourceTree = ""; }; 6E1CBE2B2BAA2AEA00519D9C /* AirshipAutomation 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AirshipAutomation 2.xcdatamodel"; sourceTree = ""; }; 6E1CBE2C2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AirshipAutomation.xcdatamodel; sourceTree = ""; }; 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 = ""; }; 6E213B172BC60AF100BF24AE /* AirshipWeakValueHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipWeakValueHolder.swift; sourceTree = ""; }; 6E213B1D2BC7054500BF24AE /* InAppActionRunner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppActionRunner.swift; sourceTree = ""; }; 6E21852A237D32B30084933A /* EventData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventData.swift; sourceTree = ""; }; 6E2486DE28945D3900657CE4 /* PreferenceCenterState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterState.swift; sourceTree = ""; }; 6E2486EB2894901E00657CE4 /* ConditionsViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsViewModifier.swift; sourceTree = ""; }; 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 /* PreferenceCenterContentLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterContentLoader.swift; sourceTree = ""; }; 6E28116B2BE40E860040D928 /* FeatureFlagVariablesTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagVariablesTest.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 = ""; }; 6E299FD428D13D00001305A7 /* DefaultAirshipRequestSessionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAirshipRequestSessionTest.swift; sourceTree = ""; }; 6E299FD628D13E54001305A7 /* AirshipRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipRequest.swift; sourceTree = ""; }; 6E299FDA28D14208001305A7 /* AirshipResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipResponse.swift; sourceTree = ""; }; 6E299FDE28D14258001305A7 /* AirshipRequestSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipRequestSession.swift; sourceTree = ""; }; 6E2D6AED26B083DB00B7C226 /* ChannelAudienceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAudienceManager.swift; sourceTree = ""; }; 6E2D6AF126B0B64E00B7C226 /* SubscriptionListAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListAPIClientTest.swift; sourceTree = ""; }; 6E2D6AF326B0C3C500B7C226 /* ChannelAudienceManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAudienceManagerTest.swift; sourceTree = ""; }; 6E2D6AF526B0C6CA00B7C226 /* TestSubscriptionListAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSubscriptionListAPIClient.swift; sourceTree = ""; }; 6E2E3CA12B32723C00B8515B /* InAppMessageNativeBridgeExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppMessageNativeBridgeExtension.swift; sourceTree = ""; }; 6E2E3CA42B32726400B8515B /* InAppMessageNativeBridgeExtensionTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppMessageNativeBridgeExtensionTest.swift; sourceTree = ""; }; 6E2F5A732A60833700CABD3D /* FeatureFlagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagManager.swift; sourceTree = ""; }; 6E2F5A752A60871E00CABD3D /* FeatureFlagPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagPayload.swift; sourceTree = ""; }; 6E2F5A852A65F00200CABD3D /* RemoteDataSourceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataSourceStatus.swift; sourceTree = ""; }; 6E2F5A892A66088100CABD3D /* AudienceDeviceInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudienceDeviceInfoProvider.swift; sourceTree = ""; }; 6E2F5A8D2A66FE8900CABD3D /* AirshipTimeCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipTimeCriteria.swift; sourceTree = ""; }; 6E2F5AB02A67434B00CABD3D /* FeatureFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlag.swift; sourceTree = ""; }; 6E2F5AB62A675ADC00CABD3D /* TestAudienceChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAudienceChecker.swift; sourceTree = ""; }; 6E2F5AB92A675D3600CABD3D /* FeatureFlagsRemoteDataAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagsRemoteDataAccess.swift; sourceTree = ""; }; 6E2FA2882D515189005893E2 /* ThomasEmailRegistrationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasEmailRegistrationOptions.swift; sourceTree = ""; }; 6E2FA28B2D515C5A005893E2 /* ThomasEmailRegistrationOptionsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasEmailRegistrationOptionsTest.swift; sourceTree = ""; }; 6E34C4B02C7D4B6400B00506 /* ExecutionWindowProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionWindowProcessor.swift; sourceTree = ""; }; 6E34C4B22C7D4C6600B00506 /* ExecutionWindowProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionWindowProcessorTest.swift; sourceTree = ""; }; 6E382C20276D3E990091A351 /* ThomasValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasValidationTests.swift; sourceTree = ""; }; 6E3B230E28A318CD0005D46E /* PreferenceCenterThemeLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterThemeLoader.swift; sourceTree = ""; }; 6E3B231228A32EC30005D46E /* PreferenceCenterViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterViewExtensions.swift; sourceTree = ""; }; 6E3B32CB27559D8B00B89C7B /* FormInputViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormInputViewModifier.swift; sourceTree = ""; }; 6E3B32CE2755D8C700B89C7B /* LayoutState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutState.swift; sourceTree = ""; }; 6E3CA5402ECB9B7400210C32 /* AirshipDisplayTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDisplayTarget.swift; sourceTree = ""; }; 6E4007132A153AB20013C2DE /* AppRemoteDataProviderDelegateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRemoteDataProviderDelegateTest.swift; sourceTree = ""; }; 6E4007152A153ABE0013C2DE /* ContactRemoteDataProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRemoteDataProviderTest.swift; sourceTree = ""; }; 6E4007172A153AFE0013C2DE /* RemoteDataURLFactoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataURLFactoryTest.swift; sourceTree = ""; }; 6E40868B2B8931C900435E2C /* AirshipViewSizeReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipViewSizeReader.swift; sourceTree = ""; }; 6E40868D2B8D036600435E2C /* AirshipEmbeddedSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipEmbeddedSize.swift; sourceTree = ""; }; 6E411B6C2538C4E500FEE4E8 /* UANativeBridge */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = UANativeBridge; sourceTree = ""; }; 6E411B6D2538C4E600FEE4E8 /* UANotificationCategories.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = UANotificationCategories.plist; sourceTree = ""; }; 6E411C752538C60900FEE4E8 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C862538C62B00FEE4E8 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C872538C62B00FEE4E8 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C882538C62B00FEE4E8 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C892538C62B00FEE4E8 /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/UrbanAirship.strings"; sourceTree = ""; }; 6E411C8A2538C62B00FEE4E8 /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C8B2538C62B00FEE4E8 /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C8C2538C62B00FEE4E8 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/UrbanAirship.strings"; sourceTree = ""; }; 6E411C8D2538C62B00FEE4E8 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C8E2538C62C00FEE4E8 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/UrbanAirship.strings"; sourceTree = ""; }; 6E411C8F2538C62C00FEE4E8 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C902538C62C00FEE4E8 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/UrbanAirship.strings"; sourceTree = ""; }; 6E411C912538C62C00FEE4E8 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C922538C62C00FEE4E8 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C932538C62C00FEE4E8 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C942538C62C00FEE4E8 /* iw */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = iw; path = iw.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C952538C62C00FEE4E8 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C962538C62C00FEE4E8 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C972538C62D00FEE4E8 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C982538C64700FEE4E8 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C992538C64700FEE4E8 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C9A2538C64700FEE4E8 /* no */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = no; path = no.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C9B2538C64700FEE4E8 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C9C2538C64800FEE4E8 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C9D2538C65100FEE4E8 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C9E2538C65400FEE4E8 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = hi.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411C9F2538C65700FEE4E8 /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411CA02538C65900FEE4E8 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411CA12538C65C00FEE4E8 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411CA22538C65F00FEE4E8 /* ms */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ms; path = ms.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411CA32538C66100FEE4E8 /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/UrbanAirship.strings; sourceTree = ""; }; 6E411CA52538C6A500FEE4E8 /* UARemoteData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = UARemoteData.xcdatamodel; sourceTree = ""; }; 6E411CA62538C6A500FEE4E8 /* UARemoteData 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UARemoteData 2.xcdatamodel"; sourceTree = ""; }; 6E411CA82538C6A500FEE4E8 /* UAEvents.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = UAEvents.xcdatamodel; sourceTree = ""; }; 6E43204826EA814F009228AB /* AirshipBasement.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipBasement.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6E43218F26EA89B6009228AB /* NativeBridgeDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeBridgeDelegate.swift; sourceTree = ""; }; 6E4325C22B7A9D9A00A9B000 /* AirshipPrivacyManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPrivacyManagerTest.swift; sourceTree = ""; }; 6E4325C42B7AC3F700A9B000 /* TestPush.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestPush.swift; sourceTree = ""; }; 6E4325CD2B7AD5A200A9B000 /* AirshipComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipComponent.swift; sourceTree = ""; }; 6E4325D22B7AD96800A9B000 /* AirshipTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipTest.swift; sourceTree = ""; }; 6E4325E82B7AEB1F00A9B000 /* AirshipEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipEvent.swift; sourceTree = ""; }; 6E4325F12B7B1EDA00A9B000 /* SessionEventFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionEventFactory.swift; sourceTree = ""; }; 6E4325F52B7B2F5800A9B000 /* FeatureFlagAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagAnalytics.swift; sourceTree = ""; }; 6E4325F72B7C08A600A9B000 /* AirshipAnalyticsFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAnalyticsFeed.swift; sourceTree = ""; }; 6E4326002B7C327C00A9B000 /* AirshipEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipEvents.swift; sourceTree = ""; }; 6E4326042B7C361F00A9B000 /* AssociatedIdentifiersTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssociatedIdentifiersTest.swift; sourceTree = ""; }; 6E4339EE2DFA039B000A7741 /* JSONValueMatcherPredicates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueMatcherPredicates.swift; sourceTree = ""; }; 6E4339F02DFA099C000A7741 /* AirshipIvyVersionMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipIvyVersionMatcher.swift; sourceTree = ""; }; 6E44626629E6813A00CB2B56 /* AsyncSerialQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncSerialQueue.swift; sourceTree = ""; }; 6E46A272272B19760089CDE3 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; 6E46A27B272B63680089CDE3 /* ThomasEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasEnvironment.swift; sourceTree = ""; }; 6E46A27E272B68660089CDE3 /* ThomasDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasDelegate.swift; sourceTree = ""; }; 6E475BFD2F5A3709003D8E42 /* VideoGroupState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoGroupState.swift; sourceTree = ""; }; 6E475CB92F5B3E45003D8E42 /* VideoMediaWebViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoMediaWebViewTests.swift; sourceTree = ""; }; 6E49D7A628401D2D00C7BB9D /* Permission.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Permission.swift; sourceTree = ""; }; 6E49D7A728401D2D00C7BB9D /* PermissionDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PermissionDelegate.swift; sourceTree = ""; }; 6E49D7A828401D2D00C7BB9D /* NotificationRegistrar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationRegistrar.swift; sourceTree = ""; }; 6E49D7A928401D2D00C7BB9D /* Atomic.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; 6E49D7AA28401D2D00C7BB9D /* NotificationPermissionDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationPermissionDelegate.swift; sourceTree = ""; }; 6E49D7AB28401D2D00C7BB9D /* PermissionsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PermissionsManager.swift; sourceTree = ""; }; 6E49D7AC28401D2D00C7BB9D /* RegistrationDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegistrationDelegate.swift; sourceTree = ""; }; 6E49D7AD28401D2D00C7BB9D /* UNNotificationRegistrar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UNNotificationRegistrar.swift; sourceTree = ""; }; 6E49D7AE28401D2D00C7BB9D /* PermissionStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PermissionStatus.swift; sourceTree = ""; }; 6E49D7AF28401D2E00C7BB9D /* PushNotificationDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushNotificationDelegate.swift; sourceTree = ""; }; 6E49D7B028401D2E00C7BB9D /* Badger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Badger.swift; sourceTree = ""; }; 6E49D7B128401D2E00C7BB9D /* APNSRegistrar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = APNSRegistrar.swift; sourceTree = ""; }; 6E49D7CC284028C600C7BB9D /* PermissionsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsManagerTests.swift; sourceTree = ""; }; 6E4A466028EF447C00A25617 /* MessageCenterUser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterUser.swift; sourceTree = ""; }; 6E4A466228EF448600A25617 /* MessageCenterStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterStore.swift; sourceTree = ""; }; 6E4A466328EF448600A25617 /* MessageCenterAPIClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterAPIClient.swift; sourceTree = ""; }; 6E4A466428EF448600A25617 /* MessageCenterMessage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterMessage.swift; sourceTree = ""; }; 6E4A466E28EF44F600A25617 /* AirshipMessageCenterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AirshipMessageCenterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6E4A467828EF453400A25617 /* MessageCenterAPIClientTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageCenterAPIClientTest.swift; sourceTree = ""; }; 6E4A467F28EF4FAF00A25617 /* TestAirshipRequestSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAirshipRequestSession.swift; sourceTree = ""; }; 6E4A469E28F4A7DF00A25617 /* MessageCenterAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterAction.swift; sourceTree = ""; }; 6E4A46A028F4AEDF00A25617 /* MessageCenterNativeBridgeExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterNativeBridgeExtension.swift; sourceTree = ""; }; 6E4A4FD92A30358F0049FEFC /* TagsActionArgs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsActionArgs.swift; sourceTree = ""; }; 6E4A4FDD2A3132850049FEFC /* AirshipSDKModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSDKModule.swift; sourceTree = ""; }; 6E4AEE222B6B2E09008AEAC1 /* alternate-airship.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "alternate-airship.jpg"; sourceTree = ""; }; 6E4AEE232B6B2E09008AEAC1 /* DefaultAssetFileManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultAssetFileManagerTest.swift; sourceTree = ""; }; 6E4AEE242B6B2E0A008AEAC1 /* airship.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = airship.jpg; sourceTree = ""; }; 6E4AEE252B6B2E0A008AEAC1 /* AssetCacheManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetCacheManagerTest.swift; sourceTree = ""; }; 6E4AEE262B6B2E0A008AEAC1 /* DefaultAssetDownloaderTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultAssetDownloaderTest.swift; sourceTree = ""; }; 6E4AEE4A2B6B4358008AEAC1 /* UAAutomation 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 7.xcdatamodel"; sourceTree = ""; }; 6E4AEE4B2B6B4358008AEAC1 /* UAAutomation 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 12.xcdatamodel"; sourceTree = ""; }; 6E4AEE4C2B6B4358008AEAC1 /* UAAutomation 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 2.xcdatamodel"; sourceTree = ""; }; 6E4AEE4D2B6B4358008AEAC1 /* UAAutomation 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 11.xcdatamodel"; sourceTree = ""; }; 6E4AEE4E2B6B4358008AEAC1 /* UAAutomation 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 8.xcdatamodel"; sourceTree = ""; }; 6E4AEE4F2B6B4358008AEAC1 /* UAAutomation 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 4.xcdatamodel"; sourceTree = ""; }; 6E4AEE502B6B4358008AEAC1 /* UAAutomation 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 3.xcdatamodel"; sourceTree = ""; }; 6E4AEE512B6B4358008AEAC1 /* UAAutomation 13.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 13.xcdatamodel"; sourceTree = ""; }; 6E4AEE522B6B4358008AEAC1 /* UAAutomation.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = UAAutomation.xcdatamodel; sourceTree = ""; }; 6E4AEE532B6B4358008AEAC1 /* UAAutomation 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 6.xcdatamodel"; sourceTree = ""; }; 6E4AEE542B6B4358008AEAC1 /* UAAutomation 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 5.xcdatamodel"; sourceTree = ""; }; 6E4AEE552B6B4358008AEAC1 /* UAAutomation 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 9.xcdatamodel"; sourceTree = ""; }; 6E4AEE562B6B4358008AEAC1 /* UAAutomation 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAAutomation 10.xcdatamodel"; sourceTree = ""; }; 6E4AEE622B6B44EA008AEAC1 /* LegacyAutomationStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyAutomationStore.swift; sourceTree = ""; }; 6E4AEE632B6B44EA008AEAC1 /* AutomationStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutomationStore.swift; sourceTree = ""; }; 6E4AEEBB2B6D6380008AEAC1 /* TriggerData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerData.swift; sourceTree = ""; }; 6E4D20712E6B760C00A8D641 /* MessageCenterListViewWithNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterListViewWithNavigation.swift; sourceTree = ""; }; 6E4D22492E6F813700A8D641 /* MessageCenterMessageViewWithNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterMessageViewWithNavigation.swift; sourceTree = ""; }; 6E4D224B2E6F968000A8D641 /* MessageCenterNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterNavigationStack.swift; sourceTree = ""; }; 6E4D224D2E6F96B100A8D641 /* MessageCenterSplitNavigationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterSplitNavigationView.swift; sourceTree = ""; }; 6E4D224F2E6F9CD700A8D641 /* MessageCenterContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterContent.swift; sourceTree = ""; }; 6E4D22512E6FA2F300A8D641 /* MessageCenterBackButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterBackButton.swift; sourceTree = ""; }; 6E4D22532E6FA5EA00A8D641 /* MessageCenterWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterWebView.swift; sourceTree = ""; }; 6E4D225C2E70ADDB00A8D641 /* MessageCenterMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterMessageViewModel.swift; sourceTree = ""; }; 6E4D225E2E70AFE300A8D641 /* MessageCenterListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterListViewModel.swift; sourceTree = ""; }; 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 = ""; }; 6E5213E22DCA7A3800CF64B9 /* ThomasEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasEvent.swift; sourceTree = ""; }; 6E5214662DCAB03600CF64B9 /* ThomasFormResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormResult.swift; sourceTree = ""; }; 6E5214682DCABFCA00CF64B9 /* ThomasLayoutContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutContext.swift; sourceTree = ""; }; 6E52146A2DCBF9BA00CF64B9 /* AirshipTimerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipTimerProtocol.swift; sourceTree = ""; }; 6E52146C2DCBFAB900CF64B9 /* ThomasPagerTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPagerTracker.swift; sourceTree = ""; }; 6E52146E2DCC075100CF64B9 /* ThomasPagerTrackerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPagerTrackerTest.swift; sourceTree = ""; }; 6E5215212DCEA10F00CF64B9 /* ThomasViewedPageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasViewedPageInfo.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 = ""; }; 6E55A4D62E1DB4CB00B07DF8 /* ThomasAssociatedLabelResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasAssociatedLabelResolver.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 = ""; }; 6E5A64C32AAB7D5C00574085 /* AirshipMeteredUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipMeteredUsage.swift; sourceTree = ""; }; 6E5A64C72AABBE7100574085 /* MeteredUsageAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeteredUsageAPIClient.swift; sourceTree = ""; }; 6E5A64CF2AABBEAF00574085 /* AirshipMeteredUsageEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipMeteredUsageEvent.swift; sourceTree = ""; }; 6E5A64D32AABBED600574085 /* MeteredUsageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeteredUsageStore.swift; sourceTree = ""; }; 6E5A64D82AABC5A400574085 /* UAMeteredUsage.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = UAMeteredUsage.xcdatamodel; sourceTree = ""; }; 6E5ADF812D7682A200A03799 /* StateSubscriptionsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateSubscriptionsModifier.swift; sourceTree = ""; }; 6E5ADF832D7682D300A03799 /* ThomasStateTrigger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasStateTrigger.swift; sourceTree = ""; }; 6E5B1A042AFF090B0019CA61 /* SessionTrackerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTrackerTest.swift; sourceTree = ""; }; 6E60EF6529DF4BB5003F7A8D /* ApplicationMetricsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationMetricsTest.swift; sourceTree = ""; }; 6E60EF6929DF542B003F7A8D /* AnonContactData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnonContactData.swift; sourceTree = ""; }; 6E6363E129DCD0CF009C358A /* ContactSubscriptionListClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSubscriptionListClient.swift; sourceTree = ""; }; 6E6363E529DCE9A2009C358A /* ContactSubscriptionListAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSubscriptionListAPIClientTest.swift; sourceTree = ""; }; 6E6363E729DCEB84009C358A /* ContactManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactManagerTest.swift; sourceTree = ""; }; 6E6363E929DCECA1009C358A /* TestContactSubscriptionListAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContactSubscriptionListAPIClient.swift; sourceTree = ""; }; 6E6363EB29DDF84B009C358A /* SerialQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialQueue.swift; sourceTree = ""; }; 6E64C87F27331ABA000EB887 /* PreferenceDataStoreTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferenceDataStoreTest.swift; sourceTree = ""; }; 6E65244B2A4FD4270019F353 /* DeviceTagSelectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTagSelectorTest.swift; sourceTree = ""; }; 6E65244D2A4FD69F0019F353 /* DeviceAudienceSelectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAudienceSelectorTest.swift; sourceTree = ""; }; 6E65244F2A4FD8D30019F353 /* JSONPredicateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONPredicateTest.swift; sourceTree = ""; }; 6E6541DF2758976D009676CA /* AirshipProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipProgressView.swift; sourceTree = ""; }; 6E65FB5F2C753CB400D9F341 /* EmbeddedViewSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedViewSelector.swift; sourceTree = ""; }; 6E664BA026C43F5400A2C8E5 /* ActivityViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActivityViewController.swift; sourceTree = ""; }; 6E664BA426C4417400A2C8E5 /* ShareAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareAction.swift; sourceTree = ""; }; 6E664BC926C4852B00A2C8E5 /* AddCustomEventAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddCustomEventAction.swift; sourceTree = ""; }; 6E664BCF26C4916600A2C8E5 /* AddTagsAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTagsAction.swift; sourceTree = ""; }; 6E664BD226C4917000A2C8E5 /* RemoveTagsAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveTagsAction.swift; sourceTree = ""; }; 6E664BD526C4CD8700A2C8E5 /* PasteboardAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PasteboardAction.swift; sourceTree = ""; }; 6E664BD626C4CD8700A2C8E5 /* EnableFeatureAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnableFeatureAction.swift; sourceTree = ""; }; 6E664BD726C4CD8700A2C8E5 /* OpenExternalURLAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenExternalURLAction.swift; sourceTree = ""; }; 6E664BD826C4CD8700A2C8E5 /* FetchDeviceInfoAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FetchDeviceInfoAction.swift; sourceTree = ""; }; 6E664BE426C5817B00A2C8E5 /* TestContact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContact.swift; sourceTree = ""; }; 6E664BE626C5B21600A2C8E5 /* ModifyAttributesAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModifyAttributesAction.swift; sourceTree = ""; }; 6E664BE926C6DB7500A2C8E5 /* AirshipUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipUtils.swift; sourceTree = ""; }; 6E66BA7E2D14B61A0083A9FD /* WrappingLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappingLayout.swift; sourceTree = ""; }; 6E66DDA52E95A67C00D44555 /* WorkRateLimiterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkRateLimiterTests.swift; sourceTree = ""; }; 6E68028D2B852F6A00F4591F /* AutomationScheduleDataTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationScheduleDataTest.swift; sourceTree = ""; }; 6E68028F2B8671E700F4591F /* InAppAutomationComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppAutomationComponent.swift; sourceTree = ""; }; 6E6802912B86732200F4591F /* PreferenceCenterComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterComponent.swift; sourceTree = ""; }; 6E6802932B8673F900F4591F /* FeatureFlagComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagComponent.swift; sourceTree = ""; }; 6E6802952B86749900F4591F /* MessageCenterComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterComponent.swift; sourceTree = ""; }; 6E6802972B8675A200F4591F /* DebugComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugComponent.swift; sourceTree = ""; }; 6E68203128EDE3E200A4F90B /* LiveActivityRestorer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRestorer.swift; sourceTree = ""; }; 6E692AFC29E0CB2F00D96CCC /* JavaScriptCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptCommand.swift; sourceTree = ""; }; 6E692AFE29E0CB4100D96CCC /* JavaScriptCommandDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptCommandDelegate.swift; sourceTree = ""; }; 6E692B0229E0CBB500D96CCC /* NativeBridgeExtensionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBridgeExtensionDelegate.swift; sourceTree = ""; }; 6E698DE826790AC300654DB2 /* PreferenceDataStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferenceDataStore.swift; sourceTree = ""; }; 6E698DEA26790AC300654DB2 /* AirshipLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipLogger.swift; sourceTree = ""; }; 6E698DEB26790AC300654DB2 /* AirshipPrivacyManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipPrivacyManager.swift; sourceTree = ""; }; 6E698E02267A799500654DB2 /* AirshipErrors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipErrors.swift; sourceTree = ""; }; 6E698E08267A7DD900654DB2 /* RemoteDataAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataAPIClient.swift; sourceTree = ""; }; 6E698E0B267A88D600654DB2 /* EventAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventAPIClient.swift; sourceTree = ""; }; 6E698E11267A98AB00654DB2 /* ChannelAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAPIClient.swift; sourceTree = ""; }; 6E698E3A267BEDC300654DB2 /* DefaultAirshipContact.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultAirshipContact.swift; sourceTree = ""; }; 6E698E3B267BEDC300654DB2 /* AttributesEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttributesEditor.swift; sourceTree = ""; }; 6E698E3C267BEDC300654DB2 /* TagGroupsEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TagGroupsEditor.swift; sourceTree = ""; }; 6E698E52267BF63A00654DB2 /* AppStateTrackerAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppStateTrackerAdapter.swift; sourceTree = ""; }; 6E698E53267BF63A00654DB2 /* AppStateTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppStateTracker.swift; sourceTree = ""; }; 6E698E56267BF63B00654DB2 /* ApplicationState.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationState.swift; sourceTree = ""; }; 6E698E61267C03C700654DB2 /* TestAppStateTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = TestAppStateTracker.swift; path = Support/TestAppStateTracker.swift; sourceTree = ""; }; 6E6A848C2B6854FC006FFB35 /* AutomationDelayProcessorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationDelayProcessorTest.swift; sourceTree = ""; }; 6E6A84912B68A571006FFB35 /* AutomationStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationStoreTest.swift; sourceTree = ""; }; 6E6B2DBD2B33B768008BF788 /* AutomationScheduleTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationScheduleTest.swift; sourceTree = ""; }; 6E6BD2412AE995DA00B9DFC9 /* DeferredResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredResolver.swift; sourceTree = ""; }; 6E6BD2452AEAFE7E00B9DFC9 /* DeferredAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredAPIClient.swift; sourceTree = ""; }; 6E6BD2492AEAFEB700B9DFC9 /* AirsihpTriggerContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirsihpTriggerContext.swift; sourceTree = ""; }; 6E6BD24D2AEAFEC500B9DFC9 /* AirshipStateOverrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipStateOverrides.swift; sourceTree = ""; }; 6E6BD2572AEC598C00B9DFC9 /* DeferredAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredAPIClientTest.swift; sourceTree = ""; }; 6E6BD2592AEC626B00B9DFC9 /* DeferredResolverTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredResolverTest.swift; sourceTree = ""; }; 6E6BD26C2AF1AC5700B9DFC9 /* AirshipCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipCache.swift; sourceTree = ""; }; 6E6BD2712AF1B05500B9DFC9 /* UAAirshipCache.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = UAAirshipCache.xcdatamodel; sourceTree = ""; }; 6E6BD2752AF1C1D800B9DFC9 /* DeferredFlagResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredFlagResolver.swift; sourceTree = ""; }; 6E6BD2772AF2B97300B9DFC9 /* AirshipTaskSleeper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipTaskSleeper.swift; sourceTree = ""; }; 6E6C3F7E27A20C3C007F55C7 /* ChannelScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelScope.swift; sourceTree = ""; }; 6E6C3F8927A266C0007F55C7 /* CachedValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedValue.swift; sourceTree = ""; }; 6E6C3F8C27A26992007F55C7 /* CachedValueTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedValueTest.swift; sourceTree = ""; }; 6E6C3F9927A47DB4007F55C7 /* PreferenceCenterConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterConfig.swift; sourceTree = ""; }; 6E6C3F9D27A4C3D4007F55C7 /* AirshipJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipJSON.swift; sourceTree = ""; }; 6E6C84452A5C8CFD00DD83A2 /* AirshipConfigTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipConfigTest.swift; sourceTree = ""; }; 6E6CC38023A3F9B4003D583C /* PushDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PushDataManager.swift; sourceTree = ""; }; 6E6ED1352683A58D00A2CBD0 /* Dispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Dispatcher.swift; sourceTree = ""; }; 6E6ED13F2683A9F200A2CBD0 /* AirshipDate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipDate.swift; sourceTree = ""; }; 6E6ED1422683B8FA00A2CBD0 /* TestDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDate.swift; sourceTree = ""; }; 6E6ED1442683BC7F00A2CBD0 /* TestDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDispatcher.swift; sourceTree = ""; }; 6E6ED1482683D8E200A2CBD0 /* LocaleManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocaleManager.swift; sourceTree = ""; }; 6E6ED14D2683DBC200A2CBD0 /* AirshipBase64.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipBase64.swift; sourceTree = ""; }; 6E6ED14F2683DBC200A2CBD0 /* AirshipCoreResources.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipCoreResources.swift; sourceTree = ""; }; 6E6ED1502683DBC300A2CBD0 /* AirshipVersion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipVersion.swift; sourceTree = ""; }; 6E6ED1512683DBC300A2CBD0 /* ApplicationMetrics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApplicationMetrics.swift; sourceTree = ""; }; 6E6ED1532683DBC300A2CBD0 /* UACoreData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UACoreData.swift; sourceTree = ""; }; 6E6ED1542683DBC300A2CBD0 /* AirshipNetworkChecker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipNetworkChecker.swift; sourceTree = ""; }; 6E6ED171268448EC00A2CBD0 /* TestNetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestNetworkMonitor.swift; sourceTree = ""; }; 6E6EF9E6270625C400D30C35 /* AirshipEventsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipEventsTest.swift; sourceTree = ""; }; 6E7112962880DACB004942E4 /* EventHandlerViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventHandlerViewModifier.swift; sourceTree = ""; }; 6E7112982880DACB004942E4 /* EnableBehaviorModifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnableBehaviorModifiers.swift; sourceTree = ""; }; 6E7112992880DACB004942E4 /* VisibilityViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VisibilityViewModifier.swift; sourceTree = ""; }; 6E71129A2880DACB004942E4 /* StateController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateController.swift; sourceTree = ""; }; 6E739D6526B9BDC100BC6F6D /* ChannelBulkUpdateAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBulkUpdateAPIClient.swift; sourceTree = ""; }; 6E739D6A26B9DFFB00BC6F6D /* AttributePendingMutations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributePendingMutations.swift; sourceTree = ""; }; 6E739D6D26B9F58700BC6F6D /* TagGroupMutations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagGroupMutations.swift; sourceTree = ""; }; 6E739D7E26BAFCB800BC6F6D /* TestChannelBulkUpdateAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestChannelBulkUpdateAPIClient.swift; sourceTree = ""; }; 6E739D8126BB33A200BC6F6D /* ChannelBulkUpdateAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelBulkUpdateAPIClientTest.swift; sourceTree = ""; }; 6E75F50429C4EAF600E3585A /* AudienceOverridesProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudienceOverridesProvider.swift; sourceTree = ""; }; 6E77CD472D8A225E0057A52C /* AirshipInputValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipInputValidator.swift; sourceTree = ""; }; 6E77CD492D8A225E0057A52C /* SMSValidatorAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSValidatorAPIClient.swift; sourceTree = ""; }; 6E77CE462D8A28B10057A52C /* CachingSMSValidatorAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachingSMSValidatorAPIClient.swift; sourceTree = ""; }; 6E78848E29B9643C00ACAE45 /* AirshipContact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipContact.swift; sourceTree = ""; }; 6E7DB38228ECDC41002725F6 /* LiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivity.swift; sourceTree = ""; }; 6E7DB38A28ECDDD9002725F6 /* LiveActivityRegistryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityRegistryTest.swift; sourceTree = ""; }; 6E7DB38D28ECFCED002725F6 /* AirshipJSONTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipJSONTest.swift; sourceTree = ""; }; 6E7E770C2DDFD0D80042086D /* AirshipAsyncSemaphore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAsyncSemaphore.swift; sourceTree = ""; }; 6E7E770E2DDFD1040042086D /* AirshipAsyncSemaphoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAsyncSemaphoreTest.swift; sourceTree = ""; }; 6E7EACD02AF4192400DA286B /* AirshipCacheTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipCacheTest.swift; sourceTree = ""; }; 6E7EACD22AF4220E00DA286B /* FeatureFlagDeferredResolverTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagDeferredResolverTest.swift; sourceTree = ""; }; 6E82482129A6D9DF00136EA0 /* CancellableValueHolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellableValueHolder.swift; sourceTree = ""; }; 6E82483729A6E1BE00136EA0 /* AirshipCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipCancellable.swift; sourceTree = ""; }; 6E8746482D8A3C64002469D7 /* TestSMSValidatorAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestSMSValidatorAPIClient.swift; sourceTree = ""; }; 6E87BD6326D594870005D20D /* ChannelRegistrationPayload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelRegistrationPayload.swift; sourceTree = ""; }; 6E87BD6626D6A39A0005D20D /* AirshipConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipConfig.swift; sourceTree = ""; }; 6E87BD8226D757CA0005D20D /* CloudSite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSite.swift; sourceTree = ""; }; 6E87BD8C26D815780005D20D /* AppIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntegration.swift; sourceTree = ""; }; 6E87BD9126D963B60005D20D /* DefaultAppIntegrationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAppIntegrationDelegate.swift; sourceTree = ""; }; 6E87BD9C26DD78CC0005D20D /* DefaultAppIntegrationDelegateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAppIntegrationDelegateTest.swift; sourceTree = ""; }; 6E87BD9E26DDDB250005D20D /* AppIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntegrationTests.swift; sourceTree = ""; }; 6E87BDBC26E01FF40005D20D /* ModuleLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleLoader.swift; sourceTree = ""; }; 6E87BDFD26E283840005D20D /* Airship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Airship.swift; sourceTree = ""; }; 6E87BDFE26E283840005D20D /* LogLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; 6E87BE0026E283850005D20D /* DeepLinkDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeepLinkDelegate.swift; sourceTree = ""; }; 6E87BE1226E28F570005D20D /* AirshipInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipInstance.swift; sourceTree = ""; }; 6E87BE1526E29BC90005D20D /* TestAirshipInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAirshipInstance.swift; sourceTree = ""; }; 6E87BE1726E2C5940005D20D /* TestAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAnalytics.swift; sourceTree = ""; }; 6E8873992763D8AB00AC248A /* AirshipImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipImageProvider.swift; sourceTree = ""; }; 6E887CD0272C5E8400E83363 /* CheckboxState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxState.swift; sourceTree = ""; }; 6E887CD2272C5F5000E83363 /* Checkbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Checkbox.swift; sourceTree = ""; }; 6E887CD4272C5F5A00E83363 /* CheckboxController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxController.swift; sourceTree = ""; }; 6E892F2D2E7A193200FB0EC4 /* PreferenceCenterContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterContent.swift; sourceTree = ""; }; 6E8932972E7B665200FB0EC4 /* APNSRegistrationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSRegistrationResult.swift; sourceTree = ""; }; 6E8932992E7B66B600FB0EC4 /* NotificationRegistrationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRegistrationResult.swift; sourceTree = ""; }; 6E8B4BEF2888606400AA336E /* ChannelTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelTest.swift; sourceTree = ""; }; 6E8BDA162B62EC9F00711DB8 /* AutomationRemoteDataSubscriber.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutomationRemoteDataSubscriber.swift; sourceTree = ""; }; 6E8BDEFC2A67937E00F816D9 /* FeatureFlagInfoTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagInfoTest.swift; sourceTree = ""; }; 6E8BDEFF2A679CD100F816D9 /* FeatureFlagRemoteDataAccessTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagRemoteDataAccessTest.swift; sourceTree = ""; }; 6E8CE761284137D600CF4B11 /* AirshipPushTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPushTest.swift; sourceTree = ""; }; 6E8E1C9A26447B3800B11791 /* AirshipLock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipLock.swift; sourceTree = ""; }; 6E916C562DB30D9E00C676FA /* AirshipWindowFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipWindowFactory.swift; sourceTree = ""; }; 6E91B43B26868A6300DDB1A8 /* CircularRegion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularRegion.swift; sourceTree = ""; }; 6E91B43E26868C3400DDB1A8 /* RegionEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegionEvent.swift; sourceTree = ""; }; 6E91B43F26868C3400DDB1A8 /* ProximityRegion.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProximityRegion.swift; sourceTree = ""; }; 6E91B4442686911B00DDB1A8 /* EventUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventUtils.swift; sourceTree = ""; }; 6E91B4662689327D00DDB1A8 /* CustomEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomEvent.swift; sourceTree = ""; }; 6E91E42F28EF423300B6F25E /* WorkBackgroundTasks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WorkBackgroundTasks.swift; sourceTree = ""; }; 6E91E43028EF423300B6F25E /* WorkConditionsMonitor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WorkConditionsMonitor.swift; sourceTree = ""; }; 6E91E43128EF423300B6F25E /* Worker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Worker.swift; sourceTree = ""; }; 6E91E43228EF423300B6F25E /* WorkRateLimiterActor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WorkRateLimiterActor.swift; sourceTree = ""; }; 6E91E43328EF423300B6F25E /* AirshipWorkManagerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipWorkManagerProtocol.swift; sourceTree = ""; }; 6E91E43528EF423300B6F25E /* AirshipWorkRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipWorkRequest.swift; sourceTree = ""; }; 6E91E43728EF423300B6F25E /* AirshipWorkManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipWorkManager.swift; sourceTree = ""; }; 6E91E43928EF423400B6F25E /* AirshipWorkResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipWorkResult.swift; sourceTree = ""; }; 6E92EC89284933750038802D /* PromptPermissionAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPermissionAction.swift; sourceTree = ""; }; 6E92EC8C2849378E0038802D /* PermissionPrompter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionPrompter.swift; sourceTree = ""; }; 6E92EC8F284954B10038802D /* ButtonState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonState.swift; sourceTree = ""; }; 6E92ECA0284A79AB0038802D /* PromptPermissionActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptPermissionActionTest.swift; sourceTree = ""; }; 6E92ECA2284A7A2A0038802D /* TestPermissionPrompter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestPermissionPrompter.swift; sourceTree = ""; }; 6E92ECA6284AC1120038802D /* EnableFeatureActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnableFeatureActionTest.swift; sourceTree = ""; }; 6E92ECAB284EA7DA0038802D /* AnalyticsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTest.swift; sourceTree = ""; }; 6E92ECB0284ECE590038802D /* CachedList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedList.swift; sourceTree = ""; }; 6E92ECB3284ED6F10038802D /* CachedListTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedListTest.swift; sourceTree = ""; }; 6E938DBB2AC39A0500F691D9 /* FeatureFlagAnalyticsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagAnalyticsTest.swift; sourceTree = ""; }; 6E94760E29BA8FA30025F364 /* ContactManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactManager.swift; sourceTree = ""; }; 6E94761429BBC0230025F364 /* AirshipButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipButton.swift; sourceTree = ""; }; 6E95291F268A6C1500398B54 /* SearchEventTemplate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchEventTemplate.swift; sourceTree = ""; }; 6E952922268B812000398B54 /* AccountEventTemplate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountEventTemplate.swift; sourceTree = ""; }; 6E952925268B8F6500398B54 /* AssociatedIdentifiers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssociatedIdentifiers.swift; sourceTree = ""; }; 6E95292B268B98A200398B54 /* MediaEventTemplate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaEventTemplate.swift; sourceTree = ""; }; 6E95292E268BBD7D00398B54 /* RetailEventTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetailEventTemplate.swift; sourceTree = ""; }; 6E96ECF1293EB7900053CC91 /* AirshipEventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipEventData.swift; sourceTree = ""; }; 6E96ECF5293FCE080053CC91 /* EventUploadScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventUploadScheduler.swift; sourceTree = ""; }; 6E96ECF9293FDDD90053CC91 /* AirshipSDKExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSDKExtension.swift; sourceTree = ""; }; 6E96ED01294115210053CC91 /* AsyncStream.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncStream.swift; sourceTree = ""; }; 6E96ED09294135500053CC91 /* EventManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventManager.swift; sourceTree = ""; }; 6E96ED0D29416E820053CC91 /* EventManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventManagerTest.swift; sourceTree = ""; }; 6E96ED0F29416E8F0053CC91 /* EventAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventAPIClientTest.swift; sourceTree = ""; }; 6E96ED1129416E990053CC91 /* EventSchedulerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventSchedulerTest.swift; sourceTree = ""; }; 6E96ED1329417A600053CC91 /* EventStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventStoreTest.swift; sourceTree = ""; }; 6E96ED15294197D90053CC91 /* EventUploadTuningInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventUploadTuningInfo.swift; sourceTree = ""; }; 6E96ED192941A0EC0053CC91 /* EventTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventTestUtils.swift; sourceTree = ""; }; 6E9752552A5F79E200E67B1A /* ExperimentTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentTest.swift; sourceTree = ""; }; 6E97D6AC2D84B1610001CF7F /* ThomasFormStateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormStateTest.swift; sourceTree = ""; }; 6E97D6AE2D84B1780001CF7F /* ThomasFormDataCollectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormDataCollectorTest.swift; sourceTree = ""; }; 6E97D6B02D84B1890001CF7F /* ThomasFormDataCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormDataCollector.swift; sourceTree = ""; }; 6E97D6B32D84B1D10001CF7F /* ThomasStateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasStateTest.swift; sourceTree = ""; }; 6E97D6B52D84B2330001CF7F /* ThomasFormFieldTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormFieldTest.swift; sourceTree = ""; }; 6E986EE32B448D3C00FBE6A0 /* AutomationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationEngine.swift; sourceTree = ""; }; 6E986EF82B44D41E00FBE6A0 /* InAppAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppAutomation.swift; sourceTree = ""; }; 6E986EFA2B44D48C00FBE6A0 /* InAppMessaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessaging.swift; sourceTree = ""; }; 6E986F052B47319E00FBE6A0 /* DeferredScheduleResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredScheduleResult.swift; sourceTree = ""; }; 6E986F0D2B473EC700FBE6A0 /* AutomationRemoteDataAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationRemoteDataAccess.swift; sourceTree = ""; }; 6E9B4873288F0CE000C905B1 /* RateAppAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateAppAction.swift; sourceTree = ""; }; 6E9B4877288F360C00C905B1 /* RateAppActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateAppActionTest.swift; sourceTree = ""; }; 6E9B488A2891962000C905B1 /* PreferenceCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterView.swift; sourceTree = ""; }; 6E9B488C2891B43F00C905B1 /* LabeledSectionBreakView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabeledSectionBreakView.swift; sourceTree = ""; }; 6E9B488E2891B57300C905B1 /* CommonSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonSectionView.swift; sourceTree = ""; }; 6E9B48902891B68B00C905B1 /* PreferenceCenterAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterAlertView.swift; sourceTree = ""; }; 6E9B48922891B6A700C905B1 /* ChannelSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSubscriptionView.swift; sourceTree = ""; }; 6E9B48942891B6B400C905B1 /* ContactSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSubscriptionView.swift; sourceTree = ""; }; 6E9B48962891B6BF00C905B1 /* ContactSubscriptionGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSubscriptionGroupView.swift; sourceTree = ""; }; 6E9C2B7C2D014426000089A9 /* APNSEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSEnvironment.swift; sourceTree = ""; }; 6E9C2BCF2D023216000089A9 /* RuntimeConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeConfig.swift; sourceTree = ""; }; 6E9C2BD92D027B5A000089A9 /* AirshipAppCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAppCredentials.swift; sourceTree = ""; }; 6E9C2BDB2D028030000089A9 /* APNSEnvironmentTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSEnvironmentTest.swift; sourceTree = ""; }; 6E9D529726C195F7004EA16B /* ActionRunner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionRunner.swift; sourceTree = ""; }; 6E9D529A26C1A77C004EA16B /* ActionRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionRegistry.swift; sourceTree = ""; }; 6EA5202227D1364E003011CA /* AirshipDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDateFormatter.swift; sourceTree = ""; }; 6EAA61482D5297A2006602F7 /* SubjectExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubjectExtension.swift; sourceTree = ""; }; 6EAC295927580063006DFA63 /* ChannelRegistrarTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRegistrarTest.swift; sourceTree = ""; }; 6EAD3AF72F45305B00FF274E /* AirshipSwizzler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipSwizzler.swift; sourceTree = ""; }; 6EAD3AF92F4530AD00FF274E /* AutoIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoIntegration.swift; sourceTree = ""; }; 6EAD3AFB2F4530E400FF274E /* UAAppIntegrationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UAAppIntegrationDelegate.swift; sourceTree = ""; }; 6EAD7CE426B216DB00B88EA7 /* DeepLinkAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkAction.swift; sourceTree = ""; }; 6EB11C862697ACBF00DC698F /* ContactOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactOperation.swift; sourceTree = ""; }; 6EB11C882697AF5600DC698F /* TagGroupUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagGroupUpdate.swift; sourceTree = ""; }; 6EB11C8A2697AFC700DC698F /* AttributeUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeUpdate.swift; sourceTree = ""; }; 6EB11C8C2698C50F00DC698F /* AudienceUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudienceUtils.swift; sourceTree = ""; }; 6EB214D12E7DBA5E001A5660 /* FeatureFlagManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagManagerProtocol.swift; sourceTree = ""; }; 6EB21A342E81BB6E001A5660 /* AirshipDebugAddEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAddEventView.swift; sourceTree = ""; }; 6EB21A352E81BB6E001A5660 /* AirshipDebugAnalyticIdentifierEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAnalyticIdentifierEditorView.swift; sourceTree = ""; }; 6EB21A362E81BB6E001A5660 /* AirshipDebugAnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAnalyticsView.swift; sourceTree = ""; }; 6EB21A372E81BB6E001A5660 /* AirshipDebugEventDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugEventDetailsView.swift; sourceTree = ""; }; 6EB21A382E81BB6E001A5660 /* AirshipDebugEventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugEventsView.swift; sourceTree = ""; }; 6EB21A3A2E81BB6E001A5660 /* AirshipDebugAppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAppInfoView.swift; sourceTree = ""; }; 6EB21A3C2E81BB6E001A5660 /* AirshipDebugExperimentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugExperimentsView.swift; sourceTree = ""; }; 6EB21A3D2E81BB6E001A5660 /* AirshipDebugInAppExperiencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugInAppExperiencesView.swift; sourceTree = ""; }; 6EB21A3E2E81BB6E001A5660 /* AirshipDebugAutomationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAutomationsView.swift; sourceTree = ""; }; 6EB21A402E81BB6E001A5660 /* AirshipDebugChannelSubscriptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugChannelSubscriptionsView.swift; sourceTree = ""; }; 6EB21A412E81BB6E001A5660 /* AirshipDebugChannelTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugChannelTagView.swift; sourceTree = ""; }; 6EB21A422E81BB6E001A5660 /* AirshipDebugChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugChannelView.swift; sourceTree = ""; }; 6EB21A442E81BB6E001A5660 /* AirshipDebugAttributesEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAttributesEditorView.swift; sourceTree = ""; }; 6EB21A452E81BB6E001A5660 /* AirshipDebugExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugExtensions.swift; sourceTree = ""; }; 6EB21A462E81BB6E001A5660 /* AirshipDebugTagGroupsEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugTagGroupsEditorView.swift; sourceTree = ""; }; 6EB21A472E81BB6E001A5660 /* AirshipJSONDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipJSONDetailsView.swift; sourceTree = ""; }; 6EB21A482E81BB6E001A5660 /* AirshipJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipJSONView.swift; sourceTree = ""; }; 6EB21A492E81BB6E001A5660 /* AirshipToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipToast.swift; sourceTree = ""; }; 6EB21A4A2E81BB6E001A5660 /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 6EB21A4C2E81BB6E001A5660 /* AirshipDebugAddEmailChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAddEmailChannelView.swift; sourceTree = ""; }; 6EB21A4D2E81BB6E001A5660 /* AirshipDebugAddOpenChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAddOpenChannelView.swift; sourceTree = ""; }; 6EB21A4E2E81BB6E001A5660 /* AirshipDebugAddSMSChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAddSMSChannelView.swift; sourceTree = ""; }; 6EB21A4F2E81BB6E001A5660 /* AirshipDebugContactSubscriptionEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugContactSubscriptionEditorView.swift; sourceTree = ""; }; 6EB21A502E81BB6E001A5660 /* AirshipDebugContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugContactView.swift; sourceTree = ""; }; 6EB21A512E81BB6E001A5660 /* AirshipDebugNamedUserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugNamedUserView.swift; sourceTree = ""; }; 6EB21A532E81BB6E001A5660 /* AirshipDebugFeatureFlagDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugFeatureFlagDetailsView.swift; sourceTree = ""; }; 6EB21A552E81BB6E001A5660 /* AirshipDebugFeatureFlagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugFeatureFlagView.swift; sourceTree = ""; }; 6EB21A572E81BB6E001A5660 /* AirshipDebugPreferencCenterItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugPreferencCenterItemView.swift; sourceTree = ""; }; 6EB21A582E81BB6E001A5660 /* AirshipDebugPreferenceCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugPreferenceCenterView.swift; sourceTree = ""; }; 6EB21A5B2E81BB6E001A5660 /* AirshipDebugPrivacyManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugPrivacyManagerView.swift; sourceTree = ""; }; 6EB21A5D2E81BB6E001A5660 /* AirshipDebugPushDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugPushDetailsView.swift; sourceTree = ""; }; 6EB21A5E2E81BB6E001A5660 /* AirshipDebugPushView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugPushView.swift; sourceTree = ""; }; 6EB21A5F2E81BB6E001A5660 /* AirshipDebugReceivedPushView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugReceivedPushView.swift; sourceTree = ""; }; 6EB21A612E81BB6E001A5660 /* TVDatePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVDatePicker.swift; sourceTree = ""; }; 6EB21A622E81BB6E001A5660 /* TVSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVSlider.swift; sourceTree = ""; }; 6EB21A642E81BB6E001A5660 /* AirshipDebugContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugContentView.swift; sourceTree = ""; }; 6EB21A652E81BB6E001A5660 /* AirshipDebugRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugRoute.swift; sourceTree = ""; }; 6EB21A662E81BB6E001A5660 /* AirshipDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugView.swift; sourceTree = ""; }; 6EB21A902E81BFB9001A5660 /* AirshipDebugAddPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAddPropertyView.swift; sourceTree = ""; }; 6EB21A922E81C7A2001A5660 /* AirshipDebugAddStringPropertyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAddStringPropertyView.swift; sourceTree = ""; }; 6EB21AFB2E82169F001A5660 /* AirshipoDebugTriggers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipoDebugTriggers.swift; sourceTree = ""; }; 6EB21B5E2E82FE98001A5660 /* AirshipDebugAudienceSubject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDebugAudienceSubject.swift; sourceTree = ""; }; 6EB3FCEE2ABCFA680018594E /* RemoteDataProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteDataProtocol.swift; sourceTree = ""; }; 6EB4E4A32549F95200E3FFD0 /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/System/Library/Frameworks/Network.framework; sourceTree = DEVELOPER_DIR; }; 6EB4E4BE2549F9B900E3FFD0 /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS14.0.sdk/System/Library/Frameworks/Network.framework; sourceTree = DEVELOPER_DIR; }; 6EB5156D28A42B5800870C5A /* AirshipPreferenceCenterResources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipPreferenceCenterResources.swift; sourceTree = ""; }; 6EB5157028A4608C00870C5A /* PreferenceCenterViewControllerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterViewControllerFactory.swift; sourceTree = ""; }; 6EB5158028A47BD700870C5A /* SubscriptionListEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListEdit.swift; sourceTree = ""; }; 6EB5158228A47C7100870C5A /* ScopedSubscriptionListEdit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopedSubscriptionListEdit.swift; sourceTree = ""; }; 6EB5158E28A5B15C00870C5A /* PreferenceThemeLoaderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceThemeLoaderTest.swift; sourceTree = ""; }; 6EB5159128A5B1B400870C5A /* TestLegacyTheme.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = TestLegacyTheme.plist; sourceTree = ""; }; 6EB5159328A5B8E900870C5A /* TestTheme.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestTheme.plist; sourceTree = ""; }; 6EB5159628A5C54400870C5A /* TestThemeEmpty.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestThemeEmpty.plist; sourceTree = ""; }; 6EB5159828A5C61D00870C5A /* TestThemeInvalid.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = TestThemeInvalid.plist; sourceTree = ""; }; 6EB515A228A5F1C600870C5A /* PreferenceCenterStateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterStateTest.swift; sourceTree = ""; }; 6EB839452BC83B96006611C4 /* DefaultInAppActionRunnerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultInAppActionRunnerTest.swift; sourceTree = ""; }; 6EB839482BC8898E006611C4 /* AirshipAsyncChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAsyncChannel.swift; sourceTree = ""; }; 6EB8394D2BC8B1F4006611C4 /* AirshipAsyncChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAsyncChannelTest.swift; sourceTree = ""; }; 6EBD12042DA73FD300F678AB /* ValidatableHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidatableHelper.swift; sourceTree = ""; }; 6EBFA9AC2D15DA70002BA3E9 /* HashChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashChecker.swift; sourceTree = ""; }; 6EBFA9AE2D15E04B002BA3E9 /* AirshipDeviceAudienceResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDeviceAudienceResult.swift; sourceTree = ""; }; 6EBFA9B02D15F491002BA3E9 /* HashCheckerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashCheckerTest.swift; sourceTree = ""; }; 6EC0CA4E2B48987700333A87 /* AutomationRemoteDataAccessTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationRemoteDataAccessTest.swift; sourceTree = ""; }; 6EC0CA522B48A2C300333A87 /* AutomationAudience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationAudience.swift; sourceTree = ""; }; 6EC0CA542B48B05000333A87 /* ActionAutomationExecutorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionAutomationExecutorTest.swift; sourceTree = ""; }; 6EC0CA5B2B48C2F500333A87 /* AutomationPreparer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationPreparer.swift; sourceTree = ""; }; 6EC0CA672B49287100333A87 /* AutomationExecutor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutomationExecutor.swift; sourceTree = ""; }; 6EC0CA6A2B4B698000333A87 /* AutomationExecutorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationExecutorTest.swift; sourceTree = ""; }; 6EC0CA6C2B4B879800333A87 /* AutomationPreparerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationPreparerTest.swift; sourceTree = ""; }; 6EC0CA6E2B4B893500333A87 /* TestDeferredResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDeferredResolver.swift; sourceTree = ""; }; 6EC0CA712B4B897B00333A87 /* TestExperimentDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestExperimentDataProvider.swift; sourceTree = ""; }; 6EC0CA752B4B8A3A00333A87 /* TestRemoteDataAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRemoteDataAccess.swift; sourceTree = ""; }; 6EC0CA772B4B8A4700333A87 /* TestFrequencyLimitsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFrequencyLimitsManager.swift; sourceTree = ""; }; 6EC0CA802B4C812A00333A87 /* DisplayAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayAdapter.swift; sourceTree = ""; }; 6EC755982A4E115400851ABB /* DeviceAudienceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAudienceSelector.swift; sourceTree = ""; }; 6EC7559A2A4E129000851ABB /* DeviceTagSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceTagSelector.swift; sourceTree = ""; }; 6EC7559E2A4E5AB200851ABB /* DeviceAudienceChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAudienceChecker.swift; sourceTree = ""; }; 6EC755AE2A4FCD8800851ABB /* AudienceHashSelectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudienceHashSelectorTest.swift; sourceTree = ""; }; 6EC7E46D269E2A4C0038CFDD /* AttributeEditorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeEditorTest.swift; sourceTree = ""; }; 6EC7E46F269E33290038CFDD /* TagGroupsEditorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagGroupsEditorTest.swift; sourceTree = ""; }; 6EC7E471269E51030038CFDD /* AttributeUpdateTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeUpdateTest.swift; sourceTree = ""; }; 6EC7E473269E52600038CFDD /* ContactOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactOperationTest.swift; sourceTree = ""; }; 6EC7E47526A5EE910038CFDD /* AudienceUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudienceUtilsTest.swift; sourceTree = ""; }; 6EC7E47726A604080038CFDD /* AirshipContactTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipContactTest.swift; sourceTree = ""; }; 6EC7E48126A60C060038CFDD /* AirshipChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipChannel.swift; sourceTree = ""; }; 6EC7E48426A60CDF0038CFDD /* TestChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestChannel.swift; sourceTree = ""; }; 6EC7E48626A60DD60038CFDD /* TestContactAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContactAPIClient.swift; sourceTree = ""; }; 6EC7E48C26A738C70038CFDD /* ContactConflictEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactConflictEvent.swift; sourceTree = ""; }; 6EC815AE2F2BBFD100E1C0C6 /* BundleExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtensions.swift; sourceTree = ""; }; 6EC81D022F2D445500E1C0C6 /* UAInboxDataMappingV1toV4.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = UAInboxDataMappingV1toV4.xcmappingmodel; sourceTree = ""; }; 6EC81D042F2D448B00E1C0C6 /* UAInboxDataMappingV2toV4.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = UAInboxDataMappingV2toV4.xcmappingmodel; sourceTree = ""; }; 6EC81D072F2D44D700E1C0C6 /* UAInboxDataMappingV3toV4.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = UAInboxDataMappingV3toV4.xcmappingmodel; sourceTree = ""; }; 6EC8249F2F33A4DD00E1C0C6 /* MessageDisplayHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDisplayHistory.swift; sourceTree = ""; }; 6EC824A12F33A5EC00E1C0C6 /* MessageCenterThemeLoaderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterThemeLoaderTest.swift; sourceTree = ""; }; 6EC824A32F33A5F600E1C0C6 /* MessageCenterMessageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterMessageTest.swift; sourceTree = ""; }; 6EC9214D2D82144A000A3A59 /* ThomasFormField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormField.swift; sourceTree = ""; }; 6EC922E02D832BAF000A3A59 /* ThomasFormFieldProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormFieldProcessor.swift; sourceTree = ""; }; 6EC922E22D838DFA000A3A59 /* ThomasFormPayloadGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormPayloadGenerator.swift; sourceTree = ""; }; 6ECB627B2A369F5B0095C85C /* OpenExternalURLActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenExternalURLActionTest.swift; sourceTree = ""; }; 6ECB627D2A36A0770095C85C /* ExternalURLProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalURLProcessor.swift; sourceTree = ""; }; 6ECB62812A36A45F0095C85C /* TestURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLOpener.swift; sourceTree = ""; }; 6ECB62832A36A7510095C85C /* DeepLinkActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkActionTest.swift; sourceTree = ""; }; 6ECB62852A36C1EE0095C85C /* NativeBridgeActionHandlerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBridgeActionHandlerTest.swift; sourceTree = ""; }; 6ECD4F6C2DD7A7060060EE72 /* RadioInputToggleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioInputToggleLayout.swift; sourceTree = ""; }; 6ECD4F6E2DD7A7090060EE72 /* CheckboxToggleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxToggleLayout.swift; sourceTree = ""; }; 6ECD4F702DD7A7C90060EE72 /* ToggleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleLayout.swift; sourceTree = ""; }; 6ECDDE6B29B7EEE9009D79DB /* AuthToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthToken.swift; sourceTree = ""; }; 6ECDDE7329B80462009D79DB /* ChannelAuthTokenProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAuthTokenProvider.swift; sourceTree = ""; }; 6ECDDE7829B804FB009D79DB /* ChannelAuthTokenAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAuthTokenAPIClient.swift; sourceTree = ""; }; 6ED040EA278B5D7C00FCF773 /* ThomasFormPayloadGeneratorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormPayloadGeneratorTest.swift; sourceTree = ""; }; 6ED2F5242B7EE648000AFC80 /* AirshipBase64Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipBase64Test.swift; sourceTree = ""; }; 6ED2F5262B7EE82B000AFC80 /* AirshipJSONUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipJSONUtilsTest.swift; sourceTree = ""; }; 6ED2F5282B7FC59F000AFC80 /* AirshipColorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipColorTests.swift; sourceTree = ""; }; 6ED2F52A2B7FC5C8000AFC80 /* AirshipIvyVersionMatcherTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipIvyVersionMatcherTest.swift; sourceTree = ""; }; 6ED2F52C2B7FD403000AFC80 /* JavaScriptCommandTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptCommandTest.swift; sourceTree = ""; }; 6ED2F52E2B7FD49B000AFC80 /* AirshipURLAllowListTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipURLAllowListTest.swift; sourceTree = ""; }; 6ED2F5302B7FF819000AFC80 /* AirshipViewUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipViewUtils.swift; sourceTree = ""; }; 6ED2F5342B7FFCD7000AFC80 /* AirshipDateFormatterTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDateFormatterTest.swift; sourceTree = ""; }; 6ED5629F2EA9434900C20B55 /* StackImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackImageButton.swift; sourceTree = ""; }; 6ED6ECA326ADCA6F00973364 /* BlockAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockAction.swift; sourceTree = ""; }; 6ED6ECA626AE05B700973364 /* EmptyAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyAction.swift; sourceTree = ""; }; 6ED735D926C73DC5003B0A7D /* DefaultAirshipChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultAirshipChannel.swift; sourceTree = ""; }; 6ED735DC26C7401D003B0A7D /* TagEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagEditor.swift; sourceTree = ""; }; 6ED735DF26C74321003B0A7D /* TagEditorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagEditorTest.swift; sourceTree = ""; }; 6ED735E126CAE2D7003B0A7D /* TestChannelRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestChannelRegistrar.swift; sourceTree = ""; }; 6ED735E326CAE8AA003B0A7D /* TestChannelAudienceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestChannelAudienceManager.swift; sourceTree = ""; }; 6ED735E526CAEABC003B0A7D /* TestLocaleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestLocaleManager.swift; sourceTree = ""; }; 6ED7BE5E2D13D9B600B6A124 /* AirshipAutomation 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AirshipAutomation 3.xcdatamodel"; sourceTree = ""; }; 6ED7BE5F2D13D9E300B6A124 /* TestCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCache.swift; sourceTree = ""; }; 6ED7BE622D13D9FE00B6A124 /* FeatureFlagResultCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagResultCache.swift; sourceTree = ""; }; 6ED7BE642D13DA0400B6A124 /* FeatureFlagResultCacheTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagResultCacheTest.swift; sourceTree = ""; }; 6ED80792273CA0C800D1F455 /* EnvironmentValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = ""; }; 6ED80799273DA56000D1F455 /* ThomasViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasViewController.swift; sourceTree = ""; }; 6ED838AA2D0CE9D6009CBB0C /* CompoundDeviceAudienceSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompoundDeviceAudienceSelector.swift; sourceTree = ""; }; 6ED838AC2D0CEF4A009CBB0C /* CompoundDeviceAudienceSelectorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompoundDeviceAudienceSelectorTest.swift; sourceTree = ""; }; 6ED838CF2D0D118B009CBB0C /* AutomationCompoundAudience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationCompoundAudience.swift; sourceTree = ""; }; 6EDAFB252CB463C1000BD4AA /* ButtonLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonLayout.swift; sourceTree = ""; }; 6EDE293E2A9802BF00235738 /* NativeBridgeActionRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBridgeActionRunner.swift; sourceTree = ""; }; 6EDE5F182B9BD7E700E33D04 /* InboxMessageData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InboxMessageData.swift; sourceTree = ""; }; 6EDE5F4E2BA248FF00E33D04 /* TouchViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TouchViewModifier.swift; sourceTree = ""; }; 6EDE5FC12BADDD96003ADF55 /* PreparedScheduleInfoTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreparedScheduleInfoTest.swift; sourceTree = ""; }; 6EDF1D922B292FB000E23BC4 /* InAppMessageTextInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageTextInfo.swift; sourceTree = ""; }; 6EDF1D952B2A25B400E23BC4 /* InAppMessageButtonInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageButtonInfo.swift; sourceTree = ""; }; 6EDF1D972B2A25C800E23BC4 /* InAppMessageMediaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageMediaInfo.swift; sourceTree = ""; }; 6EDF1D9B2B2A287A00E23BC4 /* InAppMessageButtonLayoutType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageButtonLayoutType.swift; sourceTree = ""; }; 6EDF1D9D2B2A2A5900E23BC4 /* InAppMessageDisplayContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageDisplayContent.swift; sourceTree = ""; }; 6EDF1DA32B2A2C6F00E23BC4 /* InAppMessageColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageColor.swift; sourceTree = ""; }; 6EDF1DA52B2A300100E23BC4 /* InAppMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessage.swift; sourceTree = ""; }; 6EDF1DAA2B2A6D9900E23BC4 /* InAppMessageTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageTest.swift; sourceTree = ""; }; 6EDF1DB72B2BB2B800E23BC4 /* RetryingQueue.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RetryingQueue.swift; sourceTree = ""; }; 6EDFBBC22F5780BA0043D9EF /* BasementImport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasementImport.swift; sourceTree = ""; }; 6EE49BDC2A09AD3600AB1CF4 /* AirshipNotificationStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipNotificationStatus.swift; sourceTree = ""; }; 6EE49BE02A0AADC900AB1CF4 /* AppRemoteDataProviderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRemoteDataProviderDelegate.swift; sourceTree = ""; }; 6EE49C072A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataURLFactory.swift; sourceTree = ""; }; 6EE49C0B2A0C141800AB1CF4 /* RemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataSource.swift; sourceTree = ""; }; 6EE49C0F2A0C142F00AB1CF4 /* RemoteDataInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataInfo.swift; sourceTree = ""; }; 6EE49C172A0C3CC600AB1CF4 /* ContactRemoteDataProviderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRemoteDataProviderDelegate.swift; sourceTree = ""; }; 6EE49C1B2A0D544B00AB1CF4 /* UARemoteData 3.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UARemoteData 3.xcdatamodel"; sourceTree = ""; }; 6EE49C1C2A0D9D8000AB1CF4 /* RemoteDataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataProvider.swift; sourceTree = ""; }; 6EE49C212A13E32B00AB1CF4 /* RemoteDataProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataProviderProtocol.swift; sourceTree = ""; }; 6EE49C252A1446B100AB1CF4 /* RemoteDataTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataTestUtils.swift; sourceTree = ""; }; 6EE6529222A7E3B800F7D54D /* Valid-UAInAppMessageHTMLStyle.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Valid-UAInAppMessageHTMLStyle.plist"; sourceTree = ""; }; 6EE6AA112B4F3003002FEA75 /* InAppMessageAutomationPreparerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageAutomationPreparerTest.swift; sourceTree = ""; }; 6EE6AA142B4F302A002FEA75 /* InAppMessageAutomationExecutorTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageAutomationExecutorTest.swift; sourceTree = ""; }; 6EE6AA182B4F304B002FEA75 /* DisplayAdapterFactoryTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayAdapterFactoryTest.swift; sourceTree = ""; }; 6EE6AA1A2B4F3062002FEA75 /* DisplayCoordinatorManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinatorManagerTest.swift; sourceTree = ""; }; 6EE6AA1D2B4F31B1002FEA75 /* TestCachedAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCachedAssets.swift; sourceTree = ""; }; 6EE6AA1F2B4F5246002FEA75 /* InAppMessageSceneManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageSceneManager.swift; sourceTree = ""; }; 6EE6AA272B50C91E002FEA75 /* AutomationRemoteDataSubscriberTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationRemoteDataSubscriberTest.swift; sourceTree = ""; }; 6EE6AA292B50C976002FEA75 /* TestAutomationEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAutomationEngine.swift; sourceTree = ""; }; 6EE6AA2B2B51DB1E002FEA75 /* AutomationSourceInfoStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSourceInfoStore.swift; sourceTree = ""; }; 6EE6AA372B572897002FEA75 /* AutomationSourceInfoStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSourceInfoStoreTest.swift; sourceTree = ""; }; 6EE6AAB62B58A945002FEA75 /* ThomasLayoutEventMessageID.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventMessageID.swift; sourceTree = ""; }; 6EE6AABA2B58A946002FEA75 /* ThomasLayoutEventRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventRecorder.swift; sourceTree = ""; }; 6EE6AAD42B58A977002FEA75 /* TestThomasLayoutEvent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestThomasLayoutEvent.swift; sourceTree = ""; }; 6EE6AAD62B58A9D1002FEA75 /* ThomasLayoutDisplayEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutDisplayEventTest.swift; sourceTree = ""; }; 6EE6AAD72B58A9D1002FEA75 /* ThomasLayoutEventRecorderTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventRecorderTest.swift; sourceTree = ""; }; 6EE6AAD82B58A9D1002FEA75 /* ThomasLayoutPagerCompletedEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPagerCompletedEventTest.swift; sourceTree = ""; }; 6EE6AAD92B58A9D1002FEA75 /* ThomasLayoutPermissionResultEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPermissionResultEventTest.swift; sourceTree = ""; }; 6EE6AADA2B58A9D1002FEA75 /* ThomasLayoutEventTestUtils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventTestUtils.swift; sourceTree = ""; }; 6EE6AADB2B58A9D1002FEA75 /* ThomasLayoutResolutionEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutResolutionEventTest.swift; sourceTree = ""; }; 6EE6AADC2B58A9D2002FEA75 /* ThomasLayoutPageActionEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPageActionEventTest.swift; sourceTree = ""; }; 6EE6AADD2B58A9D2002FEA75 /* ThomasLayoutEventMessageIDTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventMessageIDTest.swift; sourceTree = ""; }; 6EE6AADE2B58A9D2002FEA75 /* ThomasLayoutButtonTapEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutButtonTapEventTest.swift; sourceTree = ""; }; 6EE6AADF2B58A9D2002FEA75 /* ThomasLayoutEventContextTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventContextTest.swift; sourceTree = ""; }; 6EE6AAE02B58A9D2002FEA75 /* ThomasLayoutFormResultEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutFormResultEventTest.swift; sourceTree = ""; }; 6EE6AAE12B58A9D2002FEA75 /* ThomasLayoutPagerSummaryEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPagerSummaryEventTest.swift; sourceTree = ""; }; 6EE6AAE22B58A9D2002FEA75 /* ThomasLayoutPageViewEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPageViewEventTest.swift; sourceTree = ""; }; 6EE6AAE32B58A9D3002FEA75 /* ThomasLayoutFormDisplayEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutFormDisplayEventTest.swift; sourceTree = ""; }; 6EE6AAE42B58A9D3002FEA75 /* ThomasLayoutGestureEventTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutGestureEventTest.swift; sourceTree = ""; }; 6EE6AAE52B58A9D3002FEA75 /* InAppMessageAnalyticsTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppMessageAnalyticsTest.swift; sourceTree = ""; }; 6EE6AAE72B58A9D4002FEA75 /* ThomasLayoutPageSwipeEventAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutPageSwipeEventAction.swift; sourceTree = ""; }; 6EE6AAFA2B58AA4F002FEA75 /* ThomasLayoutEventSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventSource.swift; sourceTree = ""; }; 6EE6AAFB2B58AA4F002FEA75 /* ThomasLayoutEventContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThomasLayoutEventContext.swift; sourceTree = ""; }; 6EE6AAFE2B58AB66002FEA75 /* LegacyInAppAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyInAppAnalytics.swift; sourceTree = ""; }; 6EE6AB002B58B5AE002FEA75 /* LegacyInAppAnalyticsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyInAppAnalyticsTest.swift; sourceTree = ""; }; 6EE6AB032B59C21A002FEA75 /* CustomDisplayAdapterWrapperTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisplayAdapterWrapperTest.swift; sourceTree = ""; }; 6EE6AB052B59C231002FEA75 /* AirshipLayoutDisplayAdapterTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipLayoutDisplayAdapterTest.swift; sourceTree = ""; }; 6EE6AB172B59E80E002FEA75 /* ThomasDisplayListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasDisplayListener.swift; sourceTree = ""; }; 6EED67632CDEE75D0087CDCB /* ThomasViewInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasViewInfo.swift; sourceTree = ""; }; 6EED676C2CDEE7E50087CDCB /* ThomasPresentationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPresentationInfo.swift; sourceTree = ""; }; 6EED67702CDEE8370087CDCB /* ThomasOrientation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasOrientation.swift; sourceTree = ""; }; 6EED67742CDEE8460087CDCB /* ThomasWindowSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasWindowSize.swift; sourceTree = ""; }; 6EED67782CDEE8790087CDCB /* ThomasShadow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasShadow.swift; sourceTree = ""; }; 6EED677C2CDEE8FE0087CDCB /* ThomasSerializable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasSerializable.swift; sourceTree = ""; }; 6EED67952CDEEA2B0087CDCB /* ThomasShapeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasShapeInfo.swift; sourceTree = ""; }; 6EED67992CDEEA380087CDCB /* ThomasToggleStyleInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasToggleStyleInfo.swift; sourceTree = ""; }; 6EED679D2CDEEAA90087CDCB /* AirshipLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipLayout.swift; sourceTree = ""; }; 6EED67A12CE1A4780087CDCB /* ThomasColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasColor.swift; sourceTree = ""; }; 6EED67A52CE1A4FC0087CDCB /* ThomasBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasBorder.swift; sourceTree = ""; }; 6EED67A92CE1A5CC0087CDCB /* ThomasMarkdownOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasMarkdownOptions.swift; sourceTree = ""; }; 6EED67AD2CE1A6B70087CDCB /* ThomasPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPosition.swift; sourceTree = ""; }; 6EED67B12CE1A8300087CDCB /* ThomasEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasEventHandler.swift; sourceTree = ""; }; 6EED67B52CE1B43C0087CDCB /* ThomasTextAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasTextAppearance.swift; sourceTree = ""; }; 6EED67B92CE1B4E60087CDCB /* ThomasPlatform.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPlatform.swift; sourceTree = ""; }; 6EED67BD2CE1B5120087CDCB /* ThomasActionsPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasActionsPayload.swift; sourceTree = ""; }; 6EED67C12CE1B5850087CDCB /* ThomasIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasIcon.swift; sourceTree = ""; }; 6EED67C52CE1B5FF0087CDCB /* ThomasMediaFit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasMediaFit.swift; sourceTree = ""; }; 6EED67E22CE268630087CDCB /* ThomasButtonTapEffect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasButtonTapEffect.swift; sourceTree = ""; }; 6EED67E62CE268BB0087CDCB /* ThomasButtonClickBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasButtonClickBehavior.swift; sourceTree = ""; }; 6EED67EA2CE269930087CDCB /* ThomasDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasDirection.swift; sourceTree = ""; }; 6EED67EF2CE26CA10087CDCB /* ThomasSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasSize.swift; sourceTree = ""; }; 6EED67F32CE26CB50087CDCB /* ThomasConstrainedSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasConstrainedSize.swift; sourceTree = ""; }; 6EED67F72CE26CE20087CDCB /* ThomasSizeConstraint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasSizeConstraint.swift; sourceTree = ""; }; 6EED67FB2CE26DAF0087CDCB /* ThomasAttributeName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasAttributeName.swift; sourceTree = ""; }; 6EED67FF2CE26DCA0087CDCB /* ThomasAttributeValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasAttributeValue.swift; sourceTree = ""; }; 6EED68032CE26E180087CDCB /* ThomasMargin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasMargin.swift; sourceTree = ""; }; 6EED68072CE26E510087CDCB /* ThomasAutomatedAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasAutomatedAction.swift; sourceTree = ""; }; 6EED680C2CE2707E0087CDCB /* ThomasStateAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasStateAction.swift; sourceTree = ""; }; 6EED68102CE271E10087CDCB /* ThomasEnableBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasEnableBehavior.swift; sourceTree = ""; }; 6EED68142CE271F30087CDCB /* ThomasFormSubmitBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasFormSubmitBehavior.swift; sourceTree = ""; }; 6EED68182CE272710087CDCB /* ThomasAutomatedAccessibilityAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasAutomatedAccessibilityAction.swift; sourceTree = ""; }; 6EED681C2CE274290087CDCB /* ThomasAccessibilityAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasAccessibilityAction.swift; sourceTree = ""; }; 6EED68202CE2806B0087CDCB /* ThomasAccessibleInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasAccessibleInfo.swift; sourceTree = ""; }; 6EED68282CE28C960087CDCB /* ThomasVisibilityInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasVisibilityInfo.swift; sourceTree = ""; }; 6EED682C2CE28CBF0087CDCB /* ThomasValidationInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasValidationInfo.swift; sourceTree = ""; }; 6EED68302CE28FA70087CDCB /* ThomasConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasConstants.swift; sourceTree = ""; }; 6EED68E42CE3ECC50087CDCB /* ThomasPropertyOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasPropertyOverride.swift; sourceTree = ""; }; 6EEE8BA1290B3EDE00230528 /* AirshipKeychainAccess.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipKeychainAccess.swift; sourceTree = ""; }; 6EF02DEF2714EB500008B6C9 /* Thomas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thomas.swift; sourceTree = ""; }; 6EF1401A2A2671ED009A125D /* AirshipDeviceID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDeviceID.swift; sourceTree = ""; }; 6EF1401E2A268CE6009A125D /* TestKeychainAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestKeychainAccess.swift; sourceTree = ""; }; 6EF140202A269074009A125D /* AirshipDeviceIDTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipDeviceIDTest.swift; sourceTree = ""; }; 6EF1933B2838062B005F192A /* AirshipLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipLogHandler.swift; sourceTree = ""; }; 6EF1933D28380644005F192A /* DefaultLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLogHandler.swift; sourceTree = ""; }; 6EF1E9252CD005E2005EAA07 /* PagerUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerUtils.swift; sourceTree = ""; }; 6EF1E9292CD00698005EAA07 /* PagerSwipeDirection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerSwipeDirection.swift; sourceTree = ""; }; 6EF27DD827306C9100548DA3 /* AirshipToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipToggle.swift; sourceTree = ""; }; 6EF27DE22730E6F900548DA3 /* RadioInputController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioInputController.swift; sourceTree = ""; }; 6EF27DE52730E77300548DA3 /* RadioInputState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioInputState.swift; sourceTree = ""; }; 6EF27DE82730E85700548DA3 /* RadioInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioInput.swift; sourceTree = ""; }; 6EF553E22B7EE40B00901A22 /* AirshipLocalizationUtilsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipLocalizationUtilsTest.swift; sourceTree = ""; }; 6EF66D8C276461DA00ABCB76 /* UrlInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlInfo.swift; sourceTree = ""; }; 6EF66D902769B69C00ABCB76 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 6EFAFB77295525C3008AD187 /* ChannelAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelAPIClientTest.swift; sourceTree = ""; }; 6EFAFB79295525CD008AD187 /* ChannelCaptureTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelCaptureTest.swift; sourceTree = ""; }; 6EFAFB7B295525DF008AD187 /* ChannelRegistrationPayloadTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRegistrationPayloadTest.swift; sourceTree = ""; }; 6EFAFB8129555174008AD187 /* FetchDeviceInfoActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchDeviceInfoActionTest.swift; sourceTree = ""; }; 6EFAFB8329561F23008AD187 /* ModifyAttributesActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyAttributesActionTest.swift; sourceTree = ""; }; 6EFAFB8929562474008AD187 /* AddTagsActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTagsActionTest.swift; sourceTree = ""; }; 6EFAFB8B29562866008AD187 /* RemoveTagsActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveTagsActionTest.swift; sourceTree = ""; }; 6EFB7B322A14A0EC00133115 /* RemoteDataProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteDataProviderTest.swift; sourceTree = ""; }; 6EFD6D4A27272333005B26F1 /* EmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyView.swift; sourceTree = ""; }; 6EFD6D5B27273257005B26F1 /* Shapes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shapes.swift; sourceTree = ""; }; 6EFD6D6D27290C0B005B26F1 /* FormController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormController.swift; sourceTree = ""; }; 6EFD6D7027290C16005B26F1 /* TextInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInput.swift; sourceTree = ""; }; 6EFD6D81272A53AE005B26F1 /* PagerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerState.swift; sourceTree = ""; }; 6EFD6D84272A53FA005B26F1 /* Pager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Pager.swift; sourceTree = ""; }; 6EFD6D85272A53FA005B26F1 /* PagerIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagerIndicator.swift; sourceTree = ""; }; 6EFD6D86272A53FB005B26F1 /* PagerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PagerController.swift; sourceTree = ""; }; 6EFE7E3E2A97ED600064AC31 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 83A674F723AA7AA4005C0C8F /* AirshipDebugPushData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AirshipDebugPushData.xcdatamodel; sourceTree = ""; }; 8401769326C5671100373AF7 /* JSONMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONMatcher.swift; sourceTree = ""; }; 8401769726C5722400373AF7 /* JSONPredicate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONPredicate.swift; sourceTree = ""; }; 8401769926C5725800373AF7 /* AirshipJSONUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipJSONUtils.swift; sourceTree = ""; }; 8401769B26C5729E00373AF7 /* JSONValueMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueMatcher.swift; sourceTree = ""; }; 841E7D11268617C800EA0317 /* PreferenceCenterResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterResponse.swift; sourceTree = ""; }; 84483A67267CF0C000D0DA7D /* PreferenceCenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenter.swift; sourceTree = ""; }; 847B0012267CE558007CD249 /* PreferenceCenterSDKModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterSDKModule.swift; sourceTree = ""; }; 847BFFF4267CD739007CD249 /* AirshipPreferenceCenter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipPreferenceCenter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 847BFFF7267CD73A007CD249 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 847BFFFC267CD73A007CD249 /* AirshipPreferenceCenterTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AirshipPreferenceCenterTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9908E60D2B000DBA00DB3E2E /* CustomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomView.swift; sourceTree = ""; }; 9908E6112B0189F800DB3E2E /* ArishipCustomViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArishipCustomViewManager.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; 993F91FA2CA37874001B1C2E /* FooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FooterView.swift; sourceTree = ""; }; 99560C1D2BAE2FFA00F28BDC /* ChannelTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelTextField.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 = ""; }; 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 = ""; }; 99C3CC772BCF3DF700B5BED5 /* SMSValidatorAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSValidatorAPIClientTest.swift; sourceTree = ""; }; 99C3CC7C2BCF401B00B5BED5 /* CachingSMSValidatorAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachingSMSValidatorAPIClientTest.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 = ""; }; 99E0BD0E2B4DD71A00465B37 /* InAppMessageHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessageHostingController.swift; sourceTree = ""; }; 99E433972C9A044C006436B9 /* AirshipResources.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirshipResources.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 = ""; }; 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 /* 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 /* 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 /* PreferenceCenterContentStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferenceCenterContentStyle.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 = ""; }; 99FD20A42DEFC35900242551 /* MessageCenterUIKitAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageCenterUIKitAppearance.swift; sourceTree = ""; }; A1B2C3D4E5F60001VIDEOST /* VideoState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoState.swift; sourceTree = ""; }; A1B2C3D4E5F60003VIDEOCR /* VideoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoController.swift; sourceTree = ""; }; A61517B126A9C4C3008A41C4 /* SubscriptionListEditor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SubscriptionListEditor.swift; path = AirshipCore/Source/SubscriptionListEditor.swift; sourceTree = SOURCE_ROOT; }; A61517B426AEEAAB008A41C4 /* SubscriptionListUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SubscriptionListUpdate.swift; path = AirshipCore/Source/SubscriptionListUpdate.swift; sourceTree = SOURCE_ROOT; }; A61517C026B009D6008A41C4 /* SubscriptionListAPIClient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionListAPIClient.swift; sourceTree = ""; }; A61F3A732A5D9D6800EE94CC /* FeatureFlagManagerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagManagerTest.swift; sourceTree = ""; }; A62058692A5841330041FBF9 /* AirshipFeatureFlags.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipFeatureFlags.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A62058702A5841330041FBF9 /* AirshipFeatureFlagsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AirshipFeatureFlagsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A62058802A5842200041FBF9 /* AirshipFeatureFlagsSDKModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipFeatureFlagsSDKModule.swift; sourceTree = ""; }; A629F7D9295B514C00671647 /* PasteboardActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteboardActionTest.swift; sourceTree = ""; }; A62C3353299FD509004DB0DA /* ShareActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareActionTest.swift; sourceTree = ""; }; A63A567528F449D8004B8951 /* TestWorkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestWorkManager.swift; sourceTree = ""; }; A63A567728F457FE004B8951 /* TestWorkRateLimiterActor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestWorkRateLimiterActor.swift; sourceTree = ""; }; A641E1472BDBBDB400DE6FAA /* AirshipObjectiveC.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipObjectiveC.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A649F50A252F4F39005453CB /* UAInbox 2.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "UAInbox 2.xcdatamodel"; sourceTree = ""; }; A658DE0A2727020100007672 /* ImageButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageButton.swift; sourceTree = ""; }; A658DE182728498900007672 /* AirshipWebview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipWebview.swift; sourceTree = ""; }; A658DE2A272AFB0400007672 /* AirshipImageLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipImageLoader.swift; sourceTree = ""; }; A67229B928199D430033F54D /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS8.3.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; A67229BB28199D590033F54D /* libsqlite3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.tbd; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS8.3.sdk/usr/lib/libsqlite3.tbd; sourceTree = DEVELOPER_DIR; }; A67229BD28199D6A0033F54D /* Network.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Network.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS8.3.sdk/System/Library/Frameworks/Network.framework; sourceTree = DEVELOPER_DIR; }; A67EC248279B1A40009089E1 /* ScopedSubscriptionListEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopedSubscriptionListEditor.swift; sourceTree = ""; }; A67EC24A279B1C34009089E1 /* ScopedSubscriptionListUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScopedSubscriptionListUpdate.swift; sourceTree = ""; }; A67F87D1268DECCE00EF5F43 /* ContactAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAPIClient.swift; sourceTree = ""; }; A6849386273290520021675E /* Score.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Score.swift; sourceTree = ""; }; A684939C273436370021675E /* FontViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontViewModifier.swift; sourceTree = ""; }; A69C987E27E247B20063A101 /* SubscriptionListAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListAction.swift; sourceTree = ""; }; A6A5530926D548AF002B20F6 /* NativeBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBridge.swift; sourceTree = ""; }; A6A5530F26D548D6002B20F6 /* JavaScriptEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptEnvironment.swift; sourceTree = ""; }; A6A5531226D548FF002B20F6 /* NativeBridgeActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeBridgeActionHandler.swift; sourceTree = ""; }; A6AC44822B923ACB00769ED2 /* TestInAppMessageAutomationExecutor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestInAppMessageAutomationExecutor.swift; sourceTree = ""; }; A6AF8D2C27E8D4910068C7EE /* SubscriptionListActionTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionListActionTest.swift; sourceTree = ""; }; A6CDD8CF269491BE0040A673 /* ContactAPIClientTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactAPIClientTest.swift; sourceTree = ""; }; A6D6D48E2A0253AA0072A5CA /* ActionArguments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionArguments.swift; sourceTree = ""; }; A6D6D49C2A0260780072A5CA /* AirshipAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipAction.swift; sourceTree = ""; }; A6D6D49E2A02608C0072A5CA /* ActionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionResult.swift; sourceTree = ""; }; A6E9AD972D4D12C60091BBAF /* FeatureFlagUpdateStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagUpdateStatus.swift; sourceTree = ""; }; A6E9ADEC2D4D20300091BBAF /* InAppAutomationUpdateStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppAutomationUpdateStatus.swift; sourceTree = ""; }; A6F0B18F2B837E36002D10A4 /* AutomationEngineTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationEngineTest.swift; sourceTree = ""; }; C00ED4CE26C729390040C5D0 /* URLAllowList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLAllowList.swift; sourceTree = ""; }; C02D0B6526C1A3E200F673E6 /* ChannelCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelCapture.swift; sourceTree = ""; }; C088383526E0244C00D40838 /* TestURLAllowList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLAllowList.swift; sourceTree = ""; }; CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = AirshipConfig.xcconfig; sourceTree = ""; }; CC64F0541D8B77E3009CEF27 /* AirshipTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AirshipTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; CC64F0581D8B77E3009CEF27 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CC64F0611D8B781C009CEF27 /* CustomNotificationCategories.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = CustomNotificationCategories.plist; path = ../CustomNotificationCategories.plist; sourceTree = ""; }; CC64F0621D8B781C009CEF27 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Info.plist; sourceTree = ""; }; CC64F1451D8B7954009CEF27 /* AirshipConfig-Valid-Legacy.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "AirshipConfig-Valid-Legacy.plist"; sourceTree = ""; }; CC64F1471D8B7954009CEF27 /* AirshipConfig-Valid.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "AirshipConfig-Valid.plist"; sourceTree = ""; }; CC64F1491D8B7954009CEF27 /* development-embedded.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "development-embedded.mobileprovision"; sourceTree = ""; }; CC64F14A1D8B7954009CEF27 /* production-embedded.mobileprovision */ = {isa = PBXFileReference; lastKnownFileType = file; path = "production-embedded.mobileprovision"; sourceTree = ""; }; DFD2464D2473404C000FD565 /* DebugSDKModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugSDKModule.swift; sourceTree = ""; }; E976486E27A46CC50024518D /* ChannelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelType.swift; sourceTree = ""; }; E99605A027A071EA00365AE4 /* EmailRegistrationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailRegistrationOptions.swift; sourceTree = ""; }; E99605A327A075B800365AE4 /* SMSRegistrationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMSRegistrationOptions.swift; sourceTree = ""; }; E99605A627A075C600365AE4 /* OpenRegistrationOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenRegistrationOptions.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 6E128BA02D305F2E00733024 /* Source */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Source; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ 3CA0E29F237CCE2600EE76CF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6E4AEE0C2B6B24D7008AEAC1 /* AirshipAutomation.framework in Frameworks */, 6E29474B2AD47E15009EC6DD /* AirshipPreferenceCenter.framework in Frameworks */, 3CA0E2A0237CCE2600EE76CF /* AirshipCore.framework in Frameworks */, 3C39D3092384C8BE003C50D4 /* AirshipMessageCenter.framework in Frameworks */, 6E2F5A932A67316C00CABD3D /* AirshipFeatureFlags.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 3CA0E3A0237E4A7B00EE76CF /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6EE7725B238F179300E79944 /* AirshipCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 494DD9531B0EB677009C134E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 3261A7F6243CD7F900ADBF6B /* CoreTelephony.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E0B8727294A9C120064B7BD /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6E0B8744294A9C950064B7BD /* AirshipCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E0B872E294A9C130064B7BD /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6E0B8732294A9C130064B7BD /* AirshipAutomation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E43202026EA814F009228AB /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6E43202226EA814F009228AB /* CoreTelephony.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E4A466B28EF44F600A25617 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6E4A467228EF44F600A25617 /* AirshipMessageCenter.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 847BFFF1267CD739007CD249 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 847B000B267CD85E007CD249 /* AirshipCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 847BFFF9267CD73A007CD249 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 847BFFFD267CD73A007CD249 /* AirshipPreferenceCenter.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; A62058662A5841330041FBF9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( A61F3A782A5DBA1800EE94CC /* AirshipCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; A620586D2A5841330041FBF9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( A62058712A5841330041FBF9 /* AirshipFeatureFlags.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; A641E1442BDBBDB400DE6FAA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; CC64F0511D8B77E3009CEF27 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( CC64F0591D8B77E3009CEF27 /* AirshipCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 0A550D587E2599A4BD33CDF4 /* Pods */ = { isa = PBXGroup; children = ( 494DD9581B0EB677009C134E /* Products */, ); name = Pods; sourceTree = ""; }; 1B05132624AE100C00F5051F /* Locale */ = { isa = PBXGroup; children = ( 6E07688B29F9F0830014E2A9 /* AirshipLocaleManagerTest.swift */, ); name = Locale; sourceTree = ""; }; 1B8DCF452507B9380006E595 /* Recovered References */ = { isa = PBXGroup; children = ( ); name = "Recovered References"; sourceTree = ""; }; 2797B4172F47685900A7F848 /* StateStore */ = { isa = PBXGroup; children = ( 2797B4182F47687800A7F848 /* NativeLayoutPersistentDataStore.swift */, ); path = StateStore; sourceTree = ""; }; 27F1E18B2F0E825100E317DB /* Analytics */ = { isa = PBXGroup; children = ( 6EE6AADF2B58A9D2002FEA75 /* ThomasLayoutEventContextTest.swift */, 6EE6AADD2B58A9D2002FEA75 /* ThomasLayoutEventMessageIDTest.swift */, 6EE6AAD72B58A9D1002FEA75 /* ThomasLayoutEventRecorderTest.swift */, 6EE6AAF92B58A9DD002FEA75 /* Events */, 6E1A9BBC2B5B290200A6489B /* ThomasDisplayListenerTest.swift */, ); path = Analytics; sourceTree = ""; }; 27F1E20E2F0E911600E317DB /* Analytics */ = { isa = PBXGroup; children = ( 27F1E20F2F0E916F00E317DB /* Events */, 6EE6AAFB2B58AA4F002FEA75 /* ThomasLayoutEventContext.swift */, 6EE6AABA2B58A946002FEA75 /* ThomasLayoutEventRecorder.swift */, 6EE6AAB62B58A945002FEA75 /* ThomasLayoutEventMessageID.swift */, 6EE6AAFA2B58AA4F002FEA75 /* ThomasLayoutEventSource.swift */, 6EE6AB172B59E80E002FEA75 /* ThomasDisplayListener.swift */, ); name = Analytics; sourceTree = ""; }; 27F1E20F2F0E916F00E317DB /* Events */ = { isa = PBXGroup; children = ( 27F1E1F32F0E910B00E317DB /* ThomasLayoutButtonTapEvent.swift */, 27F1E1F42F0E910B00E317DB /* ThomasLayoutDisplayEvent.swift */, 27F1E1F52F0E910B00E317DB /* ThomasLayoutEvent.swift */, 27F1E1F62F0E910B00E317DB /* ThomasLayoutFormDisplayEvent.swift */, 27F1E1F72F0E910B00E317DB /* ThomasLayoutFormResultEvent.swift */, 27F1E1F82F0E910B00E317DB /* ThomasLayoutGestureEvent.swift */, 27F1E1F92F0E910B00E317DB /* ThomasLayoutPageActionEvent.swift */, 27F1E1FA2F0E910B00E317DB /* ThomasLayoutPagerCompletedEvent.swift */, 27F1E1FB2F0E910B00E317DB /* ThomasLayoutPagerSummaryEvent.swift */, 27F1E1FC2F0E910B00E317DB /* ThomasLayoutPageSwipeEvent.swift */, 27F1E1FD2F0E910B00E317DB /* ThomasLayoutPageViewEvent.swift */, 27F1E1FE2F0E910B00E317DB /* ThomasLayoutPermissionResultEvent.swift */, 27F1E1FF2F0E910B00E317DB /* ThomasLayoutResolutionEvent.swift */, ); name = Events; sourceTree = ""; }; 322AAB1F2B5ACA3400652DAC /* Contact management */ = { isa = PBXGroup; children = ( 99CC0D942BC87868001D93D0 /* AddChannelPromptViewModel.swift */, 32DDC0562AF1055300D23EBE /* AddChannelPromptView.swift */, 993F91FA2CA37874001B1C2E /* FooterView.swift */, 322AAB1C2B5A869000652DAC /* ContactManagementView.swift */, 322AAB202B5ACB2800652DAC /* ChannelListView.swift */, 99303B052BD97F89002174CA /* ChannelListViewCell.swift */, 99560C292BB3848A00F28BDC /* Component Views */, ); path = "Contact management"; sourceTree = ""; }; 3231127A29D5E67200CF0D86 /* Limits */ = { isa = PBXGroup; children = ( 3231127B29D5E67200CF0D86 /* FrequencyLimitStore.swift */, 3231127C29D5E67200CF0D86 /* FrequencyLimitManager.swift */, 3231127D29D5E67200CF0D86 /* Occurrence.swift */, 3231128029D5E67200CF0D86 /* FrequencyConstraint.swift */, 3231128129D5E67200CF0D86 /* FrequencyChecker.swift */, ); path = Limits; sourceTree = ""; }; 3231128929D5E69400CF0D86 /* Resources */ = { isa = PBXGroup; children = ( 6E1CBE2A2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodeld */, 6E4AEE492B6B4358008AEAC1 /* UAAutomation.xcdatamodeld */, 3231128A29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodeld */, ); path = Resources; sourceTree = ""; }; 3231128D29D5E6C600CF0D86 /* Tests */ = { isa = PBXGroup; children = ( 6E1B7B142B715FE400695561 /* Actions */, 459D405B2092476100C40E2D /* InAppMessaging */, 6E15283C2B4F0B6600DF1377 /* Action Automation */, 60FCA3082B513634005C9232 /* Legacy */, 6EC0CA742B4B8A2800333A87 /* Test Utils */, 6EC0CA4D2B48985B00333A87 /* RemoteData */, 6E0F4BEA2B326F6B00673CA4 /* Automation */, 6E1D8FFF2B2D1A94004BA130 /* Utils */, 6EDF1DA92B2A6D7B00E23BC4 /* InAppMessage */, 3231128E29D5E6C600CF0D86 /* Limits */, 60A364EC2C3479BF00B05E26 /* ExecutionWindowTest.swift */, ); path = Tests; sourceTree = ""; }; 3231128E29D5E6C600CF0D86 /* Limits */ = { isa = PBXGroup; children = ( 3231128F29D5E6C600CF0D86 /* FrequencyLimitManagerTest.swift */, ); path = Limits; sourceTree = ""; }; 32B5BE2D28F8A7D600F2254B /* Views */ = { isa = PBXGroup; children = ( 6E4D225B2E70AD2200A8D641 /* Shared */, 6E4D225A2E70ACD200A8D641 /* MessageList */, 6E4D22592E70ACA200A8D641 /* MessageView */, 6E4D20732E6B7A2700A8D641 /* MessageCenter */, 32B5BE4828F8B66500F2254B /* MessageCenterViewController.swift */, ); path = Views; sourceTree = ""; }; 32B632852906CA17000D3E34 /* Theme */ = { isa = PBXGroup; children = ( 6E1476CB2F56439A00320A36 /* MessageCenterNavigationAppearance.swift */, 32B632872906CA17000D3E34 /* MessageCenterTheme.swift */, 32B632862906CA17000D3E34 /* MessageCenterThemeLoader.swift */, ); path = Theme; sourceTree = ""; }; 32BACFAA2901827D009DA5C8 /* ViewModel */ = { isa = PBXGroup; children = ( 32B5BE2B28F8A7D600F2254B /* MessageCenterListItemViewModel.swift */, ); path = ViewModel; sourceTree = ""; }; 32BACFAB290182B7009DA5C8 /* Model */ = { isa = PBXGroup; children = ( 6EDE5F182B9BD7E700E33D04 /* InboxMessageData.swift */, 6E4A466028EF447C00A25617 /* MessageCenterUser.swift */, 6E4A466428EF448600A25617 /* MessageCenterMessage.swift */, 329DFCD42B7E59700039C8C0 /* UAInboxDataMapping.swift */, ); path = Model; sourceTree = ""; }; 3C927F8223A42609003C5FC8 /* App State */ = { isa = PBXGroup; children = ( 6E590E6D29A94CA90036DFAB /* AppStateTrackerTest.swift */, ); name = "App State"; sourceTree = ""; }; 3CA0E21A237CCBA600EE76CF /* AirshipDebug */ = { isa = PBXGroup; children = ( 3CA0E21B237CCBA600EE76CF /* Resources */, 3CA0E227237CCBA600EE76CF /* Source */, 3CA0E24F237CCBA600EE76CF /* Info.plist */, ); path = AirshipDebug; sourceTree = ""; }; 3CA0E21B237CCBA600EE76CF /* Resources */ = { isa = PBXGroup; children = ( 3CA0E222237CCBA600EE76CF /* AirshipDebugEventData.xcdatamodeld */, 83A674F623AA7AA4005C0C8F /* AirshipDebugPushData.xcdatamodeld */, ); path = Resources; sourceTree = ""; }; 3CA0E227237CCBA600EE76CF /* Source */ = { isa = PBXGroup; children = ( 6EB21A672E81BB6E001A5660 /* View */, 6E14C9B528B6D54200A55E65 /* Push */, 3CA0E240237CCBA600EE76CF /* Events */, 3CA0E233237CCBA600EE76CF /* AirshipDebugManager.swift */, DFD2464D2473404C000FD565 /* DebugSDKModule.swift */, 3CB37A1D251151A400E60392 /* AirshipDebugResources.swift */, 6E6802972B8675A200F4591F /* DebugComponent.swift */, ); path = Source; sourceTree = ""; }; 3CA0E240237CCBA600EE76CF /* Events */ = { isa = PBXGroup; children = ( 6E21852A237D32B30084933A /* EventData.swift */, 3CA0E241237CCBA600EE76CF /* AirshipEvent.swift */, 3CA0E24D237CCBA600EE76CF /* EventDataManager.swift */, ); path = Events; sourceTree = ""; }; 3CA0E302237E396100EE76CF /* AirshipMessageCenter */ = { isa = PBXGroup; children = ( 6E4A466F28EF44F600A25617 /* Tests */, 3CA0E47F237E505200EE76CF /* Info.plist */, 3CA0E303237E396100EE76CF /* Resources */, 3CA0E30B237E396100EE76CF /* Source */, ); path = AirshipMessageCenter; sourceTree = ""; }; 3CA0E303237E396100EE76CF /* Resources */ = { isa = PBXGroup; children = ( 3CA0E304237E396100EE76CF /* UAInbox.xcdatamodeld */, 6EC81D022F2D445500E1C0C6 /* UAInboxDataMappingV1toV4.xcmappingmodel */, 6EC81D042F2D448B00E1C0C6 /* UAInboxDataMappingV2toV4.xcmappingmodel */, 6EC81D072F2D44D700E1C0C6 /* UAInboxDataMappingV3toV4.xcmappingmodel */, ); path = Resources; sourceTree = ""; }; 3CA0E30B237E396100EE76CF /* Source */ = { isa = PBXGroup; children = ( 2797B4172F47685900A7F848 /* StateStore */, 32B632852906CA17000D3E34 /* Theme */, 32BACFAB290182B7009DA5C8 /* Model */, 32BACFAA2901827D009DA5C8 /* ViewModel */, 32B5BE2D28F8A7D600F2254B /* Views */, 6E4A466328EF448600A25617 /* MessageCenterAPIClient.swift */, 6E4A466228EF448600A25617 /* MessageCenterStore.swift */, 32F68CED28F07C2C00F7F52A /* MessageCenter.swift */, 32F68CE828F07C2B00F7F52A /* AirshipMessageCenterResources.swift */, 32F68CE928F07C2C00F7F52A /* MessageCenterSDKModule.swift */, 32F68CF428F07C4800F7F52A /* MessageCenterList.swift */, 6E4A469E28F4A7DF00A25617 /* MessageCenterAction.swift */, 6E4A46A028F4AEDF00A25617 /* MessageCenterNativeBridgeExtension.swift */, 32B513552B9F53A500BBE780 /* MessageCenterPredicate.swift */, 6E6802952B86749900F4591F /* MessageCenterComponent.swift */, 27CCF77C2F1656150018058F /* MessageViewAnalytics.swift */, ); path = Source; sourceTree = ""; }; 459D405B2092476100C40E2D /* InAppMessaging */ = { isa = PBXGroup; children = ( 459D40562092474A00C40E2D /* Valid-UAInAppMessageBannerStyle.plist */, 459D40542092474300C40E2D /* Valid-UAInAppMessageModalStyle.plist */, 459D4049208FE64D00C40E2D /* Valid-UAInAppMessageFullScreenStyle.plist */, 6EE6529222A7E3B800F7D54D /* Valid-UAInAppMessageHTMLStyle.plist */, 459D40582092475500C40E2D /* Invalid-UAInAppMessageBannerStyle.plist */, 459D405A2092475C00C40E2D /* Invalid-UAInAppMessageModalStyle.plist */, 459D404B208FE6BA00C40E2D /* Invalid-UAInAppMessageFullScreenStyle.plist */, ); path = InAppMessaging; sourceTree = ""; }; 494DD94D1B0EB677009C134E = { isa = PBXGroup; children = ( 6E43212826EA844E009228AB /* AirshipBasement */, 6E93769E23761FFA00AA9C2A /* AirshipCore */, 847BFFF5267CD73A007CD249 /* AirshipPreferenceCenter */, 3CA0E21A237CCBA600EE76CF /* AirshipDebug */, 6E0B872B294A9C130064B7BD /* AirshipAutomation */, 3CA0E302237E396100EE76CF /* AirshipMessageCenter */, CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */, 0A550D587E2599A4BD33CDF4 /* Pods */, A620586A2A5841330041FBF9 /* AirshipFeatureFlags */, A641E1482BDBBDB400DE6FAA /* AirshipObjectiveC */, E3E85E514DBE69D4C8BF51CE /* Frameworks */, 1B8DCF452507B9380006E595 /* Recovered References */, ); sourceTree = ""; }; 494DD9581B0EB677009C134E /* Products */ = { isa = PBXGroup; children = ( 494DD9571B0EB677009C134E /* AirshipCore.framework */, CC64F0541D8B77E3009CEF27 /* AirshipTests.xctest */, 3CA0E2AC237CCE2600EE76CF /* AirshipDebug.framework */, 3CA0E423237E4A7B00EE76CF /* AirshipMessageCenter.framework */, 847BFFF4267CD739007CD249 /* AirshipPreferenceCenter.framework */, 847BFFFC267CD73A007CD249 /* AirshipPreferenceCenterTests.xctest */, 6E43204826EA814F009228AB /* AirshipBasement.framework */, 6E4A466E28EF44F600A25617 /* AirshipMessageCenterTests.xctest */, 6E0B872A294A9C120064B7BD /* AirshipAutomation.framework */, 6E0B8731294A9C130064B7BD /* AirshipAutomationTests.xctest */, A62058692A5841330041FBF9 /* AirshipFeatureFlags.framework */, A62058702A5841330041FBF9 /* AirshipFeatureFlagsTests.xctest */, A641E1472BDBBDB400DE6FAA /* AirshipObjectiveC.framework */, ); 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 = ( 6058771C2AC73C7E0021628E /* AirshipMeteredUsageTest.swift */, 6058771E2ACAC86A0021628E /* MeteredUsageApiClientTest.swift */, ); name = MeteredUsage; sourceTree = ""; }; 6079511E2A1CD1880086578F /* Experiments */ = { isa = PBXGroup; children = ( 6079511F2A1CD19F0086578F /* ExperimentManagerTest.swift */, 6E9752552A5F79E200E67B1A /* ExperimentTest.swift */, ); name = Experiments; sourceTree = ""; }; 60C1DB0A2A8B741C00A1D3DA /* Embedded */ = { isa = PBXGroup; children = ( 60C1DB0B2A8B743B00A1D3DA /* AirshipEmbeddedView.swift */, 60C1DB0C2A8B743B00A1D3DA /* AirshipEmbeddedViewManager.swift */, 60C1DB0D2A8B743B00A1D3DA /* AirshipEmbeddedObserver.swift */, 60C1DB0E2A8B743C00A1D3DA /* EmbeddedView.swift */, 6E40868B2B8931C900435E2C /* AirshipViewSizeReader.swift */, 6E40868D2B8D036600435E2C /* AirshipEmbeddedSize.swift */, 6E1CBD802BA3A30300519D9C /* AirshipEmbeddedInfo.swift */, 6E65FB5F2C753CB400D9F341 /* EmbeddedViewSelector.swift */, ); name = Embedded; sourceTree = ""; }; 60D1D9B62B68FB4E00EBE0A4 /* TriggerProcessor */ = { isa = PBXGroup; children = ( 6E1A9BC62B5EE32E00A6489B /* AutomationTriggerProcessor.swift */, 60D1D9B72B68FB6400EBE0A4 /* PreparedTrigger.swift */, 6E4AEEBB2B6D6380008AEAC1 /* TriggerData.swift */, ); path = TriggerProcessor; sourceTree = ""; }; 60D3BCC22A1528F100E07524 /* Experimentation */ = { isa = PBXGroup; children = ( 60D3BCC32A1529D800E07524 /* ExperimentDataProvider.swift */, 60D3BCC52A152A0D00E07524 /* ExperimentManager.swift */, 60D3BCCB2A153C0700E07524 /* Experiment.swift */, 60D3BCCF2A154D9400E07524 /* MessageCriteria.swift */, ); name = Experimentation; sourceTree = ""; }; 60FCA3032B4F10D9005C9232 /* Legacy */ = { isa = PBXGroup; children = ( 60FCA3042B4F1110005C9232 /* LegacyInAppMessaging.swift */, 60FCA3062B4F1C73005C9232 /* LegacyInAppMessage.swift */, 6EE6AAFE2B58AB66002FEA75 /* LegacyInAppAnalytics.swift */, ); path = Legacy; sourceTree = ""; }; 60FCA3082B513634005C9232 /* Legacy */ = { isa = PBXGroup; children = ( 60FCA3092B51364A005C9232 /* LegacyInAppMessageTest.swift */, 60FCA30B2B51492A005C9232 /* LegacyInAppMessagingTest.swift */, 6EE6AB002B58B5AE002FEA75 /* LegacyInAppAnalyticsTest.swift */, ); path = Legacy; sourceTree = ""; }; 6E062CFF27165642001A74A1 /* Thomas */ = { isa = PBXGroup; children = ( 6E475BFD2F5A3709003D8E42 /* VideoGroupState.swift */, 6EC8249F2F33A4DD00E1C0C6 /* MessageDisplayHistory.swift */, 27F1E20E2F0E911600E317DB /* Analytics */, 6E5213E22DCA7A3800CF64B9 /* ThomasEvent.swift */, 6EED68302CE28FA70087CDCB /* ThomasConstants.swift */, 6E46A27E272B68660089CDE3 /* ThomasDelegate.swift */, 6EF02DEF2714EB500008B6C9 /* Thomas.swift */, 6EED67642CDEE75D0087CDCB /* Types */, 6ED80799273DA56000D1F455 /* ThomasViewController.swift */, 6E3B32CE2755D8C700B89C7B /* LayoutState.swift */, 6EFD6D732729D84D005B26F1 /* Environment */, 6E1C9C43271F74D9009EF9EF /* ViewModifiers */, 6E1C9C42271F744E009EF9EF /* Views */, 6EF66D8C276461DA00ABCB76 /* UrlInfo.swift */, ); name = Thomas; sourceTree = ""; }; 6E0B872B294A9C130064B7BD /* AirshipAutomation */ = { isa = PBXGroup; children = ( 6E0B8749294A9CCA0064B7BD /* Info.plist */, 3231128D29D5E6C600CF0D86 /* Tests */, 3231128929D5E69400CF0D86 /* Resources */, 6E0B8740294A9C500064B7BD /* Source */, ); path = AirshipAutomation; sourceTree = ""; }; 6E0B8740294A9C500064B7BD /* Source */ = { isa = PBXGroup; children = ( 603269512BF4B12B007F7F75 /* AudienceCheck */, 6E1B7B112B714FED00695561 /* Actions */, 6E986EF82B44D41E00FBE6A0 /* InAppAutomation.swift */, A6E9ADEC2D4D20300091BBAF /* InAppAutomationUpdateStatus.swift */, 6E986F002B44E86900FBE6A0 /* RemoteData */, 6E0F4BE32B32644200673CA4 /* Automation */, 6EDF1D912B292F8600E23BC4 /* InAppMessage */, 6E15281E2B4DC55800DF1377 /* ActionAutomation */, 3231127A29D5E67200CF0D86 /* Limits */, 6E1D90002B2D1A9B004BA130 /* Utils */, 3231126929D5E4F600CF0D86 /* AirshipAutomationResources.swift */, 6E15282B2B4DE81E00DF1377 /* AutomationSDKModule.swift */, 6E68028F2B8671E700F4591F /* InAppAutomationComponent.swift */, ); path = Source; sourceTree = ""; }; 6E0F4BE32B32644200673CA4 /* Automation */ = { isa = PBXGroup; children = ( 6ED838CF2D0D118B009CBB0C /* AutomationCompoundAudience.swift */, 6E6ED1512683DBC300A2CBD0 /* ApplicationMetrics.swift */, 6EC0CA5F2B491CC800333A87 /* Engine */, 6E0F4BE12B32190400673CA4 /* AutomationSchedule.swift */, 6E0F4BE42B32645600673CA4 /* AutomationTrigger.swift */, 6E0F4BE62B32646000673CA4 /* AutomationDelay.swift */, 6E0F4BE82B3264A400673CA4 /* DeferredAutomationData.swift */, 6EC0CA522B48A2C300333A87 /* AutomationAudience.swift */, 6E1185C52C3328A10071334E /* ExecutionWindow.swift */, ); path = Automation; sourceTree = ""; }; 6E0F4BEA2B326F6B00673CA4 /* Automation */ = { isa = PBXGroup; children = ( 603269562BF754FE007F7F75 /* AudienceCheck */, 6EC0CA692B4B696500333A87 /* Engine */, 6E6B2DBD2B33B768008BF788 /* AutomationScheduleTest.swift */, 6E60EF6529DF4BB5003F7A8D /* ApplicationMetricsTest.swift */, 6E68028D2B852F6A00F4591F /* AutomationScheduleDataTest.swift */, ); path = Automation; sourceTree = ""; }; 6E14C9B528B6D54200A55E65 /* Push */ = { isa = PBXGroup; children = ( 60653FC02CBD2CD4009CD9A7 /* PushData+CoreDataClass.swift */, 60653FC12CBD2CD4009CD9A7 /* PushData+CoreDataProperties.swift */, 6E6CC38023A3F9B4003D583C /* PushDataManager.swift */, 6E14C9A028B5E4AF00A55E65 /* PushNotification.swift */, ); path = Push; sourceTree = ""; }; 6E15281E2B4DC55800DF1377 /* ActionAutomation */ = { isa = PBXGroup; children = ( 6E1528162B4DC3C000DF1377 /* ActionAutomationExecutor.swift */, 6E15281C2B4DC43100DF1377 /* ActionAutomationPreparer.swift */, ); path = ActionAutomation; sourceTree = ""; }; 6E15282D2B4DED4F00DF1377 /* Analytics */ = { isa = PBXGroup; children = ( 6E15282E2B4DED7A00DF1377 /* InAppMessageAnalytics.swift */, 6E1528302B4DED8900DF1377 /* InAppMessageAnalyticsFactory.swift */, 6E1CBDE22BA51ED100519D9C /* InAppDisplayImpressionRuleProvider.swift */, 6E1CBDB52BA4CF0C00519D9C /* MessageDisplayHistory.swift */, ); path = Analytics; sourceTree = ""; }; 6E15283C2B4F0B6600DF1377 /* Action Automation */ = { isa = PBXGroup; children = ( 6EC0CA542B48B05000333A87 /* ActionAutomationExecutorTest.swift */, 6E15283D2B4F0B8200DF1377 /* ActionAutomationPreparerTest.swift */, ); path = "Action Automation"; sourceTree = ""; }; 6E15894E2AFEF18900954A04 /* Session */ = { isa = PBXGroup; children = ( 6E15894F2AFEF19F00954A04 /* SessionTracker.swift */, 6E1589532AFF021D00954A04 /* SessionState.swift */, 6E1589572AFF023400954A04 /* SessionEvent.swift */, 6E4325F12B7B1EDA00A9B000 /* SessionEventFactory.swift */, ); name = Session; sourceTree = ""; }; 6E16208B2B31169F009240B2 /* Display Coordinators */ = { isa = PBXGroup; children = ( 6E1528232B4DC60200DF1377 /* DisplayCoordinatorManager.swift */, 6E1620892B311219009240B2 /* DisplayCoordinator.swift */, 6E16208C2B3116AE009240B2 /* ImmediateDisplayCoordinator.swift */, 6E16208E2B3116BA009240B2 /* DefaultDisplayCoordinator.swift */, ); path = "Display Coordinators"; sourceTree = ""; }; 6E1620902B3118BF009240B2 /* Display Coordinators */ = { isa = PBXGroup; children = ( 6E1620912B3118D5009240B2 /* ImmediateDisplayCoordinatorTest.swift */, 6E1620942B311D8A009240B2 /* DefaultDisplayCoordinatorTest.swift */, 6EE6AA1A2B4F3062002FEA75 /* DisplayCoordinatorManagerTest.swift */, ); path = "Display Coordinators"; sourceTree = ""; }; 6E1892C5268D159900417887 /* Tests */ = { isa = PBXGroup; children = ( 6EB515A128A5F1B600870C5A /* view */, 6EB5159E28A5C87100870C5A /* data */, 6EB5158928A5AEFD00870C5A /* theme */, 6EB5159028A5B19400870C5A /* test data */, 6E1892C9268D16E200417887 /* Info.plist */, 6E1892C7268D15C300417887 /* PreferenceCenterTest.swift */, ); path = Tests; sourceTree = ""; }; 6E1A1BB12D6F9D020056418B /* Environment */ = { isa = PBXGroup; children = ( 6E52146E2DCC075100CF64B9 /* ThomasPagerTrackerTest.swift */, 6E97D6B32D84B1D10001CF7F /* ThomasStateTest.swift */, 6E97D6B52D84B2330001CF7F /* ThomasFormFieldTest.swift */, 6E97D6AE2D84B1780001CF7F /* ThomasFormDataCollectorTest.swift */, 6E97D6AC2D84B1610001CF7F /* ThomasFormStateTest.swift */, 6E1A1BB22D6F9D090056418B /* ThomasFormFieldProcessorTest.swift */, 6ED040EA278B5D7C00FCF773 /* ThomasFormPayloadGeneratorTest.swift */, ); path = Environment; sourceTree = ""; }; 6E1B7B112B714FED00695561 /* Actions */ = { isa = PBXGroup; children = ( 6E1B7B122B714FFC00695561 /* LandingPageAction.swift */, 60F8E75B2B8F3D4B00460EDF /* CancelSchedulesAction.swift */, 60F8E75F2B8FAF5400460EDF /* ScheduleAction.swift */, ); path = Actions; sourceTree = ""; }; 6E1B7B142B715FE400695561 /* Actions */ = { isa = PBXGroup; children = ( 6E1B7B152B715FFE00695561 /* LandingPageActionTest.swift */, 60F8E75D2B8FA12800460EDF /* CancelSchedulesActionTest.swift */, 60F8E7612B8FB2CC00460EDF /* ScheduleActionTest.swift */, ); path = Actions; sourceTree = ""; }; 6E1C9C38271E90D1009EF9EF /* Thomas */ = { isa = PBXGroup; children = ( 6E475CB92F5B3E45003D8E42 /* VideoMediaWebViewTests.swift */, 27F1E18B2F0E825100E317DB /* Analytics */, 6E1A1BB12D6F9D020056418B /* Environment */, 6E2FA28A2D515C56005893E2 /* Types */, 6E1C9C39271E90EB009EF9EF /* LayoutModelsTest.swift */, 6E382C20276D3E990091A351 /* ThomasValidationTests.swift */, 605073892B32F85100209B51 /* ThomasViewModelTest.swift */, 6050738F2B347B6400209B51 /* ThomasPresentationModelCodingTest.swift */, 2726505A2E81B80E000B6FA3 /* PagerControllerTest.swift */, ); name = Thomas; sourceTree = ""; }; 6E1C9C42271F744E009EF9EF /* Views */ = { isa = PBXGroup; children = ( 6ED5629F2EA9434900C20B55 /* StackImageButton.swift */, 6E0104FE2DDF9B26009D651F /* IconView.swift */, 6E66BA7E2D14B61A0083A9FD /* WrappingLayout.swift */, 6EF1E9292CD00698005EAA07 /* PagerSwipeDirection.swift */, 6EF1E9252CD005E2005EAA07 /* PagerUtils.swift */, 6EFD6D86272A53FB005B26F1 /* PagerController.swift */, 6EFD6D85272A53FA005B26F1 /* PagerIndicator.swift */, 32F97AC029E5986B00FED65F /* StoryIndicator.swift */, 6EDAFB252CB463C1000BD4AA /* ButtonLayout.swift */, 60C1DB0A2A8B741C00A1D3DA /* Embedded */, 6E0F557E2AE03BB900E7CB8C /* ThomasAsyncImage.swift */, 6E94761429BBC0230025F364 /* AirshipButton.swift */, 6E71129A2880DACB004942E4 /* StateController.swift */, 6E46A272272B19760089CDE3 /* ViewExtensions.swift */, 6EF27DD627306C6900548DA3 /* Forms */, 6EFD6D5B27273257005B26F1 /* Shapes.swift */, A658DE0A2727020100007672 /* ImageButton.swift */, 6E062D02271656DE001A74A1 /* Container.swift */, 6E062D04271656F8001A74A1 /* LinearLayout.swift */, 6E062D0627165709001A74A1 /* Label.swift */, 6E062D082716571F001A74A1 /* LabelButton.swift */, 6E062D0C2718B505001A74A1 /* ViewConstraints.swift */, 6E1BACDA2719ED7D0038399E /* ScrollLayout.swift */, 6E1BACDC2719FC0A0038399E /* ViewFactory.swift */, 32515866272AFB2E00DF8B44 /* Media.swift */, 6329102D2DD8103200B13C6C /* NativeVideoPlayer.swift */, 632913F92DE547A500B13C6C /* VideoMediaNativeView.swift */, 32515868272AFB2E00DF8B44 /* VideoMediaWebView.swift */, 6EFD6D4A27272333005B26F1 /* EmptyView.swift */, 3215CA9C2739349700B7D97E /* ModalView.swift */, 324D3BFE273E6B4500058EE4 /* BannerView.swift */, A658DE182728498900007672 /* AirshipWebview.swift */, 9908E6102B01841A00DB3E2E /* Custom */, 6E152BC92743235800788402 /* Icons.swift */, 6E6541DF2758976D009676CA /* AirshipProgressView.swift */, 6EF66D902769B69C00ABCB76 /* RootView.swift */, 320AD3A529E7FA2000D66106 /* PagerGestureMap.swift */, 6EFD6D84272A53FA005B26F1 /* Pager.swift */, 275D32AA2EF955AD00B75760 /* AirshipSimpleLayoutView.swift */, 275D32AB2EF955AD00B75761 /* AirshipSimpleLayoutViewModel.swift */, ); name = Views; sourceTree = ""; }; 6E1C9C43271F74D9009EF9EF /* ViewModifiers */ = { isa = PBXGroup; children = ( 6E7112982880DACB004942E4 /* EnableBehaviorModifiers.swift */, 6E5ADF812D7682A200A03799 /* StateSubscriptionsModifier.swift */, 6E7112962880DACB004942E4 /* EventHandlerViewModifier.swift */, 6E7112992880DACB004942E4 /* VisibilityViewModifier.swift */, 6E3B32CB27559D8B00B89C7B /* FormInputViewModifier.swift */, A684939C273436370021675E /* FontViewModifier.swift */, 6E1C9C4A271F7878009EF9EF /* BackgroundColorViewModifier.swift */, 6EDE5F4E2BA248FF00E33D04 /* TouchViewModifier.swift */, ); name = ViewModifiers; sourceTree = ""; }; 6E1D8FFF2B2D1A94004BA130 /* Utils */ = { isa = PBXGroup; children = ( 6E7E770E2DDFD1040042086D /* AirshipAsyncSemaphoreTest.swift */, 605073822B2CD38200209B51 /* ActiveTimerTest.swift */, 6E1D90012B2D1AB4004BA130 /* RetryingQueueTests.swift */, ); path = Utils; sourceTree = ""; }; 6E1D90002B2D1A9B004BA130 /* Utils */ = { isa = PBXGroup; children = ( 6E7E770C2DDFD0D80042086D /* AirshipAsyncSemaphore.swift */, 6EDF1DB72B2BB2B800E23BC4 /* RetryingQueue.swift */, 6E1528322B4DF2E600DF1377 /* ScheduleConditionsChangedNotifier.swift */, 6068E03A2B2CBCF200349E82 /* ActiveTimer.swift */, 6E1A9BAF2B5B0C4C00A6489B /* AutomationActionRunner.swift */, ); path = Utils; sourceTree = ""; }; 6E2486FA2899BB0E00657CE4 /* view */ = { isa = PBXGroup; children = ( 99560C272BB3843600F28BDC /* PreferenceCenterUtils.swift */, 322AAB1F2B5ACA3400652DAC /* Contact management */, 6E2486F02898341400657CE4 /* ConditionsMonitor.swift */, 6E2486EB2894901E00657CE4 /* ConditionsViewModifier.swift */, 6E9B488E2891B57300C905B1 /* CommonSectionView.swift */, 6E9B488C2891B43F00C905B1 /* LabeledSectionBreakView.swift */, 6E9B48902891B68B00C905B1 /* PreferenceCenterAlertView.swift */, 6E9B48962891B6BF00C905B1 /* ContactSubscriptionGroupView.swift */, 6E9B48922891B6A700C905B1 /* ChannelSubscriptionView.swift */, 6E9B48942891B6B400C905B1 /* ContactSubscriptionView.swift */, 99F4FE5A2BC36A6700754F0F /* PreferenceCenterContentStyle.swift */, 6E9B488A2891962000C905B1 /* PreferenceCenterView.swift */, 6E2486FC2899C06100657CE4 /* PreferenceCenterContentLoader.swift */, 6E892F2D2E7A193200FB0EC4 /* PreferenceCenterContent.swift */, 6E2486DE28945D3900657CE4 /* PreferenceCenterState.swift */, 6E3B231228A32EC30005D46E /* PreferenceCenterViewExtensions.swift */, 6EB5157028A4608C00870C5A /* PreferenceCenterViewControllerFactory.swift */, ); path = view; sourceTree = ""; }; 6E2D6AF026B0B62900B7C226 /* Subscription Lists */ = { isa = PBXGroup; children = ( 6E2D6AF126B0B64E00B7C226 /* SubscriptionListAPIClientTest.swift */, ); name = "Subscription Lists"; sourceTree = ""; }; 6E2E3CA02B3271BC00B8515B /* View */ = { isa = PBXGroup; children = ( 99E0BD0C2B4DD4AB00465B37 /* FullscreenView.swift */, 99F662B12B60425E00696098 /* InAppMessageModalView.swift */, 999DC85D2B5B721D0048C6AF /* HTMLView.swift */, 99F662D12B63047300696098 /* InAppMessageBannerView.swift */, 99E8D7962B4F17260099B6F3 /* CloseButton.swift */, 990A09932B5CA5B700244D90 /* InAppMessageExtensions.swift */, 99E8D7BE2B50C2C10099B6F3 /* ButtonGroup.swift */, 990A09582B5C677C00244D90 /* InAppMessageWebView.swift */, 990A09AE2B5DBD0400244D90 /* InAppMessageViewUtils.swift */, 99E8D7982B4F19BA0099B6F3 /* TextView.swift */, 99E8D7C02B50E5F40099B6F3 /* InAppMessageRootView.swift */, 99E8D7C42B5192D40099B6F3 /* MediaView.swift */, 99F662AF2B5DDC2900696098 /* BeveledLoadingView.swift */, 99E8D7CC2B54A6470099B6F3 /* Theme */, 6E2E3CA12B32723C00B8515B /* InAppMessageNativeBridgeExtension.swift */, 99E0BD0E2B4DD71A00465B37 /* InAppMessageHostingController.swift */, ); path = View; sourceTree = ""; }; 6E2E3CA32B32725900B8515B /* View */ = { isa = PBXGroup; children = ( 6E2E3CA42B32726400B8515B /* InAppMessageNativeBridgeExtensionTest.swift */, ); path = View; sourceTree = ""; }; 6E2FA28A2D515C56005893E2 /* Types */ = { isa = PBXGroup; children = ( 6E2FA28B2D515C5A005893E2 /* ThomasEmailRegistrationOptionsTest.swift */, 60D2B3342D9F0FCF00B0752D /* PagerDisableSwipeSelectorTest.swift */, ); path = Types; sourceTree = ""; }; 6E411CBD2538CA4100FEE4E8 /* Actions */ = { isa = PBXGroup; children = ( 6E9D529A26C1A77C004EA16B /* ActionRegistry.swift */, 6E9D529726C195F7004EA16B /* ActionRunner.swift */, 6E664BA026C43F5400A2C8E5 /* ActivityViewController.swift */, 6E664BC926C4852B00A2C8E5 /* AddCustomEventAction.swift */, 6E664BCF26C4916600A2C8E5 /* AddTagsAction.swift */, 6ED6ECA326ADCA6F00973364 /* BlockAction.swift */, 6EAD7CE426B216DB00B88EA7 /* DeepLinkAction.swift */, 6ED6ECA626AE05B700973364 /* EmptyAction.swift */, 6E664BD626C4CD8700A2C8E5 /* EnableFeatureAction.swift */, 6E664BD826C4CD8700A2C8E5 /* FetchDeviceInfoAction.swift */, 6E664BE626C5B21600A2C8E5 /* ModifyAttributesAction.swift */, 6E664BD726C4CD8700A2C8E5 /* OpenExternalURLAction.swift */, 6E664BD526C4CD8700A2C8E5 /* PasteboardAction.swift */, 6E664BD226C4917000A2C8E5 /* RemoveTagsAction.swift */, 6E664BA426C4417400A2C8E5 /* ShareAction.swift */, A69C987E27E247B20063A101 /* SubscriptionListAction.swift */, 6E92EC89284933750038802D /* PromptPermissionAction.swift */, 6E92EC8C2849378E0038802D /* PermissionPrompter.swift */, A6D6D48E2A0253AA0072A5CA /* ActionArguments.swift */, A6D6D49C2A0260780072A5CA /* AirshipAction.swift */, A6D6D49E2A02608C0072A5CA /* ActionResult.swift */, 6E4A4FD92A30358F0049FEFC /* TagsActionArgs.swift */, 271B38642DB2866200495D9F /* TagActionMutation.swift */, 27AFE70E2E733F4400767044 /* ModifyTagsAction.swift */, ); name = Actions; sourceTree = ""; }; 6E411CE12538CBA800FEE4E8 /* Locale */ = { isa = PBXGroup; children = ( 6E6ED1482683D8E200A2CBD0 /* LocaleManager.swift */, ); name = Locale; sourceTree = ""; }; 6E411CE22538CBB200FEE4E8 /* JSON */ = { isa = PBXGroup; children = ( 8401769326C5671100373AF7 /* JSONMatcher.swift */, 8401769726C5722400373AF7 /* JSONPredicate.swift */, 8401769926C5725800373AF7 /* AirshipJSONUtils.swift */, 8401769B26C5729E00373AF7 /* JSONValueMatcher.swift */, 6E4339EE2DFA039B000A7741 /* JSONValueMatcherPredicates.swift */, 6E6C3F9D27A4C3D4007F55C7 /* AirshipJSON.swift */, ); name = JSON; sourceTree = ""; }; 6E411CEF2538CC2900FEE4E8 /* Airship */ = { isa = PBXGroup; children = ( 6E87BDFD26E283840005D20D /* Airship.swift */, 6E87BE0026E283850005D20D /* DeepLinkDelegate.swift */, 6E6ED14F2683DBC200A2CBD0 /* AirshipCoreResources.swift */, 6E6ED1502683DBC300A2CBD0 /* AirshipVersion.swift */, 6E87BDBC26E01FF40005D20D /* ModuleLoader.swift */, 6E87BE1226E28F570005D20D /* AirshipInstance.swift */, 3CC95B2A2696549B00FE2ACD /* AirshipPushableComponent.swift */, 6E4325CD2B7AD5A200A9B000 /* AirshipComponent.swift */, ); name = Airship; sourceTree = ""; }; 6E411CFC2538CC7A00FEE4E8 /* Integration */ = { isa = PBXGroup; children = ( 6E87BD8C26D815780005D20D /* AppIntegration.swift */, 6E87BD9126D963B60005D20D /* DefaultAppIntegrationDelegate.swift */, ); name = Integration; sourceTree = ""; }; 6E411D092538CCDA00FEE4E8 /* Config */ = { isa = PBXGroup; children = ( 6E9C2BD92D027B5A000089A9 /* AirshipAppCredentials.swift */, C00ED4CE26C729390040C5D0 /* URLAllowList.swift */, 6E15B6F326CD85C40099C92D /* RuntimeConfig.swift */, 6E1D8AD726CC66BE0049DACB /* RemoteConfigCache.swift */, 6E87BD6626D6A39A0005D20D /* AirshipConfig.swift */, 6E87BD8226D757CA0005D20D /* CloudSite.swift */, 6E9C2B7C2D014426000089A9 /* APNSEnvironment.swift */, ); name = Config; sourceTree = ""; }; 6E411D0A2538CD1900FEE4E8 /* App State */ = { isa = PBXGroup; children = ( 6E698E56267BF63B00654DB2 /* ApplicationState.swift */, 6E698E53267BF63A00654DB2 /* AppStateTracker.swift */, 6E698E52267BF63A00654DB2 /* AppStateTrackerAdapter.swift */, ); name = "App State"; sourceTree = ""; }; 6E411D0B2538CD4B00FEE4E8 /* Remote Config */ = { isa = PBXGroup; children = ( 6E15B6D826CC749F0099C92D /* RemoteConfigManager.swift */, 6E1D8AB226CC5D490049DACB /* RemoteConfig.swift */, ); name = "Remote Config"; sourceTree = ""; }; 6E411D0C2538CD6400FEE4E8 /* Remote Data */ = { isa = PBXGroup; children = ( 6EB3FCEE2ABCFA680018594E /* RemoteDataProtocol.swift */, 6E15B72926CEDBA50099C92D /* RemoteData.swift */, 6E698E08267A7DD900654DB2 /* RemoteDataAPIClient.swift */, 6EE49C0F2A0C142F00AB1CF4 /* RemoteDataInfo.swift */, 6E15B72026CEC7030099C92D /* RemoteDataPayload.swift */, 6EE49C1C2A0D9D8000AB1CF4 /* RemoteDataProvider.swift */, 6EE49C212A13E32B00AB1CF4 /* RemoteDataProviderProtocol.swift */, 6E15B72C26CF13BC0099C92D /* RemoteDataProviderDelegate.swift */, 6EE49BE02A0AADC900AB1CF4 /* AppRemoteDataProviderDelegate.swift */, 6EE49C172A0C3CC600AB1CF4 /* ContactRemoteDataProviderDelegate.swift */, 6EE49C0B2A0C141800AB1CF4 /* RemoteDataSource.swift */, 6E15B70C26CEB4190099C92D /* RemoteDataStore.swift */, 6E15B70A26CEB4190099C92D /* RemoteDataStorePayload.swift */, 6EE49C072A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift */, 6E2F5A852A65F00200CABD3D /* RemoteDataSourceStatus.swift */, 6E2F5A8D2A66FE8900CABD3D /* AirshipTimeCriteria.swift */, 329DFCCE2B7E4DDA0039C8C0 /* UARemoteDataMapping.swift */, ); name = "Remote Data"; sourceTree = ""; }; 6E411D0E2538CDE200FEE4E8 /* Channel */ = { isa = PBXGroup; children = ( 6ECDDE7729B804BF009D79DB /* Auth */, 6E57CE3528DBBD8C00287601 /* LiveActivities */, 6E87BD6326D594870005D20D /* ChannelRegistrationPayload.swift */, 6E2D6AED26B083DB00B7C226 /* ChannelAudienceManager.swift */, C02D0B6526C1A3E200F673E6 /* ChannelCapture.swift */, 6E698E11267A98AB00654DB2 /* ChannelAPIClient.swift */, 6E1892DD2694F1C100417887 /* ChannelRegistrar.swift */, 6EC7E48126A60C060038CFDD /* AirshipChannel.swift */, 6E739D6526B9BDC100BC6F6D /* ChannelBulkUpdateAPIClient.swift */, 6ED735D926C73DC5003B0A7D /* DefaultAirshipChannel.swift */, 6ED735DC26C7401D003B0A7D /* TagEditor.swift */, E976486E27A46CC50024518D /* ChannelType.swift */, 608B16E72C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift */, ); name = Channel; sourceTree = ""; }; 6E411D1B2538CE6200FEE4E8 /* Application Metrics */ = { isa = PBXGroup; children = ( ); name = "Application Metrics"; sourceTree = ""; }; 6E411D282538CF0500FEE4E8 /* Contacts */ = { isa = PBXGroup; children = ( 608B16F02C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift */, A67F87D1268DECCE00EF5F43 /* ContactAPIClient.swift */, 6EB11C862697ACBF00DC698F /* ContactOperation.swift */, 6EC7E48C26A738C70038CFDD /* ContactConflictEvent.swift */, 6E698E3A267BEDC300654DB2 /* DefaultAirshipContact.swift */, E99605A027A071EA00365AE4 /* EmailRegistrationOptions.swift */, E99605A327A075B800365AE4 /* SMSRegistrationOptions.swift */, E99605A627A075C600365AE4 /* OpenRegistrationOptions.swift */, 6E78848E29B9643C00ACAE45 /* AirshipContact.swift */, 6E94760E29BA8FA30025F364 /* ContactManager.swift */, 6E4E2E2729CEB222002E7682 /* ContactManagerProtocol.swift */, 6E6363E129DCD0CF009C358A /* ContactSubscriptionListClient.swift */, 32BBFB3F2B274C8600C6A998 /* ContactChannelsAPIClient.swift */, 6E60EF6929DF542B003F7A8D /* AnonContactData.swift */, 6E1EEE8F2BD81AF300B45A87 /* ContactChannel.swift */, 990EB3B02BF59A1500315EAC /* ContactChannelsProvider.swift */, 608B16E52C2C1137005298FA /* SubscriptionListProvider.swift */, ); name = Contacts; sourceTree = ""; }; 6E411D292538CF1000FEE4E8 /* Native Bridge */ = { isa = PBXGroup; children = ( 6E43218F26EA89B6009228AB /* NativeBridgeDelegate.swift */, A6A5530926D548AF002B20F6 /* NativeBridge.swift */, A6A5530F26D548D6002B20F6 /* JavaScriptEnvironment.swift */, A6A5531226D548FF002B20F6 /* NativeBridgeActionHandler.swift */, 6E692AFC29E0CB2F00D96CCC /* JavaScriptCommand.swift */, 6E692AFE29E0CB4100D96CCC /* JavaScriptCommandDelegate.swift */, 6E692B0229E0CBB500D96CCC /* NativeBridgeExtensionDelegate.swift */, 6EDE293E2A9802BF00235738 /* NativeBridgeActionRunner.swift */, ); name = "Native Bridge"; sourceTree = ""; }; 6E411D362538CF3900FEE4E8 /* Push */ = { isa = PBXGroup; children = ( 6E8932992E7B66B600FB0EC4 /* NotificationRegistrationResult.swift */, 6E8932972E7B665200FB0EC4 /* APNSRegistrationResult.swift */, 6E0031AA2D08CC8A0004F53E /* AirshipAuthorizedNotificationSettings.swift */, 6E8CE760284136BF00CF4B11 /* Registration */, 6E49D7AF28401D2E00C7BB9D /* PushNotificationDelegate.swift */, 6E49D7AC28401D2D00C7BB9D /* RegistrationDelegate.swift */, 3CC8AA0526BB3C7900405614 /* DefaultAirshipPush.swift */, 3C63555F26CDD4F8006E9916 /* AirshipPush.swift */, 6EE49BDC2A09AD3600AB1CF4 /* AirshipNotificationStatus.swift */, ); name = Push; sourceTree = ""; }; 6E411D372538CF5300FEE4E8 /* Tag Groups */ = { isa = PBXGroup; children = ( 6EB11C882697AF5600DC698F /* TagGroupUpdate.swift */, 6E698E3C267BEDC300654DB2 /* TagGroupsEditor.swift */, 6E739D6D26B9F58700BC6F6D /* TagGroupMutations.swift */, ); name = "Tag Groups"; sourceTree = ""; }; 6E411D502538CFA600FEE4E8 /* HTTP */ = { isa = PBXGroup; children = ( 6ECDDE6B29B7EEE9009D79DB /* AuthToken.swift */, 6E299FD628D13E54001305A7 /* AirshipRequest.swift */, 6E299FDA28D14208001305A7 /* AirshipResponse.swift */, 6E299FDE28D14258001305A7 /* AirshipRequestSession.swift */, 6014AD662C1B5F540072DCF0 /* ChallengeResolver.swift */, ); name = HTTP; sourceTree = ""; }; 6E411D512538D00300FEE4E8 /* Analytics */ = { isa = PBXGroup; children = ( 6E4325EF2B7AEC7100A9B000 /* Events */, 6E15894E2AFEF18900954A04 /* Session */, 6E4325F72B7C08A600A9B000 /* AirshipAnalyticsFeed.swift */, 3CA84AB626DE257200A59685 /* DefaultAirshipAnalytics.swift */, 3CA84AB726DE257200A59685 /* AirshipAnalytics.swift */, 3CA84AA626DE255200A59685 /* EventStore.swift */, 6E952925268B8F6500398B54 /* AssociatedIdentifiers.swift */, 6E698E0B267A88D600654DB2 /* EventAPIClient.swift */, 6E91B4442686911B00DDB1A8 /* EventUtils.swift */, 6E96ECF5293FCE080053CC91 /* EventUploadScheduler.swift */, 6E96ECF9293FDDD90053CC91 /* AirshipSDKExtension.swift */, 6E96ED09294135500053CC91 /* EventManager.swift */, 6E96ED15294197D90053CC91 /* EventUploadTuningInfo.swift */, ); name = Analytics; sourceTree = ""; }; 6E411D5E2538D15300FEE4E8 /* Attributes */ = { isa = PBXGroup; children = ( 6EB11C8A2697AFC700DC698F /* AttributeUpdate.swift */, 6E698E3B267BEDC300654DB2 /* AttributesEditor.swift */, 6E739D6A26B9DFFB00BC6F6D /* AttributePendingMutations.swift */, 6E4E5B3A26E7F91600198175 /* Attributes.swift */, ); name = Attributes; sourceTree = ""; }; 6E411D5F2538D26200FEE4E8 /* Utils */ = { isa = PBXGroup; children = ( 6E1472D42F526DC600320A36 /* AirshipNativePlatform.swift */, 6E146FF22F525E7300320A36 /* AirshipPasteboard.swift */, 6E146D692F5241BA00320A36 /* AirshipColor.swift */, 6E146D672F523DB700320A36 /* AirshipFont.swift */, 6E146C4F2F5214D900320A36 /* AirshipDevice.swift */, 6EC815AE2F2BBFD100E1C0C6 /* BundleExtensions.swift */, 6E3CA5402ECB9B7400210C32 /* AirshipDisplayTarget.swift */, 6E4339F02DFA099C000A7741 /* AirshipIvyVersionMatcher.swift */, 6E52146A2DCBF9BA00CF64B9 /* AirshipTimerProtocol.swift */, 6E916C562DB30D9E00C676FA /* AirshipWindowFactory.swift */, 6EAA61482D5297A2006602F7 /* SubjectExtension.swift */, 99E433972C9A044C006436B9 /* AirshipResources.swift */, 3237D5F12B865D990055932B /* JSONValueTransformer.swift */, 99D1B3272B44F08900447840 /* AirshipSceneManager.swift */, 6E8E1C9A26447B3800B11791 /* AirshipLock.swift */, 6E44626629E6813A00CB2B56 /* AsyncSerialQueue.swift */, 6E96ED01294115210053CC91 /* AsyncStream.swift */, 6EEE8BA1290B3EDE00230528 /* AirshipKeychainAccess.swift */, 6E49D7A928401D2D00C7BB9D /* Atomic.swift */, 6E4E5B3926E7F91600198175 /* AirshipLocalizationUtils.swift */, 6E664BE926C6DB7500A2C8E5 /* AirshipUtils.swift */, 6EB11C8C2698C50F00DC698F /* AudienceUtils.swift */, 6E6ED14D2683DBC200A2CBD0 /* AirshipBase64.swift */, 6E6ED1532683DBC300A2CBD0 /* UACoreData.swift */, 6E6ED1542683DBC300A2CBD0 /* AirshipNetworkChecker.swift */, 6E6ED13F2683A9F200A2CBD0 /* AirshipDate.swift */, 6E6ED1352683A58D00A2CBD0 /* Dispatcher.swift */, 32D6E87A2727F7060077C784 /* Image.swift */, 6EA5202227D1364E003011CA /* AirshipDateFormatter.swift */, 6E6C3F8927A266C0007F55C7 /* CachedValue.swift */, 6E92ECB0284ECE590038802D /* CachedList.swift */, 6E0B8761294CE0DC0064B7BD /* FarmHashFingerprint64.swift */, 6E82482129A6D9DF00136EA0 /* CancellableValueHolder.swift */, 6E82483729A6E1BE00136EA0 /* AirshipCancellable.swift */, 6E12539029A81ACE0009EE58 /* AirshipCoreDataPredicate.swift */, 6E6363EB29DDF84B009C358A /* SerialQueue.swift */, 6E07688729F9D28A0014E2A9 /* AirshipNotificationCenter.swift */, 6E07689129FB39440014E2A9 /* AirshipUnsafeSendableWrapper.swift */, 6ECB627D2A36A0770095C85C /* ExternalURLProcessor.swift */, 6E6BD2772AF2B97300B9DFC9 /* AirshipTaskSleeper.swift */, 6E1528272B4DCFCB00DF1377 /* AirshipActorValue.swift */, 6ED2F5302B7FF819000AFC80 /* AirshipViewUtils.swift */, 6E213B172BC60AF100BF24AE /* AirshipWeakValueHolder.swift */, 6EB839482BC8898E006611C4 /* AirshipAsyncChannel.swift */, ); name = Utils; sourceTree = ""; }; 6E43212826EA844E009228AB /* AirshipBasement */ = { isa = PBXGroup; children = ( 6E43212926EA847D009228AB /* Source */, ); path = AirshipBasement; sourceTree = ""; }; 6E43212926EA847D009228AB /* Source */ = { isa = PBXGroup; children = ( 6EF1933828380086005F192A /* Logger */, ); path = Source; sourceTree = ""; }; 6E4325C12B7A9D6E00A9B000 /* Privacy Manager */ = { isa = PBXGroup; children = ( 6E4325C22B7A9D9A00A9B000 /* AirshipPrivacyManagerTest.swift */, ); name = "Privacy Manager"; sourceTree = ""; }; 6E4325D12B7AD94800A9B000 /* Airship */ = { isa = PBXGroup; children = ( 6E4325D22B7AD96800A9B000 /* AirshipTest.swift */, ); name = Airship; sourceTree = ""; }; 6E4325EC2B7AEC2E00A9B000 /* Custom Events */ = { isa = PBXGroup; children = ( 6E91B4662689327D00DDB1A8 /* CustomEvent.swift */, 6E4325ED2B7AEC3B00A9B000 /* Templates */, ); name = "Custom Events"; sourceTree = ""; }; 6E4325ED2B7AEC3B00A9B000 /* Templates */ = { isa = PBXGroup; children = ( 6E95292B268B98A200398B54 /* MediaEventTemplate.swift */, 6E952922268B812000398B54 /* AccountEventTemplate.swift */, 6E95291F268A6C1500398B54 /* SearchEventTemplate.swift */, 6E95292E268BBD7D00398B54 /* RetailEventTemplate.swift */, ); name = Templates; sourceTree = ""; }; 6E4325EF2B7AEC7100A9B000 /* Events */ = { isa = PBXGroup; children = ( 6E4325E82B7AEB1F00A9B000 /* AirshipEvent.swift */, 6E96ECF1293EB7900053CC91 /* AirshipEventData.swift */, 6E4326002B7C327C00A9B000 /* AirshipEvents.swift */, 6E524C722C126F5F002CA094 /* AirshipEventType.swift */, 6E4325F02B7AEC9700A9B000 /* Region */, 6E4325EC2B7AEC2E00A9B000 /* Custom Events */, ); name = Events; sourceTree = ""; }; 6E4325F02B7AEC9700A9B000 /* Region */ = { isa = PBXGroup; children = ( 6E91B43B26868A6300DDB1A8 /* CircularRegion.swift */, 6E91B43F26868C3400DDB1A8 /* ProximityRegion.swift */, 6E91B43E26868C3400DDB1A8 /* RegionEvent.swift */, ); name = Region; sourceTree = ""; }; 6E49D7CA2840257000C7BB9D /* PermissionsManager */ = { isa = PBXGroup; children = ( 6E49D7A628401D2D00C7BB9D /* Permission.swift */, 6E49D7A728401D2D00C7BB9D /* PermissionDelegate.swift */, 6E49D7AB28401D2D00C7BB9D /* PermissionsManager.swift */, 6E49D7AE28401D2D00C7BB9D /* PermissionStatus.swift */, ); name = PermissionsManager; sourceTree = ""; }; 6E49D7CB284028A900C7BB9D /* PermissionsManager */ = { isa = PBXGroup; children = ( 6E49D7CC284028C600C7BB9D /* PermissionsManagerTests.swift */, ); name = PermissionsManager; sourceTree = ""; }; 6E4A466F28EF44F600A25617 /* Tests */ = { isa = PBXGroup; children = ( 6EC824A32F33A5F600E1C0C6 /* MessageCenterMessageTest.swift */, 6EC824A12F33A5EC00E1C0C6 /* MessageCenterThemeLoaderTest.swift */, 6E4A467828EF453400A25617 /* MessageCenterAPIClientTest.swift */, 32F68CDA28F02A6B00F7F52A /* MessageCenterStoreTest.swift */, 32F615A628F708980015696D /* MessageCenterListTests.swift */, 27CCF77E2F16DA500018058F /* MessageViewAnalyticsTest.swift */, ); path = Tests; sourceTree = ""; }; 6E4AEE212B6B2DFD008AEAC1 /* Assets */ = { isa = PBXGroup; children = ( 6E4AEE242B6B2E0A008AEAC1 /* airship.jpg */, 6E4AEE222B6B2E09008AEAC1 /* alternate-airship.jpg */, 6E4AEE252B6B2E0A008AEAC1 /* AssetCacheManagerTest.swift */, 6E4AEE262B6B2E0A008AEAC1 /* DefaultAssetDownloaderTest.swift */, 6E4AEE232B6B2E09008AEAC1 /* DefaultAssetFileManagerTest.swift */, ); path = Assets; sourceTree = ""; }; 6E4D20732E6B7A2700A8D641 /* MessageCenter */ = { isa = PBXGroup; children = ( 32B5BE3A28F8A7EB00F2254B /* MessageCenterController.swift */, 6E4D224F2E6F9CD700A8D641 /* MessageCenterContent.swift */, 6E4D224D2E6F96B100A8D641 /* MessageCenterSplitNavigationView.swift */, 6E4D224B2E6F968000A8D641 /* MessageCenterNavigationStack.swift */, 99FD20A42DEFC35900242551 /* MessageCenterUIKitAppearance.swift */, 32B5BE2828F8A7D600F2254B /* MessageCenterView.swift */, ); path = MessageCenter; sourceTree = ""; }; 6E4D22592E70ACA200A8D641 /* MessageView */ = { isa = PBXGroup; children = ( 2753F6412F6C5BB50073882C /* MessageCenterMessageError.swift */, 6E4D225C2E70ADDB00A8D641 /* MessageCenterMessageViewModel.swift */, 32B5BE2928F8A7D600F2254B /* MessageCenterMessageView.swift */, 6E4D22492E6F813700A8D641 /* MessageCenterMessageViewWithNavigation.swift */, 6E4D22532E6FA5EA00A8D641 /* MessageCenterWebView.swift */, 27E419492EF59F9800D5C1A6 /* MessageCenterThomasView.swift */, ); path = MessageView; sourceTree = ""; }; 6E4D225A2E70ACD200A8D641 /* MessageList */ = { isa = PBXGroup; children = ( 6E4D225E2E70AFE300A8D641 /* MessageCenterListViewModel.swift */, 32B5BE2A28F8A7D600F2254B /* MessageCenterListView.swift */, 6E4D20712E6B760C00A8D641 /* MessageCenterListViewWithNavigation.swift */, 32B5BE2728F8A7D600F2254B /* MessageCenterListItemView.swift */, ); path = MessageList; sourceTree = ""; }; 6E4D225B2E70AD2200A8D641 /* Shared */ = { isa = PBXGroup; children = ( 6E4D22512E6FA2F300A8D641 /* MessageCenterBackButton.swift */, ); path = Shared; sourceTree = ""; }; 6E57CE3528DBBD8C00287601 /* LiveActivities */ = { isa = PBXGroup; children = ( 6E57CE3628DBBD9A00287601 /* LiveActivityRegistry.swift */, 6E57CE3128DB8BDA00287601 /* LiveActivityUpdate.swift */, 6E7DB38228ECDC41002725F6 /* LiveActivity.swift */, 6E68203128EDE3E200A4F90B /* LiveActivityRestorer.swift */, 6E29474C2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift */, 6E2947502AD5DB5A009EC6DD /* LiveActivityRegistrationStatusUpdates.swift */, ); name = LiveActivities; sourceTree = ""; }; 6E5A64C22AAB7D3B00574085 /* MeteredUsage */ = { isa = PBXGroup; children = ( 6E5A64C32AAB7D5C00574085 /* AirshipMeteredUsage.swift */, 6E5A64C72AABBE7100574085 /* MeteredUsageAPIClient.swift */, 6E5A64CF2AABBEAF00574085 /* AirshipMeteredUsageEvent.swift */, 6E5A64D32AABBED600574085 /* MeteredUsageStore.swift */, ); name = MeteredUsage; sourceTree = ""; }; 6E5B1A012AFF08F00019CA61 /* Session */ = { isa = PBXGroup; children = ( 6E5B1A042AFF090B0019CA61 /* SessionTrackerTest.swift */, ); name = Session; sourceTree = ""; }; 6E66DDA42E95A67700D44555 /* WorkManager */ = { isa = PBXGroup; children = ( 6E66DDA52E95A67C00D44555 /* WorkRateLimiterTests.swift */, ); path = WorkManager; sourceTree = ""; }; 6E698DE726790A9C00654DB2 /* Privacy Manager */ = { isa = PBXGroup; children = ( 6E698DEB26790AC300654DB2 /* AirshipPrivacyManager.swift */, ); name = "Privacy Manager"; sourceTree = ""; }; 6E6BD2402AE995C800B9DFC9 /* Deferred */ = { isa = PBXGroup; children = ( 6E6BD2412AE995DA00B9DFC9 /* DeferredResolver.swift */, 6E6BD2452AEAFE7E00B9DFC9 /* DeferredAPIClient.swift */, 6E6BD2492AEAFEB700B9DFC9 /* AirsihpTriggerContext.swift */, 6E6BD24D2AEAFEC500B9DFC9 /* AirshipStateOverrides.swift */, ); name = Deferred; sourceTree = ""; }; 6E6BD2562AEC596D00B9DFC9 /* Deferred */ = { isa = PBXGroup; children = ( 6E6BD2572AEC598C00B9DFC9 /* DeferredAPIClientTest.swift */, 6E6BD2592AEC626B00B9DFC9 /* DeferredResolverTest.swift */, ); name = Deferred; sourceTree = ""; }; 6E6C3F9227A47D78007F55C7 /* data */ = { isa = PBXGroup; children = ( 841E7D11268617C800EA0317 /* PreferenceCenterResponse.swift */, 6E1892D4268E3D8500417887 /* PreferenceCenterDecoder.swift */, 6E6C3F9927A47DB4007F55C7 /* PreferenceCenterConfig.swift */, 993AFDFD2C1B2D9A00AA875B /* PreferenceCenterConfig+ContactManagement.swift */, ); path = data; sourceTree = ""; }; 6E77CD462D8A22320057A52C /* Input Validation */ = { isa = PBXGroup; children = ( 6E77CD472D8A225E0057A52C /* AirshipInputValidator.swift */, 6E77CD492D8A225E0057A52C /* SMSValidatorAPIClient.swift */, 6E77CE462D8A28B10057A52C /* CachingSMSValidatorAPIClient.swift */, ); name = "Input Validation"; sourceTree = ""; }; 6E77CE482D8A2B9E0057A52C /* Input Validation */ = { isa = PBXGroup; children = ( 6E8746482D8A3C64002469D7 /* TestSMSValidatorAPIClient.swift */, 99C3CC772BCF3DF700B5BED5 /* SMSValidatorAPIClientTest.swift */, 99C3CC7C2BCF401B00B5BED5 /* CachingSMSValidatorAPIClientTest.swift */, 6E1A19232D6F8BD50056418B /* AirshipInputValidationTest.swift */, ); path = "Input Validation"; sourceTree = ""; }; 6E7DB38128ECDB4C002725F6 /* LiveActivities */ = { isa = PBXGroup; children = ( 6E7DB38A28ECDDD9002725F6 /* LiveActivityRegistryTest.swift */, ); name = LiveActivities; sourceTree = ""; }; 6E7DB38C28ECFCDB002725F6 /* JSON */ = { isa = PBXGroup; children = ( 6E7DB38D28ECFCED002725F6 /* AirshipJSONTest.swift */, 6E65244F2A4FD8D30019F353 /* JSONPredicateTest.swift */, 60E09FDA2B2780DB005A16EA /* JsonMatcherTest.swift */, 6087DB872B278F7600449BA8 /* JsonValueMatcherTest.swift */, ); name = JSON; sourceTree = ""; }; 6E87BD9B26DD78B40005D20D /* Integration */ = { isa = PBXGroup; children = ( 6E87BD9C26DD78CC0005D20D /* DefaultAppIntegrationDelegateTest.swift */, 6E87BD9E26DDDB250005D20D /* AppIntegrationTests.swift */, ); name = Integration; sourceTree = ""; }; 6E8873982763D80400AC248A /* Image Loading */ = { isa = PBXGroup; children = ( 32CF81E1275627F4003009D1 /* AirshipAsyncImage.swift */, A658DE2A272AFB0400007672 /* AirshipImageLoader.swift */, 6E8873992763D8AB00AC248A /* AirshipImageProvider.swift */, ); name = "Image Loading"; sourceTree = ""; }; 6E8CE760284136BF00CF4B11 /* Registration */ = { isa = PBXGroup; children = ( 6E49D7AD28401D2D00C7BB9D /* UNNotificationRegistrar.swift */, 3CC95B1E268E785900FE2ACD /* NotificationCategories.swift */, 6E49D7B128401D2E00C7BB9D /* APNSRegistrar.swift */, 6E49D7B028401D2E00C7BB9D /* Badger.swift */, 6E49D7AA28401D2D00C7BB9D /* NotificationPermissionDelegate.swift */, 6E49D7A828401D2D00C7BB9D /* NotificationRegistrar.swift */, ); name = Registration; sourceTree = ""; }; 6E90F0ED228F550900E1FCB0 /* Config */ = { isa = PBXGroup; children = ( 6E9C2BDB2D028030000089A9 /* APNSEnvironmentTest.swift */, 6E6C84452A5C8CFD00DD83A2 /* AirshipConfigTest.swift */, 6E15B6F926CDCA6A0099C92D /* RuntimeConfigTest.swift */, ); name = Config; sourceTree = ""; }; 6E91E42E28EF420700B6F25E /* WorkManager */ = { isa = PBXGroup; children = ( 6E91E43728EF423300B6F25E /* AirshipWorkManager.swift */, 6E91E43328EF423300B6F25E /* AirshipWorkManagerProtocol.swift */, 6E91E43528EF423300B6F25E /* AirshipWorkRequest.swift */, 6E91E43928EF423400B6F25E /* AirshipWorkResult.swift */, 6E91E42F28EF423300B6F25E /* WorkBackgroundTasks.swift */, 6E91E43028EF423300B6F25E /* WorkConditionsMonitor.swift */, 6E91E43128EF423300B6F25E /* Worker.swift */, 6E91E43228EF423300B6F25E /* WorkRateLimiterActor.swift */, ); name = WorkManager; sourceTree = ""; }; 6E937300237615B400AA9C2A /* Source */ = { isa = PBXGroup; children = ( 6EDFBBC22F5780BA0043D9EF /* BasementImport.swift */, 6EAD3AFB2F4530E400FF274E /* UAAppIntegrationDelegate.swift */, 6EAD3AF92F4530AD00FF274E /* AutoIntegration.swift */, 6EAD3AF72F45305B00FF274E /* AirshipSwizzler.swift */, 6E77CD462D8A22320057A52C /* Input Validation */, 6E6BD2402AE995C800B9DFC9 /* Deferred */, 6E5A64C22AAB7D3B00574085 /* MeteredUsage */, 6E8873982763D80400AC248A /* Image Loading */, 6EC755972A4E114700851ABB /* Audience Checks */, 6E91E42E28EF420700B6F25E /* WorkManager */, 6E49D7CA2840257000C7BB9D /* PermissionsManager */, 6E062CFF27165642001A74A1 /* Thomas */, A61517AD26A97419008A41C4 /* Subscription Lists */, 6E698E02267A799500654DB2 /* AirshipErrors.swift */, 6E698DE826790AC300654DB2 /* PreferenceDataStore.swift */, 6E698DE726790A9C00654DB2 /* Privacy Manager */, 6E411CBD2538CA4100FEE4E8 /* Actions */, 6E411CEF2538CC2900FEE4E8 /* Airship */, 6E411D512538D00300FEE4E8 /* Analytics */, 6E411D0A2538CD1900FEE4E8 /* App State */, 6E411D1B2538CE6200FEE4E8 /* Application Metrics */, 6E411D5E2538D15300FEE4E8 /* Attributes */, 6E411D0E2538CDE200FEE4E8 /* Channel */, 6E411D092538CCDA00FEE4E8 /* Config */, 6E411D502538CFA600FEE4E8 /* HTTP */, 6E411CFC2538CC7A00FEE4E8 /* Integration */, 6E411CE22538CBB200FEE4E8 /* JSON */, 6E411CE12538CBA800FEE4E8 /* Locale */, 6E411D282538CF0500FEE4E8 /* Contacts */, 6E411D292538CF1000FEE4E8 /* Native Bridge */, 6E411D362538CF3900FEE4E8 /* Push */, 6E411D0B2538CD4B00FEE4E8 /* Remote Config */, 6E411D0C2538CD6400FEE4E8 /* Remote Data */, 60D3BCC22A1528F100E07524 /* Experimentation */, 6E411D372538CF5300FEE4E8 /* Tag Groups */, 6E411D5F2538D26200FEE4E8 /* Utils */, 6E9B4873288F0CE000C905B1 /* RateAppAction.swift */, 6E75F50429C4EAF600E3585A /* AudienceOverridesProvider.swift */, 6E4A4FDD2A3132850049FEFC /* AirshipSDKModule.swift */, 6EF1401A2A2671ED009A125D /* AirshipDeviceID.swift */, 6E6BD26C2AF1AC5700B9DFC9 /* AirshipCache.swift */, 60EACF532B7BF2EA00CAFDBB /* AirshipApptimizeIntegration.swift */, ); path = Source; sourceTree = ""; }; 6E93769E23761FFA00AA9C2A /* AirshipCore */ = { isa = PBXGroup; children = ( 494DD95B1B0EB677009C134E /* Info.plist */, CC89997D1D8B642D00A0CECC /* Resources */, 6E937300237615B400AA9C2A /* Source */, CC64F0551D8B77E3009CEF27 /* Tests */, ); path = AirshipCore; sourceTree = ""; }; 6E986F002B44E86900FBE6A0 /* RemoteData */ = { isa = PBXGroup; children = ( 6E8BDA162B62EC9F00711DB8 /* AutomationRemoteDataSubscriber.swift */, 6E986F052B47319E00FBE6A0 /* DeferredScheduleResult.swift */, 6E986F0D2B473EC700FBE6A0 /* AutomationRemoteDataAccess.swift */, 6EE6AA2B2B51DB1E002FEA75 /* AutomationSourceInfoStore.swift */, ); path = RemoteData; sourceTree = ""; }; 6EB21A392E81BB6E001A5660 /* Analytics */ = { isa = PBXGroup; children = ( 6EB21A342E81BB6E001A5660 /* AirshipDebugAddEventView.swift */, 6EB21A352E81BB6E001A5660 /* AirshipDebugAnalyticIdentifierEditorView.swift */, 6EB21A362E81BB6E001A5660 /* AirshipDebugAnalyticsView.swift */, 6EB21A372E81BB6E001A5660 /* AirshipDebugEventDetailsView.swift */, 6EB21A382E81BB6E001A5660 /* AirshipDebugEventsView.swift */, ); path = Analytics; sourceTree = ""; }; 6EB21A3B2E81BB6E001A5660 /* AppInfo */ = { isa = PBXGroup; children = ( 6EB21A3A2E81BB6E001A5660 /* AirshipDebugAppInfoView.swift */, ); path = AppInfo; sourceTree = ""; }; 6EB21A3F2E81BB6E001A5660 /* Automations */ = { isa = PBXGroup; children = ( 6EB21A3C2E81BB6E001A5660 /* AirshipDebugExperimentsView.swift */, 6EB21A3D2E81BB6E001A5660 /* AirshipDebugInAppExperiencesView.swift */, 6EB21A3E2E81BB6E001A5660 /* AirshipDebugAutomationsView.swift */, ); path = Automations; sourceTree = ""; }; 6EB21A432E81BB6E001A5660 /* Channel */ = { isa = PBXGroup; children = ( 6EB21A402E81BB6E001A5660 /* AirshipDebugChannelSubscriptionsView.swift */, 6EB21A412E81BB6E001A5660 /* AirshipDebugChannelTagView.swift */, 6EB21A422E81BB6E001A5660 /* AirshipDebugChannelView.swift */, ); path = Channel; sourceTree = ""; }; 6EB21A4B2E81BB6E001A5660 /* Common */ = { isa = PBXGroup; children = ( 6EB21A922E81C7A2001A5660 /* AirshipDebugAddStringPropertyView.swift */, 6EB21A902E81BFB9001A5660 /* AirshipDebugAddPropertyView.swift */, 6EB21A442E81BB6E001A5660 /* AirshipDebugAttributesEditorView.swift */, 6EB21A452E81BB6E001A5660 /* AirshipDebugExtensions.swift */, 6EB21A462E81BB6E001A5660 /* AirshipDebugTagGroupsEditorView.swift */, 6EB21A472E81BB6E001A5660 /* AirshipJSONDetailsView.swift */, 6EB21A482E81BB6E001A5660 /* AirshipJSONView.swift */, 6EB21A492E81BB6E001A5660 /* AirshipToast.swift */, 6EB21A4A2E81BB6E001A5660 /* Extensions.swift */, 6EB21B5E2E82FE98001A5660 /* AirshipDebugAudienceSubject.swift */, ); path = Common; sourceTree = ""; }; 6EB21A522E81BB6E001A5660 /* Contact */ = { isa = PBXGroup; children = ( 6EB21A4C2E81BB6E001A5660 /* AirshipDebugAddEmailChannelView.swift */, 6EB21A4D2E81BB6E001A5660 /* AirshipDebugAddOpenChannelView.swift */, 6EB21A4E2E81BB6E001A5660 /* AirshipDebugAddSMSChannelView.swift */, 6EB21A4F2E81BB6E001A5660 /* AirshipDebugContactSubscriptionEditorView.swift */, 6EB21A502E81BB6E001A5660 /* AirshipDebugContactView.swift */, 6EB21A512E81BB6E001A5660 /* AirshipDebugNamedUserView.swift */, ); path = Contact; sourceTree = ""; }; 6EB21A562E81BB6E001A5660 /* FeatureFlags */ = { isa = PBXGroup; children = ( 6EB21A532E81BB6E001A5660 /* AirshipDebugFeatureFlagDetailsView.swift */, 6EB21A552E81BB6E001A5660 /* AirshipDebugFeatureFlagView.swift */, ); path = FeatureFlags; sourceTree = ""; }; 6EB21A5A2E81BB6E001A5660 /* PreferenceCenter */ = { isa = PBXGroup; children = ( 6EB21A572E81BB6E001A5660 /* AirshipDebugPreferencCenterItemView.swift */, 6EB21A582E81BB6E001A5660 /* AirshipDebugPreferenceCenterView.swift */, ); path = PreferenceCenter; sourceTree = ""; }; 6EB21A5C2E81BB6E001A5660 /* PrivacyManager */ = { isa = PBXGroup; children = ( 6EB21A5B2E81BB6E001A5660 /* AirshipDebugPrivacyManagerView.swift */, ); path = PrivacyManager; sourceTree = ""; }; 6EB21A602E81BB6E001A5660 /* Push */ = { isa = PBXGroup; children = ( 6EB21A5D2E81BB6E001A5660 /* AirshipDebugPushDetailsView.swift */, 6EB21A5E2E81BB6E001A5660 /* AirshipDebugPushView.swift */, 6EB21A5F2E81BB6E001A5660 /* AirshipDebugReceivedPushView.swift */, ); path = Push; sourceTree = ""; }; 6EB21A632E81BB6E001A5660 /* TvOSComponents */ = { isa = PBXGroup; children = ( 6EB21A612E81BB6E001A5660 /* TVDatePicker.swift */, 6EB21A622E81BB6E001A5660 /* TVSlider.swift */, ); path = TvOSComponents; sourceTree = ""; }; 6EB21A672E81BB6E001A5660 /* View */ = { isa = PBXGroup; children = ( 6EB21AFB2E82169F001A5660 /* AirshipoDebugTriggers.swift */, 6EB21A392E81BB6E001A5660 /* Analytics */, 6EB21A3B2E81BB6E001A5660 /* AppInfo */, 6EB21A3F2E81BB6E001A5660 /* Automations */, 6EB21A432E81BB6E001A5660 /* Channel */, 6EB21A4B2E81BB6E001A5660 /* Common */, 6EB21A522E81BB6E001A5660 /* Contact */, 6EB21A562E81BB6E001A5660 /* FeatureFlags */, 6EB21A5A2E81BB6E001A5660 /* PreferenceCenter */, 6EB21A5C2E81BB6E001A5660 /* PrivacyManager */, 6EB21A602E81BB6E001A5660 /* Push */, 6EB21A632E81BB6E001A5660 /* TvOSComponents */, 6EB21A642E81BB6E001A5660 /* AirshipDebugContentView.swift */, 6EB21A652E81BB6E001A5660 /* AirshipDebugRoute.swift */, 6EB21A662E81BB6E001A5660 /* AirshipDebugView.swift */, ); path = View; sourceTree = ""; }; 6EB5156F28A42F9C00870C5A /* theme */ = { isa = PBXGroup; children = ( 6E2486F628984D0D00657CE4 /* PreferenceCenterTheme.swift */, 6E3B230E28A318CD0005D46E /* PreferenceCenterThemeLoader.swift */, ); path = theme; sourceTree = ""; }; 6EB5158928A5AEFD00870C5A /* theme */ = { isa = PBXGroup; children = ( 6EB5158E28A5B15C00870C5A /* PreferenceThemeLoaderTest.swift */, ); path = theme; sourceTree = ""; }; 6EB5159028A5B19400870C5A /* test data */ = { isa = PBXGroup; children = ( 6EB5159828A5C61D00870C5A /* TestThemeInvalid.plist */, 6EB5159628A5C54400870C5A /* TestThemeEmpty.plist */, 6EB5159328A5B8E900870C5A /* TestTheme.plist */, 6EB5159128A5B1B400870C5A /* TestLegacyTheme.plist */, ); path = "test data"; sourceTree = ""; }; 6EB5159E28A5C87100870C5A /* data */ = { isa = PBXGroup; children = ( 6E1892D6268E3F1800417887 /* PreferenceCenterConfigTest.swift */, ); path = data; sourceTree = ""; }; 6EB515A128A5F1B600870C5A /* view */ = { isa = PBXGroup; children = ( 6EB515A228A5F1C600870C5A /* PreferenceCenterStateTest.swift */, ); path = view; sourceTree = ""; }; 6EC0CA4D2B48985B00333A87 /* RemoteData */ = { isa = PBXGroup; children = ( 6EC0CA4E2B48987700333A87 /* AutomationRemoteDataAccessTest.swift */, 6EE6AA272B50C91E002FEA75 /* AutomationRemoteDataSubscriberTest.swift */, 6EE6AA372B572897002FEA75 /* AutomationSourceInfoStoreTest.swift */, ); path = RemoteData; sourceTree = ""; }; 6EC0CA5F2B491CC800333A87 /* Engine */ = { isa = PBXGroup; children = ( 60D1D9B62B68FB4E00EBE0A4 /* TriggerProcessor */, 6E4AEE632B6B44EA008AEAC1 /* AutomationStore.swift */, 6E4AEE622B6B44EA008AEAC1 /* LegacyAutomationStore.swift */, 6E986EE32B448D3C00FBE6A0 /* AutomationEngine.swift */, 6EC0CA672B49287100333A87 /* AutomationExecutor.swift */, 6EC0CA5B2B48C2F500333A87 /* AutomationPreparer.swift */, 6E1A9BBE2B5EE19000A6489B /* PreparedSchedule.swift */, 6E1A9BC22B5EE1DE00A6489B /* ScheduleReadyResult.swift */, 6E1A9BC02B5EE1CF00A6489B /* SchedulePrepareResult.swift */, 6E1A9BC42B5EE1EE00A6489B /* ScheduleExecuteResult.swift */, 6E1A9BC82B5EE34600A6489B /* AutomationEventFeed.swift */, 6E1A9BD02B5EE84600A6489B /* AutomationScheduleData.swift */, 6E1A9BD22B5EE8A400A6489B /* AutomationScheduleState.swift */, 6E1A9BD42B5EE97000A6489B /* TriggeringInfo.swift */, 6E1A9BF62B606CF200A6489B /* AutomationDelayProcessor.swift */, 6E34C4B02C7D4B6400B00506 /* ExecutionWindowProcessor.swift */, 27077E4B2EE7531C0027A282 /* AutomationEventsHistory.swift */, ); path = Engine; sourceTree = ""; }; 6EC0CA692B4B696500333A87 /* Engine */ = { isa = PBXGroup; children = ( 6EC0CA6A2B4B698000333A87 /* AutomationExecutorTest.swift */, 6EC0CA6C2B4B879800333A87 /* AutomationPreparerTest.swift */, 60FCA3242B5EF3A8005C9232 /* AutomationEventFeedTest.swift */, 6E6A848C2B6854FC006FFB35 /* AutomationDelayProcessorTest.swift */, 6E6A84912B68A571006FFB35 /* AutomationStoreTest.swift */, 60D1D9BA2B6A53F000EBE0A4 /* PreparedTriggerTest.swift */, 60D1D9BC2B6AB2D100EBE0A4 /* AutomationTriggerProcessorTest.swift */, A6F0B18F2B837E36002D10A4 /* AutomationEngineTest.swift */, 6EDE5FC12BADDD96003ADF55 /* PreparedScheduleInfoTest.swift */, 6E34C4B22C7D4C6600B00506 /* ExecutionWindowProcessorTest.swift */, 27051CD62EE75E3300C770D5 /* AutomationEventsHistoryTest.swift */, ); path = Engine; sourceTree = ""; }; 6EC0CA742B4B8A2800333A87 /* Test Utils */ = { isa = PBXGroup; children = ( 6EC0CA752B4B8A3A00333A87 /* TestRemoteDataAccess.swift */, 6EC0CA772B4B8A4700333A87 /* TestFrequencyLimitsManager.swift */, 6E15283F2B4F153900DF1377 /* TestDisplayAdapter.swift */, 6E1528412B4F156200DF1377 /* TestDisplayCoordinator.swift */, 6EE6AA1D2B4F31B1002FEA75 /* TestCachedAssets.swift */, 6EE6AA292B50C976002FEA75 /* TestAutomationEngine.swift */, 6E1A9BB12B5B172F00A6489B /* TestActionRunner.swift */, 6E1A9BB62B5B1D9E00A6489B /* TestActiveTimer.swift */, 6E1A9BBA2B5B20D700A6489B /* TestInAppMessageAnalytics.swift */, A6AC44822B923ACB00769ED2 /* TestInAppMessageAutomationExecutor.swift */, ); path = "Test Utils"; sourceTree = ""; }; 6EC0CA7F2B4C811A00333A87 /* Display Adapter */ = { isa = PBXGroup; children = ( 6E1528382B4E13D400DF1377 /* AirshipLayoutDisplayAdapter.swift */, 6E1A9BAA2B5AE38A00A6489B /* InAppMessageDisplayListener.swift */, 6EC0CA802B4C812A00333A87 /* DisplayAdapter.swift */, 6E1528252B4DC64B00DF1377 /* DisplayAdapterFactory.swift */, 6E1528342B4E11DB00DF1377 /* CustomDisplayAdapter.swift */, 6E1528362B4E11E800DF1377 /* CustomDisplayAdapterWrapper.swift */, ); path = "Display Adapter"; sourceTree = ""; }; 6EC755972A4E114700851ABB /* Audience Checks */ = { isa = PBXGroup; children = ( 6EBFA9AE2D15E04B002BA3E9 /* AirshipDeviceAudienceResult.swift */, 6EBFA9AC2D15DA70002BA3E9 /* HashChecker.swift */, 6ED838AA2D0CE9D6009CBB0C /* CompoundDeviceAudienceSelector.swift */, 60D3BCCD2A15471C00E07524 /* AudienceHashSelector.swift */, 6EC755982A4E115400851ABB /* DeviceAudienceSelector.swift */, 6EC7559A2A4E129000851ABB /* DeviceTagSelector.swift */, 6EC7559E2A4E5AB200851ABB /* DeviceAudienceChecker.swift */, 6E2F5A892A66088100CABD3D /* AudienceDeviceInfoProvider.swift */, ); name = "Audience Checks"; sourceTree = ""; }; 6EC755AD2A4FCD7000851ABB /* Audience Checks */ = { isa = PBXGroup; children = ( 6EBFA9B02D15F491002BA3E9 /* HashCheckerTest.swift */, 6ED838AC2D0CEF4A009CBB0C /* CompoundDeviceAudienceSelectorTest.swift */, 6EC755AE2A4FCD8800851ABB /* AudienceHashSelectorTest.swift */, 6E65244B2A4FD4270019F353 /* DeviceTagSelectorTest.swift */, 6E65244D2A4FD69F0019F353 /* DeviceAudienceSelectorTest.swift */, ); name = "Audience Checks"; sourceTree = ""; }; 6ECDDE7729B804BF009D79DB /* Auth */ = { isa = PBXGroup; children = ( 6ECDDE7329B80462009D79DB /* ChannelAuthTokenProvider.swift */, 6ECDDE7829B804FB009D79DB /* ChannelAuthTokenAPIClient.swift */, ); name = Auth; sourceTree = ""; }; 6EDF1D912B292F8600E23BC4 /* InAppMessage */ = { isa = PBXGroup; children = ( 6E213B1D2BC7054500BF24AE /* InAppActionRunner.swift */, 60FCA3032B4F10D9005C9232 /* Legacy */, 6E986EFA2B44D48C00FBE6A0 /* InAppMessaging.swift */, 6E1528182B4DC3D000DF1377 /* InAppMessageAutomationPreparer.swift */, 6E15281A2B4DC3DF00DF1377 /* InAppMessageAutomationExecutor.swift */, 6E15281F2B4DC59C00DF1377 /* InAppMessageSceneDelegate.swift */, 6EE6AA1F2B4F5246002FEA75 /* InAppMessageSceneManager.swift */, 6E1528212B4DC5C000DF1377 /* InAppMessageDisplayDelegate.swift */, 6E15282D2B4DED4F00DF1377 /* Analytics */, 6EC0CA7F2B4C811A00333A87 /* Display Adapter */, 6E2E3CA02B3271BC00B8515B /* View */, 6E16208B2B31169F009240B2 /* Display Coordinators */, 6EDF1DA52B2A300100E23BC4 /* InAppMessage.swift */, 99E6EF692B8E36BA0006326A /* InAppMessageValidation.swift */, 99E8D7BC2B50AA060099B6F3 /* InAppMessageEnvironment.swift */, 99E8D7BA2B50A7C20099B6F3 /* InAppMessageViewDelegate.swift */, 99198E402B2FB453001F3054 /* Assets */, 6EDF1DA32B2A2C6F00E23BC4 /* InAppMessageColor.swift */, 6EDF1D9D2B2A2A5900E23BC4 /* InAppMessageDisplayContent.swift */, 6EDF1D942B2A25A000E23BC4 /* Info */, ); path = InAppMessage; sourceTree = ""; }; 6EDF1D942B2A25A000E23BC4 /* Info */ = { isa = PBXGroup; children = ( 6EDF1D922B292FB000E23BC4 /* InAppMessageTextInfo.swift */, 6EDF1D952B2A25B400E23BC4 /* InAppMessageButtonInfo.swift */, 6EDF1D972B2A25C800E23BC4 /* InAppMessageMediaInfo.swift */, 6EDF1D9B2B2A287A00E23BC4 /* InAppMessageButtonLayoutType.swift */, ); path = Info; sourceTree = ""; }; 6EDF1DA92B2A6D7B00E23BC4 /* InAppMessage */ = { isa = PBXGroup; children = ( 6E4AEE212B6B2DFD008AEAC1 /* Assets */, 6EE6AAE62B58A9D4002FEA75 /* Analytics */, 6EE6AA172B4F3038002FEA75 /* Display Adapter */, 6E2E3CA32B32725900B8515B /* View */, 6E1620902B3118BF009240B2 /* Display Coordinators */, 6EDF1DAA2B2A6D9900E23BC4 /* InAppMessageTest.swift */, 6EE6AA112B4F3003002FEA75 /* InAppMessageAutomationPreparerTest.swift */, 99E6EF6B2B8E3AF60006326A /* InAppMessageContentValidationTest.swift */, 6EE6AA142B4F302A002FEA75 /* InAppMessageAutomationExecutorTest.swift */, 99E8D79C2B4F9E830099B6F3 /* InAppMessageThemeTest.swift */, 6EB839452BC83B96006611C4 /* DefaultInAppActionRunnerTest.swift */, ); path = InAppMessage; sourceTree = ""; }; 6EE6AA172B4F3038002FEA75 /* Display Adapter */ = { isa = PBXGroup; children = ( 6EE6AA182B4F304B002FEA75 /* DisplayAdapterFactoryTest.swift */, 6EE6AB032B59C21A002FEA75 /* CustomDisplayAdapterWrapperTest.swift */, 6EE6AB052B59C231002FEA75 /* AirshipLayoutDisplayAdapterTest.swift */, 6E1A9BB82B5B20A500A6489B /* InAppMessageDisplayListenerTest.swift */, ); path = "Display Adapter"; sourceTree = ""; }; 6EE6AAE62B58A9D4002FEA75 /* Analytics */ = { isa = PBXGroup; children = ( 6E1CBDFE2BAA1DF200519D9C /* DefaultInAppDisplayImpressionRuleProviderTest.swift */, 6EE6AAE52B58A9D3002FEA75 /* InAppMessageAnalyticsTest.swift */, ); path = Analytics; sourceTree = ""; }; 6EE6AAF92B58A9DD002FEA75 /* Events */ = { isa = PBXGroup; children = ( 6EE6AADE2B58A9D2002FEA75 /* ThomasLayoutButtonTapEventTest.swift */, 6EE6AAD62B58A9D1002FEA75 /* ThomasLayoutDisplayEventTest.swift */, 6EE6AADA2B58A9D1002FEA75 /* ThomasLayoutEventTestUtils.swift */, 6EE6AAE32B58A9D3002FEA75 /* ThomasLayoutFormDisplayEventTest.swift */, 6EE6AAE02B58A9D2002FEA75 /* ThomasLayoutFormResultEventTest.swift */, 6EE6AAE42B58A9D3002FEA75 /* ThomasLayoutGestureEventTest.swift */, 6EE6AADC2B58A9D2002FEA75 /* ThomasLayoutPageActionEventTest.swift */, 6EE6AAD82B58A9D1002FEA75 /* ThomasLayoutPagerCompletedEventTest.swift */, 6EE6AAE12B58A9D2002FEA75 /* ThomasLayoutPagerSummaryEventTest.swift */, 6EE6AAE72B58A9D4002FEA75 /* ThomasLayoutPageSwipeEventAction.swift */, 6EE6AAE22B58A9D2002FEA75 /* ThomasLayoutPageViewEventTest.swift */, 6EE6AAD92B58A9D1002FEA75 /* ThomasLayoutPermissionResultEventTest.swift */, 6EE6AADB2B58A9D1002FEA75 /* ThomasLayoutResolutionEventTest.swift */, ); path = Events; sourceTree = ""; }; 6EED67642CDEE75D0087CDCB /* Types */ = { isa = PBXGroup; children = ( 6E5ADF832D7682D300A03799 /* ThomasStateTrigger.swift */, 6E1A19212D6F87550056418B /* ThomasFormValidationMode.swift */, 6E2FA2882D515189005893E2 /* ThomasEmailRegistrationOptions.swift */, 6EED68E42CE3ECC50087CDCB /* ThomasPropertyOverride.swift */, 6EED679D2CDEEAA90087CDCB /* AirshipLayout.swift */, 6EED681C2CE274290087CDCB /* ThomasAccessibilityAction.swift */, 6EED68202CE2806B0087CDCB /* ThomasAccessibleInfo.swift */, 6EED67BD2CE1B5120087CDCB /* ThomasActionsPayload.swift */, 6EED67FB2CE26DAF0087CDCB /* ThomasAttributeName.swift */, 6EED67FF2CE26DCA0087CDCB /* ThomasAttributeValue.swift */, 6EED68182CE272710087CDCB /* ThomasAutomatedAccessibilityAction.swift */, 6EED68072CE26E510087CDCB /* ThomasAutomatedAction.swift */, 6EED67A52CE1A4FC0087CDCB /* ThomasBorder.swift */, 6EED67E62CE268BB0087CDCB /* ThomasButtonClickBehavior.swift */, 6EED67E22CE268630087CDCB /* ThomasButtonTapEffect.swift */, 6EED67A12CE1A4780087CDCB /* ThomasColor.swift */, 6EED67F32CE26CB50087CDCB /* ThomasConstrainedSize.swift */, 6EED67EA2CE269930087CDCB /* ThomasDirection.swift */, 6EED68102CE271E10087CDCB /* ThomasEnableBehavior.swift */, 6EED67B12CE1A8300087CDCB /* ThomasEventHandler.swift */, 6EED68142CE271F30087CDCB /* ThomasFormSubmitBehavior.swift */, 6EED67C12CE1B5850087CDCB /* ThomasIcon.swift */, 6EED68032CE26E180087CDCB /* ThomasMargin.swift */, 6EED67A92CE1A5CC0087CDCB /* ThomasMarkdownOptions.swift */, 6EED67C52CE1B5FF0087CDCB /* ThomasMediaFit.swift */, 6EED67702CDEE8370087CDCB /* ThomasOrientation.swift */, 6EED67B92CE1B4E60087CDCB /* ThomasPlatform.swift */, 6EED67AD2CE1A6B70087CDCB /* ThomasPosition.swift */, 6EED676C2CDEE7E50087CDCB /* ThomasPresentationInfo.swift */, 6EED677C2CDEE8FE0087CDCB /* ThomasSerializable.swift */, 6EED67782CDEE8790087CDCB /* ThomasShadow.swift */, 6EED67952CDEEA2B0087CDCB /* ThomasShapeInfo.swift */, 6EED67EF2CE26CA10087CDCB /* ThomasSize.swift */, 6EED67F72CE26CE20087CDCB /* ThomasSizeConstraint.swift */, 6EED680C2CE2707E0087CDCB /* ThomasStateAction.swift */, 6EED67B52CE1B43C0087CDCB /* ThomasTextAppearance.swift */, 6EED67992CDEEA380087CDCB /* ThomasToggleStyleInfo.swift */, 6EED682C2CE28CBF0087CDCB /* ThomasValidationInfo.swift */, 6EED67632CDEE75D0087CDCB /* ThomasViewInfo.swift */, 6EED68282CE28C960087CDCB /* ThomasVisibilityInfo.swift */, 6EED67742CDEE8460087CDCB /* ThomasWindowSize.swift */, 60CE9BDD2D0B6A0900A8B625 /* ThomasPagerControllerBranching.swift */, 602AD0D42D7242B300C7D566 /* ThomasSmsLocale.swift */, ); name = Types; sourceTree = ""; }; 6EF1933828380086005F192A /* Logger */ = { isa = PBXGroup; children = ( 6E524CC22C180A39002CA094 /* AirshipLogPrivacyLevel.swift */, 6E87BDFE26E283840005D20D /* LogLevel.swift */, 6E698DEA26790AC300654DB2 /* AirshipLogger.swift */, 6EF1933B2838062B005F192A /* AirshipLogHandler.swift */, 6EF1933D28380644005F192A /* DefaultLogHandler.swift */, ); name = Logger; sourceTree = ""; }; 6EF27DD627306C6900548DA3 /* Forms */ = { isa = PBXGroup; children = ( 6E0105002DDFA5E6009D651F /* ScoreController.swift */, 6E0105022DDFA719009D651F /* ScoreToggleLayout.swift */, 3243EC612D93109C00B43B25 /* AirshipCheckboxToggleStyle.swift */, 3243EC622D93109C00B43B25 /* AirshipSwitchToggleStyle.swift */, 6EFD6D7027290C16005B26F1 /* TextInput.swift */, 6E887CD2272C5F5000E83363 /* Checkbox.swift */, 6E887CD4272C5F5A00E83363 /* CheckboxController.swift */, 6EF27DD827306C9100548DA3 /* AirshipToggle.swift */, 6EFD6D6D27290C0B005B26F1 /* FormController.swift */, 6EF27DE22730E6F900548DA3 /* RadioInputController.swift */, 6EF27DE82730E85700548DA3 /* RadioInput.swift */, A6849386273290520021675E /* Score.swift */, 32FD4C772D8079910056D141 /* BasicToggleLayout.swift */, 6ECD4F6C2DD7A7060060EE72 /* RadioInputToggleLayout.swift */, 6ECD4F6E2DD7A7090060EE72 /* CheckboxToggleLayout.swift */, 6ECD4F702DD7A7C90060EE72 /* ToggleLayout.swift */, 609843552D6F518900690371 /* SmsLocalePicker.swift */, ); name = Forms; sourceTree = ""; }; 6EFD6D732729D84D005B26F1 /* Environment */ = { isa = PBXGroup; children = ( 6E55A4D62E1DB4CB00B07DF8 /* ThomasAssociatedLabelResolver.swift */, 6E5215212DCEA10F00CF64B9 /* ThomasViewedPageInfo.swift */, 6E5214662DCAB03600CF64B9 /* ThomasFormResult.swift */, 6EBD12042DA73FD300F678AB /* ValidatableHelper.swift */, 6E97D6B02D84B1890001CF7F /* ThomasFormDataCollector.swift */, 6EC9214D2D82144A000A3A59 /* ThomasFormField.swift */, 6EC922E22D838DFA000A3A59 /* ThomasFormPayloadGenerator.swift */, 6EC922E02D832BAF000A3A59 /* ThomasFormFieldProcessor.swift */, 6E1A15052D6EA3A50056418B /* ThomasFormState.swift */, 6E1A1D842D70F36D0056418B /* ThomasState.swift */, 6E46A27B272B63680089CDE3 /* ThomasEnvironment.swift */, 6EFD6D81272A53AE005B26F1 /* PagerState.swift */, A1B2C3D4E5F60001VIDEOST /* VideoState.swift */, A1B2C3D4E5F60003VIDEOCR /* VideoController.swift */, 6E887CD0272C5E8400E83363 /* CheckboxState.swift */, 6EF27DE52730E77300548DA3 /* RadioInputState.swift */, 6E0105042DDFA735009D651F /* ScoreState.swift */, 6ED80792273CA0C800D1F455 /* EnvironmentValues.swift */, 6E92EC8F284954B10038802D /* ButtonState.swift */, 6E52146C2DCBFAB900CF64B9 /* ThomasPagerTracker.swift */, 6E5214682DCABFCA00CF64B9 /* ThomasLayoutContext.swift */, 27CCF8D22F2382750018058F /* ThomasStateStorage.swift */, ); name = Environment; sourceTree = ""; }; 847B0011267CD925007CD249 /* Source */ = { isa = PBXGroup; children = ( 6EB5156F28A42F9C00870C5A /* theme */, 6E2486FA2899BB0E00657CE4 /* view */, 6E6C3F9227A47D78007F55C7 /* data */, 6EB5156D28A42B5800870C5A /* AirshipPreferenceCenterResources.swift */, 847B0012267CE558007CD249 /* PreferenceCenterSDKModule.swift */, 84483A67267CF0C000D0DA7D /* PreferenceCenter.swift */, 6E6802912B86732200F4591F /* PreferenceCenterComponent.swift */, ); path = Source; sourceTree = ""; }; 847BFFF5267CD73A007CD249 /* AirshipPreferenceCenter */ = { isa = PBXGroup; children = ( 6E1892C5268D159900417887 /* Tests */, 847B0011267CD925007CD249 /* Source */, 847BFFF7267CD73A007CD249 /* Info.plist */, ); path = AirshipPreferenceCenter; sourceTree = ""; }; 9908E6102B01841A00DB3E2E /* Custom */ = { isa = PBXGroup; children = ( 9908E60D2B000DBA00DB3E2E /* CustomView.swift */, 9908E6112B0189F800DB3E2E /* ArishipCustomViewManager.swift */, 27264FB22E81B064000B6FA3 /* AirshipSceneController.swift */, ); name = Custom; sourceTree = ""; }; 99198E402B2FB453001F3054 /* Assets */ = { isa = PBXGroup; children = ( 99CF46172B3217C300B6FD9B /* AirshipCachedAssets.swift */, 99CF46192B3217DE00B6FD9B /* AssetCacheManager.swift */, 998572BE2B3CF95D0091E9C9 /* DefaultAssetDownloader.swift */, 998572C02B3CF97B0091E9C9 /* DefaultAssetFileManager.swift */, ); path = Assets; sourceTree = ""; }; 99560C292BB3848A00F28BDC /* Component Views */ = { isa = PBXGroup; children = ( 99560C2C2BB3855800F28BDC /* EmptySectionLabel.swift */, 99560C362BB38A5F00F28BDC /* ErrorLabel.swift */, 99560C1D2BAE2FFA00F28BDC /* ChannelTextField.swift */, 99104DF22BA6689A0040C0FD /* PreferenceCloseButton.swift */, 99560C2A2BB384A700F28BDC /* BackgroundShape.swift */, ); path = "Component Views"; sourceTree = ""; }; 99E8D7CC2B54A6470099B6F3 /* Theme */ = { isa = PBXGroup; children = ( 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 = Theme; sourceTree = ""; }; A61517AD26A97419008A41C4 /* Subscription Lists */ = { isa = PBXGroup; children = ( A61517C026B009D6008A41C4 /* SubscriptionListAPIClient.swift */, A61517B126A9C4C3008A41C4 /* SubscriptionListEditor.swift */, A61517B426AEEAAB008A41C4 /* SubscriptionListUpdate.swift */, A67EC24A279B1C34009089E1 /* ScopedSubscriptionListUpdate.swift */, A67EC248279B1A40009089E1 /* ScopedSubscriptionListEditor.swift */, 6EB5158228A47C7100870C5A /* ScopedSubscriptionListEdit.swift */, 6EB5158028A47BD700870C5A /* SubscriptionListEdit.swift */, 6E6C3F7E27A20C3C007F55C7 /* ChannelScope.swift */, ); name = "Subscription Lists"; sourceTree = ""; }; A61F3A722A5D9D2400EE94CC /* Tests */ = { isa = PBXGroup; children = ( 6ED7BE642D13DA0400B6A124 /* FeatureFlagResultCacheTest.swift */, A61F3A732A5D9D6800EE94CC /* FeatureFlagManagerTest.swift */, 6E8BDEFC2A67937E00F816D9 /* FeatureFlagInfoTest.swift */, 6E8BDEFF2A679CD100F816D9 /* FeatureFlagRemoteDataAccessTest.swift */, 6E938DBB2AC39A0500F691D9 /* FeatureFlagAnalyticsTest.swift */, 6E7EACD22AF4220E00DA286B /* FeatureFlagDeferredResolverTest.swift */, 6E28116B2BE40E860040D928 /* FeatureFlagVariablesTest.swift */, ); path = Tests; sourceTree = ""; }; A620586A2A5841330041FBF9 /* AirshipFeatureFlags */ = { isa = PBXGroup; children = ( A61F3A722A5D9D2400EE94CC /* Tests */, A620587F2A5841FA0041FBF9 /* Source */, ); path = AirshipFeatureFlags; sourceTree = ""; }; A620587F2A5841FA0041FBF9 /* Source */ = { isa = PBXGroup; children = ( 6EB214D12E7DBA5E001A5660 /* FeatureFlagManagerProtocol.swift */, 6ED7BE622D13D9FE00B6A124 /* FeatureFlagResultCache.swift */, A62058802A5842200041FBF9 /* AirshipFeatureFlagsSDKModule.swift */, 6E2F5A732A60833700CABD3D /* FeatureFlagManager.swift */, 6E2F5A752A60871E00CABD3D /* FeatureFlagPayload.swift */, 6E2F5AB02A67434B00CABD3D /* FeatureFlag.swift */, 6E2F5AB92A675D3600CABD3D /* FeatureFlagsRemoteDataAccess.swift */, 6E6BD2752AF1C1D800B9DFC9 /* DeferredFlagResolver.swift */, 6E4325F52B7B2F5800A9B000 /* FeatureFlagAnalytics.swift */, 6E6802932B8673F900F4591F /* FeatureFlagComponent.swift */, A6E9AD972D4D12C60091BBAF /* FeatureFlagUpdateStatus.swift */, ); path = Source; sourceTree = ""; }; A641E1482BDBBDB400DE6FAA /* AirshipObjectiveC */ = { isa = PBXGroup; children = ( 6E128BA02D305F2E00733024 /* Source */, ); path = AirshipObjectiveC; sourceTree = ""; }; A6CDD8CE269491850040A673 /* Contacts */ = { isa = PBXGroup; children = ( 9971A8842C125C0200092ED1 /* ContactChannelsProviderTest.swift */, A6CDD8CF269491BE0040A673 /* ContactAPIClientTest.swift */, 6EC7E46D269E2A4C0038CFDD /* AttributeEditorTest.swift */, 6EC7E46F269E33290038CFDD /* TagGroupsEditorTest.swift */, 6EC7E471269E51030038CFDD /* AttributeUpdateTest.swift */, 6EC7E473269E52600038CFDD /* ContactOperationTest.swift */, 6EC7E47526A5EE910038CFDD /* AudienceUtilsTest.swift */, 6EC7E47726A604080038CFDD /* AirshipContactTest.swift */, 6E6363E529DCE9A2009C358A /* ContactSubscriptionListAPIClientTest.swift */, 6E6363E729DCEB84009C358A /* ContactManagerTest.swift */, ); name = Contacts; sourceTree = ""; }; CC04F2001DCAB28300B4842D /* TestUtils */ = { isa = PBXGroup; children = ( 6ED7BE5F2D13D9E300B6A124 /* TestCache.swift */, 6E9C2BCF2D023216000089A9 /* RuntimeConfig.swift */, 6E4A467F28EF4FAF00A25617 /* TestAirshipRequestSession.swift */, 6E6ED1422683B8FA00A2CBD0 /* TestDate.swift */, 3299EF212949EC3E00251E70 /* AirshipBaseTest.swift */, C088383526E0244C00D40838 /* TestURLAllowList.swift */, 6E698E61267C03C700654DB2 /* TestAppStateTracker.swift */, 6E6ED1442683BC7F00A2CBD0 /* TestDispatcher.swift */, 6E6ED171268448EC00A2CBD0 /* TestNetworkMonitor.swift */, 6EC7E48426A60CDF0038CFDD /* TestChannel.swift */, 6EC7E48626A60DD60038CFDD /* TestContactAPIClient.swift */, 6E2D6AF526B0C6CA00B7C226 /* TestSubscriptionListAPIClient.swift */, 6E739D7E26BAFCB800BC6F6D /* TestChannelBulkUpdateAPIClient.swift */, 6E664BE426C5817B00A2C8E5 /* TestContact.swift */, 6ED735E126CAE2D7003B0A7D /* TestChannelRegistrar.swift */, 6ED735E326CAE8AA003B0A7D /* TestChannelAudienceManager.swift */, 6ED735E526CAEABC003B0A7D /* TestLocaleManager.swift */, 6E15B70426CE07180099C92D /* TestRemoteData.swift */, 6E15B72F26CF4F6B0099C92D /* TestRemoteDataAPIClient.swift */, 6E87BE1526E29BC90005D20D /* TestAirshipInstance.swift */, 6E87BE1726E2C5940005D20D /* TestAnalytics.swift */, A63A567528F449D8004B8951 /* TestWorkManager.swift */, A63A567728F457FE004B8951 /* TestWorkRateLimiterActor.swift */, 6E6363E929DCECA1009C358A /* TestContactSubscriptionListAPIClient.swift */, 6EE49C252A1446B100AB1CF4 /* RemoteDataTestUtils.swift */, 6ECB62812A36A45F0095C85C /* TestURLOpener.swift */, 6EF1401E2A268CE6009A125D /* TestKeychainAccess.swift */, 6E2F5AB62A675ADC00CABD3D /* TestAudienceChecker.swift */, 6EC0CA6E2B4B893500333A87 /* TestDeferredResolver.swift */, 6EC0CA712B4B897B00333A87 /* TestExperimentDataProvider.swift */, 6E4325C42B7AC3F700A9B000 /* TestPush.swift */, ); name = TestUtils; sourceTree = ""; }; CC04F2061DCAB4C000B4842D /* Push */ = { isa = PBXGroup; children = ( CC04F20B1DCAB52900B4842D /* Interactive */, 6E8CE761284137D600CF4B11 /* AirshipPushTest.swift */, ); name = Push; sourceTree = ""; }; CC04F2091DCAB4FD00B4842D /* Channel */ = { isa = PBXGroup; children = ( 6E1767F429B923D100D65F60 /* ChannelAuthTokenAPIClientTest.swift */, 6E1767F329B923D100D65F60 /* ChannelAuthTokenProviderTest.swift */, 6E1767F529B923D100D65F60 /* TestChannelAuthTokenAPIClient.swift */, 6E7DB38128ECDB4C002725F6 /* LiveActivities */, 6E8B4BEF2888606400AA336E /* ChannelTest.swift */, 6E2D6AF326B0C3C500B7C226 /* ChannelAudienceManagerTest.swift */, 6E739D8126BB33A200BC6F6D /* ChannelBulkUpdateAPIClientTest.swift */, 6ED735DF26C74321003B0A7D /* TagEditorTest.swift */, 6EAC295927580063006DFA63 /* ChannelRegistrarTest.swift */, 6EFAFB77295525C3008AD187 /* ChannelAPIClientTest.swift */, 6EFAFB79295525CD008AD187 /* ChannelCaptureTest.swift */, 6EFAFB7B295525DF008AD187 /* ChannelRegistrationPayloadTest.swift */, ); name = Channel; sourceTree = ""; }; CC04F20B1DCAB52900B4842D /* Interactive */ = { isa = PBXGroup; children = ( 60A5CC072B28DC500017EDB2 /* NotificationCategoriesTest.swift */, ); name = Interactive; sourceTree = ""; }; CC04F20E1DCAB5C600B4842D /* ActionsFramework */ = { isa = PBXGroup; children = ( CC04F20F1DCAB5D800B4842D /* Actions */, 6E92ECA0284A79AB0038802D /* PromptPermissionActionTest.swift */, 6E92ECA2284A7A2A0038802D /* TestPermissionPrompter.swift */, 6E92ECA6284AC1120038802D /* EnableFeatureActionTest.swift */, 6E9B4877288F360C00C905B1 /* RateAppActionTest.swift */, 32F293D4295AFD94004A7D9C /* ActionArgumentsTest.swift */, 325D53D9295C7979003421B4 /* ActionRegistryTest.swift */, ); name = ActionsFramework; sourceTree = ""; }; CC04F20F1DCAB5D800B4842D /* Actions */ = { isa = PBXGroup; children = ( A6AF8D2C27E8D4910068C7EE /* SubscriptionListActionTest.swift */, 6EFAFB8129555174008AD187 /* FetchDeviceInfoActionTest.swift */, 6EFAFB8329561F23008AD187 /* ModifyAttributesActionTest.swift */, 6EFAFB8929562474008AD187 /* AddTagsActionTest.swift */, 6EFAFB8B29562866008AD187 /* RemoveTagsActionTest.swift */, A629F7D9295B514C00671647 /* PasteboardActionTest.swift */, A62C3353299FD509004DB0DA /* ShareActionTest.swift */, 6ECB627B2A369F5B0095C85C /* OpenExternalURLActionTest.swift */, 6ECB62832A36A7510095C85C /* DeepLinkActionTest.swift */, 27AFE7102E73477200767044 /* ModifyTagsActionTest.swift */, ); name = Actions; sourceTree = ""; }; CC04F2151DCAB73000B4842D /* Analytics */ = { isa = PBXGroup; children = ( 6E5B1A012AFF08F00019CA61 /* Session */, 32E339E22A334A2000CD3BE5 /* AddCustomEventActionTest.swift */, CC04F2171DCAB75E00B4842D /* Events */, 6E92ECAB284EA7DA0038802D /* AnalyticsTest.swift */, 6E96ED0D29416E820053CC91 /* EventManagerTest.swift */, 6E96ED0F29416E8F0053CC91 /* EventAPIClientTest.swift */, 6E96ED1129416E990053CC91 /* EventSchedulerTest.swift */, 6E96ED1329417A600053CC91 /* EventStoreTest.swift */, 6E96ED192941A0EC0053CC91 /* EventTestUtils.swift */, 6E4326042B7C361F00A9B000 /* AssociatedIdentifiersTest.swift */, 6E1802F82C5C2DEC00198D0D /* AirshipAnalyticFeedTest.swift */, ); name = Analytics; sourceTree = ""; }; CC04F2171DCAB75E00B4842D /* Events */ = { isa = PBXGroup; children = ( 6E6EF9E6270625C400D30C35 /* AirshipEventsTest.swift */, CC04F2181DCAB78500B4842D /* Custom Events */, 60A5CC0B2B29AE890017EDB2 /* ProximityRegionTest.swift */, 60A5CC0D2B29B1B80017EDB2 /* CircularRegionTest.swift */, 60A5CC0F2B29B4100017EDB2 /* RegionEventTest.swift */, ); name = Events; sourceTree = ""; }; CC04F2181DCAB78500B4842D /* Custom Events */ = { isa = PBXGroup; children = ( 6018AF562B29C20A008E528B /* SearchEventTemplateTest.swift */, 6068E0052B2A190300349E82 /* CustomEventTest.swift */, 6068E0072B2A2A6700349E82 /* AccountEventTemplateTest.swift */, 6068E0312B2B785A00349E82 /* MediaEventTemplateTest.swift */, 6068E0332B2B7CA100349E82 /* RetailEventTemplateTest.swift */, ); name = "Custom Events"; sourceTree = ""; }; CC04F2191DCAB7BE00B4842D /* Utils */ = { isa = PBXGroup; children = ( 6E146EDC2F52536C00320A36 /* AishipFontTests.swift */, 6E07B5F72D925ED30087EC47 /* TestPrivacyManager.swift */, 6E6C3F8C27A26992007F55C7 /* CachedValueTest.swift */, 6E92ECB3284ED6F10038802D /* CachedListTest.swift */, 6E0B875F294CE0BF0064B7BD /* FarmHashFingerprint64Test.swift */, 6E1767F929B92F1700D65F60 /* AirshipUtilsTest.swift */, 6EF553E22B7EE40B00901A22 /* AirshipLocalizationUtilsTest.swift */, 6ED2F5242B7EE648000AFC80 /* AirshipBase64Test.swift */, 6ED2F5262B7EE82B000AFC80 /* AirshipJSONUtilsTest.swift */, 6ED2F5282B7FC59F000AFC80 /* AirshipColorTests.swift */, 6ED2F52A2B7FC5C8000AFC80 /* AirshipIvyVersionMatcherTest.swift */, 6ED2F5342B7FFCD7000AFC80 /* AirshipDateFormatterTest.swift */, 6EB8394D2BC8B1F4006611C4 /* AirshipAsyncChannelTest.swift */, 6032695A2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift */, 6E10A1472C2B825200ED9556 /* DefaultTaskSleeperTest.swift */, 6EE6AAD42B58A977002FEA75 /* TestThomasLayoutEvent.swift */, ); name = Utils; sourceTree = ""; }; CC04F21B1DCAB82200B4842D /* NativeBridge */ = { isa = PBXGroup; children = ( 6ECB62852A36C1EE0095C85C /* NativeBridgeActionHandlerTest.swift */, 6ED2F52C2B7FD403000AFC80 /* JavaScriptCommandTest.swift */, ); name = NativeBridge; sourceTree = ""; }; CC64F0551D8B77E3009CEF27 /* Tests */ = { isa = PBXGroup; children = ( 6E66DDA42E95A67700D44555 /* WorkManager */, 6E77CE482D8A2B9E0057A52C /* Input Validation */, 6E4325D12B7AD94800A9B000 /* Airship */, 6E4325C12B7A9D6E00A9B000 /* Privacy Manager */, 6E6BD2562AEC596D00B9DFC9 /* Deferred */, 6058771B2AC73C550021628E /* MeteredUsage */, 6EC755AD2A4FCD7000851ABB /* Audience Checks */, 6079511E2A1CD1880086578F /* Experiments */, 6E7DB38C28ECFCDB002725F6 /* JSON */, 6E49D7CB284028A900C7BB9D /* PermissionsManager */, 6E1C9C38271E90D1009EF9EF /* Thomas */, 6E64C87F27331ABA000EB887 /* PreferenceDataStoreTest.swift */, 6E87BD9B26DD78B40005D20D /* Integration */, 6E2D6AF026B0B62900B7C226 /* Subscription Lists */, A6CDD8CE269491850040A673 /* Contacts */, 1B05132624AE100C00F5051F /* Locale */, 3C927F8223A42609003C5FC8 /* App State */, 6E90F0ED228F550900E1FCB0 /* Config */, DFB5F12F1FC4EDB70085F784 /* RemoteConfig */, DF17A10E1F57614800DC39E0 /* RemoteData */, CC64F1421D8B7954009CEF27 /* Support */, CC04F2001DCAB28300B4842D /* TestUtils */, CC944EDA1DB6AEAF00C42269 /* HTTP */, CC04F2091DCAB4FD00B4842D /* Channel */, CC04F2061DCAB4C000B4842D /* Push */, CC04F20E1DCAB5C600B4842D /* ActionsFramework */, CC04F2151DCAB73000B4842D /* Analytics */, CC04F2191DCAB7BE00B4842D /* Utils */, CC04F21B1DCAB82200B4842D /* NativeBridge */, CC64F0581D8B77E3009CEF27 /* Info.plist */, 6EF140202A269074009A125D /* AirshipDeviceIDTest.swift */, 6E7EACD02AF4192400DA286B /* AirshipCacheTest.swift */, 6ED2F52E2B7FD49B000AFC80 /* AirshipURLAllowListTest.swift */, ); path = Tests; sourceTree = ""; }; CC64F1421D8B7954009CEF27 /* Support */ = { isa = PBXGroup; children = ( 6014AD742C20410A0072DCF0 /* airship.der */, CC64F0611D8B781C009CEF27 /* CustomNotificationCategories.plist */, CC64F0621D8B781C009CEF27 /* Info.plist */, CC64F1451D8B7954009CEF27 /* AirshipConfig-Valid-Legacy.plist */, CC64F1471D8B7954009CEF27 /* AirshipConfig-Valid.plist */, 45A8ADD123133E51004AD8CA /* testMCColorsCatalog.xcassets */, CC64F1491D8B7954009CEF27 /* development-embedded.mobileprovision */, CC64F14A1D8B7954009CEF27 /* production-embedded.mobileprovision */, ); path = Support; sourceTree = ""; }; CC89997D1D8B642D00A0CECC /* Resources */ = { isa = PBXGroup; children = ( 6E411CA72538C6A500FEE4E8 /* UAEvents.xcdatamodeld */, 6E411CA42538C6A500FEE4E8 /* UARemoteData.xcdatamodeld */, 329DFCCA2B7E4DA10039C8C0 /* UARemoteDataMappingV3toV4.xcmappingmodel */, 6E1F6E832BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel */, 6E1F6E872BE683E600CFC7A7 /* UARemoteDataMappingV1toV4.xcmappingmodel */, 6E411C742538C60900FEE4E8 /* UrbanAirship.strings */, 6E411B6C2538C4E500FEE4E8 /* UANativeBridge */, 6E411B6D2538C4E600FEE4E8 /* UANotificationCategories.plist */, 6EFE7E3E2A97ED600064AC31 /* PrivacyInfo.xcprivacy */, 6E5A64D72AABC5A400574085 /* UAMeteredUsage.xcdatamodeld */, 6E6BD2702AF1B05500B9DFC9 /* UAirshipCache.xcdatamodeld */, ); path = Resources; sourceTree = ""; }; CC944EDA1DB6AEAF00C42269 /* HTTP */ = { isa = PBXGroup; children = ( 6E299FD428D13D00001305A7 /* DefaultAirshipRequestSessionTest.swift */, 6014AD6A2C2032360072DCF0 /* ChallengeResolverTest.swift */, ); name = HTTP; sourceTree = ""; }; DF17A10E1F57614800DC39E0 /* RemoteData */ = { isa = PBXGroup; children = ( 32C68D0429424449006BBB29 /* RemoteDataTest.swift */, 3299EF162948CBC100251E70 /* RemoteDataAPIClientTest.swift */, 3299EF25294B222F00251E70 /* RemoteDataStoreTest.swift */, 6EFB7B322A14A0EC00133115 /* RemoteDataProviderTest.swift */, 6E4007132A153AB20013C2DE /* AppRemoteDataProviderDelegateTest.swift */, 6E4007152A153ABE0013C2DE /* ContactRemoteDataProviderTest.swift */, 6E4007172A153AFE0013C2DE /* RemoteDataURLFactoryTest.swift */, ); name = RemoteData; sourceTree = ""; }; DFB5F12F1FC4EDB70085F784 /* RemoteConfig */ = { isa = PBXGroup; children = ( 6E15B70226CDE40E0099C92D /* RemoteConfigManagerTest.swift */, 6E032A4F2B210E6000404630 /* RemoteConfigTest.swift */, ); name = RemoteConfig; sourceTree = ""; }; E3E85E514DBE69D4C8BF51CE /* Frameworks */ = { isa = PBXGroup; children = ( A67229BD28199D6A0033F54D /* Network.framework */, A67229BB28199D590033F54D /* libsqlite3.tbd */, A67229B928199D430033F54D /* libz.tbd */, 6EB4E4A32549F95200E3FFD0 /* Network.framework */, 6EB4E4BE2549F9B900E3FFD0 /* Network.framework */, 3261A7F4243CD73100ADBF6B /* CoreTelephony.framework */, ); name = Frameworks; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ 3CA0E2A2237CCE2600EE76CF /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 3CA0E3A1237E4A7B00EE76CF /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 494DD9541B0EB677009C134E /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 6E0B8725294A9C120064B7BD /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 6E43202526EA814F009228AB /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 847BFFEF267CD739007CD249 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; A62058642A5841330041FBF9 /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; A641E1422BDBBDB400DE6FAA /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ 3CA0E298237CCE2600EE76CF /* AirshipDebug */ = { isa = PBXNativeTarget; buildConfigurationList = 3CA0E2A9237CCE2600EE76CF /* Build configuration list for PBXNativeTarget "AirshipDebug" */; buildPhases = ( 3CA0E2A2237CCE2600EE76CF /* Headers */, 3CA0E29F237CCE2600EE76CF /* Frameworks */, 3CA0E29C237CCE2600EE76CF /* Sources */, 3CA0E2A8237CCE2600EE76CF /* Resources */, ); buildRules = ( ); dependencies = ( 6E4AEE0B2B6B24D1008AEAC1 /* PBXTargetDependency */, 6E29474A2AD47E0C009EC6DD /* PBXTargetDependency */, 6E2F5A922A67314A00CABD3D /* PBXTargetDependency */, 3CA0E2DA237CD59100EE76CF /* PBXTargetDependency */, 3C39D3082384C8B6003C50D4 /* PBXTargetDependency */, 6E2F5A962A67316C00CABD3D /* PBXTargetDependency */, ); name = AirshipDebug; productName = AirshipDebug; productReference = 3CA0E2AC237CCE2600EE76CF /* AirshipDebug.framework */; productType = "com.apple.product-type.framework"; }; 3CA0E346237E4A7B00EE76CF /* AirshipMessageCenter */ = { isa = PBXNativeTarget; buildConfigurationList = 3CA0E420237E4A7B00EE76CF /* Build configuration list for PBXNativeTarget "AirshipMessageCenter" */; buildPhases = ( 3CA0E3A0237E4A7B00EE76CF /* Frameworks */, 3CA0E3A1237E4A7B00EE76CF /* Headers */, 3CA0E34A237E4A7B00EE76CF /* Sources */, 3CA0E417237E4A7B00EE76CF /* Resources */, ); buildRules = ( ); dependencies = ( 3CA0E347237E4A7B00EE76CF /* PBXTargetDependency */, ); name = AirshipMessageCenter; productName = AirshipCore; productReference = 3CA0E423237E4A7B00EE76CF /* AirshipMessageCenter.framework */; productType = "com.apple.product-type.framework"; }; 494DD9561B0EB677009C134E /* AirshipCore */ = { isa = PBXNativeTarget; buildConfigurationList = 494DD96D1B0EB677009C134E /* Build configuration list for PBXNativeTarget "AirshipCore" */; buildPhases = ( 494DD9541B0EB677009C134E /* Headers */, 494DD9531B0EB677009C134E /* Frameworks */, 494DD9521B0EB677009C134E /* Sources */, 494DD9551B0EB677009C134E /* Resources */, ); buildRules = ( ); dependencies = ( 6E43218B26EA891F009228AB /* PBXTargetDependency */, ); name = AirshipCore; productName = AirshipCore; productReference = 494DD9571B0EB677009C134E /* AirshipCore.framework */; productType = "com.apple.product-type.framework"; }; 6E0B8729294A9C120064B7BD /* AirshipAutomation */ = { isa = PBXNativeTarget; buildConfigurationList = 6E0B8739294A9C130064B7BD /* Build configuration list for PBXNativeTarget "AirshipAutomation" */; buildPhases = ( 6E0B8725294A9C120064B7BD /* Headers */, 6E0B8726294A9C120064B7BD /* Sources */, 6E0B8727294A9C120064B7BD /* Frameworks */, 6E0B8728294A9C120064B7BD /* Resources */, ); buildRules = ( ); dependencies = ( 6E0B8743294A9C780064B7BD /* PBXTargetDependency */, ); name = AirshipAutomation; productName = AirshipAutomationSwift; productReference = 6E0B872A294A9C120064B7BD /* AirshipAutomation.framework */; productType = "com.apple.product-type.framework"; }; 6E0B8730294A9C130064B7BD /* AirshipAutomationTests */ = { isa = PBXNativeTarget; buildConfigurationList = 6E0B873C294A9C130064B7BD /* Build configuration list for PBXNativeTarget "AirshipAutomationTests" */; buildPhases = ( 6E0B872D294A9C130064B7BD /* Sources */, 6E0B872E294A9C130064B7BD /* Frameworks */, 6E0B872F294A9C130064B7BD /* Resources */, ); buildRules = ( ); dependencies = ( 6E107F042B30B887007AFC4D /* PBXTargetDependency */, 6E0B8734294A9C130064B7BD /* PBXTargetDependency */, ); name = AirshipAutomationTests; productName = AirshipAutomationSwiftTests; productReference = 6E0B8731294A9C130064B7BD /* AirshipAutomationTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 6E431F6B26EA814F009228AB /* AirshipBasement */ = { isa = PBXNativeTarget; buildConfigurationList = 6E43204526EA814F009228AB /* Build configuration list for PBXNativeTarget "AirshipBasement" */; buildPhases = ( 6E431F6D26EA814F009228AB /* Sources */, 6E43202026EA814F009228AB /* Frameworks */, 6E43202526EA814F009228AB /* Headers */, 6E43203F26EA814F009228AB /* Resources */, ); buildRules = ( ); dependencies = ( ); name = AirshipBasement; productName = AirshipCore; productReference = 6E43204826EA814F009228AB /* AirshipBasement.framework */; productType = "com.apple.product-type.framework"; }; 6E4A466D28EF44F600A25617 /* AirshipMessageCenterTests */ = { isa = PBXNativeTarget; buildConfigurationList = 6E4A467528EF44F600A25617 /* Build configuration list for PBXNativeTarget "AirshipMessageCenterTests" */; buildPhases = ( 6E4A466A28EF44F600A25617 /* Sources */, 6E4A466B28EF44F600A25617 /* Frameworks */, 6E4A466C28EF44F600A25617 /* Resources */, ); buildRules = ( ); dependencies = ( 6E4A467428EF44F600A25617 /* PBXTargetDependency */, ); name = AirshipMessageCenterTests; productName = AirshipMessageCenterTests; productReference = 6E4A466E28EF44F600A25617 /* AirshipMessageCenterTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; 847BFFF3267CD739007CD249 /* AirshipPreferenceCenter */ = { isa = PBXNativeTarget; buildConfigurationList = 847B0005267CD73A007CD249 /* Build configuration list for PBXNativeTarget "AirshipPreferenceCenter" */; buildPhases = ( 847BFFEF267CD739007CD249 /* Headers */, 847BFFF0267CD739007CD249 /* Sources */, 847BFFF1267CD739007CD249 /* Frameworks */, 847BFFF2267CD739007CD249 /* Resources */, ); buildRules = ( ); dependencies = ( 847B000E267CD85E007CD249 /* PBXTargetDependency */, ); name = AirshipPreferenceCenter; productName = AirshipPreferenceCenter; productReference = 847BFFF4267CD739007CD249 /* AirshipPreferenceCenter.framework */; productType = "com.apple.product-type.framework"; }; 847BFFFB267CD73A007CD249 /* AirshipPreferenceCenterTests */ = { isa = PBXNativeTarget; buildConfigurationList = 847B0008267CD73A007CD249 /* Build configuration list for PBXNativeTarget "AirshipPreferenceCenterTests" */; buildPhases = ( 847BFFF8267CD73A007CD249 /* Sources */, 847BFFF9267CD73A007CD249 /* Frameworks */, 847BFFFA267CD73A007CD249 /* Resources */, ); buildRules = ( ); dependencies = ( 847BFFFF267CD73A007CD249 /* PBXTargetDependency */, ); name = AirshipPreferenceCenterTests; productName = AirshipPreferenceCenterTests; productReference = 847BFFFC267CD73A007CD249 /* AirshipPreferenceCenterTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; A62058682A5841330041FBF9 /* AirshipFeatureFlags */ = { isa = PBXNativeTarget; buildConfigurationList = A620587C2A5841340041FBF9 /* Build configuration list for PBXNativeTarget "AirshipFeatureFlags" */; buildPhases = ( A62058642A5841330041FBF9 /* Headers */, A62058652A5841330041FBF9 /* Sources */, A62058662A5841330041FBF9 /* Frameworks */, A62058672A5841330041FBF9 /* Resources */, ); buildRules = ( ); dependencies = ( A61F3A772A5DBA0E00EE94CC /* PBXTargetDependency */, A61F3A7B2A5DBA1800EE94CC /* PBXTargetDependency */, ); name = AirshipFeatureFlags; productName = AirshipFeatureFlags; productReference = A62058692A5841330041FBF9 /* AirshipFeatureFlags.framework */; productType = "com.apple.product-type.framework"; }; A620586F2A5841330041FBF9 /* AirshipFeatureFlagsTests */ = { isa = PBXNativeTarget; buildConfigurationList = A620587D2A5841340041FBF9 /* Build configuration list for PBXNativeTarget "AirshipFeatureFlagsTests" */; buildPhases = ( A620586C2A5841330041FBF9 /* Sources */, A620586D2A5841330041FBF9 /* Frameworks */, A620586E2A5841330041FBF9 /* Resources */, ); buildRules = ( ); dependencies = ( A62058732A5841330041FBF9 /* PBXTargetDependency */, ); name = AirshipFeatureFlagsTests; productName = AirshipFeatureFlagsTests; productReference = A62058702A5841330041FBF9 /* AirshipFeatureFlagsTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; A641E1462BDBBDB400DE6FAA /* AirshipObjectiveC */ = { isa = PBXNativeTarget; buildConfigurationList = A641E14D2BDBBDB400DE6FAA /* Build configuration list for PBXNativeTarget "AirshipObjectiveC" */; buildPhases = ( A641E1422BDBBDB400DE6FAA /* Headers */, A641E1432BDBBDB400DE6FAA /* Sources */, A641E1442BDBBDB400DE6FAA /* Frameworks */, A641E1452BDBBDB400DE6FAA /* Resources */, ); buildRules = ( ); dependencies = ( A60235362CCB9E3C00CF412B /* PBXTargetDependency */, A641E1572BDBF5FF00DE6FAA /* PBXTargetDependency */, A641E1592BDBF5FF00DE6FAA /* PBXTargetDependency */, A641E15B2BDBF5FF00DE6FAA /* PBXTargetDependency */, A641E15D2BDBF5FF00DE6FAA /* PBXTargetDependency */, A641E15F2BDBF5FF00DE6FAA /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 6E128BA02D305F2E00733024 /* Source */, ); name = AirshipObjectiveC; productName = AirshipObjectiveC; productReference = A641E1472BDBBDB400DE6FAA /* AirshipObjectiveC.framework */; productType = "com.apple.product-type.framework"; }; CC64F0531D8B77E3009CEF27 /* AirshipTests */ = { isa = PBXNativeTarget; buildConfigurationList = CC64F05E1D8B77E3009CEF27 /* Build configuration list for PBXNativeTarget "AirshipTests" */; buildPhases = ( CC64F0501D8B77E3009CEF27 /* Sources */, CC64F0511D8B77E3009CEF27 /* Frameworks */, CC64F0521D8B77E3009CEF27 /* Resources */, ); buildRules = ( ); dependencies = ( CC64F05B1D8B77E3009CEF27 /* PBXTargetDependency */, ); name = AirshipTests; productName = AirshipTests; productReference = CC64F0541D8B77E3009CEF27 /* AirshipTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 494DD94E1B0EB677009C134E /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1410; LastUpgradeCheck = 1600; ORGANIZATIONNAME = "Urban Airship"; TargetAttributes = { 3CA0E298237CCE2600EE76CF = { LastSwiftMigration = 1140; }; 3CA0E346237E4A7B00EE76CF = { LastSwiftMigration = 1340; }; 494DD9561B0EB677009C134E = { CreatedOnToolsVersion = 6.3.1; DevelopmentTeam = PGJV57GD94; DevelopmentTeamName = "Urban Airship Inc."; LastSwiftMigration = 1250; }; 6E0B8729294A9C120064B7BD = { CreatedOnToolsVersion = 14.1; LastSwiftMigration = 1410; }; 6E0B8730294A9C130064B7BD = { CreatedOnToolsVersion = 14.1; }; 6E4A466D28EF44F600A25617 = { CreatedOnToolsVersion = 13.4; LastSwiftMigration = 1340; }; 6EAAE85D28C2AD3A003CAE53 = { CreatedOnToolsVersion = 13.4; }; 847BFFF3267CD739007CD249 = { CreatedOnToolsVersion = 12.5; LastSwiftMigration = 1250; }; 847BFFFB267CD73A007CD249 = { CreatedOnToolsVersion = 12.5; LastSwiftMigration = 1250; }; A62058682A5841330041FBF9 = { CreatedOnToolsVersion = 14.1; LastSwiftMigration = 1410; }; A620586F2A5841330041FBF9 = { CreatedOnToolsVersion = 14.1; }; A641E1462BDBBDB400DE6FAA = { CreatedOnToolsVersion = 15.3; LastSwiftMigration = 1530; }; CC64F0531D8B77E3009CEF27 = { CreatedOnToolsVersion = 8.0; DevelopmentTeam = PGJV57GD94; LastSwiftMigration = 1100; ProvisioningStyle = Automatic; }; }; }; buildConfigurationList = 494DD9511B0EB677009C134E /* Build configuration list for PBXProject "Airship" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, ar, cs, da, de, "es-419", es, fi, fr, hi, hu, id, it, iw, ja, ko, ms, nl, no, pl, "pt-PT", pt, ro, ru, sk, sv, th, tr, vi, "zh-Hans", "zh-Hant", he, nb, Base, af, am, bg, ca, el, et, fa, "fr-CA", hr, lt, lv, sl, sr, sw, uk, "zh-HK", zu, ); mainGroup = 494DD94D1B0EB677009C134E; productRefGroup = 494DD9581B0EB677009C134E /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 6E431F6B26EA814F009228AB /* AirshipBasement */, 494DD9561B0EB677009C134E /* AirshipCore */, 6E0B8729294A9C120064B7BD /* AirshipAutomation */, A62058682A5841330041FBF9 /* AirshipFeatureFlags */, 3CA0E346237E4A7B00EE76CF /* AirshipMessageCenter */, 847BFFF3267CD739007CD249 /* AirshipPreferenceCenter */, A641E1462BDBBDB400DE6FAA /* AirshipObjectiveC */, 3CA0E298237CCE2600EE76CF /* AirshipDebug */, CC64F0531D8B77E3009CEF27 /* AirshipTests */, 6E0B8730294A9C130064B7BD /* AirshipAutomationTests */, A620586F2A5841330041FBF9 /* AirshipFeatureFlagsTests */, 6E4A466D28EF44F600A25617 /* AirshipMessageCenterTests */, 847BFFFB267CD73A007CD249 /* AirshipPreferenceCenterTests */, 6EAAE85D28C2AD3A003CAE53 /* AirshipRelease */, 6ECCAD252CF55BC700423D86 /* AirshipRelease tvOS */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 3CA0E2A8237CCE2600EE76CF /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 3CA0E417237E4A7B00EE76CF /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 3CA0E479237E4E3000EE76CF /* UAInbox.xcdatamodeld in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 494DD9551B0EB677009C134E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 329DFCFF2B7FB8810039C8C0 /* UARemoteDataMappingV3toV4.xcmappingmodel in Resources */, 6E1F6E882BE683E600CFC7A7 /* UARemoteDataMappingV1toV4.xcmappingmodel in Resources */, 6E1F6E842BE6835400CFC7A7 /* UARemoteDataMappingV2toV4.xcmappingmodel in Resources */, 6E411C782538C60900FEE4E8 /* UrbanAirship.strings in Resources */, 6E411B782538C4E600FEE4E8 /* UANativeBridge in Resources */, 6EFE7E3F2A97ED660064AC31 /* PrivacyInfo.xcprivacy in Resources */, 6E411B7C2538C4E600FEE4E8 /* UANotificationCategories.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E0B8728294A9C120064B7BD /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 6E0B872F294A9C130064B7BD /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 6E4AEE312B6B3A6A008AEAC1 /* Valid-UAInAppMessageModalStyle.plist in Resources */, 6E4AEE292B6B2E0A008AEAC1 /* airship.jpg in Resources */, 6E4AEE322B6B3A6A008AEAC1 /* Valid-UAInAppMessageBannerStyle.plist in Resources */, 6E4AEE352B6B3A6A008AEAC1 /* Valid-UAInAppMessageHTMLStyle.plist in Resources */, 6E4AEE332B6B3A6A008AEAC1 /* Valid-UAInAppMessageFullScreenStyle.plist in Resources */, 6E4AEE362B6B3A6A008AEAC1 /* Invalid-UAInAppMessageModalStyle.plist in Resources */, 6E4AEE272B6B2E0A008AEAC1 /* alternate-airship.jpg in Resources */, 6E4AEE372B6B3A6A008AEAC1 /* Invalid-UAInAppMessageFullScreenStyle.plist in Resources */, 6E4AEE342B6B3A6A008AEAC1 /* Invalid-UAInAppMessageBannerStyle.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E43203F26EA814F009228AB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 6E4A466C28EF44F600A25617 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 847BFFF2267CD739007CD249 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 847BFFFA267CD73A007CD249 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EB5159728A5C54400870C5A /* TestThemeEmpty.plist in Resources */, 6EB5159428A5B8E900870C5A /* TestTheme.plist in Resources */, 6EB5159928A5C61D00870C5A /* TestThemeInvalid.plist in Resources */, 6EB5159228A5B1B400870C5A /* TestLegacyTheme.plist in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; A62058672A5841330041FBF9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; A620586E2A5841330041FBF9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; A641E1452BDBBDB400DE6FAA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; CC64F0521D8B77E3009CEF27 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( CC64F14F1D8B7954009CEF27 /* AirshipConfig-Valid.plist in Resources */, CC64F1511D8B7954009CEF27 /* development-embedded.mobileprovision in Resources */, CC64F14D1D8B7954009CEF27 /* AirshipConfig-Valid-Legacy.plist in Resources */, CC64F1521D8B7954009CEF27 /* production-embedded.mobileprovision in Resources */, CC64F0CE1D8B781C009CEF27 /* CustomNotificationCategories.plist in Resources */, 45A8ADF023134B38004AD8CA /* testMCColorsCatalog.xcassets in Resources */, 6014AD752C20410B0072DCF0 /* airship.der in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 3CA0E29C237CCE2600EE76CF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 3C693E4F25141CAC00EBFB88 /* AirshipDebugPushData.xcdatamodeld in Sources */, 3C693E4E25141CAC00EBFB88 /* AirshipDebugEventData.xcdatamodeld in Sources */, 60653FC22CBD2CD4009CD9A7 /* PushData+CoreDataClass.swift in Sources */, 60653FC32CBD2CD4009CD9A7 /* PushData+CoreDataProperties.swift in Sources */, 6E6802982B8675A200F4591F /* DebugComponent.swift in Sources */, DFD2464E2473404C000FD565 /* DebugSDKModule.swift in Sources */, 3CB37A1E251151A400E60392 /* AirshipDebugResources.swift in Sources */, 6E21852B237D32B30084933A /* EventData.swift in Sources */, 6E14C9A128B5E4AF00A55E65 /* PushNotification.swift in Sources */, 3CA0E2BF237CD05F00EE76CF /* AirshipEvent.swift in Sources */, 3CA0E2CA237CD05F00EE76CF /* EventDataManager.swift in Sources */, 3CA0E2CD237CD05F00EE76CF /* AirshipDebugManager.swift in Sources */, 6EB21A682E81BB6E001A5660 /* AirshipDebugAddEmailChannelView.swift in Sources */, 6EB21A692E81BB6E001A5660 /* AirshipDebugChannelTagView.swift in Sources */, 6EB21A6A2E81BB6E001A5660 /* AirshipDebugAutomationsView.swift in Sources */, 6EB21A6B2E81BB6E001A5660 /* AirshipDebugFeatureFlagDetailsView.swift in Sources */, 6EB21A6C2E81BB6E001A5660 /* AirshipDebugExperimentsView.swift in Sources */, 6EB21A6D2E81BB6E001A5660 /* AirshipDebugExtensions.swift in Sources */, 6EB21A6E2E81BB6E001A5660 /* AirshipDebugAddEventView.swift in Sources */, 6EB21A6F2E81BB6E001A5660 /* AirshipDebugPushDetailsView.swift in Sources */, 6EB21A702E81BB6E001A5660 /* AirshipDebugView.swift in Sources */, 6EB21A712E81BB6E001A5660 /* AirshipDebugAddSMSChannelView.swift in Sources */, 6EB21A722E81BB6E001A5660 /* AirshipJSONDetailsView.swift in Sources */, 6EB21A732E81BB6E001A5660 /* AirshipDebugAnalyticIdentifierEditorView.swift in Sources */, 6EB21A742E81BB6E001A5660 /* AirshipDebugContentView.swift in Sources */, 6EB21A752E81BB6E001A5660 /* AirshipDebugRoute.swift in Sources */, 6EB21A932E81C7AB001A5660 /* AirshipDebugAddStringPropertyView.swift in Sources */, 6EB21A772E81BB6E001A5660 /* AirshipDebugPreferencCenterItemView.swift in Sources */, 6EB21AFC2E8216A4001A5660 /* AirshipoDebugTriggers.swift in Sources */, 6EB21A792E81BB6E001A5660 /* AirshipDebugAttributesEditorView.swift in Sources */, 6EB21A7A2E81BB6E001A5660 /* AirshipToast.swift in Sources */, 6EB21A7B2E81BB6E001A5660 /* AirshipDebugFeatureFlagView.swift in Sources */, 6EB21A7C2E81BB6E001A5660 /* Extensions.swift in Sources */, 6EB21A7D2E81BB6E001A5660 /* AirshipDebugTagGroupsEditorView.swift in Sources */, 6EB21A7E2E81BB6E001A5660 /* AirshipDebugAppInfoView.swift in Sources */, 6EB21A7F2E81BB6E001A5660 /* AirshipDebugEventDetailsView.swift in Sources */, 6EB21A802E81BB6E001A5660 /* AirshipDebugPrivacyManagerView.swift in Sources */, 6EB21A912E81BFC1001A5660 /* AirshipDebugAddPropertyView.swift in Sources */, 6EB21A812E81BB6E001A5660 /* AirshipDebugEventsView.swift in Sources */, 6EB21A822E81BB6E001A5660 /* AirshipDebugContactSubscriptionEditorView.swift in Sources */, 6EB21A832E81BB6E001A5660 /* AirshipDebugPreferenceCenterView.swift in Sources */, 6EB21A842E81BB6E001A5660 /* AirshipDebugAnalyticsView.swift in Sources */, 6EB21A852E81BB6E001A5660 /* AirshipDebugReceivedPushView.swift in Sources */, 6EB21A862E81BB6E001A5660 /* AirshipDebugPushView.swift in Sources */, 6EB21A872E81BB6E001A5660 /* AirshipDebugNamedUserView.swift in Sources */, 6EB21A882E81BB6E001A5660 /* AirshipJSONView.swift in Sources */, 6EB21A892E81BB6E001A5660 /* AirshipDebugChannelSubscriptionsView.swift in Sources */, 6EB21A8A2E81BB6E001A5660 /* AirshipDebugInAppExperiencesView.swift in Sources */, 6EB21A8B2E81BB6E001A5660 /* TVSlider.swift in Sources */, 6EB21A8C2E81BB6E001A5660 /* TVDatePicker.swift in Sources */, 6EB21A8D2E81BB6E001A5660 /* AirshipDebugChannelView.swift in Sources */, 6EB21A8E2E81BB6E001A5660 /* AirshipDebugContactView.swift in Sources */, 6EB21B5F2E82FE9F001A5660 /* AirshipDebugAudienceSubject.swift in Sources */, 6EB21A8F2E81BB6E001A5660 /* AirshipDebugAddOpenChannelView.swift in Sources */, 6E6CC38623A3F9B4003D583C /* PushDataManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 3CA0E34A237E4A7B00EE76CF /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6E4D22502E6F9CF200A8D641 /* MessageCenterContent.swift in Sources */, 6E4A469F28F4A7DF00A25617 /* MessageCenterAction.swift in Sources */, 6E4A466528EF448600A25617 /* MessageCenterStore.swift in Sources */, 32B632892906CA17000D3E34 /* MessageCenterTheme.swift in Sources */, 2797B4192F47687800A7F848 /* NativeLayoutPersistentDataStore.swift in Sources */, 32F68CEF28F07C2C00F7F52A /* MessageCenterSDKModule.swift in Sources */, 32B5BE3F28F8A8C200F2254B /* MessageCenterView.swift in Sources */, 6E4D224C2E6F968A00A8D641 /* MessageCenterNavigationStack.swift in Sources */, 32F68CEE28F07C2C00F7F52A /* AirshipMessageCenterResources.swift in Sources */, 6E4A466628EF448600A25617 /* MessageCenterAPIClient.swift in Sources */, 6E4A466128EF447C00A25617 /* MessageCenterUser.swift in Sources */, 6E6802962B86749900F4591F /* MessageCenterComponent.swift in Sources */, 6E4A46A128F4AEDF00A25617 /* MessageCenterNativeBridgeExtension.swift in Sources */, 27CCF77D2F1656150018058F /* MessageViewAnalytics.swift in Sources */, 27E4194A2EF59F9800D5C1A6 /* MessageCenterThomasView.swift in Sources */, 32B632882906CA17000D3E34 /* MessageCenterThemeLoader.swift in Sources */, 32B5BE4928F8B66500F2254B /* MessageCenterViewController.swift in Sources */, 6E4A466728EF448600A25617 /* MessageCenterMessage.swift in Sources */, 6EC81D052F2D448B00E1C0C6 /* UAInboxDataMappingV2toV4.xcmappingmodel in Sources */, 6E4D224E2E6F96B800A8D641 /* MessageCenterSplitNavigationView.swift in Sources */, 6E1476CC2F5643A100320A36 /* MessageCenterNavigationAppearance.swift in Sources */, 32B5BE4128F8A8C700F2254B /* MessageCenterListView.swift in Sources */, 6E4D22522E6FA2F700A8D641 /* MessageCenterBackButton.swift in Sources */, 6E4D224A2E6F814000A8D641 /* MessageCenterMessageViewWithNavigation.swift in Sources */, 6E4D225F2E70AFE800A8D641 /* MessageCenterListViewModel.swift in Sources */, 329DFCD52B7E59700039C8C0 /* UAInboxDataMapping.swift in Sources */, 6EDE5F192B9BD7E700E33D04 /* InboxMessageData.swift in Sources */, 32B5BE3E28F8A8C000F2254B /* MessageCenterListItemView.swift in Sources */, 32B513562B9F53A500BBE780 /* MessageCenterPredicate.swift in Sources */, 32B5BE3B28F8A7EB00F2254B /* MessageCenterController.swift in Sources */, 32F68CF328F07C2C00F7F52A /* MessageCenter.swift in Sources */, 99FD20A52DEFC35900242551 /* MessageCenterUIKitAppearance.swift in Sources */, 6EC81D032F2D445500E1C0C6 /* UAInboxDataMappingV1toV4.xcmappingmodel in Sources */, 2753F6422F6C5BB50073882C /* MessageCenterMessageError.swift in Sources */, 6EC81D082F2D44D700E1C0C6 /* UAInboxDataMappingV3toV4.xcmappingmodel in Sources */, 6E4D22542E6FA5ED00A8D641 /* MessageCenterWebView.swift in Sources */, 6E4D20722E6B761200A8D641 /* MessageCenterListViewWithNavigation.swift in Sources */, 32F68CF528F07C4900F7F52A /* MessageCenterList.swift in Sources */, 6E4D225D2E70ADE100A8D641 /* MessageCenterMessageViewModel.swift in Sources */, 32B5BE4228F8A8CA00F2254B /* MessageCenterListItemViewModel.swift in Sources */, 32B5BE4028F8A8C500F2254B /* MessageCenterMessageView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 494DD9521B0EB677009C134E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6E52146D2DCBFABE00CF64B9 /* ThomasPagerTracker.swift in Sources */, 6EB1B3F326EAA4D6000421B9 /* ChannelAPIClient.swift in Sources */, 6EED67C02CE1B5160087CDCB /* ThomasActionsPayload.swift in Sources */, 990EB3B12BF59A1500315EAC /* ContactChannelsProvider.swift in Sources */, 6EED67722CDEE8390087CDCB /* ThomasOrientation.swift in Sources */, 6E7112A12880DACB004942E4 /* EnableBehaviorModifiers.swift in Sources */, 6E12539129A81ACE0009EE58 /* AirshipCoreDataPredicate.swift in Sources */, 6E6C3F7F27A20C3C007F55C7 /* ChannelScope.swift in Sources */, 6E4E2E2829CEB222002E7682 /* ContactManagerProtocol.swift in Sources */, 6E6ED15F2683DBC300A2CBD0 /* AirshipCoreResources.swift in Sources */, 6E87BE0126E283850005D20D /* Airship.swift in Sources */, 6E6363E229DCD0CF009C358A /* ContactSubscriptionListClient.swift in Sources */, 6E3B32CC27559D8B00B89C7B /* FormInputViewModifier.swift in Sources */, 3243EC632D93109C00B43B25 /* AirshipSwitchToggleStyle.swift in Sources */, 3243EC642D93109C00B43B25 /* AirshipCheckboxToggleStyle.swift in Sources */, 6EED67B62CE1B4420087CDCB /* ThomasTextAppearance.swift in Sources */, 6E46A27F272B68660089CDE3 /* ThomasDelegate.swift in Sources */, 6E1CBD812BA3A30300519D9C /* AirshipEmbeddedInfo.swift in Sources */, 6ECD4F6F2DD7A7090060EE72 /* CheckboxToggleLayout.swift in Sources */, 6E4325CE2B7AD5A200A9B000 /* AirshipComponent.swift in Sources */, 6EC7E48226A60C060038CFDD /* AirshipChannel.swift in Sources */, 6E2F5A8A2A66088100CABD3D /* AudienceDeviceInfoProvider.swift in Sources */, 6E664BDD26C4CD8700A2C8E5 /* OpenExternalURLAction.swift in Sources */, 6E75F50529C4EAF600E3585A /* AudienceOverridesProvider.swift in Sources */, 3237D5F22B865D990055932B /* JSONValueTransformer.swift in Sources */, 6E4339EF2DFA03A3000A7741 /* JSONValueMatcherPredicates.swift in Sources */, 6EED68162CE271F80087CDCB /* ThomasFormSubmitBehavior.swift in Sources */, 6ECD4F6D2DD7A7060060EE72 /* RadioInputToggleLayout.swift in Sources */, 6EB5158328A47C7100870C5A /* ScopedSubscriptionListEdit.swift in Sources */, 60D3BCD02A154D9400E07524 /* MessageCriteria.swift in Sources */, 6E1472D52F526DCD00320A36 /* AirshipNativePlatform.swift in Sources */, 608B16E62C2C1138005298FA /* SubscriptionListProvider.swift in Sources */, 6E94761529BBC0240025F364 /* AirshipButton.swift in Sources */, 3251586B272AFB2E00DF8B44 /* VideoMediaWebView.swift in Sources */, 6E96ED02294115210053CC91 /* AsyncStream.swift in Sources */, 6EB11C8D2698C50F00DC698F /* AudienceUtils.swift in Sources */, 6EED682D2CE28CC10087CDCB /* ThomasValidationInfo.swift in Sources */, 6E4A4FDA2A30358F0049FEFC /* TagsActionArgs.swift in Sources */, 6E57CE3728DBBD9A00287601 /* LiveActivityRegistry.swift in Sources */, 6EBFA9AD2D15DA73002BA3E9 /* HashChecker.swift in Sources */, 6E96ED0A294135500053CC91 /* EventManager.swift in Sources */, 6E4325F22B7B1EDA00A9B000 /* SessionEventFactory.swift in Sources */, 6EE49C102A0C142F00AB1CF4 /* RemoteDataInfo.swift in Sources */, 6E5214692DCABFCE00CF64B9 /* ThomasLayoutContext.swift in Sources */, 6E15B71826CEB4190099C92D /* RemoteDataStore.swift in Sources */, 6E87BDBD26E01FF40005D20D /* ModuleLoader.swift in Sources */, 271B38652DB2866200495D9F /* TagActionMutation.swift in Sources */, 6E6541E02758976D009676CA /* AirshipProgressView.swift in Sources */, A67EC249279B1A40009089E1 /* ScopedSubscriptionListEditor.swift in Sources */, 6E698DF226790AC300654DB2 /* AirshipPrivacyManager.swift in Sources */, 6E96ED16294197D90053CC91 /* EventUploadTuningInfo.swift in Sources */, 6E95292C268B98A200398B54 /* MediaEventTemplate.swift in Sources */, 6E66BA7F2D14B61A0083A9FD /* WrappingLayout.swift in Sources */, A6A5530A26D548AF002B20F6 /* NativeBridge.swift in Sources */, 6E91E45828EF423400B6F25E /* AirshipWorkResult.swift in Sources */, 6EED68192CE272790087CDCB /* ThomasAutomatedAccessibilityAction.swift in Sources */, 32CF81E2275627F4003009D1 /* AirshipAsyncImage.swift in Sources */, 6E664BD026C4916600A2C8E5 /* AddTagsAction.swift in Sources */, 6EED679C2CDEEA380087CDCB /* ThomasToggleStyleInfo.swift in Sources */, 6E664BA726C4417400A2C8E5 /* ShareAction.swift in Sources */, 6ED6ECA426ADCA6F00973364 /* BlockAction.swift in Sources */, 6E475BFE2F5A3709003D8E42 /* VideoGroupState.swift in Sources */, 6E87BD6426D594870005D20D /* ChannelRegistrationPayload.swift in Sources */, 27F1E2012F0E910B00E317DB /* ThomasLayoutButtonTapEvent.swift in Sources */, 27F1E2022F0E910B00E317DB /* ThomasLayoutDisplayEvent.swift in Sources */, 27F1E2032F0E910B00E317DB /* ThomasLayoutEvent.swift in Sources */, 27F1E2042F0E910B00E317DB /* ThomasLayoutFormDisplayEvent.swift in Sources */, 27F1E2052F0E910B00E317DB /* ThomasLayoutFormResultEvent.swift in Sources */, 27F1E2062F0E910B00E317DB /* ThomasLayoutGestureEvent.swift in Sources */, 27F1E2072F0E910B00E317DB /* ThomasLayoutPageActionEvent.swift in Sources */, 27F1E2082F0E910B00E317DB /* ThomasLayoutPagerCompletedEvent.swift in Sources */, 27F1E2092F0E910B00E317DB /* ThomasLayoutPagerSummaryEvent.swift in Sources */, 27F1E20A2F0E910B00E317DB /* ThomasLayoutPageSwipeEvent.swift in Sources */, 27F1E20B2F0E910B00E317DB /* ThomasLayoutPageViewEvent.swift in Sources */, 27F1E20C2F0E910B00E317DB /* ThomasLayoutPermissionResultEvent.swift in Sources */, 27F1E20D2F0E910B00E317DB /* ThomasLayoutResolutionEvent.swift in Sources */, 6E299FD728D13E54001305A7 /* AirshipRequest.swift in Sources */, 8401769C26C5729E00373AF7 /* JSONValueMatcher.swift in Sources */, 3CC95B2B2696549B00FE2ACD /* AirshipPushableComponent.swift in Sources */, 32F97AC129E5986B00FED65F /* StoryIndicator.swift in Sources */, 6EFD6D4B27272333005B26F1 /* EmptyView.swift in Sources */, 324D3BFF273E6B4500058EE4 /* BannerView.swift in Sources */, 6EAA61492D5297A7006602F7 /* SubjectExtension.swift in Sources */, 6E6BD26D2AF1AC5700B9DFC9 /* AirshipCache.swift in Sources */, 6EBFA9AF2D15E04D002BA3E9 /* AirshipDeviceAudienceResult.swift in Sources */, 320AD3A629E7FA2000D66106 /* PagerGestureMap.swift in Sources */, A6A5531026D548D6002B20F6 /* JavaScriptEnvironment.swift in Sources */, 6E664BE726C5B21600A2C8E5 /* ModifyAttributesAction.swift in Sources */, 6EED67BA2CE1B4E90087CDCB /* ThomasPlatform.swift in Sources */, 6E664BD926C4CD8700A2C8E5 /* PasteboardAction.swift in Sources */, 6E15B71426CEB4190099C92D /* RemoteDataStorePayload.swift in Sources */, A6D6D49F2A02608C0072A5CA /* ActionResult.swift in Sources */, 6E91B43C26868A6300DDB1A8 /* CircularRegion.swift in Sources */, 6E1589582AFF023400954A04 /* SessionEvent.swift in Sources */, 6014AD672C1B5F540072DCF0 /* ChallengeResolver.swift in Sources */, 6E5ADF822D7682A300A03799 /* StateSubscriptionsModifier.swift in Sources */, 6E6C3F9E27A4C3D4007F55C7 /* AirshipJSON.swift in Sources */, 6E49D7C828401D2E00C7BB9D /* APNSRegistrar.swift in Sources */, 6E49D7B228401D2E00C7BB9D /* Permission.swift in Sources */, 6329102E2DD8103200B13C6C /* NativeVideoPlayer.swift in Sources */, 27F1E1342F0E7C9C00E317DB /* ThomasLayoutEventRecorder.swift in Sources */, 6E49D7BC28401D2E00C7BB9D /* PermissionsManager.swift in Sources */, 6E062D0727165709001A74A1 /* Label.swift in Sources */, 60C1DB0F2A8B743C00A1D3DA /* AirshipEmbeddedView.swift in Sources */, 6E698DEC26790AC300654DB2 /* PreferenceDataStore.swift in Sources */, E99605A127A071EA00365AE4 /* EmailRegistrationOptions.swift in Sources */, 6EFD6D6E27290C0B005B26F1 /* FormController.swift in Sources */, 6E5ADF842D7682D600A03799 /* ThomasStateTrigger.swift in Sources */, 6EED67EB2CE269970087CDCB /* ThomasDirection.swift in Sources */, 6E91E44C28EF423400B6F25E /* AirshipWorkRequest.swift in Sources */, 6EE49BDD2A09AD3600AB1CF4 /* AirshipNotificationStatus.swift in Sources */, 6EED67A02CDEEAAB0087CDCB /* AirshipLayout.swift in Sources */, 608B16F12C2D5F0D005298FA /* BaseCachingRemoteDataProvider.swift in Sources */, 6EFD6D82272A53AE005B26F1 /* PagerState.swift in Sources */, A1B2C3D4E5F60002VIDEOST /* VideoState.swift in Sources */, A1B2C3D4E5F60004VIDEOCR /* VideoController.swift in Sources */, 6E9D529B26C1A77C004EA16B /* ActionRegistry.swift in Sources */, 6E87BE0726E283850005D20D /* DeepLinkDelegate.swift in Sources */, 6E0105032DDFA719009D651F /* ScoreToggleLayout.swift in Sources */, 6E8932982E7B666000FB0EC4 /* APNSRegistrationResult.swift in Sources */, 6E60EF6A29DF542B003F7A8D /* AnonContactData.swift in Sources */, 6E6363EC29DDF84B009C358A /* SerialQueue.swift in Sources */, 6E40868E2B8D036600435E2C /* AirshipEmbeddedSize.swift in Sources */, 6E92ECB1284ECE590038802D /* CachedList.swift in Sources */, 6ED562A02EA9434B00C20B55 /* StackImageButton.swift in Sources */, 6E952923268B812000398B54 /* AccountEventTemplate.swift in Sources */, A658DE2B272AFB0400007672 /* AirshipImageLoader.swift in Sources */, 6EA5202327D1364E003011CA /* AirshipDateFormatter.swift in Sources */, 6E7DB38328ECDC41002725F6 /* LiveActivity.swift in Sources */, 6E4A4FDE2A3132850049FEFC /* AirshipSDKModule.swift in Sources */, 6E062D0D2718B505001A74A1 /* ViewConstraints.swift in Sources */, 6E698E3D267BEDC300654DB2 /* DefaultAirshipContact.swift in Sources */, 6EED67972CDEEA2E0087CDCB /* ThomasShapeInfo.swift in Sources */, 60D3BCCC2A153C0700E07524 /* Experiment.swift in Sources */, 6ED838AB2D0CE9D6009CBB0C /* CompoundDeviceAudienceSelector.swift in Sources */, 6E52146B2DCBF9BD00CF64B9 /* AirshipTimerProtocol.swift in Sources */, 60C1DB122A8B743C00A1D3DA /* EmbeddedView.swift in Sources */, 27F1E1362F0E7D7B00E317DB /* ThomasLayoutEventSource.swift in Sources */, A658DE192728498900007672 /* AirshipWebview.swift in Sources */, 6EED67F02CE26CA40087CDCB /* ThomasSize.swift in Sources */, 6E15B72D26CF13BC0099C92D /* RemoteDataProviderDelegate.swift in Sources */, 6E07688829F9D28A0014E2A9 /* AirshipNotificationCenter.swift in Sources */, 99E433932C9A0362006436B9 /* PagerIndicator.swift in Sources */, 6E6ED1402683A9F200A2CBD0 /* AirshipDate.swift in Sources */, 6EC922E12D832BB8000A3A59 /* ThomasFormFieldProcessor.swift in Sources */, 6E411CAF2538C6A600FEE4E8 /* UAEvents.xcdatamodeld in Sources */, 6EF66D912769B69C00ABCB76 /* RootView.swift in Sources */, 6EED680D2CE2707F0087CDCB /* ThomasStateAction.swift in Sources */, A6849387273290520021675E /* Score.swift in Sources */, 6E062D03271656DE001A74A1 /* Container.swift in Sources */, C02D0B6626C1A3E200F673E6 /* ChannelCapture.swift in Sources */, 6EE49C222A13E32B00AB1CF4 /* RemoteDataProviderProtocol.swift in Sources */, 6EF1401B2A2671ED009A125D /* AirshipDeviceID.swift in Sources */, 99E433942C9A03D9006436B9 /* Pager.swift in Sources */, 6E698E5F267BF63B00654DB2 /* ApplicationState.swift in Sources */, 8401769826C5722400373AF7 /* JSONPredicate.swift in Sources */, 602AD0D52D7242B300C7D566 /* ThomasSmsLocale.swift in Sources */, 6E146D682F523DB900320A36 /* AirshipFont.swift in Sources */, 6EFD6D8B272A53FB005B26F1 /* PagerController.swift in Sources */, 6E664BD326C4917000A2C8E5 /* RemoveTagsAction.swift in Sources */, 6EED67B32CE1A8330087CDCB /* ThomasEventHandler.swift in Sources */, 6EF66D8D276461DA00ABCB76 /* UrlInfo.swift in Sources */, 32BBFB402B274C8600C6A998 /* ContactChannelsAPIClient.swift in Sources */, 6E1EEE902BD81AF300B45A87 /* ContactChannel.swift in Sources */, 27F1E1352F0E7D2C00E317DB /* ThomasLayoutEventMessageID.swift in Sources */, 6E6BD2422AE995DA00B9DFC9 /* DeferredResolver.swift in Sources */, 6ECDDE6C29B7EEE9009D79DB /* AuthToken.swift in Sources */, 6EAD7CE526B216DB00B88EA7 /* DeepLinkAction.swift in Sources */, 6EED67AB2CE1A5D00087CDCB /* ThomasMarkdownOptions.swift in Sources */, 6E299FDB28D14208001305A7 /* AirshipResponse.swift in Sources */, 6EED67AE2CE1A6B90087CDCB /* ThomasPosition.swift in Sources */, 6ED2F5312B7FF819000AFC80 /* AirshipViewUtils.swift in Sources */, 6E87BD9226D963B60005D20D /* DefaultAppIntegrationDelegate.swift in Sources */, 6E91B44026868C3400DDB1A8 /* RegionEvent.swift in Sources */, 60D3BCC42A1529D800E07524 /* ExperimentDataProvider.swift in Sources */, 6E87BD8D26D815780005D20D /* AppIntegration.swift in Sources */, 6E5A64D92AABC5A400574085 /* UAMeteredUsage.xcdatamodeld in Sources */, 6E4326012B7C327C00A9B000 /* AirshipEvents.swift in Sources */, 6E1A1D852D70F3700056418B /* ThomasState.swift in Sources */, 6EED67752CDEE8460087CDCB /* ThomasWindowSize.swift in Sources */, 6E887CD3272C5F5000E83363 /* Checkbox.swift in Sources */, 6EDAFB262CB463C5000BD4AA /* ButtonLayout.swift in Sources */, 275D32AC2EF957F200B75760 /* AirshipSimpleLayoutView.swift in Sources */, 275D32AD2EF957F200B75761 /* AirshipSimpleLayoutViewModel.swift in Sources */, 6EB11C8B2697AFC700DC698F /* AttributeUpdate.swift in Sources */, 6EE49C0C2A0C141800AB1CF4 /* RemoteDataSource.swift in Sources */, 6E91B4692689327D00DDB1A8 /* CustomEvent.swift in Sources */, 6E1589542AFF021D00954A04 /* SessionState.swift in Sources */, 6EED680A2CE26E550087CDCB /* ThomasAutomatedAction.swift in Sources */, 6EED68122CE271E50087CDCB /* ThomasEnableBehavior.swift in Sources */, 6E49D7C628401D2E00C7BB9D /* Badger.swift in Sources */, 6EAD3AF82F45305E00FF274E /* AirshipSwizzler.swift in Sources */, 6E96ECF6293FCE080053CC91 /* EventUploadScheduler.swift in Sources */, 6E71129D2880DACB004942E4 /* EventHandlerViewModifier.swift in Sources */, 6EF27DE62730E77300548DA3 /* RadioInputState.swift in Sources */, 6E91E44328EF423400B6F25E /* WorkRateLimiterActor.swift in Sources */, 608B16E82C2C6DDB005298FA /* ChannelSubscriptionListProvider.swift in Sources */, 6ECB627E2A36A0770095C85C /* ExternalURLProcessor.swift in Sources */, 6E1BACDD2719FC0A0038399E /* ViewFactory.swift in Sources */, 6E9B4874288F0CE000C905B1 /* RateAppAction.swift in Sources */, 6EAD3AFA2F4530BE00FF274E /* AutoIntegration.swift in Sources */, 6ED80793273CA0C800D1F455 /* EnvironmentValues.swift in Sources */, 6E49D7BE28401D2E00C7BB9D /* RegistrationDelegate.swift in Sources */, 6E6ED15B2683DBC300A2CBD0 /* AirshipBase64.swift in Sources */, A61517B226A9C4C3008A41C4 /* SubscriptionListEditor.swift in Sources */, 6E146C502F5214D900320A36 /* AirshipDevice.swift in Sources */, 6E1528282B4DCFCB00DF1377 /* AirshipActorValue.swift in Sources */, 6E664BCA26C4852B00A2C8E5 /* AddCustomEventAction.swift in Sources */, 6EC922E32D838DFF000A3A59 /* ThomasFormPayloadGenerator.swift in Sources */, 6ECDDE7929B804FB009D79DB /* ChannelAuthTokenAPIClient.swift in Sources */, 6E77CD4A2D8A225E0057A52C /* SMSValidatorAPIClient.swift in Sources */, 6E77CD4B2D8A225E0057A52C /* AirshipInputValidator.swift in Sources */, 6E952920268A6C1500398B54 /* SearchEventTemplate.swift in Sources */, 6EAD3AFC2F4530F600FF274E /* UAAppIntegrationDelegate.swift in Sources */, 6E739D6E26B9F58700BC6F6D /* TagGroupMutations.swift in Sources */, 6EF02DF02714EB500008B6C9 /* Thomas.swift in Sources */, 6E92EC90284954B10038802D /* ButtonState.swift in Sources */, 6E5215222DCEA12A00CF64B9 /* ThomasViewedPageInfo.swift in Sources */, 6E152BCA2743235800788402 /* Icons.swift in Sources */, 27F1E2112F0FF5FE00E317DB /* ThomasDisplayListener.swift in Sources */, 6E692AFD29E0CB2F00D96CCC /* JavaScriptCommand.swift in Sources */, 6E698E59267BF63B00654DB2 /* AppStateTracker.swift in Sources */, 6EF27DE32730E6F900548DA3 /* RadioInputController.swift in Sources */, 6EC9214E2D82144A000A3A59 /* ThomasFormField.swift in Sources */, 6EE49C1D2A0D9D8000AB1CF4 /* RemoteDataProvider.swift in Sources */, 6EC815AF2F2BBFD500E1C0C6 /* BundleExtensions.swift in Sources */, 6E698E09267A7DD900654DB2 /* RemoteDataAPIClient.swift in Sources */, 6EED67FC2CE26DB60087CDCB /* ThomasAttributeName.swift in Sources */, A61517C426B009D6008A41C4 /* SubscriptionListAPIClient.swift in Sources */, 8401769426C5671100373AF7 /* JSONMatcher.swift in Sources */, 6EED68292CE28C9A0087CDCB /* ThomasVisibilityInfo.swift in Sources */, 6E146D6A2F5241BC00320A36 /* AirshipColor.swift in Sources */, 9908E60E2B000DBA00DB3E2E /* CustomView.swift in Sources */, 6E739D6626B9BDC100BC6F6D /* ChannelBulkUpdateAPIClient.swift in Sources */, 27F1E1332F0E7AA400E317DB /* ThomasLayoutEventContext.swift in Sources */, 6E2F5A862A65F00200CABD3D /* RemoteDataSourceStatus.swift in Sources */, 6E7112A32880DACB004942E4 /* VisibilityViewModifier.swift in Sources */, 6E46A27C272B63680089CDE3 /* ThomasEnvironment.swift in Sources */, 6E1D8AD126CC5D490049DACB /* RemoteConfig.swift in Sources */, 6E15B72A26CEDBA50099C92D /* RemoteData.swift in Sources */, 6ED6ECA726AE05B700973364 /* EmptyAction.swift in Sources */, 6E92EC8A284933750038802D /* PromptPermissionAction.swift in Sources */, 6EED67E82CE268BF0087CDCB /* ThomasButtonClickBehavior.swift in Sources */, 6E95292F268BBD7D00398B54 /* RetailEventTemplate.swift in Sources */, 6E4E5B3B26E7F91600198175 /* AirshipLocalizationUtils.swift in Sources */, 6E1892B1268CE8FE00417887 /* AirshipLock.swift in Sources */, 6E692B0329E0CBB500D96CCC /* NativeBridgeExtensionDelegate.swift in Sources */, 6EDE293F2A9802BF00235738 /* NativeBridgeActionRunner.swift in Sources */, 632913FA2DE547A500B13C6C /* VideoMediaNativeView.swift in Sources */, 6E698E0C267A88D600654DB2 /* EventAPIClient.swift in Sources */, 6EC7559F2A4E5AB200851ABB /* DeviceAudienceChecker.swift in Sources */, 6E15B6F426CD85C40099C92D /* RuntimeConfig.swift in Sources */, 6E0104FF2DDF9B26009D651F /* IconView.swift in Sources */, 6ED8079A273DA56000D1F455 /* ThomasViewController.swift in Sources */, 6E15B6DB26CC749F0099C92D /* RemoteConfigManager.swift in Sources */, 6EED67F82CE26CE40087CDCB /* ThomasSizeConstraint.swift in Sources */, A6D6D48F2A0253AA0072A5CA /* ActionArguments.swift in Sources */, 6E6BD24E2AEAFEC500B9DFC9 /* AirshipStateOverrides.swift in Sources */, 6E49D7C228401D2E00C7BB9D /* PermissionStatus.swift in Sources */, 6E6C3F8A27A266C0007F55C7 /* CachedValue.swift in Sources */, 6E1D8AD826CC66BE0049DACB /* RemoteConfigCache.swift in Sources */, E99605A727A075C600365AE4 /* OpenRegistrationOptions.swift in Sources */, 6E664BDB26C4CD8700A2C8E5 /* EnableFeatureAction.swift in Sources */, 6EB839492BC8898E006611C4 /* AirshipAsyncChannel.swift in Sources */, A6D6D49D2A0260780072A5CA /* AirshipAction.swift in Sources */, 6E87BD6726D6A39A0005D20D /* AirshipConfig.swift in Sources */, 6E82483829A6E1BE00136EA0 /* AirshipCancellable.swift in Sources */, 6E6BD2782AF2B97300B9DFC9 /* AirshipTaskSleeper.swift in Sources */, 6E57CE3228DB8BDA00287601 /* LiveActivityUpdate.swift in Sources */, 6EED67E32CE268680087CDCB /* ThomasButtonTapEffect.swift in Sources */, 6E5214672DCAB03900CF64B9 /* ThomasFormResult.swift in Sources */, 6EED68012CE26DCD0087CDCB /* ThomasAttributeValue.swift in Sources */, 6E78848F29B9643C00ACAE45 /* AirshipContact.swift in Sources */, 6EED67A22CE1A47C0087CDCB /* ThomasColor.swift in Sources */, 6E916C572DB30DA200C676FA /* AirshipWindowFactory.swift in Sources */, 6E299FDF28D14258001305A7 /* AirshipRequestSession.swift in Sources */, 6E7112A52880DACB004942E4 /* StateController.swift in Sources */, 6ECDDE7429B80462009D79DB /* ChannelAuthTokenProvider.swift in Sources */, 6E88739A2763D8AB00AC248A /* AirshipImageProvider.swift in Sources */, 6E65FB602C753CB400D9F341 /* EmbeddedViewSelector.swift in Sources */, 6EF27DD927306C9100548DA3 /* AirshipToggle.swift in Sources */, 6EB5158128A47BD700870C5A /* SubscriptionListEdit.swift in Sources */, 6EED67652CDEE7900087CDCB /* ThomasViewInfo.swift in Sources */, 6E91E43D28EF423400B6F25E /* WorkConditionsMonitor.swift in Sources */, 6E698E03267A799500654DB2 /* AirshipErrors.swift in Sources */, 6E2D6AEE26B083DB00B7C226 /* ChannelAudienceManager.swift in Sources */, 6ECD4F712DD7A7CD0060EE72 /* ToggleLayout.swift in Sources */, 6EED677D2CDEE9040087CDCB /* ThomasSerializable.swift in Sources */, 6E698E3F267BEDC300654DB2 /* AttributesEditor.swift in Sources */, 6EB11C892697AF5600DC698F /* TagGroupUpdate.swift in Sources */, 6E3B32CF2755D8C700B89C7B /* LayoutState.swift in Sources */, 6EED68E72CE3ECCB0087CDCB /* ThomasPropertyOverride.swift in Sources */, 99E433982C9A044C006436B9 /* AirshipResources.swift in Sources */, 6E87BE1326E28F570005D20D /* AirshipInstance.swift in Sources */, 32515869272AFB2E00DF8B44 /* Media.swift in Sources */, 6E91B4452686911B00DDB1A8 /* EventUtils.swift in Sources */, 6E96ECFA293FDDD90053CC91 /* AirshipSDKExtension.swift in Sources */, E976486F27A46CC50024518D /* ChannelType.swift in Sources */, 6E68203228EDE3E200A4F90B /* LiveActivityRestorer.swift in Sources */, 6E92EC8D2849378E0038802D /* PermissionPrompter.swift in Sources */, 6EDE5F4F2BA248FF00E33D04 /* TouchViewModifier.swift in Sources */, 6E3CA5412ECB9B7900210C32 /* AirshipDisplayTarget.swift in Sources */, 609843562D6F518900690371 /* SmsLocalePicker.swift in Sources */, 6E1892DE2694F1C100417887 /* ChannelRegistrar.swift in Sources */, 6E2F5A8E2A66FE8900CABD3D /* AirshipTimeCriteria.swift in Sources */, 6E213B182BC60AF100BF24AE /* AirshipWeakValueHolder.swift in Sources */, 6E91E44628EF423400B6F25E /* AirshipWorkManagerProtocol.swift in Sources */, 6E91E45228EF423400B6F25E /* AirshipWorkManager.swift in Sources */, 6EF27DE92730E85700548DA3 /* RadioInput.swift in Sources */, 6E49D7B828401D2E00C7BB9D /* Atomic.swift in Sources */, 6EED67F62CE26CB80087CDCB /* ThomasConstrainedSize.swift in Sources */, 6E146FF32F525E7300320A36 /* AirshipPasteboard.swift in Sources */, 6EED677A2CDEE87D0087CDCB /* ThomasShadow.swift in Sources */, 6E4325E92B7AEB1F00A9B000 /* AirshipEvent.swift in Sources */, 3CA84AB826DE257200A59685 /* DefaultAirshipAnalytics.swift in Sources */, 6EED681D2CE274300087CDCB /* ThomasAccessibilityAction.swift in Sources */, 6E94760F29BA8FA30025F364 /* ContactManager.swift in Sources */, 6E55A4D72E1DB4F700B07DF8 /* ThomasAssociatedLabelResolver.swift in Sources */, 6E49D7B428401D2E00C7BB9D /* PermissionDelegate.swift in Sources */, 6E524C732C126F5F002CA094 /* AirshipEventType.swift in Sources */, 6E91E44028EF423400B6F25E /* Worker.swift in Sources */, 6E91B44226868C3400DDB1A8 /* ProximityRegion.swift in Sources */, 6EDFBBC32F5780BC0043D9EF /* BasementImport.swift in Sources */, 6E0105012DDFA5E9009D651F /* ScoreController.swift in Sources */, 6E1A15062D6EA3A50056418B /* ThomasFormState.swift in Sources */, A69C987F27E247B20063A101 /* SubscriptionListAction.swift in Sources */, 6E887CD5272C5F5A00E83363 /* CheckboxController.swift in Sources */, 6E49D7C428401D2E00C7BB9D /* PushNotificationDelegate.swift in Sources */, 6E97D6B12D84B18E0001CF7F /* ThomasFormDataCollector.swift in Sources */, 27264FB32E81B064000B6FA3 /* AirshipSceneController.swift in Sources */, 3CA84AAE26DE255200A59685 /* EventStore.swift in Sources */, 6E5A64C42AAB7D5C00574085 /* AirshipMeteredUsage.swift in Sources */, 6E6BD2462AEAFE7E00B9DFC9 /* DeferredAPIClient.swift in Sources */, 3CC8AA0626BB3C7900405614 /* DefaultAirshipPush.swift in Sources */, 60EACF542B7BF2EA00CAFDBB /* AirshipApptimizeIntegration.swift in Sources */, 60D3BCCE2A15471C00E07524 /* AudienceHashSelector.swift in Sources */, 6E40868C2B8931C900435E2C /* AirshipViewSizeReader.swift in Sources */, 6E5213E32DCA7A3B00CF64B9 /* ThomasEvent.swift in Sources */, 6E6ED1672683DBC300A2CBD0 /* UACoreData.swift in Sources */, 6E87BD8326D757CA0005D20D /* CloudSite.swift in Sources */, 6ED735DA26C73DC5003B0A7D /* DefaultAirshipChannel.swift in Sources */, 6E1BACDB2719ED7D0038399E /* ScrollLayout.swift in Sources */, 6EF1E9282CD005E4005EAA07 /* PagerUtils.swift in Sources */, 6E89329A2E7B66C300FB0EC4 /* NotificationRegistrationResult.swift in Sources */, 6EED67C32CE1B5890087CDCB /* ThomasIcon.swift in Sources */, 6E49D7C028401D2E00C7BB9D /* UNNotificationRegistrar.swift in Sources */, 6EE49BE12A0AADC900AB1CF4 /* AppRemoteDataProviderDelegate.swift in Sources */, C00ED4CF26C729390040C5D0 /* URLAllowList.swift in Sources */, 6E6BD24A2AEAFEB700B9DFC9 /* AirsihpTriggerContext.swift in Sources */, 60C1DB112A8B743C00A1D3DA /* AirshipEmbeddedObserver.swift in Sources */, 6E5A64D42AABBED600574085 /* MeteredUsageStore.swift in Sources */, 6E07689229FB39440014E2A9 /* AirshipUnsafeSendableWrapper.swift in Sources */, 6E6ED1492683D8E200A2CBD0 /* LocaleManager.swift in Sources */, 6E96ECF2293EB7900053CC91 /* AirshipEventData.swift in Sources */, E99605A427A075B800365AE4 /* SMSRegistrationOptions.swift in Sources */, 6EFD6D7127290C16005B26F1 /* TextInput.swift in Sources */, 6E5A64D02AABBEAF00574085 /* AirshipMeteredUsageEvent.swift in Sources */, 6E5A64C82AABBE7100574085 /* MeteredUsageAPIClient.swift in Sources */, 6EC755992A4E115400851ABB /* DeviceAudienceSelector.swift in Sources */, 6E44626729E6813A00CB2B56 /* AsyncSerialQueue.swift in Sources */, 6EF1E92A2CD0069B005EAA07 /* PagerSwipeDirection.swift in Sources */, 6E6ED1692683DBC300A2CBD0 /* AirshipNetworkChecker.swift in Sources */, 6E29474D2AD5DA3B009EC6DD /* LiveActivityRegistrationStatus.swift in Sources */, 6E4E5B3D26E7F91600198175 /* Attributes.swift in Sources */, 6EED68332CE28FAC0087CDCB /* ThomasConstants.swift in Sources */, 6E77CE472D8A28B90057A52C /* CachingSMSValidatorAPIClient.swift in Sources */, A658DE0C2727020200007672 /* ImageButton.swift in Sources */, 60CE9BDE2D0B6A0900A8B625 /* ThomasPagerControllerBranching.swift in Sources */, 6E0F557F2AE03BB900E7CB8C /* ThomasAsyncImage.swift in Sources */, 6E43219226EA89B6009228AB /* NativeBridgeDelegate.swift in Sources */, 6E0B8762294CE0DC0064B7BD /* FarmHashFingerprint64.swift in Sources */, 6EE49C082A0BE9F600AB1CF4 /* RemoteDataURLFactory.swift in Sources */, 6E2947512AD5DB5A009EC6DD /* LiveActivityRegistrationStatusUpdates.swift in Sources */, 6E411CAB2538C6A600FEE4E8 /* UARemoteData.xcdatamodeld in Sources */, A67EC24B279B1C34009089E1 /* ScopedSubscriptionListUpdate.swift in Sources */, A684939D273436370021675E /* FontViewModifier.swift in Sources */, 6E062D092716571F001A74A1 /* LabelButton.swift in Sources */, 6E0105052DDFA735009D651F /* ScoreState.swift in Sources */, 6EB3FCEF2ABCFA680018594E /* RemoteDataProtocol.swift in Sources */, 6E1589502AFEF19F00954A04 /* SessionTracker.swift in Sources */, 6EC7E48D26A738C80038CFDD /* ContactConflictEvent.swift in Sources */, 32FD4C782D8079910056D141 /* BasicToggleLayout.swift in Sources */, 60C1DB102A8B743C00A1D3DA /* AirshipEmbeddedViewManager.swift in Sources */, 9908E6122B0189F800DB3E2E /* ArishipCustomViewManager.swift in Sources */, 6E664BA126C43F5400A2C8E5 /* ActivityViewController.swift in Sources */, 6E82482229A6D9DF00136EA0 /* CancellableValueHolder.swift in Sources */, 6E46A273272B19760089CDE3 /* ViewExtensions.swift in Sources */, 6E664BEA26C6DB7600A2C8E5 /* AirshipUtils.swift in Sources */, 6EED68222CE2806D0087CDCB /* ThomasAccessibleInfo.swift in Sources */, 6E49D7BA28401D2E00C7BB9D /* NotificationPermissionDelegate.swift in Sources */, 6EED67A72CE1A5000087CDCB /* ThomasBorder.swift in Sources */, 6EC824A02F33A4DD00E1C0C6 /* MessageDisplayHistory.swift in Sources */, 6E1A19222D6F875A0056418B /* ThomasFormValidationMode.swift in Sources */, 6E6ED1612683DBC300A2CBD0 /* AirshipVersion.swift in Sources */, 6E49D7B628401D2E00C7BB9D /* NotificationRegistrar.swift in Sources */, 6EEE8BA2290B3EDE00230528 /* AirshipKeychainAccess.swift in Sources */, 3215CA9D2739349800B7D97E /* ModalView.swift in Sources */, 6E9C2B7D2D014438000089A9 /* APNSEnvironment.swift in Sources */, 3CC95B1F268E785900FE2ACD /* NotificationCategories.swift in Sources */, A61517B526AEEAAB008A41C4 /* SubscriptionListUpdate.swift in Sources */, 6E952926268B8F6600398B54 /* AssociatedIdentifiers.swift in Sources */, 6EB11C872697ACBF00DC698F /* ContactOperation.swift in Sources */, 27CCF8D32F2382750018058F /* ThomasStateStorage.swift in Sources */, 27AFE70F2E733F4400767044 /* ModifyTagsAction.swift in Sources */, 8401769A26C5725800373AF7 /* AirshipJSONUtils.swift in Sources */, 6E0031AB2D08CC920004F53E /* AirshipAuthorizedNotificationSettings.swift in Sources */, 6ED2F5392B7FFF68000AFC80 /* AirshipSceneManager.swift in Sources */, 6E692AFF29E0CB4100D96CCC /* JavaScriptCommandDelegate.swift in Sources */, 6EBD12052DA73FDA00F678AB /* ValidatableHelper.swift in Sources */, 6E9C2BDA2D027B5F000089A9 /* AirshipAppCredentials.swift in Sources */, 6E887CD1272C5E8400E83363 /* CheckboxState.swift in Sources */, 6E4339F12DFA099F000A7741 /* AirshipIvyVersionMatcher.swift in Sources */, 3CA84ABA26DE257200A59685 /* AirshipAnalytics.swift in Sources */, 6ED735DD26C7401D003B0A7D /* TagEditor.swift in Sources */, 6E698E41267BEDC300654DB2 /* TagGroupsEditor.swift in Sources */, 6E739D6B26B9DFFB00BC6F6D /* AttributePendingMutations.swift in Sources */, A67F87D2268DECCE00EF5F43 /* ContactAPIClient.swift in Sources */, 6EFD6D5C27273257005B26F1 /* Shapes.swift in Sources */, 6E1C9C4B271F7878009EF9EF /* BackgroundColorViewModifier.swift in Sources */, 6E6ED13B2683A58D00A2CBD0 /* Dispatcher.swift in Sources */, 6EED67C82CE1B6020087CDCB /* ThomasMediaFit.swift in Sources */, 6E6BD2722AF1B05500B9DFC9 /* UAirshipCache.xcdatamodeld in Sources */, 6EE49C182A0C3CC600AB1CF4 /* ContactRemoteDataProviderDelegate.swift in Sources */, 32D6E87B2727F7060077C784 /* Image.swift in Sources */, 6E4325F82B7C08A600A9B000 /* AirshipAnalyticsFeed.swift in Sources */, 6E062D05271656F8001A74A1 /* LinearLayout.swift in Sources */, 60D3BCC62A152A0D00E07524 /* ExperimentManager.swift in Sources */, 6E15B72326CEC7030099C92D /* RemoteDataPayload.swift in Sources */, 6E91E43A28EF423400B6F25E /* WorkBackgroundTasks.swift in Sources */, 6E2FA2892D51519B005893E2 /* ThomasEmailRegistrationOptions.swift in Sources */, 6EED68042CE26E1A0087CDCB /* ThomasMargin.swift in Sources */, 6E664BDF26C4CD8700A2C8E5 /* FetchDeviceInfoAction.swift in Sources */, 3C63556026CDD4F8006E9916 /* AirshipPush.swift in Sources */, A6A5531326D548FF002B20F6 /* NativeBridgeActionHandler.swift in Sources */, 6E698E57267BF63B00654DB2 /* AppStateTrackerAdapter.swift in Sources */, 6EED676E2CDEE7EB0087CDCB /* ThomasPresentationInfo.swift in Sources */, 6E9D529826C195F7004EA16B /* ActionRunner.swift in Sources */, 6EC7559B2A4E129000851ABB /* DeviceTagSelector.swift in Sources */, 329DFCCF2B7E4DDA0039C8C0 /* UARemoteDataMapping.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E0B8726294A9C120064B7BD /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 99E8D7BF2B50C2C10099B6F3 /* ButtonGroup.swift in Sources */, 998572BF2B3CF95D0091E9C9 /* DefaultAssetDownloader.swift in Sources */, 6E1B7B132B714FFC00695561 /* LandingPageAction.swift in Sources */, 6068E03B2B2CBCF200349E82 /* ActiveTimer.swift in Sources */, 99E8D7CE2B54A66E0099B6F3 /* InAppMessageThemeBanner.swift in Sources */, 3231128329D5E67200CF0D86 /* FrequencyLimitManager.swift in Sources */, 6E1528202B4DC59C00DF1377 /* InAppMessageSceneDelegate.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 /* InAppMessageThemeText.swift in Sources */, 6EDF1DA62B2A300100E23BC4 /* InAppMessage.swift in Sources */, 99E8D7D02B54A68F0099B6F3 /* InAppMessageThemeHTML.swift in Sources */, 6EDF1D962B2A25B400E23BC4 /* InAppMessageButtonInfo.swift in Sources */, 6EDF1DB82B2BB2B800E23BC4 /* RetryingQueue.swift in Sources */, 6E1A9BC12B5EE1CF00A6489B /* SchedulePrepareResult.swift in Sources */, 6E15281B2B4DC3DF00DF1377 /* InAppMessageAutomationExecutor.swift in Sources */, 6E1A9BB02B5B0C4C00A6489B /* AutomationActionRunner.swift in Sources */, 990A09592B5C677C00244D90 /* InAppMessageWebView.swift in Sources */, 6E986F0E2B473EC700FBE6A0 /* AutomationRemoteDataAccess.swift in Sources */, 6E1528352B4E11DB00DF1377 /* CustomDisplayAdapter.swift in Sources */, 6EC0CA5C2B48C2F500333A87 /* AutomationPreparer.swift in Sources */, 990A09AF2B5DBD0400244D90 /* InAppMessageViewUtils.swift in Sources */, 3231128429D5E67200CF0D86 /* Occurrence.swift in Sources */, 6E1CBE2D2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodeld in Sources */, 99CF46182B3217C300B6FD9B /* AirshipCachedAssets.swift in Sources */, 999DC85E2B5B721D0048C6AF /* HTMLView.swift in Sources */, 6E1185C62C3328A10071334E /* ExecutionWindow.swift in Sources */, 99CF461A2B3217DE00B6FD9B /* AssetCacheManager.swift in Sources */, 6E1A9BF72B606CF200A6489B /* AutomationDelayProcessor.swift in Sources */, 6E16208F2B3116BA009240B2 /* DefaultDisplayCoordinator.swift in Sources */, 99E0BD0F2B4DD71A00465B37 /* InAppMessageHostingController.swift in Sources */, 6E1CBDE32BA51ED100519D9C /* InAppDisplayImpressionRuleProvider.swift in Sources */, 60D1D9B82B68FB6400EBE0A4 /* PreparedTrigger.swift in Sources */, 6E2E3CA22B32723C00B8515B /* InAppMessageNativeBridgeExtension.swift in Sources */, 99F662B22B60425E00696098 /* InAppMessageModalView.swift in Sources */, 6E213B1E2BC7054500BF24AE /* InAppActionRunner.swift in Sources */, 6E0F4BE92B3264A400673CA4 /* DeferredAutomationData.swift in Sources */, 6E1528332B4DF2E600DF1377 /* ScheduleConditionsChangedNotifier.swift in Sources */, 6E1A9BC52B5EE1EE00A6489B /* ScheduleExecuteResult.swift in Sources */, 6E16208D2B3116AE009240B2 /* ImmediateDisplayCoordinator.swift in Sources */, 990A09942B5CA5B700244D90 /* InAppMessageExtensions.swift in Sources */, 99E8D7BB2B50A7C20099B6F3 /* InAppMessageViewDelegate.swift in Sources */, 6EDF1DA42B2A2C6F00E23BC4 /* InAppMessageColor.swift in Sources */, 3231128729D5E67200CF0D86 /* FrequencyConstraint.swift in Sources */, 6E1528192B4DC3D000DF1377 /* InAppMessageAutomationPreparer.swift in Sources */, 99F662B02B5DDC2900696098 /* BeveledLoadingView.swift in Sources */, 6E1528372B4E11E800DF1377 /* CustomDisplayAdapterWrapper.swift in Sources */, 6E4AEE572B6B4358008AEAC1 /* UAAutomation.xcdatamodeld in Sources */, 6E4AEE642B6B44EA008AEAC1 /* LegacyAutomationStore.swift in Sources */, 6EC0CA532B48A2C300333A87 /* AutomationAudience.swift in Sources */, 6E1A9BC92B5EE34600A6489B /* AutomationEventFeed.swift in Sources */, 99E8D7C12B50E5F40099B6F3 /* InAppMessageRootView.swift in Sources */, 99E8D7CB2B54A6340099B6F3 /* InAppMessageThemeFullscreen.swift in Sources */, 603269552BF4B8D5007F7F75 /* AdditionalAudienceCheckerResolver.swift in Sources */, 6EDF1D9E2B2A2A5900E23BC4 /* InAppMessageDisplayContent.swift in Sources */, 3231128829D5E67200CF0D86 /* FrequencyChecker.swift in Sources */, 998572C12B3CF97B0091E9C9 /* DefaultAssetFileManager.swift in Sources */, 6EE6AA2C2B51DB1E002FEA75 /* AutomationSourceInfoStore.swift in Sources */, 99E8D7992B4F19BA0099B6F3 /* TextView.swift in Sources */, 6EE6AAFF2B58AB66002FEA75 /* LegacyInAppAnalytics.swift in Sources */, 6E524D022C1A2CAE002CA094 /* InAppMessageThemeManager.swift in Sources */, 6E986F062B47319E00FBE6A0 /* DeferredScheduleResult.swift in Sources */, 6E986EF92B44D41E00FBE6A0 /* InAppAutomation.swift in Sources */, 6E1528392B4E13D400DF1377 /* AirshipLayoutDisplayAdapter.swift in Sources */, 6E1A9BD32B5EE8A400A6489B /* AutomationScheduleState.swift in Sources */, 6E1A9BBF2B5EE19000A6489B /* PreparedSchedule.swift in Sources */, 6E1A9BD12B5EE84600A6489B /* AutomationScheduleData.swift in Sources */, 6ED838D02D0D118B009CBB0C /* AutomationCompoundAudience.swift in Sources */, 3231128C29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodeld in Sources */, 6E8BDA172B62EC9F00711DB8 /* AutomationRemoteDataSubscriber.swift in Sources */, 60FCA3052B4F1110005C9232 /* LegacyInAppMessaging.swift in Sources */, 6EDF1D932B292FB100E23BC4 /* InAppMessageTextInfo.swift in Sources */, 6E16208A2B311219009240B2 /* DisplayCoordinator.swift in Sources */, 6E15281D2B4DC43100DF1377 /* ActionAutomationPreparer.swift in Sources */, 6E986EFB2B44D48C00FBE6A0 /* InAppMessaging.swift in Sources */, 99E8D7DC2B55C4C20099B6F3 /* InAppMessageThemeMedia.swift in Sources */, 99E8D7C52B5192D40099B6F3 /* MediaView.swift in Sources */, 6E524D042C1A454E002CA094 /* InAppMessageThemeShadow.swift in Sources */, A6E9ADED2D4D204B0091BBAF /* InAppAutomationUpdateStatus.swift in Sources */, 603269532BF4B141007F7F75 /* AdditionalAudienceCheckerApiClient.swift in Sources */, 6E4AEEBC2B6D6380008AEAC1 /* TriggerData.swift in Sources */, 99E8D7D82B55B0440099B6F3 /* InAppMessageThemeButton.swift in Sources */, 6E0F4BE52B32645600673CA4 /* AutomationTrigger.swift in Sources */, 6E1A9BAB2B5AE38A00A6489B /* InAppMessageDisplayListener.swift in Sources */, 6E34C4B12C7D4B6400B00506 /* ExecutionWindowProcessor.swift in Sources */, 60FCA3072B4F1C73005C9232 /* LegacyInAppMessage.swift in Sources */, 6EDF1D9C2B2A287A00E23BC4 /* InAppMessageButtonLayoutType.swift in Sources */, 6E1A9BC72B5EE32E00A6489B /* AutomationTriggerProcessor.swift in Sources */, 6EE6AA202B4F5246002FEA75 /* InAppMessageSceneManager.swift in Sources */, 6E1A9BD52B5EE97000A6489B /* TriggeringInfo.swift in Sources */, 6E68028B2B850DDE00F4591F /* ApplicationMetrics.swift in Sources */, 6E15282C2B4DE81E00DF1377 /* AutomationSDKModule.swift in Sources */, 6E7E770D2DDFD0D80042086D /* AirshipAsyncSemaphore.swift in Sources */, 99E0BD0D2B4DD4AB00465B37 /* FullscreenView.swift in Sources */, 6E6802902B8671E700F4591F /* InAppAutomationComponent.swift in Sources */, 27077E4C2EE7531C0027A282 /* AutomationEventsHistory.swift in Sources */, 6E1528262B4DC64B00DF1377 /* DisplayAdapterFactory.swift in Sources */, 3231128229D5E67200CF0D86 /* FrequencyLimitStore.swift in Sources */, 6E1528172B4DC3C000DF1377 /* ActionAutomationExecutor.swift in Sources */, 99F662D22B63047300696098 /* InAppMessageBannerView.swift in Sources */, 6E15282F2B4DED7A00DF1377 /* InAppMessageAnalytics.swift in Sources */, 6EC0CA682B49287100333A87 /* AutomationExecutor.swift in Sources */, 6E1528222B4DC5C000DF1377 /* InAppMessageDisplayDelegate.swift in Sources */, 99E8D7BD2B50AA060099B6F3 /* InAppMessageEnvironment.swift in Sources */, 6E1A9BC32B5EE1DE00A6489B /* ScheduleReadyResult.swift in Sources */, 6E0F4BE22B32190400673CA4 /* AutomationSchedule.swift in Sources */, 6E986EE42B448D3C00FBE6A0 /* AutomationEngine.swift in Sources */, 6E0F4BE72B32646000673CA4 /* AutomationDelay.swift in Sources */, 3231126A29D5E4F600CF0D86 /* AirshipAutomationResources.swift in Sources */, 6EC0CA812B4C812A00333A87 /* DisplayAdapter.swift in Sources */, 6E1528312B4DED8900DF1377 /* InAppMessageAnalyticsFactory.swift in Sources */, 6E1528242B4DC60200DF1377 /* DisplayCoordinatorManager.swift in Sources */, 99E8D7972B4F17260099B6F3 /* CloseButton.swift in Sources */, 99E8D7C92B54A5CB0099B6F3 /* InAppMessageThemeModal.swift in Sources */, 6E4AEE652B6B44EA008AEAC1 /* AutomationStore.swift in Sources */, 60F8E7602B8FAF5400460EDF /* ScheduleAction.swift in Sources */, 60F8E75C2B8F3D4B00460EDF /* CancelSchedulesAction.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E0B872D294A9C130064B7BD /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EC0CA502B4899CC00333A87 /* TestRemoteData.swift in Sources */, 6EE6AB062B59C231002FEA75 /* AirshipLayoutDisplayAdapterTest.swift in Sources */, 6EDE5FC22BADDD96003ADF55 /* PreparedScheduleInfoTest.swift in Sources */, 325D53FA29648818003421B4 /* TestAirshipRequestSession.swift in Sources */, 6E1A9BBB2B5B20D700A6489B /* TestInAppMessageAnalytics.swift in Sources */, 6E6B2DBE2B33B768008BF788 /* AutomationScheduleTest.swift in Sources */, 325D53F629646E53003421B4 /* TestChannel.swift in Sources */, 60FCA30C2B51492A005C9232 /* LegacyInAppMessagingTest.swift in Sources */, 6EC0CA782B4B8A4700333A87 /* TestFrequencyLimitsManager.swift in Sources */, 6E4AEE302B6B3041008AEAC1 /* InAppMessageThemeTest.swift in Sources */, 6E1620952B311D8A009240B2 /* DefaultDisplayCoordinatorTest.swift in Sources */, 6E07B5F92D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */, 60F8E7622B8FB2CC00460EDF /* ScheduleActionTest.swift in Sources */, 6EE6AA132B4F3009002FEA75 /* InAppMessageAutomationPreparerTest.swift in Sources */, 6EC0CA762B4B8A3A00333A87 /* TestRemoteDataAccess.swift in Sources */, 6E34C4B32C7D4C6600B00506 /* ExecutionWindowProcessorTest.swift in Sources */, 60D1D9BD2B6AB2D100EBE0A4 /* AutomationTriggerProcessorTest.swift in Sources */, 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 */, 6EC0CA792B4B8C2B00333A87 /* TestAudienceChecker.swift in Sources */, 6EC0CA512B4899DB00333A87 /* TestNetworkMonitor.swift in Sources */, 99E6EF6D2B8E3C250006326A /* InAppMessageContentValidationTest.swift in Sources */, 6EB839472BC83B9D006611C4 /* DefaultInAppActionRunnerTest.swift in Sources */, 6E6A84932B68A57E006FFB35 /* AutomationStoreTest.swift in Sources */, 6EE6AA382B572897002FEA75 /* AutomationSourceInfoStoreTest.swift in Sources */, 60FCA3252B5EF3A8005C9232 /* AutomationEventFeedTest.swift in Sources */, 6E68028E2B852F6A00F4591F /* AutomationScheduleDataTest.swift in Sources */, 60A364ED2C3479BF00B05E26 /* ExecutionWindowTest.swift in Sources */, 6EC0CA732B4B897B00333A87 /* TestExperimentDataProvider.swift in Sources */, 6EC0CA562B48B05600333A87 /* ActionAutomationExecutorTest.swift in Sources */, 6E4325C62B7AC40D00A9B000 /* TestPush.swift in Sources */, 6E1A9BB22B5B172F00A6489B /* TestActionRunner.swift in Sources */, 60FCA30D2B5534DB005C9232 /* TestAirshipInstance.swift in Sources */, 603269592BF75976007F7F75 /* AirshipCacheTest.swift in Sources */, 6EE6AB092B59C236002FEA75 /* CustomDisplayAdapterWrapperTest.swift in Sources */, 605073842B2CD46D00209B51 /* TestAppStateTracker.swift in Sources */, 27F1E19D2F0E836000E317DB /* InAppMessageAnalyticsTest.swift in Sources */, 60F8E75E2B8FA12800460EDF /* CancelSchedulesActionTest.swift in Sources */, 6E2E3CA62B327A6C00B8515B /* InAppMessageNativeBridgeExtensionTest.swift in Sources */, 6EE6AA1B2B4F3062002FEA75 /* DisplayCoordinatorManagerTest.swift in Sources */, 6EE6AA2A2B50C976002FEA75 /* TestAutomationEngine.swift in Sources */, A6F0B1912B83CD9B002D10A4 /* AutomationEngineTest.swift in Sources */, 6E6A848D2B6854FC006FFB35 /* AutomationDelayProcessorTest.swift in Sources */, 605073832B2CD38200209B51 /* ActiveTimerTest.swift in Sources */, 6EE6AA162B4F302D002FEA75 /* InAppMessageAutomationExecutorTest.swift in Sources */, 6E7E770F2DDFD10A0042086D /* AirshipAsyncSemaphoreTest.swift in Sources */, 6EDF1DAD2B2A73FC00E23BC4 /* InAppMessageTest.swift in Sources */, 325D53F729648150003421B4 /* TestDate.swift in Sources */, 6E4AEE2A2B6B2E0A008AEAC1 /* AssetCacheManagerTest.swift in Sources */, 6E4AEE2B2B6B2E0A008AEAC1 /* DefaultAssetDownloaderTest.swift in Sources */, 6EE6AA1C2B4F3066002FEA75 /* DisplayAdapterFactoryTest.swift in Sources */, 60FCA30A2B51364A005C9232 /* LegacyInAppMessageTest.swift in Sources */, 3231129129D5E6D900CF0D86 /* FrequencyLimitManagerTest.swift in Sources */, 6EC0CA4F2B48987700333A87 /* AutomationRemoteDataAccessTest.swift in Sources */, 6E1473EA2F527C4D00320A36 /* TestURLOpener.swift in Sources */, 6E1A9BB72B5B1D9E00A6489B /* TestActiveTimer.swift in Sources */, 6E1CBDFF2BAA1DF200519D9C /* DefaultInAppDisplayImpressionRuleProviderTest.swift in Sources */, 6E1A9BB92B5B20A500A6489B /* InAppMessageDisplayListenerTest.swift in Sources */, 6EE6AB022B58B6E9002FEA75 /* LegacyInAppAnalyticsTest.swift in Sources */, 27F1E1A02F0E84C300E317DB /* ThomasLayoutEventTestUtils.swift in Sources */, 60D1D9BB2B6A53F000EBE0A4 /* PreparedTriggerTest.swift in Sources */, A6AC44832B923ACB00769ED2 /* TestInAppMessageAutomationExecutor.swift in Sources */, 6EC0CA6B2B4B698000333A87 /* AutomationExecutorTest.swift in Sources */, 27F1E19F2F0E848B00E317DB /* TestThomasLayoutEvent.swift in Sources */, 6E1620932B3118D9009240B2 /* ImmediateDisplayCoordinatorTest.swift in Sources */, 6E1D90022B2D1AB4004BA130 /* RetryingQueueTests.swift in Sources */, 6E1528402B4F153900DF1377 /* TestDisplayAdapter.swift in Sources */, 6E4AEE282B6B2E0A008AEAC1 /* DefaultAssetFileManagerTest.swift in Sources */, 6E9C2BD32D02683D000089A9 /* RuntimeConfig.swift in Sources */, 6EE6AA1E2B4F31B1002FEA75 /* TestCachedAssets.swift in Sources */, 6EC0CA702B4B895A00333A87 /* TestDeferredResolver.swift in Sources */, 325D53FB2964885B003421B4 /* AirshipBaseTest.swift in Sources */, 6032695C2BF75E39007F7F75 /* AirshipHTTPResponseTest.swift in Sources */, 6E1528422B4F156200DF1377 /* TestDisplayCoordinator.swift in Sources */, 27051CD72EE75E3300C770D5 /* AutomationEventsHistoryTest.swift in Sources */, 6EE6AA282B50C91E002FEA75 /* AutomationRemoteDataSubscriberTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E431F6D26EA814F009228AB /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EDFBBC42F5780EA0043D9EF /* AirshipLogHandler.swift in Sources */, 6EDFBBC52F5780EA0043D9EF /* AirshipLogger.swift in Sources */, 6EDFBBC62F5780EA0043D9EF /* AirshipLogPrivacyLevel.swift in Sources */, 6EDFBBC72F5780EA0043D9EF /* DefaultLogHandler.swift in Sources */, 6EDFBBC82F5780EA0043D9EF /* LogLevel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6E4A466A28EF44F600A25617 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EB8394C2BC8AB51006611C4 /* TestWorkManager.swift in Sources */, 32F615A728F708980015696D /* MessageCenterListTests.swift in Sources */, 32F615A828F708AD0015696D /* TestChannel.swift in Sources */, 32F68CDC28F02A7100F7F52A /* MessageCenterStoreTest.swift in Sources */, 6E07B5FB2D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */, 6E9C2BD52D02683D000089A9 /* RuntimeConfig.swift in Sources */, 27CCF77F2F16DA500018058F /* MessageViewAnalyticsTest.swift in Sources */, 6EF13FFD2A16F390009A125D /* AirshipBaseTest.swift in Sources */, 6E4A467928EF453400A25617 /* MessageCenterAPIClientTest.swift in Sources */, 6EC824A22F33A5EC00E1C0C6 /* MessageCenterThemeLoaderTest.swift in Sources */, 6E4A468128EF4FAF00A25617 /* TestAirshipRequestSession.swift in Sources */, 60A292112CB7C5C30096F5EB /* TestDate.swift in Sources */, 6EC824A42F33A5F600E1C0C6 /* MessageCenterMessageTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 847BFFF0267CD739007CD249 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 99560C2D2BB3855800F28BDC /* EmptySectionLabel.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 */, 6E9B488B2891962000C905B1 /* PreferenceCenterView.swift in Sources */, 6E2486F728984D0D00657CE4 /* PreferenceCenterTheme.swift in Sources */, 993F91FB2CA37874001B1C2E /* FooterView.swift in Sources */, 99104DF32BA6689A0040C0FD /* PreferenceCloseButton.swift in Sources */, 6E892F2E2E7A193E00FB0EC4 /* PreferenceCenterContent.swift in Sources */, 6E9B48932891B6A700C905B1 /* ChannelSubscriptionView.swift in Sources */, 6E9B48952891B6B400C905B1 /* ContactSubscriptionView.swift in Sources */, 6EB5157128A4608C00870C5A /* PreferenceCenterViewControllerFactory.swift in Sources */, 6E9B488F2891B57300C905B1 /* CommonSectionView.swift in Sources */, 6E2486DF28945D3900657CE4 /* PreferenceCenterState.swift in Sources */, 6EB5156E28A42B5800870C5A /* AirshipPreferenceCenterResources.swift in Sources */, 841E7D12268617C800EA0317 /* PreferenceCenterResponse.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 */, 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 /* PreferenceCenterContentStyle.swift in Sources */, 322AAB222B5FCB6B00652DAC /* ContactManagementView.swift in Sources */, 99560C1E2BAE2FFA00F28BDC /* ChannelTextField.swift in Sources */, 6E6802922B86732200F4591F /* PreferenceCenterComponent.swift in Sources */, 6E2486FD2899C06100657CE4 /* PreferenceCenterContentLoader.swift in Sources */, 84483A68267CF0C000D0DA7D /* PreferenceCenter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 847BFFF8267CD73A007CD249 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6E9C2BD82D0269AE000089A9 /* TestAirshipRequestSession.swift in Sources */, 6E1892D7268E3F1800417887 /* PreferenceCenterConfigTest.swift in Sources */, 6E1892C8268D15C300417887 /* PreferenceCenterTest.swift in Sources */, 6E8BDF022A684FC700F816D9 /* TestRemoteData.swift in Sources */, 6E9C2BD62D02683D000089A9 /* RuntimeConfig.swift in Sources */, 6EF13FFE2A16F391009A125D /* AirshipBaseTest.swift in Sources */, 60956D862CBE7CFA00950172 /* AirshipLock.swift in Sources */, 6E07B5FC2D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */, 6EB5159528A5B96300870C5A /* PreferenceThemeLoaderTest.swift in Sources */, 6EB515A328A5F1C600870C5A /* PreferenceCenterStateTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; A62058652A5841330041FBF9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6E2F5A742A60833700CABD3D /* FeatureFlagManager.swift in Sources */, 6E2F5A762A60871E00CABD3D /* FeatureFlagPayload.swift in Sources */, 6E4325F62B7B2F5800A9B000 /* FeatureFlagAnalytics.swift in Sources */, 6ED7BE632D13D9FE00B6A124 /* FeatureFlagResultCache.swift in Sources */, 6E2F5AB12A67434B00CABD3D /* FeatureFlag.swift in Sources */, A62058812A5842200041FBF9 /* AirshipFeatureFlagsSDKModule.swift in Sources */, 6E6BD2762AF1C1D800B9DFC9 /* DeferredFlagResolver.swift in Sources */, A6E9AD982D4D12D00091BBAF /* FeatureFlagUpdateStatus.swift in Sources */, 6E6802942B8673F900F4591F /* FeatureFlagComponent.swift in Sources */, 6E2F5ABA2A675D3600CABD3D /* FeatureFlagsRemoteDataAccess.swift in Sources */, 6EB214D22E7DBA61001A5660 /* FeatureFlagManagerProtocol.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; A620586C2A5841330041FBF9 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( A61F3A752A5DA58500EE94CC /* FeatureFlagManagerTest.swift in Sources */, 6E9C2BD72D0269AE000089A9 /* TestAirshipRequestSession.swift in Sources */, 6E2811682BE406A50040D928 /* FeatureFlagDeferredResolverTest.swift in Sources */, 6E8BDF012A679E5000F816D9 /* FeatureFlagRemoteDataAccessTest.swift in Sources */, 6E28116C2BE40E860040D928 /* FeatureFlagVariablesTest.swift in Sources */, 6E9C2BD42D02683D000089A9 /* RuntimeConfig.swift in Sources */, 6E4326092B7C396F00A9B000 /* TestAnalytics.swift in Sources */, 6E07B5FA2D925F2A0087EC47 /* TestPrivacyManager.swift in Sources */, 6E938DBC2AC39A0500F691D9 /* FeatureFlagAnalyticsTest.swift in Sources */, 6E8BDEFE2A67938200F816D9 /* FeatureFlagInfoTest.swift in Sources */, 6E2F5AB52A67599400CABD3D /* TestRemoteData.swift in Sources */, 6ED7BE602D13D9E300B6A124 /* TestCache.swift in Sources */, 6E2F5AB22A67589400CABD3D /* TestDate.swift in Sources */, 6E2F5AB82A675ADC00CABD3D /* TestAudienceChecker.swift in Sources */, 6ED7BE652D13DA0400B6A124 /* FeatureFlagResultCacheTest.swift in Sources */, 6E2F5AB32A6758ED00CABD3D /* TestNetworkMonitor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; A641E1432BDBBDB400DE6FAA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; CC64F0501D8B77E3009CEF27 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6ECB62862A36C1EE0095C85C /* NativeBridgeActionHandlerTest.swift in Sources */, 6EBFA9B12D15F499002BA3E9 /* HashCheckerTest.swift in Sources */, 6E5B1A052AFF090B0019CA61 /* SessionTrackerTest.swift in Sources */, 6E97D6B42D84B1D70001CF7F /* ThomasStateTest.swift in Sources */, 6E4325D32B7AD96800A9B000 /* AirshipTest.swift in Sources */, 6068E0342B2B7CA100349E82 /* RetailEventTemplateTest.swift in Sources */, 6E475CBA2F5B3E45003D8E42 /* VideoMediaWebViewTests.swift in Sources */, 6087DB882B278F7600449BA8 /* JsonValueMatcherTest.swift in Sources */, 6E1802F92C5C2DEC00198D0D /* AirshipAnalyticFeedTest.swift in Sources */, 6E4325C52B7AC3F700A9B000 /* TestPush.swift in Sources */, 6E8B4BF12888606D00AA336E /* ChannelTest.swift in Sources */, 32C68D0529424449006BBB29 /* RemoteDataTest.swift in Sources */, 6E1767F729B923D100D65F60 /* ChannelAuthTokenAPIClientTest.swift in Sources */, 6E6ED1432683B8FA00A2CBD0 /* TestDate.swift in Sources */, 6E92ECA7284AC1120038802D /* EnableFeatureActionTest.swift in Sources */, 60D2B3352D9F0FCF00B0752D /* PagerDisableSwipeSelectorTest.swift in Sources */, 6050738A2B32F85100209B51 /* ThomasViewModelTest.swift in Sources */, 6E6EF9E7270625C400D30C35 /* AirshipEventsTest.swift in Sources */, 6E52146F2DCC075500CF64B9 /* ThomasPagerTrackerTest.swift in Sources */, 6E590E6E29A94CA90036DFAB /* AppStateTrackerTest.swift in Sources */, 6ED2F5292B7FC59F000AFC80 /* AirshipColorTests.swift in Sources */, 6E4A468028EF4FAF00A25617 /* TestAirshipRequestSession.swift in Sources */, 6E66DDA62E95A68300D44555 /* WorkRateLimiterTests.swift in Sources */, 6E4007182A153AFE0013C2DE /* RemoteDataURLFactoryTest.swift in Sources */, 6E4007142A153AB20013C2DE /* AppRemoteDataProviderDelegateTest.swift in Sources */, 6E25DD052D515F33009CF1A4 /* ThomasEmailRegistrationOptionsTest.swift in Sources */, 6E1767F829B923D100D65F60 /* TestChannelAuthTokenAPIClient.swift in Sources */, 6E92ECA1284A79AB0038802D /* PromptPermissionActionTest.swift in Sources */, 6EC0CA722B4B897B00333A87 /* TestExperimentDataProvider.swift in Sources */, 6E6363EA29DCECA1009C358A /* TestContactSubscriptionListAPIClient.swift in Sources */, 6E6ED172268448EC00A2CBD0 /* TestNetworkMonitor.swift in Sources */, 6058771D2AC73C940021628E /* AirshipMeteredUsageTest.swift in Sources */, 27AFE7112E73477200767044 /* ModifyTagsActionTest.swift in Sources */, 6E15B70326CDE40E0099C92D /* RemoteConfigManagerTest.swift in Sources */, 6E6524502A4FD8D30019F353 /* JSONPredicateTest.swift in Sources */, 6E97D6AF2D84B17D0001CF7F /* ThomasFormDataCollectorTest.swift in Sources */, A62C3354299FD509004DB0DA /* ShareActionTest.swift in Sources */, 6EE49C262A1446B100AB1CF4 /* RemoteDataTestUtils.swift in Sources */, 6E7DB38E28ECFCED002725F6 /* AirshipJSONTest.swift in Sources */, 6E96ED1429417A600053CC91 /* EventStoreTest.swift in Sources */, 6E299FD528D13D00001305A7 /* DefaultAirshipRequestSessionTest.swift in Sources */, 6E146EDD2F52537000320A36 /* AishipFontTests.swift in Sources */, 6E92ECA5284A7A5C0038802D /* TestPermissionPrompter.swift in Sources */, 6068E0062B2A190300349E82 /* CustomEventTest.swift in Sources */, 6E664BE526C5817B00A2C8E5 /* TestContact.swift in Sources */, 6E9752562A5F79E200E67B1A /* ExperimentTest.swift in Sources */, 6ED040EB278B5D7C00FCF773 /* ThomasFormPayloadGeneratorTest.swift in Sources */, 6E1767FA29B92F1700D65F60 /* AirshipUtilsTest.swift in Sources */, 6E1767F629B923D100D65F60 /* ChannelAuthTokenProviderTest.swift in Sources */, 60E09FDB2B2780DB005A16EA /* JsonMatcherTest.swift in Sources */, 6EC755AF2A4FCD8800851ABB /* AudienceHashSelectorTest.swift in Sources */, 3299EF26294B222F00251E70 /* RemoteDataStoreTest.swift in Sources */, 99C3CC792BCF3E5B00B5BED5 /* SMSValidatorAPIClientTest.swift in Sources */, 6E1A1BB32D6F9D0F0056418B /* ThomasFormFieldProcessorTest.swift in Sources */, A629F7DA295B514C00671647 /* PasteboardActionTest.swift in Sources */, 32E339E32A334A2000CD3BE5 /* AddCustomEventActionTest.swift in Sources */, 6EFAFB8429561F23008AD187 /* ModifyAttributesActionTest.swift in Sources */, 6018AF572B29C20A008E528B /* SearchEventTemplateTest.swift in Sources */, 60A5CC0C2B29AE890017EDB2 /* ProximityRegionTest.swift in Sources */, 6ED7BE612D13D9E300B6A124 /* TestCache.swift in Sources */, 27F1E2122F0FF6EB00E317DB /* ThomasDisplayListenerTest.swift in Sources */, 6E92ECAC284EA7DB0038802D /* AnalyticsTest.swift in Sources */, 6ECB62842A36A7510095C85C /* DeepLinkActionTest.swift in Sources */, 6E87BE1B26E2C59D0005D20D /* TestAnalytics.swift in Sources */, 6E739D8226BB33A200BC6F6D /* ChannelBulkUpdateAPIClientTest.swift in Sources */, 6ECB62822A36A45F0095C85C /* TestURLOpener.swift in Sources */, 6EC0CA6F2B4B893500333A87 /* TestDeferredResolver.swift in Sources */, 6E65244E2A4FD69F0019F353 /* DeviceAudienceSelectorTest.swift in Sources */, 6E6363E629DCE9A2009C358A /* ContactSubscriptionListAPIClientTest.swift in Sources */, 6032695B2BF75D69007F7F75 /* AirshipHTTPResponseTest.swift in Sources */, 6E9C2BDC2D028034000089A9 /* APNSEnvironmentTest.swift in Sources */, 6E15B6FA26CDCA6A0099C92D /* RuntimeConfigTest.swift in Sources */, 6E4325C32B7A9D9A00A9B000 /* AirshipPrivacyManagerTest.swift in Sources */, 6E87BE1626E29BC90005D20D /* TestAirshipInstance.swift in Sources */, 6EB8394E2BC8B1F4006611C4 /* AirshipAsyncChannelTest.swift in Sources */, 6068E0082B2A2A6700349E82 /* AccountEventTemplateTest.swift in Sources */, 2726505B2E81B80E000B6FA3 /* PagerControllerTest.swift in Sources */, 6068E0322B2B785A00349E82 /* MediaEventTemplateTest.swift in Sources */, 6E032A502B210E6000404630 /* RemoteConfigTest.swift in Sources */, A63A567828F457FE004B8951 /* TestWorkRateLimiterActor.swift in Sources */, 6EC7E46E269E2A4C0038CFDD /* AttributeEditorTest.swift in Sources */, 6EF140212A269074009A125D /* AirshipDeviceIDTest.swift in Sources */, 6E1C9C3A271E90EB009EF9EF /* LayoutModelsTest.swift in Sources */, 6EC7E470269E33290038CFDD /* TagGroupsEditorTest.swift in Sources */, 60A5CC102B29B4100017EDB2 /* RegionEventTest.swift in Sources */, 6E7DB39228ED0D7C002725F6 /* LiveActivityRegistryTest.swift in Sources */, 6EAC295A27580063006DFA63 /* ChannelRegistrarTest.swift in Sources */, 6E49D7CD284028C600C7BB9D /* PermissionsManagerTests.swift in Sources */, 27F1E19E2F0E846B00E317DB /* TestThomasLayoutEvent.swift in Sources */, C088383726E0244C00D40838 /* TestURLAllowList.swift in Sources */, 6ED2F5252B7EE648000AFC80 /* AirshipBase64Test.swift in Sources */, 27F1E18C2F0E828D00E317DB /* ThomasLayoutEventMessageIDTest.swift in Sources */, 27F1E18D2F0E828D00E317DB /* ThomasLayoutEventRecorderTest.swift in Sources */, 27F1E18E2F0E828D00E317DB /* ThomasLayoutEventContextTest.swift in Sources */, 6ED735E026C74321003B0A7D /* TagEditorTest.swift in Sources */, 6EFAFB8229555174008AD187 /* FetchDeviceInfoActionTest.swift in Sources */, 6E96ED1229416E990053CC91 /* EventSchedulerTest.swift in Sources */, 6E96ED0E29416E820053CC91 /* EventManagerTest.swift in Sources */, 6EC7E474269E52600038CFDD /* ContactOperationTest.swift in Sources */, 6EC7E47626A5EE910038CFDD /* AudienceUtilsTest.swift in Sources */, 325D53DA295C7979003421B4 /* ActionRegistryTest.swift in Sources */, 32F293D5295AFD94004A7D9C /* ActionArgumentsTest.swift in Sources */, 6ED2F52D2B7FD403000AFC80 /* JavaScriptCommandTest.swift in Sources */, 6EFAFB7C295525DF008AD187 /* ChannelRegistrationPayloadTest.swift in Sources */, 6E2D6AF226B0B64E00B7C226 /* SubscriptionListAPIClientTest.swift in Sources */, 60A5CC0E2B29B1B80017EDB2 /* CircularRegionTest.swift in Sources */, 6E8CE762284137D600CF4B11 /* AirshipPushTest.swift in Sources */, 6E07688C29F9F0830014E2A9 /* AirshipLocaleManagerTest.swift in Sources */, 6E382C21276D3E990091A351 /* ThomasValidationTests.swift in Sources */, 6EF1401F2A268CE6009A125D /* TestKeychainAccess.swift in Sources */, 6E96ED1029416E8F0053CC91 /* EventAPIClientTest.swift in Sources */, 6E92ECB6284ED7330038802D /* CachedListTest.swift in Sources */, 6ED735E426CAE8AA003B0A7D /* TestChannelAudienceManager.swift in Sources */, 6E15B70526CE07180099C92D /* TestRemoteData.swift in Sources */, 6EFAFB7A295525CD008AD187 /* ChannelCaptureTest.swift in Sources */, 6EFB7B342A14A0F400133115 /* RemoteDataProviderTest.swift in Sources */, 6E2D6AF426B0C3C500B7C226 /* ChannelAudienceManagerTest.swift in Sources */, 6EC7E48526A60CDF0038CFDD /* TestChannel.swift in Sources */, 6E2F5AB72A675ADC00CABD3D /* TestAudienceChecker.swift in Sources */, 6EF553E32B7EE40B00901A22 /* AirshipLocalizationUtilsTest.swift in Sources */, 6E97D6B62D84B2350001CF7F /* ThomasFormFieldTest.swift in Sources */, A6CDD8D0269491BE0040A673 /* ContactAPIClientTest.swift in Sources */, 6E64C88027331ABA000EB887 /* PreferenceDataStoreTest.swift in Sources */, 6EC7E48726A60DD60038CFDD /* TestContactAPIClient.swift in Sources */, 6E10A1482C2B825200ED9556 /* DefaultTaskSleeperTest.swift in Sources */, 6E6ED1452683BC7F00A2CBD0 /* TestDispatcher.swift in Sources */, A6AF8D2D27E8D4910068C7EE /* SubscriptionListActionTest.swift in Sources */, 6ED2F5382B7FFCEB000AFC80 /* AirshipDateFormatterTest.swift in Sources */, 6E9C2BD02D02321D000089A9 /* RuntimeConfig.swift in Sources */, 6E6BD2582AEC598C00B9DFC9 /* DeferredAPIClientTest.swift in Sources */, 6EC7E47826A604080038CFDD /* AirshipContactTest.swift in Sources */, 6EC7E472269E51030038CFDD /* AttributeUpdateTest.swift in Sources */, 6ED2F52F2B7FD49B000AFC80 /* AirshipURLAllowListTest.swift in Sources */, 6E698E62267C03C700654DB2 /* TestAppStateTracker.swift in Sources */, 6E6BD25A2AEC626B00B9DFC9 /* DeferredResolverTest.swift in Sources */, 6ED2F5272B7EE82B000AFC80 /* AirshipJSONUtilsTest.swift in Sources */, 6E1A19242D6F8BDD0056418B /* AirshipInputValidationTest.swift in Sources */, 6ED838AD2D0CEF4A009CBB0C /* CompoundDeviceAudienceSelectorTest.swift in Sources */, 6ED735E226CAE2D7003B0A7D /* TestChannelRegistrar.swift in Sources */, 6ED735E626CAEABC003B0A7D /* TestLocaleManager.swift in Sources */, 605877202ACAC8700021628E /* MeteredUsageApiClientTest.swift in Sources */, 607951222A1CD1A50086578F /* ExperimentManagerTest.swift in Sources */, 6E96ED1A2941A0EC0053CC91 /* EventTestUtils.swift in Sources */, 60A5CC082B28DC500017EDB2 /* NotificationCategoriesTest.swift in Sources */, 99C3CC7E2BCF40B200B5BED5 /* CachingSMSValidatorAPIClientTest.swift in Sources */, 6E739D7F26BAFCB800BC6F6D /* TestChannelBulkUpdateAPIClient.swift in Sources */, 6E6C3F8D27A26992007F55C7 /* CachedValueTest.swift in Sources */, 6E4007162A153ABE0013C2DE /* ContactRemoteDataProviderTest.swift in Sources */, 6E87BD9D26DD78CC0005D20D /* DefaultAppIntegrationDelegateTest.swift in Sources */, 6E6363E829DCEB84009C358A /* ContactManagerTest.swift in Sources */, 6E15B73026CF4F6B0099C92D /* TestRemoteDataAPIClient.swift in Sources */, 6ECB627C2A369F5B0095C85C /* OpenExternalURLActionTest.swift in Sources */, 6E8746492D8A3C71002469D7 /* TestSMSValidatorAPIClient.swift in Sources */, A63A567628F449D8004B8951 /* TestWorkManager.swift in Sources */, 6EFAFB8C29562866008AD187 /* RemoveTagsActionTest.swift in Sources */, 6E87BD9F26DDDB250005D20D /* AppIntegrationTests.swift in Sources */, 6014AD6C2C2032730072DCF0 /* ChallengeResolverTest.swift in Sources */, 6E2D6AF626B0C6CA00B7C226 /* TestSubscriptionListAPIClient.swift in Sources */, 6E07B5F82D925ED60087EC47 /* TestPrivacyManager.swift in Sources */, 6E0B8760294CE0BF0064B7BD /* FarmHashFingerprint64Test.swift in Sources */, 3299EF172948CBC100251E70 /* RemoteDataAPIClientTest.swift in Sources */, 605073902B347B6400209B51 /* ThomasPresentationModelCodingTest.swift in Sources */, 6EFAFB78295525C3008AD187 /* ChannelAPIClientTest.swift in Sources */, 6E4326072B7C364300A9B000 /* AssociatedIdentifiersTest.swift in Sources */, 6ED2F52B2B7FC5C8000AFC80 /* AirshipIvyVersionMatcherTest.swift in Sources */, 9971A8852C125C0200092ED1 /* ContactChannelsProviderTest.swift in Sources */, 6E65244C2A4FD4270019F353 /* DeviceTagSelectorTest.swift in Sources */, 3299EF222949EC3E00251E70 /* AirshipBaseTest.swift in Sources */, 6E7EACD12AF4192400DA286B /* AirshipCacheTest.swift in Sources */, 6E6C84462A5C8CFE00DD83A2 /* AirshipConfigTest.swift in Sources */, 6E97D6AD2D84B1660001CF7F /* ThomasFormStateTest.swift in Sources */, 27F1E18F2F0E82C700E317DB /* ThomasLayoutResolutionEventTest.swift in Sources */, 27F1E1902F0E82C700E317DB /* ThomasLayoutEventTestUtils.swift in Sources */, 27F1E1912F0E82C700E317DB /* ThomasLayoutFormResultEventTest.swift in Sources */, 27F1E1922F0E82C700E317DB /* ThomasLayoutPageSwipeEventAction.swift in Sources */, 27F1E1932F0E82C700E317DB /* ThomasLayoutDisplayEventTest.swift in Sources */, 27F1E1942F0E82C700E317DB /* ThomasLayoutButtonTapEventTest.swift in Sources */, 27F1E1952F0E82C700E317DB /* ThomasLayoutPagerSummaryEventTest.swift in Sources */, 27F1E1962F0E82C700E317DB /* ThomasLayoutPageActionEventTest.swift in Sources */, 27F1E1972F0E82C700E317DB /* ThomasLayoutGestureEventTest.swift in Sources */, 27F1E1992F0E82C700E317DB /* ThomasLayoutFormDisplayEventTest.swift in Sources */, 27F1E19A2F0E82C700E317DB /* ThomasLayoutPermissionResultEventTest.swift in Sources */, 27F1E19B2F0E82C700E317DB /* ThomasLayoutPageViewEventTest.swift in Sources */, 27F1E19C2F0E82C700E317DB /* ThomasLayoutPagerCompletedEventTest.swift in Sources */, 6EFAFB8A29562474008AD187 /* AddTagsActionTest.swift in Sources */, 6E9B4878288F360C00C905B1 /* RateAppActionTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 3C39D3082384C8B6003C50D4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3CA0E346237E4A7B00EE76CF /* AirshipMessageCenter */; targetProxy = 3C39D3072384C8B6003C50D4 /* PBXContainerItemProxy */; }; 3CA0E2DA237CD59100EE76CF /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = 3CA0E2D9237CD59100EE76CF /* PBXContainerItemProxy */; }; 3CA0E347237E4A7B00EE76CF /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = 3CA0E348237E4A7B00EE76CF /* PBXContainerItemProxy */; }; 6E0B8734294A9C130064B7BD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E0B8729294A9C120064B7BD /* AirshipAutomation */; targetProxy = 6E0B8733294A9C130064B7BD /* PBXContainerItemProxy */; }; 6E0B8743294A9C780064B7BD /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilters = ( ios, maccatalyst, macos, tvos, xros, ); target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = 6E0B8742294A9C780064B7BD /* PBXContainerItemProxy */; }; 6E107F042B30B887007AFC4D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = 6E107F032B30B887007AFC4D /* PBXContainerItemProxy */; }; 6E128B9D2D305C4600733024 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A641E1462BDBBDB400DE6FAA /* AirshipObjectiveC */; targetProxy = 6E128B9C2D305C4600733024 /* PBXContainerItemProxy */; }; 6E29474A2AD47E0C009EC6DD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 847BFFF3267CD739007CD249 /* AirshipPreferenceCenter */; targetProxy = 6E2947492AD47E0C009EC6DD /* PBXContainerItemProxy */; }; 6E2F5A922A67314A00CABD3D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A62058682A5841330041FBF9 /* AirshipFeatureFlags */; targetProxy = 6E2F5A912A67314A00CABD3D /* PBXContainerItemProxy */; }; 6E2F5A962A67316C00CABD3D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A62058682A5841330041FBF9 /* AirshipFeatureFlags */; targetProxy = 6E2F5A952A67316C00CABD3D /* PBXContainerItemProxy */; }; 6E43218B26EA891F009228AB /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E431F6B26EA814F009228AB /* AirshipBasement */; targetProxy = 6E43218A26EA891F009228AB /* PBXContainerItemProxy */; }; 6E4A467428EF44F600A25617 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3CA0E346237E4A7B00EE76CF /* AirshipMessageCenter */; targetProxy = 6E4A467328EF44F600A25617 /* PBXContainerItemProxy */; }; 6E4AEE0B2B6B24D1008AEAC1 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E0B8729294A9C120064B7BD /* AirshipAutomation */; targetProxy = 6E4AEE0A2B6B24D1008AEAC1 /* PBXContainerItemProxy */; }; 6E5917892B28E93A0084BBBF /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E0B8729294A9C120064B7BD /* AirshipAutomation */; targetProxy = 6E5917882B28E93A0084BBBF /* PBXContainerItemProxy */; }; 6E6B493E2A787D0A00AF98D8 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A62058682A5841330041FBF9 /* AirshipFeatureFlags */; targetProxy = 6E6B493D2A787D0A00AF98D8 /* PBXContainerItemProxy */; }; 6EAAE87728C2AD80003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E431F6B26EA814F009228AB /* AirshipBasement */; targetProxy = 6EAAE87628C2AD80003CAE53 /* PBXContainerItemProxy */; }; 6EAAE87928C2AD80003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = 6EAAE87828C2AD80003CAE53 /* PBXContainerItemProxy */; }; 6EAAE87B28C2AD80003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3CA0E298237CCE2600EE76CF /* AirshipDebug */; targetProxy = 6EAAE87A28C2AD80003CAE53 /* PBXContainerItemProxy */; }; 6EAAE87D28C2AD80003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3CA0E346237E4A7B00EE76CF /* AirshipMessageCenter */; targetProxy = 6EAAE87C28C2AD80003CAE53 /* PBXContainerItemProxy */; }; 6EAAE87F28C2AD80003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 847BFFF3267CD739007CD249 /* AirshipPreferenceCenter */; targetProxy = 6EAAE87E28C2AD80003CAE53 /* PBXContainerItemProxy */; }; 6ECCAD282CF55BC700423D86 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A62058682A5841330041FBF9 /* AirshipFeatureFlags */; targetProxy = 6ECCAD292CF55BC700423D86 /* PBXContainerItemProxy */; }; 6ECCAD2A2CF55BC700423D86 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E431F6B26EA814F009228AB /* AirshipBasement */; targetProxy = 6ECCAD2B2CF55BC700423D86 /* PBXContainerItemProxy */; }; 6ECCAD2C2CF55BC700423D86 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = 6ECCAD2D2CF55BC700423D86 /* PBXContainerItemProxy */; }; 6ECCAD322CF55BC700423D86 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 847BFFF3267CD739007CD249 /* AirshipPreferenceCenter */; targetProxy = 6ECCAD332CF55BC700423D86 /* PBXContainerItemProxy */; }; 847B000E267CD85E007CD249 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = 847B000D267CD85E007CD249 /* PBXContainerItemProxy */; }; 847BFFFF267CD73A007CD249 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 847BFFF3267CD739007CD249 /* AirshipPreferenceCenter */; targetProxy = 847BFFFE267CD73A007CD249 /* PBXContainerItemProxy */; }; A60235362CCB9E3C00CF412B /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E431F6B26EA814F009228AB /* AirshipBasement */; targetProxy = A60235352CCB9E3C00CF412B /* PBXContainerItemProxy */; }; A61F3A772A5DBA0E00EE94CC /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilters = ( ios, maccatalyst, macos, tvos, xros, ); target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = A61F3A762A5DBA0E00EE94CC /* PBXContainerItemProxy */; }; A61F3A7B2A5DBA1800EE94CC /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilters = ( ios, maccatalyst, macos, tvos, xros, ); target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = A61F3A7A2A5DBA1800EE94CC /* PBXContainerItemProxy */; }; A62058732A5841330041FBF9 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A62058682A5841330041FBF9 /* AirshipFeatureFlags */; targetProxy = A62058722A5841330041FBF9 /* PBXContainerItemProxy */; }; A641E1572BDBF5FF00DE6FAA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6E0B8729294A9C120064B7BD /* AirshipAutomation */; targetProxy = A641E1562BDBF5FF00DE6FAA /* PBXContainerItemProxy */; }; A641E1592BDBF5FF00DE6FAA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = A641E1582BDBF5FF00DE6FAA /* PBXContainerItemProxy */; }; A641E15B2BDBF5FF00DE6FAA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A62058682A5841330041FBF9 /* AirshipFeatureFlags */; targetProxy = A641E15A2BDBF5FF00DE6FAA /* PBXContainerItemProxy */; }; A641E15D2BDBF5FF00DE6FAA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3CA0E346237E4A7B00EE76CF /* AirshipMessageCenter */; targetProxy = A641E15C2BDBF5FF00DE6FAA /* PBXContainerItemProxy */; }; A641E15F2BDBF5FF00DE6FAA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 847BFFF3267CD739007CD249 /* AirshipPreferenceCenter */; targetProxy = A641E15E2BDBF5FF00DE6FAA /* PBXContainerItemProxy */; }; CC64F05B1D8B77E3009CEF27 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 494DD9561B0EB677009C134E /* AirshipCore */; targetProxy = CC64F05A1D8B77E3009CEF27 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ 6E411C742538C60900FEE4E8 /* UrbanAirship.strings */ = { isa = PBXVariantGroup; children = ( 6E411C752538C60900FEE4E8 /* en */, 6E411C862538C62B00FEE4E8 /* vi */, 6E411C872538C62B00FEE4E8 /* ru */, 6E411C882538C62B00FEE4E8 /* de */, 6E411C892538C62B00FEE4E8 /* es-419 */, 6E411C8A2538C62B00FEE4E8 /* ro */, 6E411C8B2538C62B00FEE4E8 /* da */, 6E411C8C2538C62B00FEE4E8 /* zh-Hant */, 6E411C8D2538C62B00FEE4E8 /* ja */, 6E411C8E2538C62C00FEE4E8 /* pt-PT */, 6E411C8F2538C62C00FEE4E8 /* fr */, 6E411C902538C62C00FEE4E8 /* zh-Hans */, 6E411C912538C62C00FEE4E8 /* sv */, 6E411C922538C62C00FEE4E8 /* it */, 6E411C932538C62C00FEE4E8 /* pl */, 6E411C942538C62C00FEE4E8 /* iw */, 6E411C952538C62C00FEE4E8 /* fi */, 6E411C962538C62C00FEE4E8 /* es */, 6E411C972538C62D00FEE4E8 /* cs */, 6E411C982538C64700FEE4E8 /* tr */, 6E411C992538C64700FEE4E8 /* pt */, 6E411C9A2538C64700FEE4E8 /* no */, 6E411C9B2538C64700FEE4E8 /* th */, 6E411C9C2538C64800FEE4E8 /* nl */, 6E411C9D2538C65100FEE4E8 /* ar */, 6E411C9E2538C65400FEE4E8 /* hi */, 6E411C9F2538C65700FEE4E8 /* hu */, 6E411CA02538C65900FEE4E8 /* id */, 6E411CA12538C65C00FEE4E8 /* ko */, 6E411CA22538C65F00FEE4E8 /* ms */, 6E411CA32538C66100FEE4E8 /* sk */, 03525B87280DE48100320AA9 /* af */, 03525B88280DE49B00320AA9 /* am */, 03525B89280DE4E200320AA9 /* bg */, 03525B8A280DE4ED00320AA9 /* ca */, 03525B8B280DE4F800320AA9 /* el */, 03525B8C280DE50D00320AA9 /* et */, 03525B8D280DE54900320AA9 /* fa */, 03525B8E280DE55900320AA9 /* fr-CA */, 03525B8F280DE58800320AA9 /* hr */, 03525B90280DE5A200320AA9 /* lt */, 03525B91280DE5B800320AA9 /* lv */, 03525B92280DE5CE00320AA9 /* sl */, 03525B93280DE5DD00320AA9 /* sr */, 03525B94280DE5EA00320AA9 /* sw */, 03525B95280DE5F400320AA9 /* uk */, 03525B96280DE60200320AA9 /* zh-HK */, 03525B97280DE61200320AA9 /* zu */, ); name = UrbanAirship.strings; sourceTree = ""; }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ 3CA0E2AA237CCE2600EE76CF /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipDebug/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Debug; }; 3CA0E2AB237CCE2600EE76CF /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipDebug/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Release; }; 3CA0E421237E4A7B00EE76CF /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipMessageCenter/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Debug; }; 3CA0E422237E4A7B00EE76CF /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipMessageCenter/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Release; }; 494DD96B1B0EB677009C134E /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = 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_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_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_SYMBOLS_PRIVATE_EXTERN = NO; 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 = 16.6; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos iphonesimulator iphoneos appletvsimulator appletvos"; SUPPORTS_MACCATALYST = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TVOS_DEPLOYMENT_TARGET = 18.0; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; WATCHOS_DEPLOYMENT_TARGET = 11.0; XROS_DEPLOYMENT_TARGET = 2.0; }; name = Debug; }; 494DD96C1B0EB677009C134E /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = 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_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_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; "GCC_PREPROCESSOR_DEFINITIONS[arch=*]" = "$(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 = 16.6; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos iphonesimulator iphoneos appletvsimulator appletvos"; SUPPORTS_MACCATALYST = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; SWIFT_UPCOMING_FEATURE_IMPORT_OBJC_FORWARD_DECLS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; TVOS_DEPLOYMENT_TARGET = 18.0; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; WATCHOS_DEPLOYMENT_TARGET = 11.0; XROS_DEPLOYMENT_TARGET = 2.0; }; name = Release; }; 494DD96E1B0EB677009C134E /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipCore/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Debug; }; 494DD96F1B0EB677009C134E /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "$(SRCROOT)/AirshipCore/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Release; }; 6E0B873A294A9C130064B7BD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { 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_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipAutomation; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; 6E0B873B294A9C130064B7BD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { 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_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipAutomation; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; 6E0B873D294A9C130064B7BD /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipAutomationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; SWIFT_VERSION = 5.0; }; name = Debug; }; 6E0B873E294A9C130064B7BD /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipAutomationTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; SWIFT_VERSION = 5.0; }; name = Release; }; 6E43204626EA814F009228AB /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipBasement/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Debug; }; 6E43204726EA814F009228AB /* Release */ = { isa = XCBuildConfiguration; baseConfigurationReference = CC40DDB31D8CAF7300BABD4F /* AirshipConfig.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "$(SRCROOT)/AirshipBasement/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); OTHER_LDFLAGS = "-ObjC"; PRODUCT_BUNDLE_IDENTIFIER = "com.urbanairship.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx watchos watchsimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,4,7"; VALID_ARCHS = "$(inherited) x86_64"; }; name = Release; }; 6E4A467628EF44F600A25617 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = airship.AirshipMessageCenterTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; }; name = Debug; }; 6E4A467728EF44F600A25617 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = airship.AirshipMessageCenterTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; }; name = Release; }; 6EAAE85F28C2AD3A003CAE53 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "xrsimulator xros watchsimulator watchos iphonesimulator iphoneos"; }; name = Debug; }; 6EAAE86028C2AD3A003CAE53 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; }; 6ECCAD352CF55BC700423D86 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "appletvsimulator appletvos"; }; name = Debug; }; 6ECCAD362CF55BC700423D86 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; PRODUCT_NAME = "$(TARGET_NAME)"; }; name = Release; }; 847B0006267CD73A007CD249 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 14.5.0; DEBUG_INFORMATION_FORMAT = dwarf; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = AirshipPreferenceCenter/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipPreferenceCenter; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; }; name = Debug; }; 847B0007267CD73A007CD249 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 14.5.0; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = AirshipPreferenceCenter/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipPreferenceCenter; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; }; name = Release; }; 847B0009267CD73A007CD249 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = AirshipPreferenceCenter/Tests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = Airship.AirshipPreferenceCenterTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; }; name = Debug; }; 847B000A267CD73A007CD249 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = AirshipPreferenceCenter/Tests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = Airship.AirshipPreferenceCenterTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; }; name = Release; }; A62058782A5841340041FBF9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { 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_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipFeatureFlags/Info.plist"; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.AirshipFeatureFlags; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; }; name = Debug; }; A62058792A5841340041FBF9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { 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_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "$(SRCROOT)/AirshipFeatureFlags/Info.plist"; INFOPLIST_KEY_NSPrincipalClass = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.AirshipFeatureFlags; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,3,7"; }; name = Release; }; A620587A2A5841340041FBF9 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.AirshipFeatureFlagsTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; A620587B2A5841340041FBF9 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.AirshipFeatureFlagsTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; A641E14B2BDBBDB400DE6FAA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { 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_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipObjectiveC; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; A641E14C2BDBBDB400DE6FAA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { 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_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_MODULE_VERIFIER = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipObjectiveC; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; SKIP_INSTALL = YES; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; CC64F05C1D8B77E3009CEF27 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = PGJV57GD94; ENABLE_TESTABILITY = YES; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "XCODE_VERSION_MAJOR=\"$(XCODE_VERSION_MAJOR)\"", ); INFOPLIST_FILE = AirshipCore/Tests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); OTHER_LDFLAGS = ( "-ObjC", "$(inherited)", ); OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = NO; SWIFT_VERSION = 5.0; VALID_ARCHS = "$(inherited) x86_64"; }; name = Debug; }; CC64F05D1D8B77E3009CEF27 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; DEVELOPMENT_TEAM = PGJV57GD94; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "XCODE_VERSION_MAJOR=\"$(XCODE_VERSION_MAJOR)\"", ); INFOPLIST_FILE = Core/Tests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); OTHER_LDFLAGS = ( "-ObjC", "$(inherited)", ); OTHER_SWIFT_FLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = NO; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = NO; SWIFT_UPCOMING_FEATURE_NONISOLATED_NONSENDING_BY_DEFAULT = NO; SWIFT_VERSION = 5.0; VALID_ARCHS = "$(inherited) x86_64"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 3CA0E2A9237CCE2600EE76CF /* Build configuration list for PBXNativeTarget "AirshipDebug" */ = { isa = XCConfigurationList; buildConfigurations = ( 3CA0E2AA237CCE2600EE76CF /* Debug */, 3CA0E2AB237CCE2600EE76CF /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 3CA0E420237E4A7B00EE76CF /* Build configuration list for PBXNativeTarget "AirshipMessageCenter" */ = { isa = XCConfigurationList; buildConfigurations = ( 3CA0E421237E4A7B00EE76CF /* Debug */, 3CA0E422237E4A7B00EE76CF /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 494DD9511B0EB677009C134E /* Build configuration list for PBXProject "Airship" */ = { isa = XCConfigurationList; buildConfigurations = ( 494DD96B1B0EB677009C134E /* Debug */, 494DD96C1B0EB677009C134E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 494DD96D1B0EB677009C134E /* Build configuration list for PBXNativeTarget "AirshipCore" */ = { isa = XCConfigurationList; buildConfigurations = ( 494DD96E1B0EB677009C134E /* Debug */, 494DD96F1B0EB677009C134E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6E0B8739294A9C130064B7BD /* Build configuration list for PBXNativeTarget "AirshipAutomation" */ = { isa = XCConfigurationList; buildConfigurations = ( 6E0B873A294A9C130064B7BD /* Debug */, 6E0B873B294A9C130064B7BD /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6E0B873C294A9C130064B7BD /* Build configuration list for PBXNativeTarget "AirshipAutomationTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 6E0B873D294A9C130064B7BD /* Debug */, 6E0B873E294A9C130064B7BD /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6E43204526EA814F009228AB /* Build configuration list for PBXNativeTarget "AirshipBasement" */ = { isa = XCConfigurationList; buildConfigurations = ( 6E43204626EA814F009228AB /* Debug */, 6E43204726EA814F009228AB /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6E4A467528EF44F600A25617 /* Build configuration list for PBXNativeTarget "AirshipMessageCenterTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 6E4A467628EF44F600A25617 /* Debug */, 6E4A467728EF44F600A25617 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6EAAE85E28C2AD3A003CAE53 /* Build configuration list for PBXAggregateTarget "AirshipRelease" */ = { isa = XCConfigurationList; buildConfigurations = ( 6EAAE85F28C2AD3A003CAE53 /* Debug */, 6EAAE86028C2AD3A003CAE53 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6ECCAD342CF55BC700423D86 /* Build configuration list for PBXAggregateTarget "AirshipRelease tvOS" */ = { isa = XCConfigurationList; buildConfigurations = ( 6ECCAD352CF55BC700423D86 /* Debug */, 6ECCAD362CF55BC700423D86 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 847B0005267CD73A007CD249 /* Build configuration list for PBXNativeTarget "AirshipPreferenceCenter" */ = { isa = XCConfigurationList; buildConfigurations = ( 847B0006267CD73A007CD249 /* Debug */, 847B0007267CD73A007CD249 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 847B0008267CD73A007CD249 /* Build configuration list for PBXNativeTarget "AirshipPreferenceCenterTests" */ = { isa = XCConfigurationList; buildConfigurations = ( 847B0009267CD73A007CD249 /* Debug */, 847B000A267CD73A007CD249 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; A620587C2A5841340041FBF9 /* Build configuration list for PBXNativeTarget "AirshipFeatureFlags" */ = { isa = XCConfigurationList; buildConfigurations = ( A62058782A5841340041FBF9 /* Debug */, A62058792A5841340041FBF9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; A620587D2A5841340041FBF9 /* Build configuration list for PBXNativeTarget "AirshipFeatureFlagsTests" */ = { isa = XCConfigurationList; buildConfigurations = ( A620587A2A5841340041FBF9 /* Debug */, A620587B2A5841340041FBF9 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; A641E14D2BDBBDB400DE6FAA /* Build configuration list for PBXNativeTarget "AirshipObjectiveC" */ = { isa = XCConfigurationList; buildConfigurations = ( A641E14B2BDBBDB400DE6FAA /* Debug */, A641E14C2BDBBDB400DE6FAA /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; CC64F05E1D8B77E3009CEF27 /* Build configuration list for PBXNativeTarget "AirshipTests" */ = { isa = XCConfigurationList; buildConfigurations = ( CC64F05C1D8B77E3009CEF27 /* Debug */, CC64F05D1D8B77E3009CEF27 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCVersionGroup section */ 3231128A29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 3231128B29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodel */, ); currentVersion = 3231128B29D5E69400CF0D86 /* UAFrequencyLimits.xcdatamodel */; path = UAFrequencyLimits.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 3CA0E222237CCBA600EE76CF /* AirshipDebugEventData.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 3CA0E223237CCBA600EE76CF /* AirshipEventData.xcdatamodel */, ); currentVersion = 3CA0E223237CCBA600EE76CF /* AirshipEventData.xcdatamodel */; path = AirshipDebugEventData.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 3CA0E304237E396100EE76CF /* UAInbox.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 27E419482EF484DB00D5C1A6 /* UAInbox 4.xcdatamodel */, 325108C72B7A596F0028F508 /* UAInbox 3.xcdatamodel */, A649F50A252F4F39005453CB /* UAInbox 2.xcdatamodel */, 3CA0E305237E396100EE76CF /* UAInbox.xcdatamodel */, ); currentVersion = 27E419482EF484DB00D5C1A6 /* UAInbox 4.xcdatamodel */; path = UAInbox.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 6E1CBE2A2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 6ED7BE5E2D13D9B600B6A124 /* AirshipAutomation 3.xcdatamodel */, 6E1CBE2B2BAA2AEA00519D9C /* AirshipAutomation 2.xcdatamodel */, 6E1CBE2C2BAA2AEA00519D9C /* AirshipAutomation.xcdatamodel */, ); currentVersion = 6ED7BE5E2D13D9B600B6A124 /* AirshipAutomation 3.xcdatamodel */; path = AirshipAutomation.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 6E411CA42538C6A500FEE4E8 /* UARemoteData.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 325108C82B7A5A220028F508 /* UARemoteData 4.xcdatamodel */, 6EE49C1B2A0D544B00AB1CF4 /* UARemoteData 3.xcdatamodel */, 6E411CA52538C6A500FEE4E8 /* UARemoteData.xcdatamodel */, 6E411CA62538C6A500FEE4E8 /* UARemoteData 2.xcdatamodel */, ); currentVersion = 325108C82B7A5A220028F508 /* UARemoteData 4.xcdatamodel */; path = UARemoteData.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 6E411CA72538C6A500FEE4E8 /* UAEvents.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 6E411CA82538C6A500FEE4E8 /* UAEvents.xcdatamodel */, ); currentVersion = 6E411CA82538C6A500FEE4E8 /* UAEvents.xcdatamodel */; path = UAEvents.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 6E4AEE492B6B4358008AEAC1 /* UAAutomation.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 6E4AEE4A2B6B4358008AEAC1 /* UAAutomation 7.xcdatamodel */, 6E4AEE4B2B6B4358008AEAC1 /* UAAutomation 12.xcdatamodel */, 6E4AEE4C2B6B4358008AEAC1 /* UAAutomation 2.xcdatamodel */, 6E4AEE4D2B6B4358008AEAC1 /* UAAutomation 11.xcdatamodel */, 6E4AEE4E2B6B4358008AEAC1 /* UAAutomation 8.xcdatamodel */, 6E4AEE4F2B6B4358008AEAC1 /* UAAutomation 4.xcdatamodel */, 6E4AEE502B6B4358008AEAC1 /* UAAutomation 3.xcdatamodel */, 6E4AEE512B6B4358008AEAC1 /* UAAutomation 13.xcdatamodel */, 6E4AEE522B6B4358008AEAC1 /* UAAutomation.xcdatamodel */, 6E4AEE532B6B4358008AEAC1 /* UAAutomation 6.xcdatamodel */, 6E4AEE542B6B4358008AEAC1 /* UAAutomation 5.xcdatamodel */, 6E4AEE552B6B4358008AEAC1 /* UAAutomation 9.xcdatamodel */, 6E4AEE562B6B4358008AEAC1 /* UAAutomation 10.xcdatamodel */, ); currentVersion = 6E4AEE512B6B4358008AEAC1 /* UAAutomation 13.xcdatamodel */; path = UAAutomation.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 6E5A64D72AABC5A400574085 /* UAMeteredUsage.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 6E5A64D82AABC5A400574085 /* UAMeteredUsage.xcdatamodel */, ); currentVersion = 6E5A64D82AABC5A400574085 /* UAMeteredUsage.xcdatamodel */; path = UAMeteredUsage.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 6E6BD2702AF1B05500B9DFC9 /* UAirshipCache.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 6E6BD2712AF1B05500B9DFC9 /* UAAirshipCache.xcdatamodel */, ); currentVersion = 6E6BD2712AF1B05500B9DFC9 /* UAAirshipCache.xcdatamodel */; path = UAirshipCache.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; 83A674F623AA7AA4005C0C8F /* AirshipDebugPushData.xcdatamodeld */ = { isa = XCVersionGroup; children = ( 83A674F723AA7AA4005C0C8F /* AirshipDebugPushData.xcdatamodel */, ); currentVersion = 83A674F723AA7AA4005C0C8F /* AirshipDebugPushData.xcdatamodel */; path = AirshipDebugPushData.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; }; /* End XCVersionGroup section */ }; rootObject = 494DD94E1B0EB677009C134E /* Project object */; } ================================================ FILE: Airship/Airship.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ ================================================ FILE: Airship/Airship.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ IDEDidComputeMac32BitWarning ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipAutomation.xcscheme ================================================ ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipBasement.xcscheme ================================================ ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipCore.xcscheme ================================================ ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipDebug.xcscheme ================================================ ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipFeatureFlags.xcscheme ================================================ ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipMessageCenter.xcscheme ================================================ ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipObjectiveC.xcscheme ================================================ ================================================ FILE: Airship/Airship.xcodeproj/xcshareddata/xcschemes/AirshipPreferenceCenter.xcscheme ================================================ ================================================ FILE: Airship/AirshipAutomation/Info.plist ================================================ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString 1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) ================================================ FILE: Airship/AirshipAutomation/Resources/AirshipAutomation.xcdatamodeld/.xccurrentversion ================================================ _XCCurrentVersionName AirshipAutomation 3.xcdatamodel ================================================ FILE: Airship/AirshipAutomation/Resources/AirshipAutomation.xcdatamodeld/AirshipAutomation 2.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/AirshipAutomation.xcdatamodeld/AirshipAutomation 3.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/AirshipAutomation.xcdatamodeld/AirshipAutomation.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/.xccurrentversion ================================================ _XCCurrentVersionName UAAutomation 13.xcdatamodel ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 10.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 11.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 12.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 13.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 2.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 3.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 4.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 5.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 6.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 7.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 8.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation 9.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAAutomation.xcdatamodeld/UAAutomation.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Resources/UAFrequencyLimits.xcdatamodeld/UAFrequencyLimits.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipAutomation/Source/ActionAutomation/ActionAutomationExecutor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct ActionAutomationExecutor: AutomationExecutorDelegate { typealias PrepareDataIn = AirshipJSON typealias PrepareDataOut = AirshipJSON typealias ExecutionData = AirshipJSON private let actionRunner: any AutomationActionRunnerProtocol init(actionRunner: any AutomationActionRunnerProtocol = AutomationActionRunner()) { self.actionRunner = actionRunner } func isReady(data: AirshipJSON, preparedScheduleInfo: PreparedScheduleInfo) -> ScheduleReadyResult { return .ready } func execute(data: AirshipJSON, preparedScheduleInfo: PreparedScheduleInfo) async -> ScheduleExecuteResult { guard preparedScheduleInfo.additionalAudienceCheckResult else { return .finished } await actionRunner.runActions(data, situation: .automation, metadata: [:]) return .finished } func interrupted(schedule: AutomationSchedule, preparedScheduleInfo: PreparedScheduleInfo) async -> InterruptedBehavior { return .retry } } ================================================ FILE: Airship/AirshipAutomation/Source/ActionAutomation/ActionAutomationPreparer.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct ActionAutomationPreparer: AutomationPreparerDelegate { typealias PrepareDataIn = AirshipJSON typealias PrepareDataOut = AirshipJSON func prepare(data: AirshipJSON, preparedScheduleInfo: PreparedScheduleInfo) async throws -> AirshipJSON { return data } func cancelled(scheduleID: String) async { // no-op } } ================================================ FILE: Airship/AirshipAutomation/Source/Actions/CancelSchedulesAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /** * Action to cancel automation schedules. * * This action is registered under the names cancel_scheduled_actions and ^csa. * * Expected argument values: String with the value "all" or a Dictionary with: * - "groups": A schedule group or an array of schedule groups. * - "ids": A schedule ID or an array of schedule IDs. * * Valid situations: ActionSituation.backgroundPush, .foregroundPush, .webViewInvocation, .manualInvocation, and .automation * * Result value: nil. */ public final class CancelSchedulesAction: AirshipAction { //used for tests private let overrideAutomation: (any InternalInAppAutomation)? /// Cancel schedules action names. public static let defaultNames: [String] = ["cancel_scheduled_actions", "^csa"] init(overrideAutomation: (any InternalInAppAutomation)? = nil) { self.overrideAutomation = overrideAutomation } var automation: any InternalInAppAutomation { return overrideAutomation ?? Airship.requireComponent(ofType: InAppAutomationComponent.self).inAppAutomation } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .manualInvocation, .backgroundPush, .foregroundPush, .webViewInvocation, .automation: return true default: return false } } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let args: Arguments = try arguments.value.decode() let automation = self.automation if args.cancellAll { try await automation.cancelSchedulesWith(type: .actions) return nil } if let groups = args.groups { for item in groups { try await automation.cancelSchedules(group: item) } } if let ids = args.scheduleIDs { try await automation.cancelSchedule(identifiers: ids) } return nil } fileprivate struct Arguments: Decodable, Sendable { static let all = "all" let cancellAll: Bool let scheduleIDs: [String]? let groups: [String]? enum CodingKeys: String, CodingKey { case ids = "ids" case groups = "groups" } init(from decoder: any Decoder) throws { func decodeSingleOrArray(from container: KeyedDecodingContainer, key: K) throws -> [T]? where T: Decodable { guard container.contains(key) else { return nil } do { return try container.decode([T].self, forKey: key) } catch { let value = try container.decode(T.self, forKey: key) return [value] } } var scheduleIds: [String]? var groups: [String]? do { let container = try decoder.container(keyedBy: CodingKeys.self) scheduleIds = try decodeSingleOrArray(from: container, key: .ids) groups = try decodeSingleOrArray(from: container, key: .groups) self.cancellAll = false } catch { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) guard value == Self.all else { throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, debugDescription: "Invalid cancel action")) } self.cancellAll = true } if !cancellAll, scheduleIds == nil, groups == nil { throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid cacncel action")) } self.scheduleIDs = scheduleIds self.groups = groups } } } ================================================ FILE: Airship/AirshipAutomation/Source/Actions/LandingPageAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Landing page action public final class LandingPageAction: AirshipAction { private static let productID: String = "landing_page" private static let queue: String = "landing_page" /// Landing page action names. public static let defaultNames: [String] = ["landing_page_action", "^p"] /// Default predicate - rejects `ActionSituation.foregroundPush` public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.situation != .foregroundPush } /// Schedule extender block. public typealias ScheduleExtender = @Sendable (ActionArguments, inout AutomationSchedule) -> Void private let borderRadius: Double private let scheduleExtender: ScheduleExtender? private let allowListChecker: @Sendable @MainActor (URL) -> Bool private let scheduler: @Sendable (AutomationSchedule) async throws -> Void /// Default constructor /// - Parameters: /// - borderRadius: Optional border radius in points. Defaults to 2. /// - scheduleExtender: Optional extender. Can be used to modify the landing page action schedule. public convenience init( borderRadius: Double = 2.0, scheduleExtender: ScheduleExtender? = nil ) { self.init( borderRadius: borderRadius, scheduleExtender: scheduleExtender, allowListChecker: { Airship.urlAllowList.isAllowed($0, scope: .openURL) }, scheduler: { try await Airship.inAppAutomation.upsertSchedules([$0]) } ) } init( borderRadius: Double, scheduleExtender: ScheduleExtender?, allowListChecker: @escaping @MainActor @Sendable (URL) -> Bool, scheduler: @escaping @Sendable (AutomationSchedule) async throws -> Void ) { self.borderRadius = borderRadius self.scheduleExtender = scheduleExtender self.allowListChecker = allowListChecker self.scheduler = scheduler } public func accepts(arguments: ActionArguments) async -> Bool { switch(arguments.situation) { case .manualInvocation: return true case .launchedFromPush: return true case .foregroundPush: return true case .backgroundPush: return false case .webViewInvocation: return true case .foregroundInteractiveButton: return true case .backgroundInteractiveButton: return false case .automation: return true #if canImport(AirshipCore) @unknown default: return false #endif } } @MainActor public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let pushMetadata = arguments.metadata[ActionArguments.pushPayloadJSONMetadataKey] as? AirshipJSON let messageID = pushMetadata?.object?["_"]?.string let args: LandingPageArgs = try arguments.value.decode() guard self.allowListChecker(args.url) else { throw AirshipErrors.error("Landing page URL not allowed \(args.url)") } let message = InAppMessage( name: "Landing Page \(args.url)", displayContent: .html( .init( url: args.url.absoluteString, height: args.height, width: args.width, aspectLock: args.aspectLock, requiresConnectivity: false, borderRadius: self.borderRadius ) ), isReportingEnabled: messageID != nil, displayBehavior: .immediate ) var schedule = AutomationSchedule( identifier: messageID ?? UUID().uuidString, data: .inAppMessage(message), triggers: [AutomationTrigger.activeSession(count: 1)], priority: Int.min, bypassHoldoutGroups: true, productID: Self.productID, queue: Self.queue ) self.scheduleExtender?(arguments, &schedule) try await self.scheduler(schedule) return nil } fileprivate struct LandingPageArgs: Decodable, Sendable { var url: URL var height: Double? var width: Double? var aspectLock: Bool? enum CodingKeys: String, CodingKey { case url case height case width case aspectLock = "aspect_lock" case aspectLockLegacy = "aspectLock" } init(from decoder: any Decoder) throws { do { let container: KeyedDecodingContainer = try decoder.container(keyedBy: Self.CodingKeys.self) self.url = try Self.parseURL( string: try container.decode(String.self, forKey: CodingKeys.url) ) self.height = try container.decodeIfPresent(Double.self, forKey: CodingKeys.height) self.width = try container.decodeIfPresent(Double.self, forKey: CodingKeys.width) self.aspectLock = try container.decodeIfPresent(Bool.self, forKey: CodingKeys.aspectLock) } catch { let container = try decoder.singleValueContainer() self.url = try Self.parseURL( string: try container.decode(String.self) ) self.height = nil self.width = nil self.aspectLock = nil } } private static func parseURL(string: String) throws -> URL { guard let url = URL(string: string) else { throw AirshipErrors.error("Invalid URL \(string)") } if !url.absoluteString.isEmpty, url.scheme?.isEmpty ?? true { return URL(string: "https://" + string) ?? url } return url } } } ================================================ FILE: Airship/AirshipAutomation/Source/Actions/ScheduleAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /** * Action to schedule other actions. * * This action is registered under the names schedule_actions and ^sa. * * Expected argument values: Dictionary representing a schedule info JSON. * * Valid situations: ActionSituation.backgroundPush, .foregroundPush, .webViewInvocation, .manualInvocation, and .automation * * Result value: Schedule ID or throw if the schedule failed. */ public final class ScheduleAction: AirshipAction { //used for tests private let overrideAutomation: (any InAppAutomation)? /// Cancel schedules action names. public static let defaultNames: [String] = ["schedule_actions", "^sa"] init(overrideAutomation: (any InAppAutomation)? = nil) { self.overrideAutomation = overrideAutomation } var automation: any InAppAutomation { return overrideAutomation ?? Airship.inAppAutomation } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .manualInvocation, .backgroundPush, .foregroundPush, .webViewInvocation, .automation: return true default: return false } } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let schedule: AutomationSchedule = try arguments.value.decode() try await self.automation.upsertSchedules([schedule]) return .string(schedule.identifier) } } ================================================ FILE: Airship/AirshipAutomation/Source/AirshipAutomationResources.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Resources for AirshipAutomation public final class AirshipAutomationResources { /// Module bundle public static let bundle: Bundle = resolveBundle() private static func resolveBundle() -> Bundle { #if SWIFT_PACKAGE AirshipLogger.trace("Using Bundle.module for AirshipAutomation") let bundle = Bundle.module #if DEBUG if bundle.resourceURL == nil { assertionFailure(""" AirshipAutomation module was built with SWIFT_PACKAGE but no resources were found. Check your build configuration. """) } #endif return bundle #endif return Bundle.airshipFindModule( moduleName: "AirshipAutomation", sourceBundle: Bundle(for: Self.self) ) } } ================================================ FILE: Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerApiClient.swift ================================================ /* 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: any AirshipRequestSession private let encoder: JSONEncoder init(config: RuntimeConfig, session: any 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 } } } ================================================ FILE: Airship/AirshipAutomation/Source/AudienceCheck/AdditionalAudienceCheckerResolver.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol AdditionalAudienceCheckerResolverProtocol: Actor { func resolve( deviceInfoProvider: any AudienceDeviceInfoProvider, additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? ) async throws -> Bool } actor AdditionalAudienceCheckerResolver: AdditionalAudienceCheckerResolverProtocol { private let cache: any AirshipCache private let apiClient: any AdditionalAudienceCheckerAPIClientProtocol private let date: any AirshipDateProtocol private var inProgress: Task? private let configProvider: () -> RemoteConfig.AdditionalAudienceCheckConfig? private var additionalAudienceConfig: RemoteConfig.AdditionalAudienceCheckConfig? { get { configProvider() } } init( config: RuntimeConfig, cache: any AirshipCache, date: any AirshipDateProtocol = AirshipDate.shared ) { self.cache = cache self.apiClient = AdditionalAudienceCheckerAPIClient(config: config) self.date = date self.configProvider = { config.remoteConfig.iaaConfig?.additionalAudienceConfig } } /// Testing init( cache: any AirshipCache, apiClient: any AdditionalAudienceCheckerAPIClientProtocol, date: any AirshipDateProtocol, configProvider: @escaping () -> RemoteConfig.AdditionalAudienceCheckConfig? ) { self.cache = cache self.apiClient = apiClient self.date = date self.configProvider = configProvider } func resolve( deviceInfoProvider: any 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: any 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: ":")) } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/ApplicationMetrics.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol ApplicationMetricsProtocol: Sendable { var isAppVersionUpdated: Bool { get } var currentAppVersion: String? { get } } /// The ApplicationMetrics class keeps track of application-related metrics. final class ApplicationMetrics: ApplicationMetricsProtocol { private static let lastOpenDataKey = "UAApplicationMetricLastOpenDate" private static let lastAppVersionKey = "UAApplicationMetricsLastAppVersion" private let dataStore: PreferenceDataStore private let privacyManager: any AirshipPrivacyManager /** * Determines whether the application's short version string has been updated. * Only tracked if Feature.inAppAutomation or Feature.analytics are enabled in the privacy manager. */ public var isAppVersionUpdated: Bool { guard self.privacyManager.isApplicationMetricsEnabled, let currentVersion = self.currentAppVersion, let lastVersion = self.lastAppVersion, AirshipUtils.compareVersion(lastVersion, toVersion: currentVersion) == .orderedAscending else { return false } return true } /** * The application's current short version string also known as the marketing version. */ public let currentAppVersion: String? /** * The application's last short version string also known as the marketing version. */ public let lastAppVersion: String? public init( dataStore: PreferenceDataStore, privacyManager: any AirshipPrivacyManager, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, appVersion: String? = AirshipUtils.bundleShortVersionString() ) { self.dataStore = dataStore self.privacyManager = privacyManager self.currentAppVersion = appVersion self.lastAppVersion = if privacyManager.isApplicationMetricsEnabled { self.dataStore.string( forKey: ApplicationMetrics.lastAppVersionKey ) } else { nil } // Delete old self.dataStore.removeObject( forKey: ApplicationMetrics.lastOpenDataKey ) updateData() notificationCenter.addObserver( self, selector: #selector(updateData), name: AirshipNotifications.PrivacyManagerUpdated.name, object: nil ) } @objc func updateData() { if self.privacyManager.isApplicationMetricsEnabled { guard let currentVersion = self.currentAppVersion else { return } self.dataStore.setObject( currentVersion, forKey: ApplicationMetrics.lastAppVersionKey ) } else { self.dataStore.removeObject( forKey: ApplicationMetrics.lastAppVersionKey ) } } } fileprivate extension AirshipPrivacyManager { var isApplicationMetricsEnabled: Bool { self.isEnabled(.inAppAutomation) || self.isEnabled(.analytics) } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/AutomationAudience.swift ================================================ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Automation device audience public struct AutomationAudience: Codable, Sendable, Equatable { /// Miss behavior when the audience is not a match public enum MissBehavior: String, Codable, Sendable { /// Cancel the schedule case cancel /// Skip the execution case skip /// Skip the execution but count towards the limit case penalize } let audienceSelector: DeviceAudienceSelector let missBehavior: MissBehavior? enum CodingKeys: String, CodingKey { case missBehavior = "miss_behavior" } /// Automation audience initialized /// - Parameters: /// - audienceSelector: The audience selector /// - missBehavior: Behavior when audience selector is not a match public init( audienceSelector: DeviceAudienceSelector, missBehavior: MissBehavior? = nil ) { self.audienceSelector = audienceSelector self.missBehavior = missBehavior } public func encode(to encoder: any Encoder) throws { try audienceSelector.encode(to: encoder) var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(self.missBehavior, forKey: .missBehavior) } public init(from decoder: any Decoder) throws { self.audienceSelector = try DeviceAudienceSelector(from: decoder) let container = try decoder.container(keyedBy: CodingKeys.self) self.missBehavior = try container.decodeIfPresent(AutomationAudience.MissBehavior.self, forKey: .missBehavior) } } struct AdditionalAudienceCheckOverrides: Codable, Sendable, Equatable { let bypass: Bool? let context: AirshipJSON? let url: String? enum CodingKeys: String, CodingKey { case bypass, context, url } } extension AutomationAudience.MissBehavior { var schedulePrepareResult: SchedulePrepareResult { switch self { case .cancel: return .cancel case .penalize: return .penalize case .skip: return .skip } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/AutomationCompoundAudience.swift ================================================ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Automation compound audience public struct AutomationCompoundAudience: Codable, Sendable, Equatable { let selector: CompoundDeviceAudienceSelector let missBehavior: AutomationAudience.MissBehavior enum CodingKeys: String, CodingKey { case selector = "selector" case missBehavior = "miss_behavior" } /// Automation compound audience initialized /// - Parameters: /// - audienceSelector: The audience selector /// - missBehavior: Behavior when audience selector is not a match public init( selector: CompoundDeviceAudienceSelector, missBehavior: AutomationAudience.MissBehavior ) { self.selector = selector self.missBehavior = missBehavior } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/AutomationDelay.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Automation app state public enum AutomationAppState: String, Sendable, Codable { /// App is in the foreground (active/inactive) case foreground /// App is in the background case background } /// Automation delay public struct AutomationDelay: Sendable, Codable, Equatable { /// Number of seconds to delay the execution of the IAA var seconds: TimeInterval? /// Screen restrictions var screens: [String]? /// If a region ID restriction public var regionID: String? /// App state restriction public var appState: AutomationAppState? /// Cancellation triggers. These triggers only cancel the execution of the schedule not the entire schedule public var cancellationTriggers: [AutomationTrigger]? public var executionWindow: ExecutionWindow? enum CodingKeys: String, CodingKey { case seconds case screens = "screen" case regionID = "region" case appState = "app_state" case cancellationTriggers = "cancellation_triggers" case executionWindow = "execution_window" } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/AutomationSchedule.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Automation schedule public struct AutomationSchedule: Sendable, Codable, Equatable { /// Schedule data public enum ScheduleData: Sendable, Equatable { /// Actions case actions(AirshipJSON) /// In-App message case inAppMessage(InAppMessage) /// Deferred /// NOTE: For internal use only. :nodoc: case deferred(DeferredAutomationData) } /// The schedule ID. public let identifier: String /// List of triggers public var triggers: [AutomationTrigger] /// Optional schedule group. Can be used to cancel a set of schedules. public var group: String? /// Priority for determining order during simultaneous schedule processing public var priority: Int? /// Number of times the schedule can execute. public var limit: UInt? /// Start date public var start: Date? /// End date public var end: Date? /// On device automation selector public var audience: AutomationAudience? /// Compound audience. If both `audience` and `compoundAudience`, they will both /// be evaluated to determine if the schedule should be executed. public var compoundAudience: AutomationCompoundAudience? /// Delay after trigger and prepare steps before execution public var delay: AutomationDelay? /// Execution interval. public var interval: TimeInterval? /// Schedule data public var data: ScheduleData /// If the schedule should bypass holdout groups or not public var bypassHoldoutGroups: Bool? /// After the schedule ends or is finished, how long to hold on to the schedule before /// deleting it. This is used to keep schedule state around for a period of time /// after the schedule finishes to allow for extending the schedule. public var editGracePeriodDays: UInt? /// internal let additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? var metadata: AirshipJSON? var frequencyConstraintIDs: [String]? var messageType: String? var campaigns: AirshipJSON? var reportingContext: AirshipJSON? var productID: String? var minSDKVersion: String? var created: Date? var queue: String? enum CodingKeys: String, CodingKey { case identifier = "id" case triggers case created case group case metadata case priority case limit case start case end case audience case compoundAudience = "compound_audience" case delay case interval case campaigns case reportingContext = "reporting_context" case productID = "product_id" case bypassHoldoutGroups = "bypass_holdout_groups" case editGracePeriodDays = "edit_grace_period" case frequencyConstraintIDs = "frequency_constraint_ids" case messageType = "message_type" case scheduleType = "type" case actions case deferred case message case minSDKVersion = "min_sdk_version" case queue case additionalAudienceCheckOverrides = "additional_audience_check_overrides" } enum ScheduleType: String, Codable { case actions case inAppMessage = "in_app_message" case deferred } /// <#Description#> /// - Parameters: /// - identifier: The schedule ID /// - triggers: List of triggers for the schedule /// - data: Schedule data /// - group: Schedule group that can be used to cancel a set of schedules /// - priority: Priority for determining order during simultaneous schedule processing /// - limit: Number of times the schedule can execute /// - start: Start date /// - end: End date /// - audience: On device automation selector /// - compoundAudience: Compound automation selector /// - delay: Duration after trigger and prepare steps after which execution occurs /// - interval: Execution interval /// - bypassHoldoutGroups: If the schedule should bypass holdout groups or not /// - editGracePeriodDays: Duration after which post-execution deletion occurs public init( identifier: String, triggers: [AutomationTrigger], data: ScheduleData, group: String? = nil, priority: Int? = nil, limit: UInt? = nil, start: Date? = nil, end: Date? = nil, audience: AutomationAudience? = nil, compoundAudience: AutomationCompoundAudience? = nil, delay: AutomationDelay? = nil, interval: TimeInterval? = nil, bypassHoldoutGroups: Bool? = nil, editGracePeriodDays: UInt? = nil ) { self.identifier = identifier self.triggers = triggers self.created = Date() self.group = group self.priority = priority self.limit = limit self.start = start self.end = end self.audience = audience self.compoundAudience = compoundAudience self.delay = delay self.interval = interval self.data = data self.bypassHoldoutGroups = bypassHoldoutGroups self.editGracePeriodDays = editGracePeriodDays self.metadata = nil self.frequencyConstraintIDs = nil self.messageType = nil self.campaigns = nil self.reportingContext = nil self.productID = nil self.queue = nil self.additionalAudienceCheckOverrides = nil } init( identifier: String, data: ScheduleData, triggers: [AutomationTrigger], created: Date? = Date(), lastUpdated: Date? = nil, group: String? = nil, priority: Int? = nil, limit: UInt? = nil, start: Date? = nil, end: Date? = nil, audience: AutomationAudience? = nil, compoundAudience: AutomationCompoundAudience? = nil, delay: AutomationDelay? = nil, interval: TimeInterval? = nil, bypassHoldoutGroups: Bool? = nil, editGracePeriodDays: UInt? = nil, metadata: AirshipJSON? = nil, campaigns: AirshipJSON? = nil, reportingContext: AirshipJSON? = nil, productID: String? = nil, frequencyConstraintIDs: [String]? = nil, messageType: String? = nil, minSDKVersion: String? = nil, queue: String? = nil, additionalAudienceCheckOverrides: AdditionalAudienceCheckOverrides? = nil ) { self.identifier = identifier self.triggers = triggers self.group = group self.priority = priority self.limit = limit self.start = start self.end = end self.audience = audience self.compoundAudience = compoundAudience self.delay = delay self.interval = interval self.data = data self.bypassHoldoutGroups = bypassHoldoutGroups self.editGracePeriodDays = editGracePeriodDays self.metadata = metadata self.campaigns = campaigns self.reportingContext = reportingContext self.productID = productID self.frequencyConstraintIDs = frequencyConstraintIDs self.messageType = messageType self.created = created self.minSDKVersion = minSDKVersion self.queue = queue self.additionalAudienceCheckOverrides = additionalAudienceCheckOverrides } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.identifier = try container.decode(String.self, forKey: .identifier) self.triggers = try container.decode([AutomationTrigger].self, forKey: .triggers) self.group = try container.decodeIfPresent(String.self, forKey: .group) self.metadata = try container.decodeIfPresent(AirshipJSON.self, forKey: .metadata) self.priority = try container.decodeIfPresent(Int.self, forKey: .priority) self.limit = try container.decodeIfPresent(UInt.self, forKey: .limit) self.start = try container.decodeIfPresent(String.self, forKey: .start)?.toDate() self.end = try container.decodeIfPresent(String.self, forKey: .end)?.toDate() self.audience = try container.decodeIfPresent(AutomationAudience.self, forKey: .audience) self.compoundAudience = try container.decodeIfPresent(AutomationCompoundAudience.self, forKey: .compoundAudience) self.delay = try container.decodeIfPresent(AutomationDelay.self, forKey: .delay) self.interval = try container.decodeIfPresent(TimeInterval.self, forKey: .interval) self.campaigns = try container.decodeIfPresent(AirshipJSON.self, forKey: .campaigns) self.reportingContext = try container.decodeIfPresent(AirshipJSON.self, forKey: .reportingContext) self.productID = try container.decodeIfPresent(String.self, forKey: .productID) self.bypassHoldoutGroups = try container.decodeIfPresent(Bool.self, forKey: .bypassHoldoutGroups) self.editGracePeriodDays = try container.decodeIfPresent(UInt.self, forKey: .editGracePeriodDays) self.frequencyConstraintIDs = try container.decodeIfPresent([String].self, forKey: .frequencyConstraintIDs) 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) { case .actions: let actions = try container.decode(AirshipJSON.self, forKey: .actions) self.data = .actions(actions) case .inAppMessage: let inAppMessage = try container.decode(InAppMessage.self, forKey: .message) self.data = .inAppMessage(inAppMessage) case .deferred: let deferred = try container.decode(DeferredAutomationData.self, forKey: .deferred) self.data = .deferred(deferred) } let created = try container.decodeIfPresent(String.self, forKey: .created) if let created = created { guard let date = created.toDate() else { throw DecodingError.typeMismatch( AutomationSchedule.self, DecodingError.Context( codingPath: container.codingPath, debugDescription: "Invalid created date string.", underlyingError: nil ) ) } self.created = date } } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.identifier, forKey: .identifier) try container.encode(self.triggers, forKey: .triggers) try container.encodeIfPresent(self.created?.toISOString(), forKey: .created) try container.encodeIfPresent(self.group, forKey: .group) try container.encodeIfPresent(self.metadata, forKey: .metadata) try container.encodeIfPresent(self.priority, forKey: .priority) try container.encodeIfPresent(self.limit, forKey: .limit) try container.encodeIfPresent(self.start?.toISOString(), forKey: .start) try container.encodeIfPresent(self.end?.toISOString(), forKey: .end) try container.encodeIfPresent(self.audience, forKey: .audience) try container.encodeIfPresent(self.compoundAudience, forKey: .compoundAudience) try container.encodeIfPresent(self.delay, forKey: .delay) try container.encodeIfPresent(self.interval, forKey: .interval) try container.encodeIfPresent(self.campaigns, forKey: .campaigns) try container.encodeIfPresent(self.reportingContext, forKey: .reportingContext) try container.encodeIfPresent(self.productID, forKey: .productID) try container.encodeIfPresent(self.bypassHoldoutGroups, forKey: .bypassHoldoutGroups) try container.encodeIfPresent(self.editGracePeriodDays, forKey: .editGracePeriodDays) try container.encodeIfPresent(self.frequencyConstraintIDs, forKey: .frequencyConstraintIDs) 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): try container.encode(ScheduleType.actions, forKey: .scheduleType) try container.encode(actions, forKey: .actions) case .inAppMessage(let message): try container.encode(ScheduleType.inAppMessage, forKey: .scheduleType) try container.encode(message, forKey: .message) case .deferred(let deferred): try container.encode(ScheduleType.deferred, forKey: .scheduleType) try container.encode(deferred, forKey: .deferred) } } } fileprivate extension String { func toDate() -> Date? { return AirshipDateFormatter.date(fromISOString: self) } } fileprivate extension Date { func toISOString() -> String { return AirshipDateFormatter.string(fromDate: self, format: .iso) } } extension AutomationSchedule { var isInAppMessageType: Bool { switch (data) { case .actions(_): return false case .inAppMessage(_): return true case .deferred(let deferred): switch(deferred.type) { case .actions: return false case .inAppMessage: return true } } } static func isNewSchedule( created: Date?, minSDKVersion: String?, sinceDate: Date, lastSDKVersion: String? ) -> Bool { guard let created = created else { return false } if created > sinceDate { return true } guard let minSDKVersion = minSDKVersion else { return false } // We can skip checking if the min_sdk_version is newer than the current SDK version since // remote-data will filter them out. This flag is only a hint to the SDK to treat a schedule with // an older created timestamp as a new schedule. let constraint = if let lastSDKVersion = lastSDKVersion { "]\(lastSDKVersion),)" } else { // If we do not have a last SDK version, then we are coming from an SDK older than // 16.2.0. Check for a min SDK version newer or equal to 16.2.0. "[16.2.0,)" } guard let matcher = try? AirshipIvyVersionMatcher(versionConstraint: constraint) else { return false } return matcher.evaluate(version: minSDKVersion) } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/AutomationTrigger.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Automation trigger types public enum EventAutomationTriggerType: String, Sendable, Codable, Equatable, CaseIterable { /// Foreground case foreground /// Background case background /// Screen view case screen /// Version update case version /// App init case appInit = "app_init" // Region enter case regionEnter = "region_enter" /// Region exit case regionExit = "region_exit" /// Custom event count case customEventCount = "custom_event_count" /// Custom event value case customEventValue = "custom_event_value" /// Feature flag interaction case featureFlagInteraction = "feature_flag_interaction" /// 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" } /// Logical operator for combining multiple automation triggers. public enum CompoundAutomationTriggerType: String, Sendable, Codable, Equatable { /// Any of the triggers must fire. case or /// All triggers must fire. case and /// Triggers must fire in sequence. case chain } /// Defines what causes an automation to run: a single event trigger or a compound of triggers. public enum AutomationTrigger: Sendable, Codable, Equatable { /// Trigger based on a single event (e.g. foreground, custom event). case event(EventAutomationTrigger) /// Trigger that combines multiple triggers with a logical operator (and/or/chain). case compound(CompoundAutomationTrigger) public func encode(to encoder: any Encoder) throws { switch self { case .event(let trigger): try trigger.encode(to: encoder) case .compound(let trigger): try trigger.encode(to: encoder) } } enum CodingKeys: CodingKey { case type } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) if CompoundAutomationTriggerType(rawValue: type) != nil { self = try .compound(CompoundAutomationTrigger(from: decoder)) } else { self = try .event(EventAutomationTrigger(from: decoder)) } } var id: String { switch self { case .compound(let trigger): return trigger.id case .event(let trigger): return trigger.id } } var goal: Double { switch self { case .compound(let trigger): return trigger.goal case .event(let trigger): return trigger.goal } } var type: String { switch self { case .compound(let trigger): return trigger.type.rawValue case .event(let trigger): return trigger.type.rawValue } } } extension AutomationTrigger { var shouldBackFillIdentifier: Bool { switch self { case .compound(_): return false case .event(let trigger): return trigger.allowBackfillID } } func backfilledIdentifier(executionType: TriggerExecutionType) -> AutomationTrigger { /// compound triggers should have IDs guard case .event(var eventTrigger) = self else { return self } eventTrigger.backfillIdentifier(executionType: executionType) return .event(eventTrigger) } } /// Model for defining when an automation is triggered. public struct EventAutomationTrigger: Sendable, Codable, Equatable { /// The trigger type public var type: EventAutomationTriggerType /// The trigger goal public var goal: Double /// Predicate to run on the event's data public var predicate: JSONPredicate? var id: String /// Tracks if we should allow backfilling the ID. Will be true if the id was generated,, otherwise false. /// This field is only relevant when parsing JSON without an ID. Once it encodes/decodes /// an ID will be set and this will always be false. var allowBackfillID: Bool /// Event automation trigger initializer /// - Parameters: /// - type: Trigger type /// - goal: Trigger goal /// - predicate: Predicate to run on the event data public init( type: EventAutomationTriggerType, goal: Double, predicate: JSONPredicate? = nil ) { self.type = type self.goal = goal self.predicate = predicate self.id = UUID().uuidString // Programatically generated triggers should not allow backfilling the ID // even though we generated an ID. These triggers are not created from // remote-data so we just need to ensure they are unique. self.allowBackfillID = false } /// Used for tests init( id: String, type: EventAutomationTriggerType, goal: Double, predicate: JSONPredicate? = nil, children: [AutomationTrigger] = [] ) { self.type = type self.goal = goal self.predicate = predicate self.id = id // Programatically generated triggers should not allow backfilling the ID // even though we generated an ID. These triggers are not created from // remote-data so we just need to ensure they are unique. self.allowBackfillID = false } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.type = try container.decode(EventAutomationTriggerType.self, forKey: .type) self.goal = try container.decode(Double.self, forKey: .goal) self.predicate = try container.decodeIfPresent(JSONPredicate.self, forKey: .predicate) let id = try container.decodeIfPresent(String.self, forKey: .id) if let id = id { self.id = id self.allowBackfillID = false } else { self.id = UUID().uuidString self.allowBackfillID = true } } enum CodingKeys: String, CodingKey { case type case goal case predicate case id } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.type, forKey: .type) try container.encode(self.goal, forKey: .goal) try container.encodeIfPresent(self.predicate, forKey: .predicate) try container.encode(self.id, forKey: .id) } mutating func backfillIdentifier(executionType: TriggerExecutionType) { guard self.allowBackfillID else { return } // Sha256(trigger_type:goal:execution_type:?) var components: [String] = [] components.append(contentsOf: [self.type.rawValue, String(self.goal), executionType.rawValue]) if let predicate = predicate, let json = try? AirshipJSONUtils.string(predicate, options: .sortedKeys) { components.append(json) } self.id = AirshipUtils.sha256Hash( input: components.joined(separator: ":") ) self.allowBackfillID = false } } /// NOTE: For internal use only. :nodoc: public struct CompoundAutomationTrigger: Sendable, Codable, Equatable { /// The ID var id: String /// The type var type: CompoundAutomationTriggerType /// The trigger goal var goal: Double var children: [Child] public struct Child: Sendable, Codable, Equatable { var trigger: AutomationTrigger var isSticky: Bool? var resetOnIncrement: Bool? enum CodingKeys: String, CodingKey { case trigger case isSticky = "is_sticky" case resetOnIncrement = "reset_on_increment" } } } /// NOTE: For internal use only. :nodoc: public extension AutomationTrigger { static func activeSession(count: UInt) -> AutomationTrigger { return .event(EventAutomationTrigger(type: .activeSession, goal: Double(count))) } static func foreground(count: UInt) -> AutomationTrigger { return .event(EventAutomationTrigger(type: .foreground, goal: Double(count))) } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/DeferredAutomationData.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// NOTE: For internal use only. :nodoc: public struct DeferredAutomationData: Sendable, Codable, Equatable { enum DeferredType: String, Codable, Sendable { case inAppMessage = "in_app_message" case actions } let url: URL let retryOnTimeOut: Bool? let type: DeferredType enum CodingKeys: String, CodingKey { case url case retryOnTimeOut = "retry_on_timeout" case type } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationDelayProcessor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol AutomationDelayProcessorProtocol: Sendable { // Waits for the delay @MainActor func process(delay: AutomationDelay?, triggerDate: Date) async // Waits for any delay - 30s and to be a display window if set func preprocess(delay: AutomationDelay?, triggerDate: Date) async throws // Checks if conditions are met @MainActor func areConditionsMet(delay: AutomationDelay?) -> Bool } final class AutomationDelayProcessor: AutomationDelayProcessorProtocol { private let analytics: any InternalAirshipAnalytics private let appStateTracker: any AppStateTrackerProtocol private let taskSleeper: any AirshipTaskSleeper private let date: any AirshipDateProtocol private let screen: AirshipMainActorValue = AirshipMainActorValue(nil) private let executionWindowProcessor: any ExecutionWindowProcessorProtocol private static let preprocessSecondsDelayAllowance: TimeInterval = 30.0 @MainActor init( analytics: any InternalAirshipAnalytics, appStateTracker: (any AppStateTrackerProtocol)? = nil, taskSleeper: any AirshipTaskSleeper = .shared, date: any AirshipDateProtocol = AirshipDate.shared, executionWindowProcessor: (any ExecutionWindowProcessorProtocol)? = nil ) { self.analytics = analytics self.appStateTracker = appStateTracker ?? AppStateTracker.shared self.taskSleeper = taskSleeper self.date = date self.executionWindowProcessor = executionWindowProcessor ?? ExecutionWindowProcessor( taskSleeper: taskSleeper, date: date ) } private func remainingSeconds(delay: AutomationDelay, triggerDate: Date) -> TimeInterval { guard let seconds = delay.seconds else { return 0 } let remaining = seconds - date.now.timeIntervalSince(triggerDate) return remaining > 0 ? remaining : 0 } @MainActor func process(delay: AutomationDelay?, triggerDate: Date) async { guard let delay = delay else { return } /// Seconds let seconds = remainingSeconds(delay: delay, triggerDate: triggerDate) if seconds > 0 { try? await self.taskSleeper.sleep(timeInterval: seconds) } while !Task.isCancelled, !areConditionsMet(delay:delay) { /// App state if let appState = delay.appState { for await update in self.appStateTracker.stateUpdates { guard !Task.isCancelled else { return } if (appState == update.automationAppState) { break } } } guard !Task.isCancelled else { return } // Screen if let screens = delay.screens { for await update in self.analytics.screenUpdates { guard !Task.isCancelled else { return } if let update = update, screens.contains(update) { break } } } guard !Task.isCancelled else { return } // Region if let regionID = delay.regionID { guard !Task.isCancelled else { return } for await update in self.analytics.regionUpdates { if update.contains(regionID) { break } } } guard !Task.isCancelled else { return } if let window = delay.executionWindow { try? await executionWindowProcessor.process(window: window) } } } func preprocess(delay: AutomationDelay?, triggerDate: Date) async throws { guard let delay = delay else { return } // Handle delay - preprocessSecondsDelayAllowance let seconds = remainingSeconds(delay: delay, triggerDate: triggerDate) - Self.preprocessSecondsDelayAllowance if seconds > 0 { try await self.taskSleeper.sleep(timeInterval: seconds) } try Task.checkCancellation() if let window = delay.executionWindow { try await executionWindowProcessor.process(window: window) } } @MainActor func areConditionsMet(delay: AutomationDelay?) -> Bool { guard let delay = delay else { return true } // State if let appState = delay.appState { guard appState == self.appStateTracker.state.automationAppState else { return false } } // Screen if let screens = delay.screens { guard let currentScreen = analytics.currentScreen, screens.contains(currentScreen) else { return false } } // Region if let regionID = delay.regionID { guard self.analytics.currentRegions.contains(regionID) else { return false } } if let window = delay.executionWindow { guard executionWindowProcessor.isActive(window: window) else { return false } } return true } } fileprivate extension ApplicationState { @MainActor var automationAppState: AutomationAppState { if self == .active { return .foreground } else { return .background } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationEngine.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif actor AutomationEngine : AutomationEngineProtocol { internal var startTask: Task? internal var listenerTask: Task? nonisolated internal let isEnginePaused: AirshipMainActorValue = AirshipMainActorValue(false) nonisolated internal let isExecutionPaused: AirshipMainActorValue = AirshipMainActorValue(false) private let triggerQueue: AirshipSerialQueue = AirshipSerialQueue() private let store: AutomationStore private let executor: AutomationExecutor private let preparer: AutomationPreparer private let scheduleConditionsChangedNotifier: any ScheduleConditionsChangedNotifierProtocol private let eventFeed: any AutomationEventFeedProtocol private let triggersProcessor: any AutomationTriggerProcessorProtocol private let delayProcessor: any AutomationDelayProcessorProtocol private let date: any AirshipDateProtocol private let taskSleeper: any AirshipTaskSleeper private let eventsHistory: any AutomationEventsHistory private var processPendingExecutionTask: Task? private var pendingExecution: [String: PreparedData] = [:] private var preprocessDelayTasks: Set> = Set() init( store: AutomationStore, executor: AutomationExecutor, preparer: AutomationPreparer, scheduleConditionsChangedNotifier: any ScheduleConditionsChangedNotifierProtocol, eventFeed: any AutomationEventFeedProtocol, triggersProcessor: any AutomationTriggerProcessorProtocol, delayProcessor: any AutomationDelayProcessorProtocol, eventsHistory: any AutomationEventsHistory, date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = .shared ) { self.store = store self.executor = executor self.preparer = preparer self.scheduleConditionsChangedNotifier = scheduleConditionsChangedNotifier self.eventFeed = eventFeed self.triggersProcessor = triggersProcessor self.delayProcessor = delayProcessor self.date = date self.taskSleeper = taskSleeper self.eventsHistory = eventsHistory } @MainActor func setEnginePaused(_ paused: Bool) { self.isEnginePaused.set(paused) self.triggersProcessor.setPaused(paused) if !isExecutionPaused.value && !isEnginePaused.value { self.scheduleConditionsChangedNotifier.notify() } } @MainActor func setExecutionPaused(_ paused: Bool) { self.isExecutionPaused.set(paused) if !isExecutionPaused.value && !isEnginePaused.value { self.scheduleConditionsChangedNotifier.notify() } } func start() async { self.startTask = Task { do { try await self.restoreSchedules() } catch { AirshipLogger.error("Failed to restore schedules \(error)") } } self.listenerTask = Task { await self.startTask?.value await withTaskGroup(of: Void.self) { group in group.addTask { [weak self] in guard !Task.isCancelled, let resultsStream = await self?.triggersProcessor.triggerResults else { return } for await result in resultsStream { guard !Task.isCancelled else { return } await self?.processTriggerResult(result) } } group.addTask { [weak self] in guard !Task.isCancelled, let eventsFeed = self?.eventFeed.feed else { return } for await event in eventsFeed { guard !Task.isCancelled else { return } await self?.triggersProcessor.processEvent(event) await self?.eventsHistory.add(event) } } } } Task { while true { await self.scheduleConditionsChangedNotifier.wait() await startProcessingPendingExecution() } } } func stop() async { self.listenerTask?.cancel() self.listenerTask = nil self.startTask?.cancel() self.startTask = nil } func stopSchedules(identifiers: [String]) async throws { AirshipLogger.debug("Stopping schedules \(identifiers)") await self.startTask?.value let now = self.date.now for identifier in identifiers { try await self.updateState(identifier: identifier) { data in data.schedule.end = now data.lastScheduleModifiedDate = now data.finished(date: now) } } } func upsertSchedules(_ schedules: [AutomationSchedule]) async throws { await self.startTask?.value let map = schedules.reduce(into: [String: AutomationSchedule]()) { $0[$1.identifier] = $1 } AirshipLogger.debug("Upserting schedules \(map.keys)") let updated = try await store.upsertSchedules(scheduleIDs: Array(map.keys)) { [date] identifier, data in guard let schedule = map[identifier] else { throw AirshipErrors.error("Failed to upsert") } var updated = try schedule.updateOrCreate(data: data, date: self.date.now) updated.updateState(date: date.now) updated.lastScheduleModifiedDate = date.now return updated } await self.triggersProcessor.updateSchedules(updated) self.cancelPreprocessDelayTasks() } func cancelSchedules(identifiers: [String]) async throws { AirshipLogger.debug("Cancelling schedules \(identifiers)") await self.startTask?.value try await store.deleteSchedules(scheduleIDs: identifiers) await self.triggersProcessor.cancel(scheduleIDs: identifiers) } func cancelSchedules(group: String) async throws { AirshipLogger.debug("Cancelling schedules with group \(group)") await self.startTask?.value try await store.deleteSchedules(group: group) await self.triggersProcessor.cancel(group: group) } func cancelSchedulesWith(type: AutomationSchedule.ScheduleType) async throws { AirshipLogger.debug("Cancelling schedules with type \(type)") await self.startTask?.value //we don't store schedule type as a separate field, but it's a part of airship json, so we // can't utilize core data to filter out our results let ids = try await self.schedules.compactMap { schedule in switch schedule.data { case .actions: return type == .actions ? schedule.identifier : nil case .inAppMessage: return type == .inAppMessage ? schedule.identifier : nil case .deferred: return type == .deferred ? schedule.identifier : nil } } try await store.deleteSchedules(scheduleIDs: ids) await self.triggersProcessor.cancel(scheduleIDs: ids) } var schedules: [AutomationSchedule] { get async throws { return try await self.store.getSchedules() .filter { !$0.shouldDelete(date: self.date.now) } .map { $0.schedule } } } func getSchedule(identifier: String) async throws -> AutomationSchedule? { guard let data = try await self.store.getSchedule(scheduleID: identifier), !data.shouldDelete(date: self.date.now) else { return nil } return data.schedule } func getSchedules(group: String) async throws -> [AutomationSchedule] { return try await self.store.getSchedules(group: group) .filter { !$0.shouldDelete(date: self.date.now) } .map { $0.schedule } } private func restoreSchedules() async throws { let now = self.date.now let schedules = try await self.store.getSchedules() .sorted { left, right in if (left.schedule.priority ?? 0) < (right.schedule.priority ?? 0) { return true } let leftDate = left.triggerInfo?.date ?? now let rightDate = left.triggerInfo?.date ?? now return leftDate > rightDate } // Restore triggers try await self.triggersProcessor.restoreSchedules(schedules) // Handle interrupted let interrupted = schedules.filter { $0.isInState([.executing, .prepared, .triggered]) } for data in interrupted { var updated: AutomationScheduleData? if data.scheduleState == .executing, let preparedInfo = data.preparedScheduleInfo { let behavior = await self.executor.interrupted(schedule: data.schedule, preparedScheduleInfo: preparedInfo) updated = try await self.updateState(data: data) { data in data.executionInterrupted(date: now, retry: behavior == .retry) } if (updated?.scheduleState == .paused) { handleInterval(updated?.schedule.interval ?? 0.0, scheduleID: data.schedule.identifier) } } else { updated = try await self.updateState(data: data) { data in data.prepareInterrupted(date: now) } } if (updated?.scheduleState == .triggered) { await startTaskToProcessTriggeredSchedule(scheduleID: data.schedule.identifier) } } // Restore Intervals let paused = schedules.filter { $0.scheduleState == .paused } for data in paused { let interval = data.schedule.interval ?? 0.0 let remaining = interval - self.date.now.timeIntervalSince(data.scheduleStateChangeDate) handleInterval(remaining, scheduleID: data.schedule.identifier) } /// Delete finished schedules let shouldDelete = schedules .filter { $0.shouldDelete(date: now) } .map { $0.schedule.identifier } if !shouldDelete.isEmpty { try await self.store.deleteSchedules(scheduleIDs: shouldDelete) await self.triggersProcessor.cancel(scheduleIDs: shouldDelete) } } private func handleInterval(_ interval: TimeInterval, scheduleID: String) { Task { [weak self, date] in try await self?.taskSleeper.sleep(timeInterval: interval) try await self?.updateState(identifier: scheduleID) { data in data.idle(date: date.now) } } } } /// Schedule processing fileprivate extension AutomationEngine { private func processTriggerResult(_ result: TriggerResult) async { let now = self.date.now await self.triggerQueue.runSafe { do { switch (result.triggerExecutionType) { case .delayCancellation: let updated = try await self.updateState(identifier: result.scheduleID) { data in data.executionCancelled(date: now) } if let updated = updated { await self.preparer.cancelled(schedule: updated.schedule) } break case .execution: try await self.updateState(identifier: result.scheduleID) { data in data.triggered(triggerInfo: result.triggerInfo, date: now) } await self.startTaskToProcessTriggeredSchedule( scheduleID: result.scheduleID ) } } catch { AirshipLogger.error("Failed to process trigger result: \(result), error: \(error)") } } } private func startTaskToProcessTriggeredSchedule(scheduleID: String) async { AirshipLogger.trace("Starting task to process schedule \(scheduleID)") // pause the current context await withUnsafeContinuation { continuation in Task { // actor context continuation.resume() do { AirshipLogger.trace("Processing triggered schedule \(scheduleID)") try await self.processTriggeredSchedule(scheduleID: scheduleID) } catch { AirshipLogger.error("Failed to process triggered schedule \(scheduleID) error: \(error)") } } } } private func preprocessDelay(data: AutomationScheduleData) async -> Bool { guard let delay = data.schedule.delay else { return true } let scheduleID = data.schedule.identifier let triggerDate = data.triggerInfo?.date ?? data.scheduleStateChangeDate let task = Task { AirshipLogger.trace("Preprocessing delay \(scheduleID)") try await self.delayProcessor.preprocess( delay: delay, triggerDate: triggerDate ) AirshipLogger.trace("Finished preprocessing delay \(scheduleID)") return true } preprocessDelayTasks.insert(task) let result = try? await task.value preprocessDelayTasks.remove(task) return result ?? false } private func cancelPreprocessDelayTasks() { preprocessDelayTasks.forEach { $0.cancel() } preprocessDelayTasks.removeAll() } private func processTriggeredSchedule(scheduleID: String) async throws { if await self.isEnginePaused.value { // Wait for resume _ = await self.isExecutionPaused.updates.first(where: { paused in paused == false }) } guard let data = try await self.store.getSchedule(scheduleID: scheduleID) else { AirshipLogger.trace("Aborting processing schedule \(scheduleID), no longer in database.") return } guard data.isInState([.triggered]) else { AirshipLogger.trace("Aborting processing schedule \(data), no longer triggered.") return } guard await preprocessDelay(data: data) else { AirshipLogger.trace("Preprocess delay was interrupted, retrying \(scheduleID)") try await processTriggeredSchedule(scheduleID: scheduleID) return } guard let isCurrent = try? await self.store.isCurrent( scheduleID: scheduleID, lastScheduleModifiedDate: data.lastScheduleModifiedDate, scheduleState: data.scheduleState ), isCurrent else { AirshipLogger.trace("Trigger data has changed since preprocessing, retrying \(scheduleID)") try await processTriggeredSchedule(scheduleID: scheduleID) return } guard data.isActive(date: self.date.now) else { AirshipLogger.trace("Aborting processing schedule \(data), no longer active.") await self.preparer.cancelled(schedule: data.schedule) return } /// Prepare guard let prepared = try await self.prepareSchedule(data: data) else { return } try await processPrepared(preparedData: prepared) } private func processPrepared(preparedData: PreparedData) async throws { await waitForConditions(preparedData: preparedData) guard await checkStillValid(prepared: preparedData) else { let updated = try await self.updateState(data: preparedData.scheduleData) { [date] data in data.executionInvalidated(date: date.now) } if updated?.scheduleState == .triggered { await self.startTaskToProcessTriggeredSchedule( scheduleID: preparedData.scheduleID ) } else { await self.preparer.cancelled(schedule: preparedData.scheduleData.schedule) } return } self.addPending(preparedData: preparedData) await self.startProcessingPendingExecution() } private func startProcessingPendingExecution() async { await self.processPendingExecutionTask?.value self.processPendingExecutionTask = Task { await processPendingExecution() } } private func processPendingExecution() async { var processedScheduleIDs = Set() while true { let next = self.pendingExecution.values.filter { data in !processedScheduleIDs.contains(data.scheduleID) }.sorted { l, r in l.priority < r.priority }.first guard let next else { return } processedScheduleIDs.insert(next.scheduleID) guard await checkStillValid(prepared: next), await self.delayProcessor.areConditionsMet(delay: next.scheduleData.schedule.delay) else { self.pendingExecution.removeValue(forKey: next.scheduleID) Task { do { try await processPrepared(preparedData: next) } catch { AirshipLogger.error("Failed to execute schedule \(next.scheduleData) \(error)") } } continue } self.pendingExecution.removeValue(forKey: next.scheduleID) Task { @MainActor in do { let handled = try await attemptExecution( data: next.scheduleData, preparedSchedule: next.preparedSchedule ) if (!handled) { await addPending(preparedData: next) } } catch { AirshipLogger.error("Failed to execute schedule \(next.scheduleData) \(error)") } } } } private func addPending(preparedData: PreparedData) { AirshipLogger.trace("Adding \(preparedData.scheduleID) to pending execution queue") self.pendingExecution[preparedData.scheduleID] = preparedData } private func checkStillValid(prepared: PreparedData) async -> Bool { // Make sure we are still up to date. Data might change due to a change // in the data, schedule was cancelled, or if a delay cancellation trigger // was fired. guard let isCurrent = try? await self.store.isCurrent( scheduleID: prepared.scheduleID, lastScheduleModifiedDate: prepared.scheduleData.lastScheduleModifiedDate, scheduleState: prepared.scheduleData.scheduleState ), isCurrent else { AirshipLogger.trace("Prepared schedule no longer up to date, no longer valid \(prepared.scheduleData)") return false } guard prepared.scheduleData.isActive(date: self.date.now) else { AirshipLogger.trace("Prepared schedule no longer active, no longer valid \(prepared.scheduleData)") return false } guard await self.executor.isValid( schedule: prepared.scheduleData.schedule ) else { AirshipLogger.trace("Prepared schedule no longer valid \(prepared.scheduleData)") return false } return true } private func waitForConditions(preparedData: PreparedData) async { let triggerDate = preparedData.scheduleData.triggerInfo?.date ?? preparedData.scheduleData.scheduleStateChangeDate // Wait for conditions AirshipLogger.trace("Waiting for delay conditions \(preparedData.scheduleID)") await self.delayProcessor.process( delay: preparedData.scheduleData.schedule.delay, triggerDate: triggerDate ) AirshipLogger.trace("Delay conditions met \(preparedData.scheduleID)") } private func prepareSchedule(data: AutomationScheduleData) async throws -> PreparedData? { AirshipLogger.trace("Preparing schedule \(data.schedule.identifier)") let prepareResult = await self.preparer.prepare( schedule: data.schedule, triggerContext: data.triggerInfo?.context, triggerSessionID: data.triggerSessionID ) AirshipLogger.trace("Finished preparing schedule \(data.schedule.identifier) result: \(prepareResult)") switch prepareResult { case .prepared(let preparedSchedule): let updated = try await self.updateState(data: data) { [date] data in data.prepared(info: preparedSchedule.info, date: date.now) } // Make sure its updated guard let updated else { await preparer.cancelled(schedule: data.schedule) return nil } return PreparedData( scheduleData: updated, preparedSchedule: preparedSchedule ) case .invalidate: await self.startTaskToProcessTriggeredSchedule( scheduleID: data.schedule.identifier ) return nil case .cancel: try await self.store.deleteSchedules(scheduleIDs: [data.schedule.identifier]) return nil case .skip: _ = try await self.updateState(data: data) { [date] data in data.prepareCancelled(date: date.now, penalize: false) } return nil case .penalize: _ = try await self.updateState(data: data) { [date] data in data.prepareCancelled(date: date.now, penalize: true) } return nil } } @MainActor private func attemptExecution(data: AutomationScheduleData, preparedSchedule: PreparedSchedule) async throws -> Bool { AirshipLogger.trace("Starting to execute schedule \(data)") let readyResult = self.checkReady(data: data, preparedSchedule: preparedSchedule) switch (readyResult) { case .ready: break case .invalidate: let updated = try await self.updateState(data: data) { [date] data in data.executionInvalidated(date: date.now) } if updated?.scheduleState == .triggered { await self.startTaskToProcessTriggeredSchedule( scheduleID: data.schedule.identifier ) } else { await self.preparer.cancelled(schedule: data.schedule) } return true case .notReady: return false case .skip: try await self.updateState(data: data) { [date] data in data.executionSkipped(date: date.now) } await self.preparer.cancelled(schedule: data.schedule) return true } let executeResult = try await self.execute(preparedSchedule: preparedSchedule) let scheduleID = data.schedule.identifier switch (executeResult) { case .cancel: try await self.store.deleteSchedules(scheduleIDs: [scheduleID]) await self.triggersProcessor.cancel(scheduleIDs: [scheduleID]) return true case .finished: let updated = try await self.updateState(identifier: scheduleID) { [date] data in data.finishedExecuting(date: date.now) } if let updated = updated, updated.scheduleState == .paused { await handleInterval(updated.schedule.interval ?? 0.0, scheduleID: updated.schedule.identifier) } return true case .retry: return false } } @MainActor private func execute(preparedSchedule: PreparedSchedule) async throws -> ScheduleExecuteResult { AirshipLogger.trace("Executing schedule \(preparedSchedule.info.scheduleID)") // Execute let updateStateTask = Task { try await self.updateState(identifier: preparedSchedule.info.scheduleID) { [date] data in data.executing(date: date.now) } } let executeResult = await self.executor.execute( preparedSchedule: preparedSchedule ) _ = try await updateStateTask.value AirshipLogger.trace("Executing result \(preparedSchedule.info.scheduleID) \(executeResult)") return executeResult } @MainActor private func checkReady(data: AutomationScheduleData, preparedSchedule: PreparedSchedule) -> ScheduleReadyResult { AirshipLogger.trace("Checking if schedule is ready \(data)") // Execution should not be paused guard !self.isExecutionPaused.value, !self.isEnginePaused.value else { AirshipLogger.trace("Executor paused, not ready \(data)") return .notReady } // Still active guard data.isActive(date: self.date.now) else { AirshipLogger.trace("Schedule no longer active, Invalidating \(data)") return .invalidate } let result = self.executor.isReady(preparedSchedule: preparedSchedule) if result != .ready { AirshipLogger.trace("Schedule not ready \(data)") } return result } /// Same as updateState(identifier:block) but optimized to skip parsing the schedule if last modified time /// is unchanged. This reduces energy usage by avoiding unnecessary schedule parsing. /// TODO: Move start/end to top level of schedule to allow state-only mutations without full parsing. @discardableResult func updateState( data: AutomationScheduleData, block: @escaping @Sendable (inout AutomationScheduleData) throws -> Void ) async throws -> AutomationScheduleData? { let updated = try await self.store.updateSchedule(scheduleData: data, block:block) if let updated { try await self.triggersProcessor.updateScheduleState( scheduleID: updated.schedule.identifier, state: updated.scheduleState ) } return updated } @discardableResult func updateState( identifier: String, block: @escaping @Sendable (inout AutomationScheduleData) throws -> Void ) async throws -> AutomationScheduleData? { let updated = try await self.store.updateSchedule(scheduleID: identifier, block: block) if let updated { try await self.triggersProcessor.updateScheduleState( scheduleID: identifier, state: updated.scheduleState ) } return updated } } fileprivate extension AutomationSchedule { func updateOrCreate(data: AutomationScheduleData?, date: Date) throws -> AutomationScheduleData { guard var existing = data else { return AutomationScheduleData( schedule: self, scheduleState: .idle, lastScheduleModifiedDate: date, scheduleStateChangeDate: date, executionCount: 0, triggerSessionID: UUID().uuidString ) } existing.schedule = self return existing } } fileprivate struct PreparedData: Sendable { let scheduleData: AutomationScheduleData let preparedSchedule: PreparedSchedule var scheduleID: String { return scheduleData.schedule.identifier } var priority: Int { return scheduleData.schedule.priority ?? 0 } } /// Automation engine protocol AutomationEngineProtocol: Actor, Sendable { @MainActor func setEnginePaused(_ paused: Bool) @MainActor func setExecutionPaused(_ paused: Bool) func start() async func upsertSchedules(_ schedules: [AutomationSchedule]) async throws func stopSchedules(identifiers: [String]) async throws func cancelSchedules(identifiers: [String]) async throws func cancelSchedules(group: String) async throws func cancelSchedulesWith(type: AutomationSchedule.ScheduleType) async throws var schedules: [AutomationSchedule] { get async throws } func getSchedule(identifier: String) async throws -> AutomationSchedule? func getSchedules(group: String) async throws -> [AutomationSchedule] } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationEventFeed.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol AutomationEventFeedProtocol: Sendable { var feed: AsyncStream { get } } struct TriggerableState: Equatable, Codable { var appSessionID: String? // set on foreground event, resets on background var versionUpdated: String? // versionUpdate event } enum AutomationEvent: Sendable, Equatable { case stateChanged(state: TriggerableState) case event(type: EventAutomationTriggerType, data: AirshipJSON? = nil, value: Double = 1.0) var eventData: AirshipJSON? { switch self { case .event(_, let data, _): return data default: return nil } } } @MainActor final class AutomationEventFeed: AutomationEventFeedProtocol { typealias Stream = AsyncStream private let continuation: Stream.Continuation private var observers: [AnyObject] = [] private var isFirstAttach = false private var listenerTask: Task? private let applicationMetrics: any ApplicationMetricsProtocol private let applicationStateTracker: any AppStateTrackerProtocol private let analyticsFeed: AirshipAnalyticsFeed private var appSessionState = TriggerableState() private var regions: Set = Set() nonisolated let feed: Stream init( applicationMetrics: any ApplicationMetricsProtocol, applicationStateTracker: any AppStateTrackerProtocol, analyticsFeed: AirshipAnalyticsFeed ) { self.applicationMetrics = applicationMetrics self.applicationStateTracker = applicationStateTracker self.analyticsFeed = analyticsFeed (self.feed, self.continuation) = Stream.airshipMakeStreamWithContinuation() } @discardableResult func attach() -> Self { guard listenerTask == nil else { return self } if !isFirstAttach { isFirstAttach = true self.continuation.yield(.event(type: .appInit)) if applicationMetrics.isAppVersionUpdated, let version = applicationMetrics.currentAppVersion { self.appSessionState.versionUpdated = version self.continuation.yield(.stateChanged(state: self.appSessionState)) } } self.listenerTask = startListenerTask { [weak self] event in self?.emit(event: event) } return self } @discardableResult func detach() -> Self { self.listenerTask?.cancel() return self } deinit { self.listenerTask?.cancel() self.continuation.finish() } private func startListenerTask( onEvent: @escaping @Sendable @MainActor (AutomationEvent) -> Void ) -> Task { return Task { [analyticsFeed, applicationStateTracker] in await withTaskGroup(of: Void.self) { group in group.addTask { for await state in await applicationStateTracker.stateUpdates { guard !Task.isCancelled else { return } if (state == .active) { await onEvent(.event(type: .foreground)) } if (state == .background) { await onEvent(.event(type: .background)) } } } group.addTask { for await event in await analyticsFeed.updates { guard !Task.isCancelled else { return } guard let converted = event.toAutomationEvent() else { continue } for item in converted { await onEvent(item) } } } } } } private func setAppSessionID(_ id: String?) { guard self.appSessionState.appSessionID != id else { return } self.appSessionState.appSessionID = id emit(event: .stateChanged(state: self.appSessionState)) } private func emit(event: AutomationEvent) { self.continuation.yield(event) switch event { 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 } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationEventsHistory.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol AutomationEventsHistory: Sendable { var events: [AutomationEvent] { get async } func add(_ event: AutomationEvent) async } final actor DefaultAutomationEventsHistory: AutomationEventsHistory { private static let maxEvents: Int = 100 private static let maxDuration: TimeInterval = 30 // seconds private let clock: any AirshipDateProtocol private var eventsHistory: [Entry] = [] init(clock: any AirshipDateProtocol = AirshipDate()) { self.clock = clock } private struct Entry { let event: AutomationEvent let timestamp: Date } var events: [AutomationEvent] { return prunedEvents().map(\.event) } func add(_ event: AutomationEvent) { var filtered = prunedEvents() filtered.append(Entry(event: event, timestamp: clock.now)) eventsHistory = filtered } private func prunedEvents() -> [Entry] { return eventsHistory .suffix(Self.maxEvents) .filter { self.clock.now.timeIntervalSince($0.timestamp) < Self.maxDuration } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationExecutor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol AutomationExecutorProtocol: Sendable { @MainActor func isValid( schedule: AutomationSchedule ) async -> Bool @MainActor func isReady(preparedSchedule: PreparedSchedule) -> ScheduleReadyResult @MainActor func execute(preparedSchedule: PreparedSchedule) async -> ScheduleExecuteResult func interrupted( schedule: AutomationSchedule, preparedScheduleInfo: PreparedScheduleInfo ) async -> InterruptedBehavior } protocol AutomationExecutorDelegate: Sendable { associatedtype ExecutionData: Sendable @MainActor func isReady( data: ExecutionData, preparedScheduleInfo: PreparedScheduleInfo ) -> ScheduleReadyResult @MainActor func execute( data: ExecutionData, preparedScheduleInfo: PreparedScheduleInfo ) async throws -> ScheduleExecuteResult @MainActor func interrupted( schedule: AutomationSchedule, preparedScheduleInfo: PreparedScheduleInfo ) async -> InterruptedBehavior } final class AutomationExecutor: AutomationExecutorProtocol { private let actionExecutor: any AutomationExecutorDelegate private let messageExecutor: any AutomationExecutorDelegate private let remoteDataAccess: any AutomationRemoteDataAccessProtocol init( actionExecutor: any AutomationExecutorDelegate, messageExecutor: any AutomationExecutorDelegate, remoteDataAccess: any AutomationRemoteDataAccessProtocol ) { self.actionExecutor = actionExecutor self.messageExecutor = messageExecutor self.remoteDataAccess = remoteDataAccess } @MainActor func isValid(schedule: AutomationSchedule) async -> Bool { return await self.remoteDataAccess.isCurrent(schedule: schedule) } @MainActor func isReady(preparedSchedule: PreparedSchedule) -> ScheduleReadyResult { let result = switch (preparedSchedule.data) { case .inAppMessage(let data): self.messageExecutor.isReady( data: data, preparedScheduleInfo: preparedSchedule.info ) case .actions(let data): self.actionExecutor.isReady( data: data, preparedScheduleInfo: preparedSchedule.info ) } guard result == .ready else { return result } if (preparedSchedule.frequencyChecker?.checkAndIncrement() == false) { return .skip } return .ready } @MainActor func execute(preparedSchedule: PreparedSchedule) async -> ScheduleExecuteResult { do { switch (preparedSchedule.data) { case .inAppMessage(let data): return try await self.messageExecutor.execute( data: data, preparedScheduleInfo: preparedSchedule.info ) case .actions(let data): return try await self.actionExecutor.execute( data: data, preparedScheduleInfo: preparedSchedule.info ) } } catch { AirshipLogger.warn("Failed to execute automation: \(preparedSchedule.info.scheduleID) error:\(error)") return .retry } } func interrupted( schedule: AutomationSchedule, preparedScheduleInfo: PreparedScheduleInfo ) async -> InterruptedBehavior { return if schedule.isInAppMessageType { await self.messageExecutor.interrupted( schedule: schedule, preparedScheduleInfo: preparedScheduleInfo ) } else { await self.actionExecutor.interrupted( schedule: schedule, preparedScheduleInfo: preparedScheduleInfo ) } } } enum InterruptedBehavior: Sendable { case retry case finish } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationPreparer.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol AutomationPreparerProtocol: Sendable { func prepare( schedule: AutomationSchedule, triggerContext: AirshipTriggerContext?, triggerSessionID: String ) async -> SchedulePrepareResult func cancelled(schedule: AutomationSchedule) async } protocol AutomationPreparerDelegate: Sendable { associatedtype PrepareDataIn: Sendable associatedtype PrepareDataOut: Sendable func prepare( data: PrepareDataIn, preparedScheduleInfo: PreparedScheduleInfo ) async throws -> PrepareDataOut func cancelled(scheduleID: String) async } struct AutomationPreparer: AutomationPreparerProtocol { private let actionPreparer: any AutomationPreparerDelegate private let messagePreparer: any AutomationPreparerDelegate private let deferredResolver: any AirshipDeferredResolverProtocol private let frequencyLimits: any FrequencyLimitManagerProtocol private let audienceChecker: any DeviceAudienceChecker private let experiments: any ExperimentDataProvider private let remoteDataAccess: any AutomationRemoteDataAccessProtocol private let queues: Queues private let config: RuntimeConfig private let additionalAudienceResolver: any AdditionalAudienceCheckerResolverProtocol private static let deferredResultKey: String = "AirshipAutomation#deferredResult" private static let defaultMessageType: String = "transactional" private let deviceInfoProviderFactory: @Sendable (String?) -> any AudienceDeviceInfoProvider @MainActor init( actionPreparer: any AutomationPreparerDelegate, messagePreparer: any AutomationPreparerDelegate, deferredResolver: any AirshipDeferredResolverProtocol, frequencyLimits: any FrequencyLimitManagerProtocol, audienceChecker: any DeviceAudienceChecker, experiments: any ExperimentDataProvider, remoteDataAccess: any AutomationRemoteDataAccessProtocol, config: RuntimeConfig, deviceInfoProviderFactory: @escaping @Sendable (String?) -> any AudienceDeviceInfoProvider = { contactID in CachingAudienceDeviceInfoProvider(contactID: contactID) }, additionalAudienceResolver: any AdditionalAudienceCheckerResolverProtocol ) { self.actionPreparer = actionPreparer self.messagePreparer = messagePreparer self.deferredResolver = deferredResolver self.frequencyLimits = frequencyLimits self.audienceChecker = audienceChecker 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 { if schedule.isInAppMessageType { await self.messagePreparer.cancelled(scheduleID: schedule.identifier) } else { await self.actionPreparer.cancelled(scheduleID: schedule.identifier) } } func prepare( schedule: AutomationSchedule, triggerContext: AirshipTriggerContext?, triggerSessionID: String ) async -> SchedulePrepareResult { AirshipLogger.trace("Preparing \(schedule.identifier)") let queue = await self.queues.queue(id: schedule.queue) return await queue.run(name: "schedule: \(schedule.identifier)") { retryState in guard await !self.remoteDataAccess.requiresUpdate(schedule: schedule) else { AirshipLogger.trace("Schedule out of date \(schedule.identifier)") await self.remoteDataAccess.waitFullRefresh(schedule: schedule) return .success(result: .invalidate) } guard await self.remoteDataAccess.bestEffortRefresh(schedule: schedule) else { AirshipLogger.trace("Schedule out of date \(schedule.identifier)") return .success(result: .invalidate) } var frequencyChecker: (any FrequencyCheckerProtocol)! do { frequencyChecker = try await self.frequencyLimits.getFrequencyChecker( constraintIDs: schedule.frequencyConstraintIDs ) } catch { AirshipLogger.error("Failed to fetch frequency checker for schedule \(schedule.identifier) error: \(error)") return .success(result: .skip) } let deviceInfoProvider = self.deviceInfoProviderFactory( self.remoteDataAccess.contactID(forSchedule: schedule) ) let audience = CompoundDeviceAudienceSelector.combine( compoundSelector: schedule.compoundAudience?.selector, deviceSelector: schedule.audience?.audienceSelector ) if let audience { let match = try await self.audienceChecker.evaluate( audienceSelector: audience, newUserEvaluationDate: schedule.created ?? .distantPast, deviceInfoProvider: deviceInfoProvider ) if (!match.isMatch) { AirshipLogger.trace("Local audience miss \(schedule.identifier)") return .success( result: schedule.audienceMissBehaviorResult, ignoreReturnOrder: true ) } } let experimentResult: ExperimentResult? = if schedule.evaluateExperiments { try await self.experiments.evaluateExperiments( info: MessageInfo( messageType: schedule.messageType ?? Self.defaultMessageType, campaigns: schedule.campaigns ), deviceInfoProvider: deviceInfoProvider ) } else { nil } AirshipLogger.trace("Preparing data \(schedule.identifier)") return try await self.prepareData( data: schedule.data, schedule: schedule, retryState: retryState, deferredRequest: { url in DeferredRequest( url: url, channelID: try await deviceInfoProvider.channelID, contactID: await deviceInfoProvider.stableContactInfo.contactID, 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, priority: schedule.priority ?? 0 ) }, prepareSchedule: { [frequencyChecker] scheduleInfo, data in return PreparedSchedule( info: scheduleInfo, data: data, frequencyChecker: frequencyChecker ) } ) } } private func prepareData( data: AutomationSchedule.ScheduleData, schedule: AutomationSchedule, 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: preparedInfo ) let preparedSchedule = prepareSchedule(preparedInfo, .actions(result)) return .success(result: .prepared(preparedSchedule)) case .inAppMessage(let data): guard data.displayContent.validate() else { AirshipLogger.debug("⚠️ Message did not pass validation: \(data.name) - skipping.") return .success(result: .skip) } let preparedInfo = try await prepareScheduleInfo() let result = try await self.messagePreparer.prepare( data: data, preparedScheduleInfo: preparedInfo ) let preparedSchedule = prepareSchedule(preparedInfo, .inAppMessage(result)) return .success(result: .prepared(preparedSchedule)) case .deferred(let deferred): return try await self.prepareDeferred( deferred: deferred, schedule: schedule, retryState: retryState, deferredRequest: deferredRequest ) { data in try await self.prepareData( data: data, schedule: schedule, retryState: retryState, deferredRequest: deferredRequest, prepareScheduleInfo: prepareScheduleInfo, prepareSchedule: prepareSchedule ) } } } private func prepareDeferred( deferred: DeferredAutomationData, schedule: AutomationSchedule, 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)") 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)") return try await onResult(cached) } let result: AirshipDeferredResult = await deferredResolver.resolve(request: request) { data in return try JSONDecoder().decode(DeferredScheduleResult.self, from: data) } AirshipLogger.trace("Deferred result \(schedule.identifier) \(result)") switch (result) { case .success(let result): if (result.isAudienceMatch) { switch (deferred.type) { case .actions: guard let actions = result.actions else { AirshipLogger.error("Failed to get result for deferred.") return .retry } return try await onResult(.actions(actions)) case .inAppMessage: guard var message = result.message else { AirshipLogger.error("Failed to get result for deferred.") return .retry } message.source = .remoteData return try await onResult(.inAppMessage(message)) } } else { return .success( result: schedule.audienceMissBehaviorResult, ignoreReturnOrder: true ) } case .timedOut: if (deferred.retryOnTimeOut != false) { return .retry } return .success(result: .penalize, ignoreReturnOrder: true) case .outOfDate: await self.remoteDataAccess.notifyOutdated(schedule: schedule) return .success(result: .invalidate) case .notFound: await self.remoteDataAccess.notifyOutdated(schedule: schedule) return .success(result: .invalidate) case .retriableError(retryAfter: let retryAfter, statusCode: _): if let retryAfter { return .retryAfter(retryAfter) } else { return .retry } #if canImport(AirshipCore) @unknown default: // Not possible return .retry #endif } } } fileprivate extension AutomationSchedule { var audienceMissBehaviorResult: SchedulePrepareResult { if let compoundAudience { return compoundAudience.missBehavior.schedulePrepareResult } else if let audienceMiss = audience?.missBehavior { return audienceMiss.schedulePrepareResult } else { return .penalize } } var evaluateExperiments: Bool { return self.isInAppMessageType && self.bypassHoldoutGroups != true } } fileprivate actor Queues { var queues: [String: RetryingQueue] = [:] lazy var defaultQueue: RetryingQueue = { return RetryingQueue( id: "default", config: config.remoteConfig.iaaConfig?.retryingQueue ) }() private let config: RuntimeConfig @MainActor init(config: RuntimeConfig) { self.config = config } func queue(id: String?) -> RetryingQueue { guard let id, !id.isEmpty else { return defaultQueue } if let queue = queues[id] { return queue } let queue: RetryingQueue = RetryingQueue( id: id, config: config.remoteConfig.iaaConfig?.retryingQueue ) queues[id] = queue return queue } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationScheduleData.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) import AirshipCore #endif import Foundation struct AutomationScheduleData: Sendable, Equatable, CustomDebugStringConvertible { var schedule: AutomationSchedule var scheduleState: AutomationScheduleState /// The last time the `schedule` field was updated. var lastScheduleModifiedDate: Date var scheduleStateChangeDate: Date var executionCount: Int var triggerInfo: TriggeringInfo? var preparedScheduleInfo: PreparedScheduleInfo? var associatedData: Data? var triggerSessionID: String var debugDescription: String { return "AutomationSchedule(id: \(schedule.identifier), state: \(scheduleState))" } } extension AutomationScheduleData { func isInState(_ state: [AutomationScheduleState]) -> Bool { return state.contains(self.scheduleState) } func isActive(date: Date) -> Bool { guard !self.isExpired(date: date) else { return false } guard let start = self.schedule.start else { return true } return date >= start } func isExpired(date: Date) -> Bool { guard let end = self.schedule.end else { return false } return end <= date } var isOverLimit: Bool { // 0 means no limit guard self.schedule.limit != 0 else { return false } return (self.schedule.limit ?? 1) <= self.executionCount } private mutating func setState(_ state: AutomationScheduleState, date: Date) { guard scheduleState != state else { return } self.scheduleState = state self.scheduleStateChangeDate = date } mutating func finished(date: Date) { self.setState(.finished, date: date) self.preparedScheduleInfo = nil self.triggerInfo = nil } mutating func idle(date: Date) { self.setState(.idle, date: date) self.preparedScheduleInfo = nil self.triggerInfo = nil } mutating func paused(date: Date) { self.setState(.paused, date: date) self.preparedScheduleInfo = nil self.triggerInfo = nil } mutating func updateState(date: Date) { if isOverLimit || isExpired(date: date) { finished(date: date) } else if isInState([.finished]) { self.idle(date: date) } } mutating func prepareCancelled(date: Date, penalize: Bool) { guard self.isInState([.triggered]) else { return } if (penalize) { self.executionCount += 1 } guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } idle(date: date) } mutating func prepareInterrupted(date: Date) { guard self.isInState([.prepared, .triggered]) else { return } guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } setState(.triggered, date: date) } mutating func prepared(info: PreparedScheduleInfo, date: Date) { guard self.isInState([.triggered]) else { return } guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } self.preparedScheduleInfo = info self.setState(.prepared, date: date) } mutating func executionCancelled(date: Date) { guard self.isInState([.prepared]) else { return } guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } idle(date: date) } mutating func executionSkipped(date: Date) { guard self.isInState([.prepared]) else { return } guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } if self.schedule.interval != nil { paused(date: date) } else { idle(date: date) } } mutating func executionInvalidated(date: Date) { guard self.isInState([.prepared]) else { return } guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } self.preparedScheduleInfo = nil self.setState(.triggered, date: date) } mutating func executing(date: Date) { guard self.isInState([.prepared]) else { return } self.scheduleState = .executing self.scheduleStateChangeDate = date } mutating func executionInterrupted(date: Date, retry: Bool) { guard self.isInState([.executing]) else { return } if (retry) { guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } self.preparedScheduleInfo = nil self.setState(.triggered, date: date) } else { finishedExecuting(date: date) } } mutating func finishedExecuting(date: Date) { guard self.isInState([.executing]) else { return } self.executionCount += 1 guard !isOverLimit, !isExpired(date: date) else { finished(date: date) return } if self.schedule.interval != nil { paused(date: date) } else { idle(date: date) } } func shouldDelete(date: Date) -> Bool { guard self.scheduleState == .finished else { return false } guard let editGracePeriod = self.schedule.editGracePeriodDays else { return true } let timeSinceFinished = date.timeIntervalSince(self.scheduleStateChangeDate) return timeSinceFinished >= Double(editGracePeriod * 86400) // days to seconds } mutating func triggered( triggerInfo: TriggeringInfo, date: Date ) { guard self.scheduleState == .idle else { return } guard !isOverLimit, !isExpired(date: date) else { self.finished(date: date) return } self.triggerSessionID = UUID().uuidString self.preparedScheduleInfo = nil self.triggerInfo = triggerInfo setState(.triggered, date: date) } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationScheduleState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum AutomationScheduleState: String, Equatable, Sendable { case idle case triggered case prepared case executing // interval case paused // waiting to be cleaned up after grace period case finished } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/AutomationStore.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import CoreData #if canImport(AirshipCore) import AirshipCore #endif protocol TriggerStoreProtocol: Sendable { func getTrigger(scheduleID: String, triggerID: String) async throws -> TriggerData? func upsertTriggers(_ triggers: [TriggerData]) async throws func deleteTriggers(excludingScheduleIDs: Set) async throws func deleteTriggers(scheduleIDs: [String]) async throws func deleteTriggers(scheduleID: String, triggerIDs: Set) async throws } protocol ScheduleStoreProtocol: Sendable { func getSchedules() async throws -> [AutomationScheduleData] @discardableResult func updateSchedule( scheduleID: String, block: @escaping @Sendable (inout AutomationScheduleData) throws -> Void ) async throws -> AutomationScheduleData? @discardableResult func updateSchedule( scheduleData: AutomationScheduleData, block: @escaping @Sendable (inout AutomationScheduleData) throws -> Void ) async throws -> AutomationScheduleData? @discardableResult func upsertSchedules( scheduleIDs: [String], updateBlock: @Sendable @escaping (String, AutomationScheduleData?) throws -> AutomationScheduleData ) async throws -> [AutomationScheduleData] func deleteSchedules(scheduleIDs: [String]) async throws func deleteSchedules(group: String) async throws func getSchedule(scheduleID: String) async throws -> AutomationScheduleData? func getSchedules(group: String) async throws -> [AutomationScheduleData] func getSchedules(scheduleIDs: [String]) async throws -> [AutomationScheduleData] func isCurrent(scheduleID: String, lastScheduleModifiedDate: Date, scheduleState: AutomationScheduleState) async throws -> Bool } actor AutomationStore: ScheduleStoreProtocol, TriggerStoreProtocol { private let coreData: UACoreData? private let inMemory: Bool private let legacyStore: LegacyAutomationStore private var migrationTask: Task? init(appKey: String, inMemory: Bool = false) { let modelURL = AirshipAutomationResources.bundle.url( forResource: "AirshipAutomation", withExtension:"momd" ) self.coreData = if let modelURL = modelURL { UACoreData( name: "AirshipAutomation", modelURL: modelURL, inMemory: inMemory, stores: ["AirshipAutomation-\(appKey).sqlite"] ) } else { nil } self.inMemory = inMemory self.legacyStore = LegacyAutomationStore(appKey: appKey, inMemory: inMemory) } init(config: RuntimeConfig) { self.init(appKey: config.appCredentials.appKey) } func getSchedules() async throws -> [AutomationScheduleData] { return try await prepareCoreData().performWithResult { context in return try self.fetchSchedules(context: context) } } @discardableResult func updateSchedule( scheduleID: String, block: @escaping @Sendable (inout AutomationScheduleData) throws -> Void ) async throws -> AutomationScheduleData? { return try await prepareCoreData().performWithResult { context in let request: NSFetchRequest = ScheduleEntity.fetchRequest() request.includesPropertyValues = true request.predicate = NSPredicate(format: "identifier == %@", scheduleID) guard let entity = try context.fetch(request).first else { return nil } var data = try entity.toScheduleData() try block(&data) try entity.update(data: data) return data } } @discardableResult func updateSchedule( scheduleData: AutomationScheduleData, block: @escaping @Sendable (inout AutomationScheduleData) throws -> Void ) async throws -> AutomationScheduleData? { return try await prepareCoreData().performWithResult { context in let request: NSFetchRequest = ScheduleEntity.fetchRequest() request.includesPropertyValues = true request.predicate = NSPredicate(format: "identifier == %@", scheduleData.schedule.identifier) guard let entity = try context.fetch(request).first else { return nil } var data = try entity.toScheduleData(existingData: scheduleData) try block(&data) try entity.update(data: data) return data } } @discardableResult func upsertSchedules( scheduleIDs: [String], updateBlock: @Sendable @escaping (String, AutomationScheduleData?) throws -> AutomationScheduleData ) async throws -> [AutomationScheduleData] { return try await prepareCoreData().performWithResult { context in let request: NSFetchRequest = ScheduleEntity.fetchRequest() request.includesPropertyValues = true request.predicate = NSPredicate(format: "identifier in %@", scheduleIDs) let entityMap = try context.fetch(request).reduce(into: [String: ScheduleEntity]()) { $0[$1.identifier] = $1 } var result: [AutomationScheduleData] = [] for identifier in scheduleIDs { let existing: AutomationScheduleData? = if let entity = entityMap[identifier] { try entity.toScheduleData() } else { nil } let data = try updateBlock(identifier, existing) let entity = try (entityMap[identifier] ?? ScheduleEntity.make(context: context)) try entity.update(data: data) result.append(data) } return result } } func deleteSchedules(scheduleIDs: [String]) async throws { return try await prepareCoreData().performWithResult { context in let predicate = NSPredicate(format: "identifier in %@", scheduleIDs) return try self.deleteSchedules(predicate: predicate, context: context) } } func deleteSchedules(group: String) async throws { return try await prepareCoreData().performWithResult { context in let predicate = NSPredicate(format: "group == %@", group) return try self.deleteSchedules(predicate: predicate, context: context) } } func isCurrent(scheduleID: String, lastScheduleModifiedDate: Date, scheduleState: AutomationScheduleState) async throws -> Bool { return try await prepareCoreData().performWithResult { context in let request: NSFetchRequest = ScheduleEntity.fetchRequest() request.predicate = NSPredicate(format: "identifier == %@", scheduleID) request.propertiesToFetch = ["lastScheduleModifiedDate", "scheduleState"] request.includesPropertyValues = true let entity = try context.fetch(request).first return entity?.lastScheduleModifiedDate == lastScheduleModifiedDate && entity?.scheduleState == scheduleState.rawValue } } func getSchedule(scheduleID: String) async throws -> AutomationScheduleData? { return try await prepareCoreData().performWithResult { context in let predicate = NSPredicate(format: "identifier == %@", scheduleID) return try self.fetchSchedules(predicate: predicate, context: context).first } } func getAssociatedData(scheduleID: String) async throws -> Data? { return try await prepareCoreData().performWithResult { context in let predicate = NSPredicate(format: "identifier == %@", scheduleID) let request: NSFetchRequest = ScheduleEntity.fetchRequest() request.includesPropertyValues = true request.predicate = predicate return try context.fetch(request).first?.associatedData } } func getSchedules(group: String) async throws -> [AutomationScheduleData] { return try await prepareCoreData().performWithResult { context in let predicate = NSPredicate(format: "group == %@", group) return try self.fetchSchedules(predicate: predicate, context: context) } } func getSchedules(scheduleIDs: [String]) async throws -> [AutomationScheduleData] { return try await prepareCoreData().performWithResult { context in let predicate = NSPredicate(format: "identifier in %@", scheduleIDs) return try self.fetchSchedules(predicate: predicate, context: context) } } func getTrigger(scheduleID: String, triggerID: String) async throws -> TriggerData? { return try await prepareCoreData().performWithResult { context in let request: NSFetchRequest = TriggerEntity.fetchRequest() request.predicate = NSPredicate(format: "scheduleID == %@ AND triggerID == %@", scheduleID, triggerID) return try context.fetch(request).first?.toTriggerData() } } func upsertTriggers(_ triggers: [TriggerData]) async throws { guard !triggers.isEmpty else { return } let groupedTriggers = triggers.reduce(into: [String: [TriggerData]]()) { result, trigger in var array = result[trigger.scheduleID] ?? [] array.append(trigger) result[trigger.scheduleID] = array } try await prepareCoreData().perform { context in let request: NSFetchRequest = TriggerEntity.fetchRequest() try groupedTriggers.forEach { scheduleID, triggers in request.predicate = NSPredicate(format: "scheduleID == %@ AND triggerID in %@", scheduleID, triggers.map { $0.triggerID }) let entityMap = try context.fetch(request).reduce(into: [String: TriggerEntity]()) { $0[$1.triggerID] = $1 } for trigger in triggers { let entity = try (entityMap[trigger.triggerID] ?? TriggerEntity.make(context: context)) try entity.update(data: trigger) } } } } func deleteTriggers(scheduleID: String, triggerIDs: Set) async throws { return try await prepareCoreData().perform { context in let predicate = NSPredicate(format: "(scheduleID == %@) AND (triggerID in %@)", scheduleID, triggerIDs) try self.deleteTriggers(predicate: predicate, context: context) } } func deleteTriggers(excludingScheduleIDs: Set) async throws { return try await prepareCoreData().perform { context in let predicate = NSPredicate(format: "not (scheduleID in %@)", excludingScheduleIDs) try self.deleteTriggers(predicate: predicate, context: context) } } func deleteTriggers(scheduleIDs: [String]) async throws { try await prepareCoreData().perform { context in let predicate = NSPredicate(format: "scheduleID in %@", scheduleIDs) try self.deleteTriggers(predicate: predicate, context: context) } } private nonisolated func deleteTriggers( predicate: NSPredicate? = nil, context: NSManagedObjectContext ) throws { let request: NSFetchRequest = TriggerEntity.fetchRequest() request.predicate = predicate if self.inMemory { request.includesPropertyValues = false let results = try context.fetch(request) as? [NSManagedObject] results?.forEach(context.delete) } else { let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) } } private nonisolated func fetchSchedules( predicate: NSPredicate? = nil, context: NSManagedObjectContext ) throws -> [AutomationScheduleData] { let request: NSFetchRequest = ScheduleEntity.fetchRequest() request.includesPropertyValues = true request.predicate = predicate return try context.fetch(request).map { entity in try entity.toScheduleData() } } private nonisolated func deleteSchedules( predicate: NSPredicate? = nil, context: NSManagedObjectContext ) throws { let request = NSFetchRequest( entityName: ScheduleEntity.entityName ) request.predicate = predicate if self.inMemory { request.includesPropertyValues = false let schedules = try context.fetch(request) as? [NSManagedObject] schedules?.forEach { schedule in context.delete(schedule) } } else { let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) } } private func migrateData() async throws { guard let coredata = self.coreData else { throw AirshipErrors.error("Failed to create core data.") } do { if let migrationTask = migrationTask { try await migrationTask.value return } } catch {} self.migrationTask = Task { let legacyData = try await self.legacyStore.legacyScheduleData guard !legacyData.isEmpty else { return } let identifiers = legacyData.map { $0.scheduleData.schedule.identifier } try await coredata.perform { context in let request: NSFetchRequest = ScheduleEntity.fetchRequest() request.includesPropertyValues = true request.predicate = NSPredicate(format: "identifier in %@", identifiers) guard try context.fetch(request).isEmpty else { // Migration already happened, probably failed to delete before return } do { for legacy in legacyData { let scheduleEntity = try ScheduleEntity.make(context: context) try scheduleEntity.update(data: legacy.scheduleData) for triggerData in legacy.triggerDatas { let triggerEntity = try TriggerEntity.make(context: context) try triggerEntity.update(data: triggerData) } } } catch { context.rollback() throw error } } do { try await self.legacyStore.deleteAll() } catch { AirshipLogger.error("Failed to delete legacy store \(error)") } } try await self.migrationTask?.value } func prepareCoreData() async throws -> UACoreData { guard let coreData = coreData else { throw AirshipErrors.error("Failed to create core data.") } try await migrateData() return coreData } } @objc(UAScheduleEntity) fileprivate class ScheduleEntity: NSManagedObject { static let entityName = "UAScheduleEntity" @nonobjc class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: ScheduleEntity.entityName) } @NSManaged var identifier: String @NSManaged var group: String? @NSManaged var schedule: Data @NSManaged var scheduleState: String @NSManaged var scheduleStateChangeDate: Date @NSManaged var lastScheduleModifiedDate: Date? @NSManaged var executionCount: Int @NSManaged var triggerInfo: Data? @NSManaged var preparedScheduleInfo: Data? @NSManaged var triggerSessionID: String? @NSManaged var associatedData: Data? class func make(context: NSManagedObjectContext) throws -> Self { guard let data = NSEntityDescription.insertNewObject( forEntityName: ScheduleEntity.entityName, into:context) as? Self else { throw AirshipErrors.error("Failed to make schedule entity") } return data } func update(data: AutomationScheduleData) throws { let encoder = JSONEncoder() self.identifier = data.schedule.identifier self.group = data.schedule.group self.scheduleState = data.scheduleState.rawValue self.scheduleStateChangeDate = data.scheduleStateChangeDate self.executionCount = data.executionCount self.triggerSessionID = data.triggerSessionID self.associatedData = data.associatedData self.lastScheduleModifiedDate = data.lastScheduleModifiedDate self.schedule = try encoder.encode(data.schedule) self.preparedScheduleInfo = if let info = data.preparedScheduleInfo { try encoder.encode(info) } else { nil } self.triggerInfo = if let info = data.triggerInfo { try encoder.encode(info) } else { nil } } func toScheduleData(existingData: AutomationScheduleData? = nil) throws -> AutomationScheduleData { let decoder = JSONDecoder() let existingScheduleMatch = existingData?.scheduleStateChangeDate == self.scheduleStateChangeDate let schedule: AutomationSchedule = if let existingData, existingScheduleMatch { existingData.schedule } else { try decoder.decode(AutomationSchedule.self, from: self.schedule) } let triggerInfo: TriggeringInfo? = if let data = self.triggerInfo { try decoder.decode(TriggeringInfo.self, from: data) } else { nil } let preparedScheduleInfo: PreparedScheduleInfo? = if let data = self.preparedScheduleInfo { try decoder.decode(PreparedScheduleInfo.self, from: data) } else { nil } guard let scheduleState = AutomationScheduleState(rawValue: self.scheduleState) else { throw AirshipErrors.error("Invalid schedule state \(self.scheduleState)") } return AutomationScheduleData( schedule: schedule, scheduleState: scheduleState, lastScheduleModifiedDate: self.lastScheduleModifiedDate ?? .distantPast, scheduleStateChangeDate: self.scheduleStateChangeDate, executionCount: executionCount, triggerInfo: triggerInfo, preparedScheduleInfo: preparedScheduleInfo, associatedData: associatedData, triggerSessionID: self.triggerSessionID ?? UUID().uuidString ) } } @objc(UATriggerEntity) fileprivate class TriggerEntity: NSManagedObject { static let entityName = "UATriggerEntity" @nonobjc class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: Self.entityName) } @NSManaged var state: Data @NSManaged var scheduleID: String @NSManaged var triggerID: String class func make(context: NSManagedObjectContext) throws -> Self { guard let result = NSEntityDescription.insertNewObject( forEntityName: Self.entityName, into:context) as? Self else { throw AirshipErrors.error("Failed to make schedule entity") } return result } func update(data: TriggerData) throws { self.triggerID = data.triggerID self.scheduleID = data.scheduleID self.state = try JSONEncoder().encode(data) } func toTriggerData() throws -> TriggerData { try JSONDecoder().decode(TriggerData.self, from: self.state) } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/ExecutionWindowProcessor.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) import AirshipCore #endif import Foundation protocol ExecutionWindowProcessorProtocol: Actor { func process(window: ExecutionWindow) async throws @MainActor func isActive(window: ExecutionWindow) -> Bool } actor ExecutionWindowProcessor: ExecutionWindowProcessorProtocol { private let taskSleeper: any AirshipTaskSleeper private let date: any AirshipDateProtocol private let onEvaluate: @Sendable (ExecutionWindow, Date) throws -> ExecutionWindowResult private var sleepTasks: [String: Task] = [:] init( taskSleeper: any AirshipTaskSleeper, date: any AirshipDateProtocol, notificationCenter: NotificationCenter = NotificationCenter.default, onEvaluate: @escaping @Sendable (ExecutionWindow, Date) throws -> ExecutionWindowResult = { window, date in try window.nextAvailability(date: date) } ) { self.taskSleeper = taskSleeper self.date = date self.onEvaluate = onEvaluate notificationCenter.addObserver(forName: .NSSystemTimeZoneDidChange, object: nil, queue: nil) { [weak self] _ in Task { [weak self] in await self?.timeZoneChanged() } } } private func timeZoneChanged() { self.sleepTasks.values.forEach { $0.cancel() } } private func sleep(delay: TimeInterval) async { let id = UUID().uuidString let sleepTask = Task { try await self.taskSleeper.sleep(timeInterval: delay) } sleepTasks[id] = sleepTask try? await sleepTask.value sleepTasks[id] = nil } private nonisolated func nextAvailability(window: ExecutionWindow) -> ExecutionWindowResult { do { return try onEvaluate(window, date.now) } catch { // We failed to process the window, use a long retry to prevent it from // busy waiting AirshipLogger.error("Failed to process execution window \(error)") return .retry(60 * 60 * 24) } } @MainActor func process(window: ExecutionWindow) async { while case .retry(let delay) = nextAvailability(window: window) { if Task.isCancelled { return } await sleep(delay: delay) if Task.isCancelled { return } } } @MainActor func isActive(window: ExecutionWindow) -> Bool { return nextAvailability(window: window) == .now } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/LegacyAutomationStore.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import CoreData #if canImport(AirshipCore) import AirshipCore #endif actor LegacyAutomationStore { private let coreData: UACoreData? init(appKey: String, inMemory: Bool = false) { let modelURL = AirshipAutomationResources.bundle.url( forResource: "UAAutomation", withExtension:"momd" ) self.coreData = if let modelURL = modelURL { UACoreData( name: "UAAutomation", modelURL: modelURL, inMemory: inMemory, stores: ["Automation-\(appKey).sqlite", "In-app-automation-\(appKey).sqlite"] ) } else { nil } } var legacyScheduleData: [LegacyScheduleData] { get async throws { return try await requireCoreData().performWithNullableResult(skipIfStoreNotCreated: true) { context in let request: NSFetchRequest = NSFetchRequest(entityName: "UAScheduleData") request.includesPropertyValues = true return try context.fetch(request).compactMap { entity in do { try entity.migrateData() return try entity.convert() } catch { AirshipLogger.error("Failed to convert schedule \(entity) error \(error)") } return nil } } ?? [] } } func deleteAll() async throws { try await self.requireCoreData().deleteStoresOnDisk() } private func requireCoreData() throws -> UACoreData { guard let coreData = coreData else { throw AirshipErrors.error("Failed to create core data.") } return coreData } } struct LegacyScheduleData: Equatable, Sendable { var scheduleData: AutomationScheduleData var triggerDatas: [TriggerData] } fileprivate enum UAScheduleDelayAppState: Int { case any case foreground case background } fileprivate enum UAScheduleState: Int { case idle = 0 case timeDelayed = 5 case waitingScheduleConditions = 1 case preparingSchedule = 6 case executing = 2 case paused = 3 case finished = 4 } fileprivate enum UAScheduleType: UInt { case inAppMessage = 0 case actions = 1 case deferred = 2 } @objc(UAScheduleData) fileprivate class UAScheduleData: NSManagedObject { @NSManaged var identifier: String @NSManaged var group: String? @NSManaged var limit: NSNumber? @NSManaged var triggeredCount: NSNumber? @NSManaged var data: String @NSManaged var metadata: String? @NSManaged var dataVersion: NSNumber @NSManaged var priority: NSNumber? @NSManaged var triggers: Set? @NSManaged var start: Date? @NSManaged var end: Date? @NSManaged var delay: UAScheduleDelayData? @NSManaged var executionState: NSNumber? @NSManaged var executionStateChangeDate: Date? @NSManaged var delayedExecutionDate: Date? @NSManaged var editGracePeriod: NSNumber? @NSManaged var interval: NSNumber? @NSManaged var type: NSNumber? @NSManaged var audience: String? @NSManaged var campaigns: NSDictionary? @NSManaged var reportingContext: NSDictionary? @NSManaged var frequencyConstraintIDs: [String]? @NSManaged var triggeredTime: Date? @NSManaged var messageType: String? @NSManaged var bypassHoldoutGroups: NSNumber? @NSManaged var isNewUserEvaluationDate: Date? @NSManaged var productId: String? } @objc(UAScheduleTriggerData) fileprivate class UAScheduleTriggerData: NSManagedObject { @NSManaged var goal: NSNumber @NSManaged var goalProgress: NSNumber? @NSManaged var predicateData: Data? @NSManaged var type: NSNumber @NSManaged var schedule: UAScheduleData? @NSManaged var delay: UAScheduleDelayData? @NSManaged var start: Date? } @objc(UAScheduleDelayData) fileprivate class UAScheduleDelayData: NSManagedObject { @NSManaged var seconds: NSNumber? @NSManaged var screens: String? @NSManaged var regionID: String? @NSManaged var appState: NSNumber? @NSManaged var schedule: UAScheduleData? @NSManaged var cancellationTriggers: Set? } fileprivate enum UAScheduleTriggerType: Int { case appForeground case appBackground case regionEnter case regionExit case customEventCount case customEventValue case screen case appInit case activeSession case version case featureFlagInterracted } fileprivate extension UAScheduleDelayData { func convert( scheduleID: String ) throws -> (delay: AutomationDelay, triggerData: [TriggerData]) { var screens: [String]? let json = try AirshipJSON.from(json: self.screens) if json.isString, let string = json.unWrap() as? String { screens = [string] } else if json.isArray, let strings = json.unWrap() as? [String] { screens = strings } var appState: AutomationAppState? = nil if let rawValue = self.appState?.intValue, let parsed = UAScheduleDelayAppState(rawValue: rawValue) { appState = switch(parsed) { case .any: nil case .background: .background case .foreground: .foreground } } let cancellationTriggerData = try self.cancellationTriggers?.map { data in try data.convert(scheduleID: scheduleID, executionType: .delayCancellation) } ?? [] let delay = AutomationDelay( seconds: self.seconds?.doubleValue, screens: screens, regionID: self.regionID, appState: appState, cancellationTriggers: cancellationTriggerData.map { $0.trigger } ) return (delay, cancellationTriggerData.map { $0.triggerData }) } } fileprivate extension UAScheduleTriggerData { func convert( scheduleID: String, executionType: TriggerExecutionType ) throws -> (trigger: AutomationTrigger, triggerData: TriggerData) { let decoder = JSONDecoder() let predicate: JSONPredicate? = if let data = self.predicateData { try decoder.decode(JSONPredicate.self, from: data) } else { nil } guard let legacyType = UAScheduleTriggerType(rawValue: self.type.intValue) else { throw AirshipErrors.error("Invalid type \(self.type)") } let type: EventAutomationTriggerType = switch(legacyType) { case .appForeground: .foreground case .appBackground: .background case .regionEnter: .regionEnter case .regionExit: .regionExit case .customEventCount: .customEventCount case .customEventValue: .customEventValue case .screen: .screen case .appInit: .appInit case .activeSession: .activeSession case .version: .version case .featureFlagInterracted: .featureFlagInteraction } var trigger = EventAutomationTrigger( type: type, goal: self.goal.doubleValue, predicate: predicate ) trigger.backfillIdentifier(executionType: executionType) let triggerData = TriggerData( scheduleID: scheduleID, triggerID: trigger.id, count: self.goalProgress?.doubleValue ?? 0 ) return (.event(trigger), triggerData) } } fileprivate extension UAScheduleData { struct LegacyKeys { static let displayType = "display_type" static let display = "display" static let audience = "audience" static let source = "source" static let duration = "duration" } func convert() throws -> LegacyScheduleData { guard let rawType = self.type?.uintValue, let type = UAScheduleType(rawValue: rawType) else { throw AirshipErrors.error("Failed to convert message, invalid type: \(String(describing: self.type))") } guard let data = self.data.data(using: .utf8) else { throw AirshipErrors.error("Unable to parse data") } let decoder = JSONDecoder() let scheduleData: AutomationSchedule.ScheduleData = switch(type) { case .inAppMessage: .inAppMessage(try decoder.decode(InAppMessage.self, from: data)) case .actions: .actions(try decoder.decode(AirshipJSON.self, from: data)) case .deferred: .deferred(try decoder.decode(DeferredAutomationData.self, from: data)) } var audience: AutomationAudience? if let data = self.audience?.data(using: .utf8) { audience = try decoder.decode(AutomationAudience.self, from: data) } var editGracePeriodDays: UInt? if let period = self.editGracePeriod?.doubleValue { editGracePeriodDays = UInt(period / (24 * 60 * 60)) // convert to days } let executionTriggers = try self.triggers?.map { data in try data.convert(scheduleID: self.identifier, executionType: .execution) } ?? [] let delayData = try self.delay?.convert(scheduleID: self.identifier) let schedule: AutomationSchedule = AutomationSchedule( identifier: self.identifier, data: scheduleData, triggers: executionTriggers.map { $0.trigger }, created: self.isNewUserEvaluationDate, group: self.group, priority: self.priority?.intValue, limit: self.limit?.uintValue, start: self.start, end: self.end, audience: audience, delay: delayData?.delay, interval: self.interval?.doubleValue, bypassHoldoutGroups: self.bypassHoldoutGroups?.boolValue, editGracePeriodDays: editGracePeriodDays, metadata: try AirshipJSON.from(json: self.metadata), campaigns: self.campaigns == nil ? nil : try AirshipJSON.wrap(self.campaigns), reportingContext: self.reportingContext == nil ? nil : try AirshipJSON.wrap(self.reportingContext), productID: self.productId, frequencyConstraintIDs: self.frequencyConstraintIDs, messageType: self.messageType ) var scheduleState: AutomationScheduleState = .idle if let rawValue = self.executionState?.intValue, let parsed = UAScheduleState(rawValue: rawValue) { scheduleState = switch(parsed) { case .idle: .idle case .timeDelayed: .prepared case .waitingScheduleConditions: .prepared case .preparingSchedule: .triggered case .executing: .executing case .paused: .paused case .finished: .finished } } var preparedInfo: PreparedScheduleInfo? if scheduleState == .prepared || scheduleState == .executing { preparedInfo = PreparedScheduleInfo( scheduleID: schedule.identifier, productID: schedule.productID, campaigns: schedule.campaigns, reportingContext: schedule.reportingContext, triggerSessionID: UUID().uuidString, priority: schedule.priority ?? 0 ) } var triggerInfo: TriggeringInfo? if scheduleState == .prepared || scheduleState == .executing || scheduleState == .executing { triggerInfo = TriggeringInfo( context: nil, date: self.triggeredTime ?? self.executionStateChangeDate ?? Date.distantPast ) } let automationScheduleData = AutomationScheduleData( schedule: schedule, scheduleState: scheduleState, lastScheduleModifiedDate: AirshipDate().now, scheduleStateChangeDate: self.executionStateChangeDate ?? Date.distantPast, executionCount: self.triggeredCount?.intValue ?? 0, triggerInfo: triggerInfo, preparedScheduleInfo: preparedInfo, associatedData: nil, triggerSessionID: UUID().uuidString ) return LegacyScheduleData( scheduleData: automationScheduleData, triggerDatas: (delayData?.triggerData ?? []) + executionTriggers.map { $0.triggerData } ) } func migrateData() throws { let version = self.dataVersion if (version != 3) { guard var json = AirshipJSONUtils.object(self.data) as? [String: Any] else { return } switch(version) { case 0: try perform0To1DataMigration(json: &json) fallthrough case 1: try perform1To2DataMigration(json: &json) fallthrough case 2: try perform2To3DataMigration(json: &json) break default: break } self.data = try AirshipJSONUtils.string(json, options: .fragmentsAllowed) } } // migrate duration from milliseconds to seconds private func perform0To1DataMigration(json: inout [String: Any]) throws { guard json[LegacyKeys.displayType] as? String == "banner", var display = json[LegacyKeys.display] as? [String: Any], let duration = display[LegacyKeys.duration] as? Double else { return } display[LegacyKeys.duration] = duration / 1000.0 json[LegacyKeys.display] = display } // some remote-data schedules had their source field set incorrectly to app-defined by faulty edit code // this code migrates all app-defined sources to remote-data private func perform1To2DataMigration(json: inout [String: Any]) throws { if let source = json[LegacyKeys.source] as? String, source == InAppMessageSource.appDefined.rawValue { json[LegacyKeys.source] = InAppMessageSource.remoteData.rawValue } } // move scheduleData.message.audience to scheduleData.audience // use message ID as schedule ID // set the schedule type private func perform2To3DataMigration(json: inout [String: Any]) throws { if json[LegacyKeys.displayType] != nil && json[LegacyKeys.display] != nil { self.type = NSNumber(value: UAScheduleType.inAppMessage.rawValue) // Audience if let audience = json[LegacyKeys.audience] { self.audience = try AirshipJSON.wrap(audience).toString() } // If source is not app defined, set the group (message ID) as the ID if let source = json[LegacyKeys.source] as? String, let group = self.group { if source == InAppMessageSource.appDefined.rawValue { self.identifier = UUID().uuidString } else { self.identifier = group } } } else { self.type = NSNumber(value: UAScheduleType.actions.rawValue) } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/PreparedSchedule.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// A prepared schedule struct PreparedSchedule: Sendable { let info: PreparedScheduleInfo let data: PreparedScheduleData let frequencyChecker: (any FrequencyCheckerProtocol)? } /// Persisted info for a schedule that has been prepared for execution struct PreparedScheduleInfo: Codable, Equatable { var scheduleID: String var productID: String? var campaigns: AirshipJSON? var contactID: String? var experimentResult: ExperimentResult? var reportingContext: AirshipJSON? var triggerSessionID: String var additionalAudienceCheckResult: Bool var priority: Int init( scheduleID: String, productID: String? = nil, campaigns: AirshipJSON? = nil, contactID: String? = nil, experimentResult: ExperimentResult? = nil, reportingContext: AirshipJSON? = nil, triggerSessionID: String, additionalAudienceCheckResult: Bool = true, priority: Int ) { self.scheduleID = scheduleID self.productID = productID self.campaigns = campaigns self.contactID = contactID self.experimentResult = experimentResult self.reportingContext = reportingContext self.triggerSessionID = triggerSessionID self.additionalAudienceCheckResult = additionalAudienceCheckResult self.priority = priority } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.scheduleID = try container.decode(String.self, forKey: .scheduleID) self.productID = try container.decodeIfPresent(String.self, forKey: .productID) self.campaigns = try container.decodeIfPresent(AirshipJSON.self, forKey: .campaigns) self.contactID = try container.decodeIfPresent(String.self, forKey: .contactID) 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 self.priority = try container.decodeIfPresent(Int.self, forKey: .priority) ?? 0 } } /// Prepared schedule data enum PreparedScheduleData: Equatable { case inAppMessage(PreparedInAppMessageData) case actions(AirshipJSON) public static func == (lhs: PreparedScheduleData, rhs: PreparedScheduleData) -> Bool { switch lhs { case .actions(let lhsJson): switch rhs { case .actions(let rhsJson): return lhsJson == rhsJson default: return false } case .inAppMessage(let lhsMessageData): switch rhs { case .inAppMessage(let rhsMessageData): return rhsMessageData.message == lhsMessageData.message default: return false } } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/ScheduleExecuteResult.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Schedule execute result enum ScheduleExecuteResult: Sendable { case cancel case finished case retry } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/SchedulePrepareResult.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Schedule prepare result enum SchedulePrepareResult: Sendable, CustomStringConvertible { case prepared(PreparedSchedule) case cancel case invalidate case skip case penalize var description: String { switch(self) { case .prepared: "prepared" case .cancel: "cancel" case .invalidate: "invalidate" case .skip: "skip" case .penalize: "penalize" } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/ScheduleReadyResult.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Schedule ready result enum ScheduleReadyResult: Sendable { case ready case invalidate case notReady case skip } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/TriggerProcessor/AutomationTriggerProcessor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol AutomationTriggerProcessorProtocol: Sendable { @MainActor func setPaused(_ paused: Bool) var triggerResults: AsyncStream { get async } func processEvent( _ event: AutomationEvent ) async func restoreSchedules( _ datas: [AutomationScheduleData] ) async throws func updateSchedules( _ datas: [AutomationScheduleData] ) async func updateScheduleState( scheduleID: String, state: AutomationScheduleState ) async throws /// Cancels/deletes all data for the given schedule ids func cancel(scheduleIDs: [String]) async /// Cancels/deletes all data for the given group func cancel(group: String) async } final actor AutomationTriggerProcessor: AutomationTriggerProcessorProtocol { let store: any TriggerStoreProtocol private let date: any AirshipDateProtocol private let eventsHistory: any AutomationEventsHistory private let stream: AsyncStream private let continuation: AsyncStream.Continuation @MainActor private var isPaused = false // scheduleID to [PreparedTriggers] private var preparedTriggers: [String: [PreparedTrigger]] = [:] /// scheduleID to group private var scheduleGroups: [String: String] = [:] private var appSessionState: TriggerableState? init( store: any TriggerStoreProtocol, history: any AutomationEventsHistory, date: any AirshipDateProtocol = AirshipDate.shared ) { self.store = store self.date = date self.eventsHistory = history (self.stream, self.continuation) = AsyncStream.airshipMakeStreamWithContinuation() } @MainActor func setPaused(_ paused: Bool) { self.isPaused = paused } var triggerResults: AsyncStream { return self.stream } // check triggers for events func processEvent(_ event: AutomationEvent) async { await ingest(event: event, triggers: preparedTriggers.values.flatMap(\.self)) } private func ingest(event: AutomationEvent, triggers: [PreparedTrigger], isReplay: Bool = false) async { if !isReplay { //save current app state self.trackStateChange(event: event) } guard await self.isPaused == false else { return } var results = triggers.compactMap { item in item.process(event: event) } results.sort { left, right in left.priority < right.priority } results.forEach { result in if let triggerResult = result.triggerResult { self.continuation.yield(triggerResult) } } let triggerDatas = results.map { $0.triggerData } do { try await self.store.upsertTriggers(triggerDatas) } catch { AirshipLogger.error( "Failed to save tigger data \(triggerDatas) error \(error)" ) } } /// Called once to update all schedules from the DB. func restoreSchedules(_ datas: [AutomationScheduleData]) async throws { await updateSchedules(datas) let activeSchedules = Set(datas.map({ $0.schedule.identifier })) try await self.store.deleteTriggers(excludingScheduleIDs: activeSchedules) } /// Called whenever the schedules are updated func updateSchedules(_ datas: [AutomationScheduleData]) async { /// Sort by priority so wheen we restore the triggers we get events in order let sorted = datas.sorted( by: { l, r in (l.schedule.priority ?? 0) < (r.schedule.priority ?? 0) } ) var allNewTriggers: [PreparedTrigger] = [] for data in sorted { let schedule = data.schedule scheduleGroups[schedule.identifier] = schedule.group var new: [PreparedTrigger] = [] let old = self.preparedTriggers[data.schedule.identifier] ?? [] for trigger in data.schedule.triggers { let existing = old.first( where: { $0.trigger.id == trigger.id } ) if let existing = existing { existing.update( trigger: trigger, startDate: data.schedule.start, endDate: data.schedule.end, priority: data.schedule.priority ?? 0 ) new.append(existing) } else { AirshipLogger.debug( "New execution trigger for schedule \(schedule.identifier) id \(trigger.id) type \(trigger.type) — no existing match, count starts from store or zero" ) let prepared = await makePreparedTrigger( schedule: data.schedule, trigger: trigger, type: .execution ) new.append(prepared) allNewTriggers.append(prepared) } } for trigger in data.schedule.delay?.cancellationTriggers ?? [] { let existing = old.first( where: { $0.trigger.id == trigger.id } ) if let existing = existing { existing.update( trigger: trigger, startDate: data.schedule.start, endDate: data.schedule.end, priority: data.schedule.priority ?? 0 ) new.append(existing) } else { AirshipLogger.debug( "New cancellation trigger for schedule \(schedule.identifier) id \(trigger.id) type \(trigger.type) — no existing match, count starts from store or zero" ) let prepared = await makePreparedTrigger( schedule: data.schedule, trigger: trigger, type: .delayCancellation ) new.append(prepared) allNewTriggers.append(prepared) } } self.preparedTriggers[schedule.identifier] = new let newIDs = Set(new.map { $0.trigger.id }) let oldIDs = Set(old.map { $0.trigger.id }) do { let stale = oldIDs.subtracting(newIDs) if !stale.isEmpty { AirshipLogger.debug( "Deleting \(stale.count) stale trigger(s) for schedule \(schedule.identifier): \(stale.map { String($0.prefix(8)) })" ) try await self.store.deleteTriggers(scheduleID: schedule.identifier, triggerIDs: stale) } } catch { AirshipLogger.error("Failed to delete trigger states error \(error)") } await self.updateScheduleState( scheduleID: schedule.identifier, state: data.scheduleState ) } if !allNewTriggers.isEmpty { let history = await self.eventsHistory.events for event in history { await self.ingest(event: event, triggers: allNewTriggers, isReplay: true) } } } /// delete trigger state func cancel(scheduleIDs: [String]) async { scheduleIDs.forEach { scheduleID in self.preparedTriggers.removeValue(forKey: scheduleID) self.scheduleGroups.removeValue(forKey: scheduleID) } do { try await self.store.deleteTriggers(scheduleIDs: scheduleIDs) } catch { AirshipLogger.error("Failed to delete trigger state \(scheduleIDs) error \(error)") } } /// delete trigger state func cancel(group: String) async { let scheduleIDs = self.scheduleGroups.filter { $0.value == group}.map { $0.key } await cancel(scheduleIDs: scheduleIDs) } private func trackStateChange(event: AutomationEvent) { guard case .stateChanged(let state) = event else { return } self.appSessionState = state } func updateScheduleState(scheduleID: String, state: AutomationScheduleState) async { AirshipLogger.trace("Schedule state update: \(scheduleID) -> \(state)") switch state { case .idle: await self.updateActiveTriggerType(for: scheduleID, type: .execution) case .triggered, .prepared: await self.updateActiveTriggerType(for: scheduleID, type: .delayCancellation) case .paused, .finished: await self.updateActiveTriggerType(for: scheduleID, type: nil) default: break } } private func updateActiveTriggerType(for scheduleID: String, type: TriggerExecutionType?) async { guard let triggers = self.preparedTriggers[scheduleID] else { return } guard let type = type else { self.preparedTriggers[scheduleID]?.forEach { $0.disable() } return } triggers.forEach { if (type == $0.executionType) { $0.activate() } else { $0.disable() } } guard let state = self.appSessionState else { return } let results = triggers.compactMap { trigger in trigger.process(event: .stateChanged(state: state)) } results.forEach { result in if let triggerResult = result.triggerResult { self.continuation.yield(triggerResult) } } let triggerDatas = results.map { $0.triggerData } do { try await self.store.upsertTriggers(triggerDatas) } catch { AirshipLogger.error("Failed to save trigger data \(triggerDatas) \(error)") } } private func emit(result: TriggerResult) async { guard await self.isPaused == false else { return } self.continuation.yield(result) } private func makePreparedTrigger( schedule: AutomationSchedule, trigger: AutomationTrigger, type: TriggerExecutionType ) async -> PreparedTrigger { var triggerData: TriggerData? do { triggerData = try await self.store.getTrigger(scheduleID: schedule.identifier, triggerID: trigger.id) } catch { AirshipLogger.error("Failed to load trigger state for \(trigger) error \(error)") } if let loaded = triggerData { AirshipLogger.debug( "Restored trigger data for schedule \(schedule.identifier) trigger \(trigger.id) type \(trigger.type) count \(loaded.count)/\(trigger.goal)" ) } else { AirshipLogger.debug( "No stored data for schedule \(schedule.identifier) trigger \(trigger.id) type \(trigger.type) — starting at count 0" ) } return PreparedTrigger( scheduleID: schedule.identifier, trigger: trigger, type: type, startDate: schedule.start, endDate: schedule.end, triggerData: triggerData, priority: schedule.priority ?? 0, date: self.date ) } } enum TriggerExecutionType: String, Equatable, Hashable { case execution case delayCancellation = "delay_cancellation" } struct TriggerResult: Sendable { var scheduleID: String var triggerExecutionType: TriggerExecutionType var triggerInfo: TriggeringInfo } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/TriggerProcessor/PreparedTrigger.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif // This is only called from an actor `AutomationTriggerProcessor` final class PreparedTrigger { struct EventProcessResult { var triggerData: TriggerData var triggerResult: TriggerResult? var priority: Int } let date: any AirshipDateProtocol let scheduleID: String let executionType: TriggerExecutionType private(set) var triggerData: TriggerData private(set) var trigger: AutomationTrigger private(set) var isActive: Bool = false private(set) var startDate: Date? private(set) var endDate: Date? private(set) var priority: Int init( scheduleID: String, trigger: AutomationTrigger, type: TriggerExecutionType, startDate: Date?, endDate: Date?, triggerData: TriggerData?, priority: Int, date: any AirshipDateProtocol = AirshipDate.shared ) { self.scheduleID = scheduleID self.executionType = type self.date = date self.trigger = trigger self.startDate = startDate self.endDate = endDate self.triggerData = triggerData ?? TriggerData( scheduleID: scheduleID, triggerID: trigger.id ) self.priority = priority self.trigger.removeStaleChildData(data: &self.triggerData) } func process(event: AutomationEvent) -> EventProcessResult? { guard self.isActive else { AirshipLogger.trace("Trigger skipped (inactive): schedule \(scheduleID) trigger \(trigger.id) type \(trigger.type) executionType \(executionType)") return nil } guard self.isWithingDateRange() else { AirshipLogger.trace("Trigger skipped (out of date range): schedule \(scheduleID) trigger \(trigger.id) type \(trigger.type)") return nil } var currentData = self.triggerData let match = self.trigger.matchEvent(event, data: ¤tData, resetOnTrigger: true) guard currentData != self.triggerData || match?.isTriggered == true else { return nil } self.triggerData = currentData if match?.isTriggered == true { AirshipLogger.debug("Trigger fired: schedule \(scheduleID) trigger \(trigger.id) type \(trigger.type)") } else { AirshipLogger.trace("Trigger updated: schedule \(scheduleID) trigger \(trigger.id) type \(trigger.type) count \(currentData.count)/\(trigger.goal)") } return EventProcessResult( triggerData: triggerData, triggerResult: match?.isTriggered == true ? generateTriggerResult(eventData: event.eventData ?? .null) : nil, priority: self.priority ) } func update( trigger: AutomationTrigger, startDate: Date?, endDate: Date?, priority: Int ) { self.trigger = trigger self.startDate = startDate self.endDate = endDate self.priority = priority self.trigger.removeStaleChildData(data: &triggerData) } func activate() { guard !self.isActive else { return } AirshipLogger.debug( "Trigger activated: schedule \(scheduleID) trigger \(trigger.id) type \(trigger.type) executionType \(executionType) count \(triggerData.count)/\(trigger.goal)" ) self.isActive = true if self.executionType == .delayCancellation { self.triggerData.resetCount() } } func disable() { guard self.isActive else { return } AirshipLogger.debug( "Trigger disabled: schedule \(scheduleID) trigger \(trigger.id) type \(trigger.type) executionType \(executionType)" ) self.isActive = false } private func generateTriggerResult(eventData: AirshipJSON) -> TriggerResult { return TriggerResult( scheduleID: self.scheduleID, triggerExecutionType: self.executionType, triggerInfo: TriggeringInfo( context: AirshipTriggerContext( type: trigger.type, goal: trigger.goal, event: eventData), date: self.date.now ) ) } private func isWithingDateRange() -> Bool { let now = self.date.now if let start = self.startDate, start > now { return false } if let end = self.endDate, end < now { return false } return true } } extension TriggerData { func childData(triggerID: String) -> TriggerData { guard let data = self.children[triggerID] else { return TriggerData(scheduleID: self.scheduleID, triggerID: triggerID, count: 0) } return data } } extension EventAutomationTrigger { fileprivate func matchEvent(_ event: AutomationEvent, data: inout TriggerData) -> MatchResult? { switch event { case .stateChanged(let state): return stateTriggerMatch(state: state, data: &data) case .event(let type, let eventData, let value): guard self.type == type else { return nil } guard isPredicateMatching(value: eventData) else { AirshipLogger.trace("Event trigger predicate no-match: trigger \(self.id) type \(self.type)") return nil } return evaluateResults(data: &data, increment: value) } } private func stateTriggerMatch(state: TriggerableState, data: inout TriggerData) -> MatchResult? { switch self.type { case .version: guard let versionUpdated = state.versionUpdated, versionUpdated != data.lastTriggerableState?.versionUpdated, isPredicateMatching( value: [ "ios": ["version": .string(versionUpdated)] ] ) else { return nil } data.lastTriggerableState = state return evaluateResults(data: &data, increment: 1) case .activeSession: guard let appSessionID = state.appSessionID, appSessionID != data.lastTriggerableState?.appSessionID else { return nil } data.lastTriggerableState = state return evaluateResults(data: &data, increment: 1) default: return nil } } private func isPredicateMatching(value: AirshipJSON?) -> Bool { guard let predicate = self.predicate else { return true } return predicate.evaluate(json: value ?? .null) } private func evaluateResults( data: inout TriggerData, increment: Double ) -> MatchResult { data.incrementCount(increment) return MatchResult( triggerID: self.id, isTriggered: data.count >= self.goal ) } } extension CompoundAutomationTrigger { fileprivate func matchEvent(_ event: AutomationEvent, data: inout TriggerData) -> MatchResult? { let triggeredChildren = triggeredChildrenCount(data: data) var childResults = self.matchChildren(event: event, data: &data) // Resend state event if children is triggered for chain triggers if self.type == .chain, let state = data.lastTriggerableState, !event.isStateEvent, triggeredChildren != triggeredChildrenCount(data: data) { childResults = self.matchChildren(event: .stateChanged(state: state), data: &data) } else if case .stateChanged(let state) = event { // Remember state on compound trigger level in order to be able to re-send it data.lastTriggerableState = state } switch self.type { case .and, .chain: let shouldIncrement = childResults.allSatisfy { result in result.isTriggered } if (shouldIncrement) { self.children.forEach { child in // Only reset the child if its not sticky if child.isSticky != true { var childData = data.childData(triggerID: child.trigger.id) childData.resetCount() data.children[child.trigger.id] = childData } } data.incrementCount(1.0) } case .or: let shouldIncrement = childResults.contains( where: { result in result.isTriggered } ) if (shouldIncrement) { self.children.forEach { child in var childData = data.childData(triggerID: child.trigger.id) // Reset the child if it reached the goal or if we are resetting it // on increment if (childData.count >= child.trigger.goal || child.resetOnIncrement == true) { childData.resetCount() } data.children[child.trigger.id] = childData } data.incrementCount(1.0) } } let result = MatchResult(triggerID: self.id, isTriggered: data.count >= self.goal) AirshipLogger.trace( "Compound trigger[\(self.type)] id \(self.id) count \(data.count)/\(self.goal) triggered \(result.isTriggered) childResults \(childResults.map { "\($0.triggerID.prefix(8)):\($0.isTriggered)" })" ) return result } private func matchChildren( event: AutomationEvent, data: inout TriggerData ) -> [MatchResult] { var evaluateRemaining = true return children.enumerated().map { index, child in var childData = data.childData(triggerID: child.trigger.id) var matchResult: MatchResult? if evaluateRemaining { // Match the child without resetting it on trigger. We will process resets // after we get all the child results matchResult = child.trigger.matchEvent(event, data: &childData, resetOnTrigger: false) } let result = matchResult ?? MatchResult( triggerID: child.trigger.id, isTriggered: child.trigger.isTriggered(data: childData) ) AirshipLogger.trace( "Compound child[\(index)] id \(child.trigger.id) type \(child.trigger.type) count \(childData.count)/\(child.trigger.goal) triggered \(result.isTriggered) evaluated \(evaluateRemaining)" ) if self.type == .chain, evaluateRemaining, !result.isTriggered { AirshipLogger.debug("Chain stopped at child[\(index)] id \(child.trigger.id) type \(child.trigger.type) count \(childData.count)/\(child.trigger.goal)") evaluateRemaining = false } data.children[child.trigger.id] = childData return result } } func removeStaleChildData(data: inout TriggerData) { guard data.children.isEmpty else { return } var updatedData: [String: TriggerData] = [:] self.children.forEach { child in var childData = data.childData(triggerID: child.trigger.id) child.trigger.removeStaleChildData(data: &childData) updatedData[child.trigger.id] = childData } data.children = updatedData } private func triggeredChildrenCount(data: TriggerData) -> Int { return children .filter { child in guard let state = data.children[child.trigger.id] else { return false } return state.count >= child.trigger.goal }.count } } extension AutomationTrigger { fileprivate func matchEvent( _ event: AutomationEvent, data: inout TriggerData, resetOnTrigger: Bool ) -> MatchResult? { let result: MatchResult? = switch self { case .compound(let compoundTrigger): compoundTrigger.matchEvent(event, data: &data) case .event(let eventTrigger): eventTrigger.matchEvent(event, data: &data) } if resetOnTrigger, result?.isTriggered == true { data.resetCount() } return result } func isTriggered(data: TriggerData) -> Bool { return data.count >= self.goal } func removeStaleChildData(data: inout TriggerData) { guard case .compound(let compoundTrigger) = self else { return } compoundTrigger.removeStaleChildData(data: &data) } } fileprivate struct MatchResult { var triggerID: String var isTriggered: Bool } fileprivate extension AutomationEvent { var isStateEvent: Bool { switch self { case .stateChanged: return true default: return false } } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/TriggerProcessor/TriggerData.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct TriggerData: Sendable, Equatable, Codable { var scheduleID: String var triggerID: String var count: Double var children: [String: TriggerData] var lastTriggerableState: TriggerableState? init( scheduleID: String, triggerID: String, count: Double = 0.0, children: [String : TriggerData] = [:] ) { self.scheduleID = scheduleID self.triggerID = triggerID self.count = count self.children = children } } extension TriggerData { mutating func incrementCount(_ value: Double) { self.count = self.count + value } mutating func resetCount() { self.count = 0 } } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/Engine/TriggeringInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct TriggeringInfo: Equatable, Sendable, Codable { var context: AirshipTriggerContext? var date: Date } ================================================ FILE: Airship/AirshipAutomation/Source/Automation/ExecutionWindow.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Defines when an automation is allowed to run (e.g. daily, weekly, or monthly time windows). public struct ExecutionWindow: Sendable, Equatable, Codable { let include: [Rule]? let exclude: [Rule]? init(include: [Rule]? = nil, exclude: [Rule]? = nil) throws { self.include = include self.exclude = exclude try self.validate() } /// Creates an execution window from a decoder (e.g. JSON). /// - Parameter decoder: The decoder to read from. public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.include = try container.decodeIfPresent([ExecutionWindow.Rule].self, forKey: .include) self.exclude = try container.decodeIfPresent([ExecutionWindow.Rule].self, forKey: .exclude) try self.validate() } fileprivate func validate() throws { try include?.forEach { try $0.validate() } try exclude?.forEach { try $0.validate() } } enum Rule: Sendable, Codable, Equatable { case daily(timeRange: TimeRange, timeZone: TimeZone? = nil) case weekly(daysOfWeek: [Int], timeRange: TimeRange? = nil, timeZone: TimeZone? = nil) case monthly(months: [Int]? = nil, daysOfMonth: [Int]? = nil, timeRange: TimeRange? = nil, timeZone: TimeZone? = nil) enum CodingKeys: String, CodingKey { case type case timeRange = "time_range" case daysOfWeek = "days_of_week" case daysOfMonth = "days_of_month" case timeZone = "time_zone" case months = "months" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(RuleType.self, forKey: .type) switch(type) { case .daily: self = .daily( timeRange: try container.decode(TimeRange.self, forKey: .timeRange), timeZone: try container.decodeIfPresent(TimeZone.self, forKey: .timeZone) ) case .weekly: self = .weekly( daysOfWeek: try container.decode([Int].self, forKey: .daysOfWeek), timeRange: try container.decodeIfPresent(TimeRange.self, forKey: .timeRange), timeZone: try container.decodeIfPresent(TimeZone.self, forKey: .timeZone) ) case .monthly: self = .monthly( months: try container.decodeIfPresent([Int].self, forKey: .months), daysOfMonth: try container.decodeIfPresent([Int].self, forKey: .daysOfMonth), timeRange: try container.decodeIfPresent(TimeRange.self, forKey: .timeRange), timeZone: try container.decodeIfPresent(TimeZone.self, forKey: .timeZone) ) } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch(self) { case .daily(timeRange: let timeRange, timeZone: let timeZone): try container.encode(RuleType.daily, forKey: .type) try container.encode(timeRange, forKey: .timeRange) try container.encodeIfPresent(timeZone, forKey: .timeZone) case .weekly(daysOfWeek: let daysOfWeek, timeRange: let timeRange, timeZone: let timeZone): try container.encode(RuleType.weekly, forKey: .type) try container.encodeIfPresent(daysOfWeek, forKey: .daysOfWeek) try container.encodeIfPresent(timeRange, forKey: .timeRange) try container.encodeIfPresent(timeZone, forKey: .timeZone) case .monthly(months: let months, daysOfMonth: let daysOfMonth, timeRange: let timeRange, timeZone: let timeZone): try container.encode(RuleType.monthly, forKey: .type) try container.encodeIfPresent(months, forKey: .months) try container.encodeIfPresent(daysOfMonth, forKey: .daysOfMonth) try container.encodeIfPresent(timeRange, forKey: .timeRange) try container.encodeIfPresent(timeZone, forKey: .timeZone) } } fileprivate func validate() throws { switch(self) { case .daily(let timeRange, _): try timeRange.validate() case .weekly(daysOfWeek: let daysOfWeek, timeRange: let timeRange, timeZone: _): guard !daysOfWeek.isEmpty else { throw AirshipErrors.error("Invalid daysOfWeek: \(daysOfWeek), must contain at least 1 day of week") } try daysOfWeek.forEach { dayOfWeek in guard dayOfWeek >= 1 && dayOfWeek <= 7 else { throw AirshipErrors.error("Invalid daysOfWeek: \(daysOfWeek), all values must be [1-7]") } } try timeRange?.validate() case .monthly(months: let months, daysOfMonth: let daysOfMonth, timeRange: let timeRange, timeZone: _): guard months?.isEmpty == false || daysOfMonth?.isEmpty == false else { throw AirshipErrors.error("monthly rule must define either months or days of month") } try months?.forEach { month in guard month >= 1 && month <= 12 else { throw AirshipErrors.error("Invalid month: \(months ?? []), all values must be [1-12]") } } try daysOfMonth?.forEach { dayOfMonth in guard dayOfMonth >= 1 && dayOfMonth <= 31 else { throw AirshipErrors.error("Invalid days of month: \(daysOfMonth ?? []), all values must be [1-31]") } } try timeRange?.validate() } } } enum TimeZone: Sendable, Equatable, Codable{ case utc case identifiers([String], secondsFromUTC: Int? = nil, onFailure: TimeZoneFailureMode = .error) case local enum CodingKeys: String, CodingKey { case type case identifiers case secondsFromUTC = "fallback_seconds_from_utc" case onFailure = "on_failure" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(TimeZoneType.self, forKey: .type) switch(type) { case .local: self = .local case .utc: self = .utc case .identifiers: self = .identifiers( try container.decode([String].self, forKey: .identifiers), secondsFromUTC: try container.decodeIfPresent(Int.self, forKey: .secondsFromUTC), onFailure: try container.decode(TimeZoneFailureMode.self, forKey: .onFailure) ) } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch(self) { case .local: try container.encode(TimeZoneType.local, forKey: .type) case .utc: try container.encode(TimeZoneType.utc, forKey: .type) case .identifiers(let identifiers, let secondsFromUTC, let failureMode): try container.encode(TimeZoneType.identifiers, forKey: .type) try container.encode(identifiers, forKey: .identifiers) try container.encodeIfPresent(secondsFromUTC, forKey: .secondsFromUTC) try container.encode(failureMode, forKey: .onFailure) } } } enum TimeZoneFailureMode: String, Sendable, Equatable, Codable { case error = "error" case skip = "skip" } struct TimeRange: Hashable, Equatable, Sendable, Codable { var startHour: Int var startMinute: Int var endHour: Int var endMinute: Int enum CodingKeys: String, CodingKey { case startHour = "start_hour" case startMinute = "start_minute" case endHour = "end_hour" case endMinute = "end_minute" } init(startHour: Int, startMinute: Int = 0, endHour: Int, endMinute: Int = 0) { self.startHour = startHour self.startMinute = startMinute self.endHour = endHour self.endMinute = endMinute } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.startHour = try container.decode(Int.self, forKey: .startHour) self.startMinute = try container.decode(Int.self, forKey: .startMinute) self.endHour = try container.decode(Int.self, forKey: .endHour) self.endMinute = try container.decode(Int.self, forKey: .endMinute) } fileprivate func validate() throws { guard startHour >= 0 && startHour <= 23 else { throw AirshipErrors.error("Invalid startHour: \(startHour), must be [0-23]") } guard startMinute >= 0 && startMinute <= 59 else { throw AirshipErrors.error("Invalid startMinute: \(startMinute), must be [0-59]") } guard endHour >= 0 && endHour <= 23 else { throw AirshipErrors.error("Invalid endHour: \(endHour), must be [0-23]") } guard endMinute >= 0 && endMinute <= 59 else { throw AirshipErrors.error("Invalid endMinute: \(endMinute), must be [0-59]") } } } private enum RuleType: String, Sendable, Codable { case daily = "daily" case weekly = "weekly" case monthly = "monthly" } private enum TimeZoneType: String, Sendable, Codable { case utc = "utc" case local = "local" case identifiers = "identifiers" } } enum ExecutionWindowResult: Equatable { case now case retry(TimeInterval) } extension ExecutionWindow { func nextAvailability(date: Date, currentTimeZone: Foundation.TimeZone? = nil) throws -> ExecutionWindowResult { let timeZone = currentTimeZone ?? Foundation.TimeZone.current let excluded = try self.exclude?.compactMap { try $0.resolve(date: date, currentTimeZone: timeZone) }.filter { $0.isWithin(date: date) }.sorted { l, r in /// Sort them with the longest exclude first l.end > r.end }.first if let excluded { return .retry(max(1.seconds, excluded.end.timeIntervalSince(date))) } let nextInclude = try include?.compactMap { try $0.resolve(date: date, currentTimeZone: timeZone) }.sorted { l, r in // Sort with the next window first l.start < r.start }.first guard let nextInclude, !nextInclude.isWithin(date: date) else { return .now } return .retry(max(1.seconds, nextInclude.start.timeIntervalSince(date))) } } fileprivate extension Int { var hours: TimeInterval { TimeInterval(self) * 60 * 60 } var minutes: TimeInterval { TimeInterval(self) * 60 } var seconds: TimeInterval { TimeInterval(self) } } fileprivate extension ExecutionWindow.TimeRange { var start: TimeInterval { return startHour.hours + startMinute.minutes } var end: TimeInterval { return endHour.hours + endMinute.minutes } } fileprivate extension ExecutionWindow.TimeZone { enum TimeZoneResult { case resolved(Foundation.TimeZone) case error(ExecutionWindow.TimeZoneFailureMode) } func resolve(currentTimeZone: TimeZone) -> TimeZoneResult { switch(self) { case .utc: return .resolved(.gmt) case .local: return .resolved(currentTimeZone) case .identifiers(let identifiers, let secondsFromUTC, let failureMode): for identifier in identifiers { if let timeZone = TimeZone(identifier: identifier) { return .resolved(timeZone) } } if let secondsFromUTC, let timeZone = TimeZone(secondsFromGMT: secondsFromUTC) { return .resolved(timeZone) } AirshipLogger.error("Failed to resolve time zone identifiers: \(identifiers)") return .error(failureMode) } } } fileprivate extension ExecutionWindow.Rule { private func calendar(timeZone: ExecutionWindow.TimeZone?, currentTimeZone: Foundation.TimeZone) throws -> AirshipCalendar? { guard let timeZone else { return AirshipCalendar(timeZone: currentTimeZone) } switch (timeZone.resolve(currentTimeZone: currentTimeZone)) { case .resolved(let resolved): return AirshipCalendar(timeZone: resolved) case .error(let failureMode): switch(failureMode) { case .skip: return nil case .error: throw AirshipErrors.error("Unable to resolve time zone: \(timeZone)") } } } func resolve(date: Date, currentTimeZone: Foundation.TimeZone) throws -> DateInterval? { switch (self) { case .daily(timeRange: let timeRange, timeZone: let timeZone): guard let calendar = try calendar( timeZone: timeZone, currentTimeZone: currentTimeZone ) else { return nil } return calendar.dateInterval(date: date, timeRange: timeRange) case .weekly(daysOfWeek: let daysOfWeek, timeRange: let timeRange, timeZone: let timeZone): guard let calendar = try calendar( timeZone: timeZone, currentTimeZone: currentTimeZone ) else { return nil } guard let timeRange else { let nextDate = calendar.nextDate(date: date, weekdays: daysOfWeek) return calendar.remainingDay(date: nextDate) } var nextDate = calendar.nextDate(date: date, weekdays: daysOfWeek) while true { let timeInterval = calendar.dateInterval(date: nextDate, timeRange: timeRange) let remainingDay = calendar.remainingDay(date: nextDate) guard let result = timeInterval.intersection(with: remainingDay) else { nextDate = calendar.nextDate( date: calendar.startOfDay(date: date, dayOffset: 1), weekdays: daysOfWeek ) continue } return result } case .monthly(months: let months, daysOfMonth: let daysOfMonth, timeRange: let timeRange, timeZone: let timeZone): guard let calendar = try calendar( timeZone: timeZone, currentTimeZone: currentTimeZone ) else { return nil } guard let timeRange else { let nextDate = calendar.nextDate(date: date, months: months, days: daysOfMonth) return calendar.remainingDay(date: nextDate) } var nextDate = calendar.nextDate(date: date, months: months, days: daysOfMonth) while true { let timeInterval = calendar.dateInterval(date: nextDate, timeRange: timeRange) let remainingDay = calendar.remainingDay(date: nextDate) guard let result = timeInterval.intersection(with: remainingDay) else { nextDate = calendar.nextDate( date: calendar.startOfDay(date: date, dayOffset: 1), months: months, days: daysOfMonth ) continue } return result } } } } fileprivate struct AirshipCalendar : Hashable, Equatable, Sendable { private let calendar: Calendar init(timeZone: TimeZone) { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = timeZone self.calendar = calendar } func startOfDay(date: Date, dayOffset: Int = 0) -> Date { guard dayOffset != 0 else { return calendar.startOfDay(for: date) } guard let targetDate = calendar.date(byAdding: .day, value: dayOffset, to: date) else { // Fallback to using hours offset. Should be fine for most // dates except for time zones with daylight savings on // transition days. return calendar.startOfDay(for: date + 24.hours) } return calendar.startOfDay(for: targetDate) } func endOfDay(date: Date, dayOffset: Int = 0) -> Date { let day = startOfDay(date: date, dayOffset: dayOffset) guard let endOfDay = calendar.date( bySettingHour: 23, minute: 59, second: 59, of: day ) else { // Fallback to using hours offset. Should be fine for most // dates except for time zones with daylight savings on // transition days. return day + 24.hours - 1.seconds } return endOfDay } private func date(date: Date, hour: Int, minute: Int) -> Date { guard let newDate = calendar.date( bySettingHour: hour, minute: minute, second: 0, of: date ) else { return startOfDay(date: date).advanced(by: hour.hours + minute.minutes) } return newDate } // Returns the date interval for the rest of the day func remainingDay(date: Date) -> DateInterval { return DateInterval(start: date, end: startOfDay(date: date, dayOffset: 1)) } // Returns the date interval for the given date and timeRange. If the // date is passed the time range, the DateInterval will be for the next day. func dateInterval(date: Date, timeRange: ExecutionWindow.TimeRange) -> DateInterval { guard timeRange.start != timeRange.end else { let todayStart = self.date( date: startOfDay(date: date), hour: timeRange.startHour, minute: timeRange.startMinute ) if (todayStart == date) { return DateInterval(start: todayStart, duration: 1) } else { let tomorrowStart = self.date( date: startOfDay(date: date, dayOffset: 1), hour: timeRange.startHour, minute: timeRange.startMinute ) return DateInterval(start: tomorrowStart, duration: 1) } } /// start: 23, end: 1 let yesterdayInterval = DateInterval( start: self.date( date: startOfDay(date: date, dayOffset: -1), hour: timeRange.startHour, minute: timeRange.startMinute ), end: self.date( date: startOfDay( date: date, dayOffset: (timeRange.start > timeRange.end ? 0 : -1) ), hour: timeRange.endHour, minute: timeRange.endMinute ) ) if yesterdayInterval.isWithin(date: date) { return yesterdayInterval } let todayInterval = DateInterval( start: self.date( date: startOfDay(date: date), hour: timeRange.startHour, minute: timeRange.startMinute ), end: self.date( date: startOfDay( date: date, dayOffset: (timeRange.start > timeRange.end ? 1 : 0) ), hour: timeRange.endHour, minute: timeRange.endMinute ) ) if todayInterval.isWithin(date: date) || todayInterval.start >= date { return todayInterval } return DateInterval( start: self.date( date: startOfDay(date: date, dayOffset: 1), hour: timeRange.startHour, minute: timeRange.startMinute ), end: self.date( date: startOfDay( date: date, dayOffset: (timeRange.start > timeRange.end ? 2 : 1) ), hour: timeRange.endHour, minute: timeRange.endMinute ) ) } // Returns the current date if it matches the weekdays, // or the date of the start of the next requested weekday func nextDate(date: Date, weekdays: [Int]) -> Date { let currentWeekday = calendar.component(.weekday, from: date) let sortedWeekdays = weekdays.sorted() let targetWeekday = sortedWeekdays.first { $0 >= currentWeekday } ?? sortedWeekdays.first ?? currentWeekday // Mod it with number of days in the week let daysUntilNextSlot = if targetWeekday >= currentWeekday { targetWeekday - currentWeekday } else { targetWeekday + (7 - currentWeekday) } return if (daysUntilNextSlot > 0) { startOfDay(date: date, dayOffset: daysUntilNextSlot ) } else { date } } func nextDate(date: Date, months: [Int]? = nil, days: [Int]?) -> Date { guard months?.isEmpty == false || days?.isEmpty == false else { return date } let currentDay = calendar.component(.day, from: date) let currentMonth = calendar.component(.month, from: date) let sortedMonths = months?.sorted() let sortedDays = days?.sorted() let targetMonth = sortedMonths?.first { $0 >= currentMonth } ?? sortedMonths?.first ?? currentMonth var targetDay = sortedDays?.first(where: { $0 >= currentDay }) // Our target month is this month if targetMonth == currentMonth { if let targetDay { return if targetDay == currentDay { date } else { startOfDay(date: date, dayOffset: (targetDay - currentDay)) } } else if sortedDays?.isEmpty != false { return date } } // Pick the earliest day targetDay = sortedDays?.first ?? 1 guard let sortedMonths, !sortedMonths.isEmpty else { return calendar.nextDate( after: date, matching: DateComponents( day: targetDay ), matchingPolicy: .strict ) ?? Date.distantFuture } let results = sortedMonths.compactMap { month in let next = calendar.nextDate( after: date, matching: DateComponents( month: month, day: targetDay ), matchingPolicy: .strict ) return if let next { startOfDay(date: next) } else { nil } }.sorted() return results.first ?? Date.distantFuture } } fileprivate extension DateInterval { func isWithin(date: Date) -> Bool { return contains(date) && self.end != date } } ================================================ FILE: Airship/AirshipAutomation/Source/AutomationSDKModule.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) public import AirshipCore #endif public import Foundation /// NOTE: For internal use only. :nodoc: @objc(UAAutomationSDKModule) public class AutomationSDKModule: NSObject, AirshipSDKModule { public let components: [any AirshipComponent] public let actionsManifest: (any ActionsManifest)? = AutomationActionManifest() init(components: [any AirshipComponent]) { self.components = components } public static func load(_ args: AirshiopModuleLoaderArgs) -> (any AirshipSDKModule)? { /// Utils let remoteDataAccess = AutomationRemoteDataAccess(remoteData: args.remoteData) let assetManager = AssetCacheManager() let displayCoordinatorManager = DisplayCoordinatorManager(dataStore: args.dataStore) let frequencyLimits = FrequencyLimitManager(config: args.config) let scheduleConditionsChangedNotifier = ScheduleConditionsChangedNotifier() let eventRecorder = ThomasLayoutEventRecorder(airshipAnalytics: args.analytics, meteredUsage: args.meteredUsage) let metrics = ApplicationMetrics(dataStore: args.dataStore, privacyManager: args.privacyManager) let automationStore = AutomationStore(config: args.config) let history = DefaultAutomationEventsHistory() let analyticsFactory = InAppMessageAnalyticsFactory( eventRecorder: eventRecorder, displayHistoryStore: MessageDisplayHistoryStore( storageGetter: { scheduleID in try await automationStore.getAssociatedData(scheduleID: scheduleID) }, storageSetter: { scheduleID, history in try await automationStore.updateSchedule(scheduleID: scheduleID) { data in data.associatedData = try JSONEncoder().encode(history) } } ), displayImpressionRuleProvider: DefaultInAppDisplayImpressionRuleProvider() ) /// Preperation let actionPreparer = ActionAutomationPreparer() let messagePreparer = InAppMessageAutomationPreparer( assetManager: assetManager, displayCoordinatorManager: displayCoordinatorManager, analyticsFactory: analyticsFactory ) let automationPreparer = AutomationPreparer( actionPreparer: actionPreparer, messagePreparer: messagePreparer, deferredResolver: args.deferredResolver, frequencyLimits: frequencyLimits, audienceChecker: args.audienceChecker, experiments: args.experimentsManager, remoteDataAccess: remoteDataAccess, config: args.config, additionalAudienceResolver: AdditionalAudienceCheckerResolver( config: args.config, cache: args.cache ) ) // Execution let actionExecutor = ActionAutomationExecutor() #if os(macOS) let messageExecutor = InAppMessageAutomationExecutor( assetManager: assetManager, analyticsFactory: analyticsFactory, scheduleConditionsChangedNotifier: scheduleConditionsChangedNotifier ) #else let messageSceneManager = InAppMessageSceneManager(sceneManger: AirshipSceneManager.shared) let messageExecutor = InAppMessageAutomationExecutor( sceneManager: messageSceneManager, assetManager: assetManager, analyticsFactory: analyticsFactory, scheduleConditionsChangedNotifier: scheduleConditionsChangedNotifier ) #endif let automationExecutor = AutomationExecutor( actionExecutor: actionExecutor, messageExecutor: messageExecutor, remoteDataAccess: remoteDataAccess ) let feed = AutomationEventFeed( applicationMetrics: metrics, applicationStateTracker: AppStateTracker.shared, analyticsFeed: args.analytics.eventFeed ) feed.attach() // Engine let engine = AutomationEngine( store: automationStore, executor: automationExecutor, preparer: automationPreparer, scheduleConditionsChangedNotifier: scheduleConditionsChangedNotifier, eventFeed: feed, triggersProcessor: AutomationTriggerProcessor( store: automationStore, history: history ), delayProcessor: AutomationDelayProcessor(analytics: args.analytics), eventsHistory: history, ) let remoteDataSubscriber = AutomationRemoteDataSubscriber( dataStore: args.dataStore, remoteDataAccess: remoteDataAccess, engine: engine, frequencyLimitManager: frequencyLimits ) let inAppMessaging = DefaultInAppMessaging( executor: messageExecutor, preparer: messagePreparer ) let legacyInAppMessaging = DefaultLegacyInAppMessaging( analytics: LegacyInAppAnalytics(recorder: eventRecorder), dataStore: args.dataStore, automationEngine: engine ) let inAppAutomation = DefaultInAppAutomation( engine: engine, inAppMessaging: inAppMessaging, legacyInAppMessaging: legacyInAppMessaging, remoteData: args.remoteData, remoteDataSubscriber: remoteDataSubscriber, dataStore: args.dataStore, privacyManager: args.privacyManager, config: args.config ) return AutomationSDKModule( components: [ InAppAutomationComponent(inAppAutomation: inAppAutomation) ] ) } } fileprivate struct AutomationActionManifest : ActionsManifest { var manifest: [[String] : () -> ActionEntry] = [ LandingPageAction.defaultNames: { return ActionEntry( action: LandingPageAction(), predicate: LandingPageAction.defaultPredicate ) } ] } ================================================ FILE: Airship/AirshipAutomation/Source/InAppAutomation.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(AirshipCore) public import AirshipCore #endif /** * Provides a control interface for creating, canceling and executing in-app automations. */ public protocol InAppAutomation: AnyObject, Sendable { /// In-App Messaging var inAppMessaging: any InAppMessaging { get } /// Legacy In-App Messaging var legacyInAppMessaging: any LegacyInAppMessaging { get } /// Paused state of in-app automation. @MainActor var isPaused: Bool { get set } /// Creates the provided schedules or updates them if they already exist. /// - Parameter schedules: The schedules to create or update. func upsertSchedules(_ schedules: [AutomationSchedule]) async throws /// Cancels an in-app automation via its schedule identifier. /// - Parameter identifier: The schedule identifier to cancel. func cancelSchedule(identifier: String) async throws /// Cancels multiple in-app automations via their schedule identifiers. /// - Parameter identifiers: The schedule identifiers to cancel. func cancelSchedule(identifiers: [String]) async throws /// Cancels multiple in-app automations via their group. /// - Parameter group: The group to cancel. func cancelSchedules(group: String) async throws /// Gets the in-app automation with the provided schedule identifier. /// - Parameter identifier: The schedule identifier. /// - Returns: The in-app automation corresponding to the provided schedule identifier. func getSchedule(identifier: String) async throws -> AutomationSchedule? /// Gets the in-app automation with the provided group. /// - Parameter identifier: The group to get. /// - Returns: The in-app automation corresponding to the provided group. func getSchedules(group: String) async throws -> [AutomationSchedule] /// Inapp Automation status updates. Possible values are upToDate, stale and outOfDate. var statusUpdates: AsyncStream { get async } /// Current inApp Automation status. Possible values are upToDate, stale and outOfDate. var status: InAppAutomationUpdateStatus { get async } /// Allows to wait for the refresh of the InApp Automation rules. /// - Parameters /// - maxTime: Timeout in seconds. func waitRefresh(maxTime: TimeInterval?) async } internal protocol InternalInAppAutomation: InAppAutomation { func cancelSchedulesWith(type: AutomationSchedule.ScheduleType) async throws } final class DefaultInAppAutomation: InternalInAppAutomation, Sendable { private let engine: any AutomationEngineProtocol private let remoteDataSubscriber: any AutomationRemoteDataSubscriberProtocol private let dataStore: PreferenceDataStore private let privacyManager: any AirshipPrivacyManager private let notificationCenter: AirshipNotificationCenter private static let pausedStoreKey: String = "UAInAppMessageManagerPaused" private let _legacyInAppMessaging: any InternalLegacyInAppMessaging private let remoteData: any RemoteDataProtocol /// In-App Messaging let inAppMessaging: any InAppMessaging /// Legacy In-App Messaging var legacyInAppMessaging: any LegacyInAppMessaging { return _legacyInAppMessaging } @MainActor init( engine: any AutomationEngineProtocol, inAppMessaging: any InAppMessaging, legacyInAppMessaging: any InternalLegacyInAppMessaging, remoteData: any RemoteDataProtocol, remoteDataSubscriber: any AutomationRemoteDataSubscriberProtocol, dataStore: PreferenceDataStore, privacyManager: any AirshipPrivacyManager, config: RuntimeConfig, notificationCenter: AirshipNotificationCenter = .shared ) { self.engine = engine self.inAppMessaging = inAppMessaging self._legacyInAppMessaging = legacyInAppMessaging self.remoteDataSubscriber = remoteDataSubscriber self.dataStore = dataStore self.privacyManager = privacyManager self.notificationCenter = notificationCenter self.remoteData = remoteData if (config.airshipConfig.autoPauseInAppAutomationOnLaunch) { self.isPaused = true } } /// Paused state of in-app automation. @MainActor public var isPaused: Bool { get { return self.dataStore.bool(forKey: Self.pausedStoreKey) } set { self.dataStore.setBool(newValue, forKey: Self.pausedStoreKey) self.engine.setExecutionPaused(newValue) } } /// Creates the provided schedules or updates them if they already exist. /// - Parameter schedules: The schedules to create or update. func upsertSchedules(_ schedules: [AutomationSchedule]) async throws { try await self.engine.upsertSchedules(schedules) } /// Cancels an in-app automation via its schedule identifier. /// - Parameter identifier: The schedule identifier to cancel. func cancelSchedule(identifier: String) async throws { try await self.engine.cancelSchedules(identifiers: [identifier]) } /// Cancels multiple in-app automations via their schedule identifiers. /// - Parameter identifiers: The schedule identifiers to cancel. func cancelSchedule(identifiers: [String]) async throws { try await self.engine.cancelSchedules(identifiers: identifiers) } /// Cancels multiple in-app automations via their group. /// - Parameter group: The group to cancel. func cancelSchedules(group: String) async throws { try await self.engine.cancelSchedules(group: group) } func cancelSchedulesWith(type: AutomationSchedule.ScheduleType) async throws { try await self.engine.cancelSchedulesWith(type: type) } /// Gets the in-app automation with the provided schedule identifier. /// - Parameter identifier: The schedule identifier. /// - Returns: The in-app automation corresponding to the provided schedule identifier. public func getSchedule(identifier: String) async throws -> AutomationSchedule? { return try await self.engine.getSchedule(identifier: identifier) } /// Gets the in-app automation with the provided group. /// - Parameter identifier: The group to get. /// - Returns: The in-app automation corresponding to the provided group. public func getSchedules(group: String) async throws -> [AutomationSchedule] { return try await self.engine.getSchedules(group: group) } /// Inapp Automation status updates. Possible values are upToDate, stale and outOfDate. public var statusUpdates: AsyncStream { get async { return await self.remoteData.statusUpdates(sources: [RemoteDataSource.app, RemoteDataSource.contact], map: { statuses in if statuses.values.contains(.outOfDate) { return InAppAutomationUpdateStatus.outOfDate } else if statuses.values.contains(.stale) { return InAppAutomationUpdateStatus.stale } else { return InAppAutomationUpdateStatus.upToDate } }) } } /// Current inApp Automation status. Possible values are upToDate, stale and outOfDate. public var status: InAppAutomationUpdateStatus { get async { let statuses = await self.remoteData.statusUpdates(sources: [RemoteDataSource.app, RemoteDataSource.contact], map: { statuses in if statuses.values.contains(.outOfDate) { return InAppAutomationUpdateStatus.outOfDate } else if statuses.values.contains(.stale) { return InAppAutomationUpdateStatus.stale } else { return InAppAutomationUpdateStatus.upToDate } }) return await statuses.first {_ in true } ?? .upToDate } } /// Allows to wait for the refresh of the InApp Automation rules. /// - Parameters /// - maxTime: Timeout in seconds. public func waitRefresh(maxTime: TimeInterval? = nil) async { await self.remoteData.waitRefresh(source: RemoteDataSource.app, maxTime: maxTime) } @MainActor private func privacyManagerUpdated() { if self.privacyManager.isEnabled(.inAppAutomation) { self.engine.setEnginePaused(false) self.remoteDataSubscriber.subscribe() } else { self.engine.setEnginePaused(true) self.remoteDataSubscriber.unsubscribe() } } } extension DefaultInAppAutomation { @MainActor func airshipReady() { self.engine.setExecutionPaused(self.isPaused) Task { await self.engine.start() } self.notificationCenter.addObserver(forName: AirshipNotifications.PrivacyManagerUpdated.name) { [weak self] _ in Task { @MainActor in self?.privacyManagerUpdated() } } self.privacyManagerUpdated() } func receivedRemoteNotification( _ notification: AirshipJSON // wrapped [AnyHashable: Any] ) async -> UABackgroundFetchResult { return await self._legacyInAppMessaging.receivedRemoteNotification(notification) } #if !os(tvOS) func receivedNotificationResponse(_ response: UNNotificationResponse) async { await self._legacyInAppMessaging.receivedNotificationResponse(response) } #endif } public extension Airship { /// The shared `InAppAutomation` instance. `Airship.takeOff` must be called before accessing this instance. static var inAppAutomation: any InAppAutomation { return Airship.requireComponent( ofType: InAppAutomationComponent.self ).inAppAutomation } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppAutomationComponent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @preconcurrency import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(AirshipCore) import AirshipCore #endif /// Actual airship component for InAppAutomation. Used to hide AirshipComponent methods. final class InAppAutomationComponent: AirshipComponent, AirshipPushableComponent { let inAppAutomation: DefaultInAppAutomation init(inAppAutomation: DefaultInAppAutomation) { self.inAppAutomation = inAppAutomation } @MainActor func airshipReady() { self.inAppAutomation.airshipReady() } func receivedRemoteNotification( _ notification: AirshipJSON ) async -> UABackgroundFetchResult { return await self.inAppAutomation.receivedRemoteNotification(notification) } #if !os(tvOS) func receivedNotificationResponse(_ response: UNNotificationResponse) async { await self.inAppAutomation.receivedNotificationResponse(response) } #endif } ================================================ FILE: Airship/AirshipAutomation/Source/InAppAutomationUpdateStatus.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// In-app automation remote data status. public enum InAppAutomationUpdateStatus: Sendable { /// Remote data is current. case upToDate /// Remote data may be outdated; refresh in progress or deferred. case stale /// Remote data is known to be out of date. case outOfDate } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Analytics/InAppDisplayImpressionRuleProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif enum InAppDisplayImpressionRule: Equatable, Sendable { case once case interval(TimeInterval) } protocol InAppDisplayImpressionRuleProvider: Sendable { func impressionRules(for message: InAppMessage) -> InAppDisplayImpressionRule } final class DefaultInAppDisplayImpressionRuleProvider: InAppDisplayImpressionRuleProvider { private static let defaultEmbeddedImpressionInterval: TimeInterval = 1800.0 // 30 mins func impressionRules(for message: InAppMessage) -> InAppDisplayImpressionRule { if (message.isEmbedded) { return .interval(Self.defaultEmbeddedImpressionInterval) } else { return .once } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Analytics/InAppMessageAnalytics.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct InAppCustomEventContext: Sendable, Encodable, Equatable { var id: ThomasLayoutEventMessageID var context: ThomasLayoutEventContext? } protocol InAppMessageAnalyticsProtocol: ThomasLayoutMessageAnalyticsProtocol { @MainActor func makeCustomEventContext(layoutContext: ThomasLayoutContext?) -> InAppCustomEventContext? } final class LoggingInAppMessageAnalytics: InAppMessageAnalyticsProtocol { func makeCustomEventContext(layoutContext: ThomasLayoutContext?) -> InAppCustomEventContext? { return nil } func recordEvent(_ event: any ThomasLayoutEvent, layoutContext: ThomasLayoutContext?) { do { let body = try event.data?.prettyString ?? "nil" let context = try layoutContext?.prettyString ?? "nil" AirshipLogger.debug( "Adding event \(event.name.reportingName):\n body: \(body),\n layoutContext: \(context)" ) } catch { AirshipLogger.error("Failed to log event \(event): \(error)") } } } fileprivate extension Encodable { var prettyString: String? { get throws { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted] return String(data: try encoder.encode(self), encoding: .utf8) } } } final class InAppMessageAnalytics: InAppMessageAnalyticsProtocol { private let preparedScheduleInfo: PreparedScheduleInfo private let messageID: ThomasLayoutEventMessageID private let source: ThomasLayoutEventSource private let renderedLocale: AirshipJSON? private let eventRecorder: any ThomasLayoutEventRecorderProtocol private let isReportingEnabled: Bool private let date: any AirshipDateProtocol private let historyStore: any MessageDisplayHistoryStoreProtocol private let displayImpressionRule: InAppDisplayImpressionRule private let displayHistory: AirshipMainActorValue private let displayContext: AirshipMainActorValue init( preparedScheduleInfo: PreparedScheduleInfo, message: InAppMessage, displayImpressionRule: InAppDisplayImpressionRule, eventRecorder: any ThomasLayoutEventRecorderProtocol, historyStore: any MessageDisplayHistoryStoreProtocol, displayHistory: MessageDisplayHistory, date: any AirshipDateProtocol = AirshipDate.shared ) { self.preparedScheduleInfo = preparedScheduleInfo self.messageID = Self.makeMessageID( message: message, scheduleID: preparedScheduleInfo.scheduleID, campaigns: preparedScheduleInfo.campaigns ) self.source = Self.makeEventSource(message: message) self.renderedLocale = message.renderedLocale self.eventRecorder = eventRecorder self.isReportingEnabled = message.isReportingEnabled ?? true self.displayImpressionRule = displayImpressionRule self.historyStore = historyStore self.date = date self.displayHistory = AirshipMainActorValue(displayHistory) self.displayContext = AirshipMainActorValue( ThomasLayoutEventContext.Display( triggerSessionID: preparedScheduleInfo.triggerSessionID, isFirstDisplay: displayHistory.lastDisplay == nil, isFirstDisplayTriggerSessionID: preparedScheduleInfo.triggerSessionID != displayHistory.lastDisplay?.triggerSessionID ) ) } @MainActor func makeCustomEventContext(layoutContext: ThomasLayoutContext?) -> InAppCustomEventContext? { return InAppCustomEventContext( id: self.messageID, context: ThomasLayoutEventContext.makeContext( reportingContext: self.preparedScheduleInfo.reportingContext, experimentsResult: self.preparedScheduleInfo.experimentResult, layoutContext: layoutContext, displayContext: self.displayContext.value ) ) } func recordEvent( _ event: any ThomasLayoutEvent, layoutContext: ThomasLayoutContext? ) { let now = self.date.now if event is ThomasLayoutDisplayEvent { if let lastDisplay = displayHistory.value.lastDisplay { if self.preparedScheduleInfo.triggerSessionID == lastDisplay.triggerSessionID { self.displayContext.update { value in value.isFirstDisplay = false value.isFirstDisplayTriggerSessionID = false } } else { self.displayContext.update { value in value.isFirstDisplay = false } } } if (recordImpression(date: now)) { self.displayHistory.update { value in value.lastImpression = MessageDisplayHistory.LastImpression( date: now, triggerSessionID: self.preparedScheduleInfo.triggerSessionID ) } } self.displayHistory.update { value in value.lastDisplay = MessageDisplayHistory.LastDisplay( triggerSessionID: self.preparedScheduleInfo.triggerSessionID ) } self.historyStore.set(displayHistory.value, scheduleID: preparedScheduleInfo.scheduleID) } guard self.isReportingEnabled else { return } let data = ThomasLayoutEventData( event: event, context: ThomasLayoutEventContext.makeContext( reportingContext: self.preparedScheduleInfo.reportingContext, experimentsResult: self.preparedScheduleInfo.experimentResult, layoutContext: layoutContext, displayContext: self.displayContext.value ), source: self.source, messageID: self.messageID, renderedLocale: self.renderedLocale ) self.eventRecorder.recordEvent(inAppEventData: data) } @MainActor var shouldRecordImpression: Bool { guard let lastImpression = displayHistory.value.lastImpression, lastImpression.triggerSessionID == self.preparedScheduleInfo.triggerSessionID else { return true } switch (self.displayImpressionRule) { case .interval(let interval): return self.date.now.timeIntervalSince(lastImpression.date) >= interval case .once: return false } } @MainActor private func recordImpression(date: Date) -> Bool { guard shouldRecordImpression else { return false } guard let productID = self.preparedScheduleInfo.productID else { return false } let event = AirshipMeteredUsageEvent( eventID: UUID().uuidString, entityID: self.messageID.identifier, usageType: .inAppExperienceImpression, product: productID, reportingContext: self.preparedScheduleInfo.reportingContext, timestamp: date, contactID: self.preparedScheduleInfo.contactID ) self.eventRecorder.recordImpressionEvent(event) return true } private static func makeMessageID( message: InAppMessage, scheduleID: String, campaigns: AirshipJSON? ) -> ThomasLayoutEventMessageID { switch (message.source ?? .remoteData) { case .appDefined: return .appDefined(identifier: scheduleID) case .remoteData: return .airship(identifier: scheduleID, campaigns: campaigns) case .legacyPush: return .legacy(identifier: scheduleID) } } private static func makeEventSource( message: InAppMessage ) -> ThomasLayoutEventSource { switch (message.source ?? .remoteData) { case .appDefined: return .appDefined case .remoteData: return .airship case .legacyPush: return .airship } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Analytics/InAppMessageAnalyticsFactory.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol InAppMessageAnalyticsFactoryProtocol: Sendable { func makeAnalytics( preparedScheduleInfo: PreparedScheduleInfo, message: InAppMessage ) async -> any InAppMessageAnalyticsProtocol } struct InAppMessageAnalyticsFactory: InAppMessageAnalyticsFactoryProtocol { private let eventRecorder: ThomasLayoutEventRecorder private let displayHistoryStore: any MessageDisplayHistoryStoreProtocol private let displayImpressionRuleProvider: any InAppDisplayImpressionRuleProvider init( eventRecorder: ThomasLayoutEventRecorder, displayHistoryStore: any MessageDisplayHistoryStoreProtocol, displayImpressionRuleProvider: any InAppDisplayImpressionRuleProvider ) { self.eventRecorder = eventRecorder self.displayHistoryStore = displayHistoryStore self.displayImpressionRuleProvider = displayImpressionRuleProvider } func makeAnalytics( preparedScheduleInfo: PreparedScheduleInfo, message: InAppMessage ) async -> any InAppMessageAnalyticsProtocol { return InAppMessageAnalytics( preparedScheduleInfo: preparedScheduleInfo, message: message, displayImpressionRule: displayImpressionRuleProvider.impressionRules( for: message ), eventRecorder: eventRecorder, historyStore: displayHistoryStore, displayHistory: await self.displayHistoryStore.get( scheduleID: preparedScheduleInfo.scheduleID ) ) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Assets/AirshipCachedAssets.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Convenience struct representing an assets directory containing asset files /// with filenames derived from their remote URL using sha256. public protocol AirshipCachedAssetsProtocol: Sendable { /// Return URL at which to cache a given asset /// - Parameters: /// - remoteURL: URL from which the cached data is fetched /// - Returns: URL at which to cache a given asset func cachedURL(remoteURL: URL) -> URL? /// Checks if a URL is cached /// - Parameters: /// - remoteURL: URL from which the cached data is fetched /// - Returns: true if cached, otherwise false. func isCached(remoteURL: URL) -> Bool } struct EmptyAirshipCachedAssets: AirshipCachedAssetsProtocol { func cachedURL(remoteURL: URL) -> URL? { return nil } func isCached(remoteURL: URL) -> Bool { return false } } struct AirshipCachedAssets: AirshipCachedAssetsProtocol, Equatable { static func == (lhs: AirshipCachedAssets, rhs: AirshipCachedAssets) -> Bool { lhs.directory == rhs.directory } private let directory: URL private let assetFileManager: any AssetFileManager internal init(directory: URL, assetFileManager: any AssetFileManager = DefaultAssetFileManager()) { self.directory = directory self.assetFileManager = assetFileManager } private func getCachedAsset(from remoteURL: URL) -> URL { /// Derive a unique and consistent asset filename from the remote URL using sha256 let filename: String = remoteURL.assetFilename return directory.appendingPathComponent(filename, isDirectory: false) } func cachedURL(remoteURL: URL) -> URL? { let cached: URL = getCachedAsset(from: remoteURL) /// Ensure directory exists guard assetFileManager.assetItemExists(at: directory) else { return nil } return cached } func isCached(remoteURL: URL) -> Bool { let cached: URL = getCachedAsset(from: remoteURL) return assetFileManager.assetItemExists(at: cached) } } fileprivate extension URL { var assetFilename: String { return AirshipUtils.sha256Hash(input: self.path) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Assets/AssetCacheManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Wrapper for the download tasks that is responsible for downloading assets protocol AssetDownloader: Sendable { /// Downloads the asset from a remote URL and returns its temporary local URL func downloadAsset(remoteURL: URL) async throws -> URL } /// Wrapper for the filesystem that is responsible for asset-caching related file and directory operations protocol AssetFileManager: Sendable { /// Gets or creates the root directory var rootDirectory: URL? { get } /// Gets or creates cache directory based on the root directory with the provided identifier (usually a schedule ID) and returns its full cache URL func ensureCacheDirectory(identifier: String) throws -> URL /// Checks if asset file or directory exists at cache URL func assetItemExists(at cacheURL: URL) -> Bool /// Moves the asset from a temporary URL to its asset cache directory func moveAsset(from tempURL: URL, to cacheURL: URL) throws /// Clears all assets corresponding to the provided identifier func clearAssets(cacheURL: URL) throws } protocol AssetCacheManagerProtocol: Actor { func cacheAssets( identifier: String, assets: [String] ) async throws -> any AirshipCachedAssetsProtocol func clearCache(identifier: String) async } /// Downloads and caches asset files in filesystem using cancelable thread-safe tasks. actor AssetCacheManager: AssetCacheManagerProtocol { private let assetDownloader: any AssetDownloader private let assetFileManager: any AssetFileManager private var cacheRoot: URL? private var taskMap: [String: Task] = [:] private let downloadSemaphore: AirshipAsyncSemaphore = AirshipAsyncSemaphore(value: 6) internal init( assetDownloader: any AssetDownloader = DefaultAssetDownloader(), assetFileManager: any AssetFileManager = DefaultAssetFileManager() ) { self.assetDownloader = assetDownloader self.assetFileManager = assetFileManager /// Set cache root for clearing operations self.cacheRoot = assetFileManager.rootDirectory } /// Cache assets for a given identifer. /// Downloads assets from remote paths and stores them in an identifier-named cache directory with consistent and unique file names /// derived from their remote paths using sha256. /// - Parameters: /// - identifier: Name of the directory within the root cache directory, usually an in-app message schedule ID /// - assets: An array of remote URL paths for the assets assoicated with the provided identifer /// - Returns: AirshipCachesAssets instance func cacheAssets( identifier: String, assets: [String] ) async throws -> any AirshipCachedAssetsProtocol { if let running = taskMap[identifier] { return try await running.result.get() } let task: Task = Task { let startTime = Date() // Deduplicate URLs to prevent concurrent operations on the same asset let uniqueAssets = Array(Set(assets)) let assetURLs = uniqueAssets.compactMap({ URL(string:$0) }) // Log if duplicate URLs were found if assets.count != uniqueAssets.count { AirshipLogger.debug("Found duplicate asset URLs for identifier \(identifier): \(assets.count) URLs reduced to \(uniqueAssets.count) unique URLs") } /// Create or get the directory for the assets corresponding to a specific identifier let cacheDirectory = try assetFileManager.ensureCacheDirectory(identifier: identifier) let cachedAssets = AirshipCachedAssets(directory: cacheDirectory, assetFileManager: assetFileManager) try await withThrowingTaskGroup(of: Void.self) { [downloadSemaphore] group in for asset in assetURLs { group.addTask { try await downloadSemaphore.withPermit { if Task.isCancelled || cachedAssets.isCached(remoteURL: asset) { return } let tempURL = try await self.assetDownloader.downloadAsset(remoteURL: asset) // Double-check after download in case another task cached it if cachedAssets.isCached(remoteURL: asset) { // Clean up temp file and return try? FileManager.default.removeItem(at: tempURL) AirshipLogger.trace("Asset was cached by another task during download, skipping: \(asset)") return } if let cacheURL = cachedAssets.cachedURL(remoteURL: asset) { try self.assetFileManager.moveAsset(from: tempURL, to: cacheURL) } } } } try await group.waitForAll() } let duration = Date().timeIntervalSince(startTime) AirshipLogger.debug("In-app message \(identifier): \(assets.count) assets prepared in \(duration) seconds") return cachedAssets } taskMap[identifier] = task return try await task.result.get() } /// Clears the cache directory associated with the identifier /// - Parameter identifier: Name of the directory within the root cache directory, usually an in-app message schedule ID func clearCache(identifier: String) async { taskMap[identifier]?.cancel() taskMap.removeValue(forKey: identifier) if let root = self.cacheRoot { let cache = root.appendingPathComponent(identifier, isDirectory: true) do { try assetFileManager.clearAssets(cacheURL: cache) } catch { AirshipLogger.debug("Unable to clear asset cache for identifier: \(identifier) with error:\(error)") } } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Assets/DefaultAssetDownloader.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Data task wrapper used for testing the default asset downloader protocol AssetDownloaderSession: Sendable { func autoResumingDataTask(with url: URL, completion: @Sendable @escaping (Data?, URLResponse?, (any Error)?) -> Void) -> any AirshipCancellable } extension URLSession: AssetDownloaderSession { func autoResumingDataTask(with url: URL, completion: @Sendable @escaping (Data?, URLResponse?, (any Error)?) -> Void) -> any AirshipCancellable { let task = self.dataTask(with: url, completionHandler: { data, response, error in completion(data, response, error) }) task.resume() return CancellableValueHolder(value: task) { task in task.cancel() } } } struct DefaultAssetDownloader : AssetDownloader { var session: any AssetDownloaderSession init(session: any AssetDownloaderSession = URLSession.airshipSecureSession) { self.session = session } func downloadAsset(remoteURL: URL) async throws -> URL { let cancellable = CancellableValueHolder() { cancellable in cancellable.cancel() } return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in cancellable.value = session.autoResumingDataTask(with: remoteURL) { data, response, error in if let error = error { continuation.resume(throwing: error) return } guard let data = data else { continuation.resume(throwing: URLError(.badServerResponse)) return } do { let tempDirectory = FileManager.default.temporaryDirectory let tempFileURL = tempDirectory.appendingPathComponent(UUID().uuidString + remoteURL.lastPathComponent) try data.write(to: tempFileURL) continuation.resume(returning: tempFileURL) } catch { continuation.resume(throwing: error) } } } } onCancel: { cancellable.cancel() } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Assets/DefaultAssetFileManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct DefaultAssetFileManager: AssetFileManager { private let rootPathComponent: String init(rootPathComponent: String = "com.urbanairship.iamassetcache") { self.rootPathComponent = rootPathComponent } var rootDirectory: URL? { try? ensureCacheRootDirectory(rootPathComponent: rootPathComponent) } func ensureCacheDirectory(identifier: String) throws -> URL { let url = try ensureCacheRootDirectory(rootPathComponent: rootPathComponent) let cacheDirectory = url.appendingPathComponent(identifier, isDirectory: true) return try ensureCacheDirectory(url: cacheDirectory) } func assetItemExists(at cacheURL: URL) -> Bool { return FileManager.default.fileExists(atPath: cacheURL.path) } func moveAsset(from tempURL: URL, to cacheURL: URL) throws { let fileManager = FileManager.default do { // Ensure parent directory exists let parentDir = cacheURL.deletingLastPathComponent() try fileManager.createDirectory(at: parentDir, withIntermediateDirectories: true, attributes: nil) if fileManager.fileExists(atPath: cacheURL.path) { // Use replaceItem for atomic replacement _ = try fileManager.replaceItem(at: cacheURL, withItemAt: tempURL, backupItemName: nil, options: [], resultingItemURL: nil) } else { try fileManager.moveItem(at: tempURL, to: cacheURL) } } catch let error as NSError { // Handle the specific case where file already exists if error.domain == NSCocoaErrorDomain && error.code == NSFileWriteFileExistsError { // File already exists - this is okay, just clean up temp file try? fileManager.removeItem(at: tempURL) AirshipLogger.trace("Asset already exists at cache URL, skipping move: \(cacheURL)") } else { throw AirshipErrors.error("Error moving asset to asset cache \(error)") } } } func clearAssets(cacheURL: URL) throws { let fileManager = FileManager.default try fileManager.removeItem(at: cacheURL) } // MARK: Helpers private func ensureCacheRootDirectory(rootPathComponent: String) throws -> URL { let fileManager = FileManager.default guard let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first else { throw AirshipErrors.error("Error creating asset cache root directory: user caches directory unavailable.") } fileManager.urls(for: .cachesDirectory, in: .userDomainMask) let cacheRootDirectory = cacheDirectory.appendingPathComponent(rootPathComponent, isDirectory: true) return try ensureCacheDirectory(url: cacheRootDirectory) } private func ensureCacheDirectory(url:URL) throws -> URL { let fileManager = FileManager.default var isDirectory: ObjCBool = false let fileExists = fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) do { if !fileExists { try fileManager.createDirectory(at: url, withIntermediateDirectories: true) } else if !isDirectory.boolValue { AirshipLogger.debug("Path:\(url) exists but is not a directory. Removing the file and creating the directory.") try fileManager.removeItem(at: url) try fileManager.createDirectory(at: url, withIntermediateDirectories: true) } return url } catch { AirshipLogger.debug("Error creating directory at \(url): \(error)") throw error } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/AirshipLayoutDisplayAdapter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif final class AirshipLayoutDisplayAdapter: DisplayAdapter { private let message: InAppMessage private let priority: Int private let assets: any AirshipCachedAssetsProtocol private let actionRunner: (any InternalInAppActionRunner)? private let networkChecker: any AirshipNetworkCheckerProtocol @MainActor var themeManager: InAppAutomationThemeManager { return Airship.inAppAutomation.inAppMessaging.themeManager } init( message: InAppMessage, priority: Int, assets: any AirshipCachedAssetsProtocol, actionRunner: (any InternalInAppActionRunner)? = nil, networkChecker: any AirshipNetworkCheckerProtocol = AirshipNetworkChecker.shared ) throws { self.message = message self.priority = priority self.assets = assets self.actionRunner = actionRunner self.networkChecker = networkChecker if case .custom(_) = message.displayContent { throw AirshipErrors.error("Invalid adapter for layout type") } } var isReady: Bool { let urlInfos = message.urlInfos let needsNetwork = urlInfos.contains { info in switch(info) { case .web(url: _, requireNetwork: let requireNetwork): if (requireNetwork) { return true } case .video(url: _, requireNetwork: let requireNetwork): if (requireNetwork) { return true } case .image(url: let url, prefetch: let prefetch): if let url = URL(string: url), prefetch, !assets.isCached(remoteURL: url) { return true } #if canImport(AirshipCore) @unknown default: return true #endif } return false } return needsNetwork ? networkChecker.isConnected : true } func waitForReady() async { guard await !self.isReady else { return } for await isConnected in await networkChecker.connectionUpdates { if (isConnected) { return } } } func display( displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol ) async throws -> DisplayResult { switch (message.displayContent) { case .banner(let banner): return try await displayBanner( banner, displayTarget: displayTarget, analytics: analytics ) case .modal(let modal): return try await displayModal( modal, displayTarget: displayTarget, analytics: analytics ) case .fullscreen(let fullscreen): return try await displayFullscreen( fullscreen, displayTarget: displayTarget, analytics: analytics ) case .html(let html): return try await displayHTML( html, displayTarget: displayTarget, analytics: analytics ) case .airshipLayout(let layout): return try await displayThomasLayout( layout, displayTarget: displayTarget, analytics: analytics ) case .custom(_): // This should never happen - constructor will throw return .finished } } private func makeInAppExtensions() -> InAppMessageExtensions { #if !os(tvOS) InAppMessageExtensions( nativeBridgeExtension: InAppMessageNativeBridgeExtension( message: message ), imageProvider: AssetCacheImageProvider(assets: assets), actionRunner: actionRunner ) #else InAppMessageExtensions( imageProvider: AssetCacheImageProvider(assets: assets), actionRunner: actionRunner ) #endif } @MainActor private func displayBanner( _ banner: InAppMessageDisplayContent.Banner, displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol ) async throws -> DisplayResult { return try await withCheckedThrowingContinuation { continuation in let displayable = displayTarget.prepareDisplay(for: .banner) let dismissViewController = { displayable.dismiss() } let listener = InAppMessageDisplayListener( analytics: analytics ) { result in // Dismiss the In app message banner view controller continuation.resume(returning: result) } let theme = self.themeManager.makeBannerTheme(message: self.message) let environment = InAppMessageEnvironment( delegate: listener, extensions: makeInAppExtensions() ) do { try displayable.display { windowInfo in let bannerConstraints = InAppMessageBannerConstraints( size: windowInfo.size ) let rootView = InAppMessageBannerView( environment: environment, displayContent: banner, bannerConstraints: bannerConstraints, theme: theme, onDismiss: dismissViewController ) return InAppMessageBannerViewController( rootView: rootView, placement: banner.placement, bannerConstraints: bannerConstraints ) } } catch { continuation.resume( throwing: AirshipErrors.error("Failed to find window to display in-app banner \(error)") ) } } } @MainActor private func displayModal( _ modal: InAppMessageDisplayContent.Modal, displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol ) async throws -> DisplayResult { return try await withCheckedThrowingContinuation { continuation in let displayable = displayTarget.prepareDisplay(for: .modal) let listener = InAppMessageDisplayListener( analytics: analytics ) { result in displayable.dismiss() continuation.resume(returning: result) } let theme = self.themeManager.makeModalTheme(message: self.message) let environment = InAppMessageEnvironment( delegate: listener, extensions: makeInAppExtensions() ) let rootView = InAppMessageRootView(inAppMessageEnvironment: environment) { InAppMessageModalView(displayContent: modal, theme: theme) } do { try displayable.display { _ in let viewController = InAppMessageHostingController(rootView: rootView) #if !os(macOS) viewController.modalPresentationStyle = UIModalPresentationStyle.fullScreen #endif return viewController } } catch { continuation.resume( throwing: AirshipErrors.error("Failed to find window to display in-app banner \(error)") ) } } } @MainActor private func displayFullscreen( _ fullscreen: InAppMessageDisplayContent.Fullscreen, displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol ) async throws -> DisplayResult { return try await withCheckedThrowingContinuation { continuation in let displayable = displayTarget.prepareDisplay(for: .modal) let listener = InAppMessageDisplayListener( analytics: analytics ) { result in displayable.dismiss() continuation.resume(returning: result) } let theme = self.themeManager.makeFullscreenTheme(message: self.message) let environment = InAppMessageEnvironment( delegate: listener, extensions: makeInAppExtensions() ) let rootView = InAppMessageRootView(inAppMessageEnvironment: environment) { FullscreenView(displayContent: fullscreen, theme: theme) } do { try displayable.display { _ in let viewController = InAppMessageHostingController(rootView: rootView) #if !os(macOS) viewController.modalPresentationStyle = UIModalPresentationStyle.fullScreen #endif return viewController } } catch { continuation.resume( throwing: AirshipErrors.error("Failed to find window to display in-app banner \(error)") ) } } } @MainActor private func displayHTML( _ html: InAppMessageDisplayContent.HTML, displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol ) async throws -> DisplayResult { #if !os(tvOS) return try await withCheckedThrowingContinuation { continuation in let displayable = displayTarget.prepareDisplay(for: .modal) let listener = InAppMessageDisplayListener( analytics: analytics ) { result in displayable.dismiss() continuation.resume(returning: result) } let theme = self.themeManager.makeHTMLTheme(message: self.message) let environment = InAppMessageEnvironment( delegate: listener, extensions: makeInAppExtensions() ) let rootView = InAppMessageRootView(inAppMessageEnvironment: environment) { HTMLView(displayContent: html, theme: theme) } do { try displayable.display { _ in let viewController = InAppMessageHostingController(rootView: rootView) #if !os(macOS) viewController.modalPresentationStyle = UIModalPresentationStyle.fullScreen #endif return viewController } } catch { continuation.resume( throwing: AirshipErrors.error("Failed to find window to display in-app banner \(error)") ) } } #else return .cancel #endif } @MainActor private func displayThomasLayout( _ layout: AirshipLayout, displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol ) async throws -> DisplayResult { return try await withCheckedThrowingContinuation { continuation in let listener = ThomasDisplayListener(analytics: analytics) { result in continuation.resume(returning: result.automationDisplayResult) } #if !os(tvOS) let extensions = ThomasExtensions( nativeBridgeExtension: InAppMessageNativeBridgeExtension( message: message ), imageProvider: AssetCacheImageProvider(assets: assets), actionRunner: actionRunner ) #else let extensions = ThomasExtensions( imageProvider: AssetCacheImageProvider(assets: assets), actionRunner: actionRunner ) #endif do { try Thomas.display( layout: layout, displayTarget: displayTarget, extensions: extensions, delegate: listener, extras: message.extras, priority: priority ) } catch { continuation.resume(throwing: error) } } } } fileprivate final class AssetCacheImageProvider : AirshipImageProvider { let assets: any AirshipCachedAssetsProtocol init(assets: any AirshipCachedAssetsProtocol) { self.assets = assets } func get(url: URL) -> AirshipImageData? { guard let url = assets.cachedURL(remoteURL: url), let data = FileManager.default.contents(atPath: url.path), let imageData = try? AirshipImageData(data: data) else { return nil } return imageData } } extension ThomasDisplayListener.DisplayResult { var automationDisplayResult: DisplayResult { return switch self { case .finished: .finished case .cancel: .cancel @unknown default: .finished } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/CustomDisplayAdapter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) public import UIKit #endif #if canImport(AirshipCore) import AirshipCore #endif /// Custom display adapter types public enum CustomDisplayAdapterType: Sendable { /// HTML adapter case html /// Modal adapter case modal /// Fullscreen adapter case fullscreen /// Banner adapter case banner /// Custom adapter case custom } /// Custom display adapter public protocol CustomDisplayAdapter: Sendable { /// Checks if the adapter is ready /// Whether the adapter has finished loading and is ready to display. @MainActor var isReady: Bool { get } /// Suspends until the adapter is ready to display (e.g. assets loaded). @MainActor func waitForReady() async #if !os(macOS) /// Called to display the message /// - Parameters: /// - scene: The window scene /// - Returns a CustomDisplayResolution @MainActor func display(scene: UIWindowScene) async -> CustomDisplayResolution #else /// Called to display the message /// - Returns a CustomDisplayResolution @MainActor func display() async -> CustomDisplayResolution #endif } /// Resolution data public enum CustomDisplayResolution { /// Button tap case buttonTap(InAppMessageButtonInfo) /// Message tap case messageTap /// User dismissed case userDismissed /// Timed out case timedOut } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/CustomDisplayAdapterWrapper.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Wraps a custom display adapter as a DisplayAdapter final class CustomDisplayAdapterWrapper: DisplayAdapter { let adapter: any CustomDisplayAdapter @MainActor var isReady: Bool { return adapter.isReady } func waitForReady() async { await adapter.waitForReady() } init( adapter: any CustomDisplayAdapter ) { self.adapter = adapter } @MainActor func display(displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol) async throws -> DisplayResult { analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) let timer = ActiveTimer() #if os(macOS) timer.start() let result = await self.adapter.display() #else let scene = try displayTarget.sceneProvider() timer.start() let result = await self.adapter.display(scene: scene) #endif timer.stop() switch(result) { case .buttonTap(let buttonInfo): analytics.recordEvent( ThomasLayoutResolutionEvent.buttonTap( identifier: buttonInfo.identifier, description: buttonInfo.label.text, displayTime: timer.time ), layoutContext: nil ) return buttonInfo.behavior == .cancel ? .cancel : .finished case .messageTap: analytics.recordEvent( ThomasLayoutResolutionEvent.messageTap(displayTime: timer.time), layoutContext: nil ) case .userDismissed: analytics.recordEvent( ThomasLayoutResolutionEvent.userDismissed(displayTime: timer.time), layoutContext: nil ) case .timedOut: analytics.recordEvent( ThomasLayoutResolutionEvent.timedOut(displayTime: timer.time), layoutContext: nil ) } return .finished } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/DisplayAdapter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif // Internal display adapter protocol DisplayAdapter: Sendable { @MainActor var isReady: Bool { get } func waitForReady() async @MainActor func display( displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol ) async throws -> DisplayResult } enum DisplayResult: Sendable, Equatable { case cancel case finished } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/DisplayAdapterFactory.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Arguments passed to display adapters when creating or displaying an in-app message. public struct DisplayAdapterArgs: Sendable { /// The in-app message public var message: InAppMessage /// The assets public var assets: any AirshipCachedAssetsProtocol /// The schedule priority public var priority: Int /// Action runner public var actionRunner: any InAppActionRunner { return _actionRunner } var _actionRunner: any InternalInAppActionRunner } protocol DisplayAdapterFactoryProtocol: Sendable { @MainActor func setAdapterFactoryBlock( forType: CustomDisplayAdapterType, factoryBlock: @Sendable @escaping (DisplayAdapterArgs) -> (any CustomDisplayAdapter)? ) @MainActor func makeAdapter( args: DisplayAdapterArgs ) throws -> any DisplayAdapter } final class DisplayAdapterFactory: DisplayAdapterFactoryProtocol, Sendable { @MainActor var customAdapters: [CustomDisplayAdapterType: @Sendable (DisplayAdapterArgs) -> (any CustomDisplayAdapter)?] = [:] @MainActor func setAdapterFactoryBlock( forType type: CustomDisplayAdapterType, factoryBlock: @Sendable @escaping (DisplayAdapterArgs) -> (any CustomDisplayAdapter)? ) { customAdapters[type] = factoryBlock } @MainActor func makeAdapter( args: DisplayAdapterArgs ) throws -> any DisplayAdapter { switch (args.message.displayContent) { case .banner(_): if let custom = customAdapters[.banner]?(args) { return CustomDisplayAdapterWrapper(adapter: custom) } case .fullscreen(_): if let custom = customAdapters[.fullscreen]?(args) { return CustomDisplayAdapterWrapper(adapter: custom) } case .modal(_): if let custom = customAdapters[.modal]?(args) { return CustomDisplayAdapterWrapper(adapter: custom) } case .html(_): if let custom = customAdapters[.html]?(args) { return CustomDisplayAdapterWrapper(adapter: custom) } case .custom(_): if let custom = customAdapters[.custom]?(args) { return CustomDisplayAdapterWrapper(adapter: custom) } else { throw AirshipErrors.error("No adapter for message: \(args.message)") } case .airshipLayout(_): break } return try AirshipLayoutDisplayAdapter( message: args.message, priority: args.priority, assets: args.assets, actionRunner: args._actionRunner ) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Adapter/InAppMessageDisplayListener.swift ================================================ import Foundation #if canImport(AirshipCore) import AirshipCore #endif @MainActor final class InAppMessageDisplayListener: InAppMessageViewDelegate { private let analytics: any InAppMessageAnalyticsProtocol private let timer: any AirshipTimerProtocol private var onDismiss: (@MainActor @Sendable (DisplayResult) -> Void)? init( analytics: any InAppMessageAnalyticsProtocol, timer: (any AirshipTimerProtocol)? = nil, onDismiss: @MainActor @escaping @Sendable (DisplayResult) -> Void ) { self.analytics = analytics self.onDismiss = onDismiss self.timer = timer ?? ActiveTimer() } func onAppear() { timer.start() analytics.recordEvent( ThomasLayoutDisplayEvent(), layoutContext: nil ) } func onButtonDismissed(buttonInfo: InAppMessageButtonInfo) { tryDismiss { time in analytics.recordEvent( ThomasLayoutResolutionEvent.buttonTap( identifier: buttonInfo.identifier, description: buttonInfo.label.text, displayTime: time ), layoutContext: nil ) return buttonInfo.behavior == .cancel ? .cancel : .finished } } func onTimedOut() { tryDismiss { time in analytics.recordEvent( ThomasLayoutResolutionEvent.timedOut(displayTime: time), layoutContext: nil ) return .finished } } func onUserDismissed() { tryDismiss { time in analytics.recordEvent( ThomasLayoutResolutionEvent.userDismissed(displayTime: time), layoutContext: nil ) return .finished } } func onMessageTapDismissed() { tryDismiss { time in analytics.recordEvent( ThomasLayoutResolutionEvent.messageTap(displayTime: time), layoutContext: nil ) return .finished } } private func tryDismiss(dismissBlock: (TimeInterval) -> DisplayResult) { guard let onDismiss = onDismiss else { AirshipLogger.error("Dismissed already called!") return } self.timer.stop() let result = dismissBlock(self.timer.time) onDismiss(result) self.onDismiss = nil } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Coordinators/DefaultDisplayCoordinator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Display coordinator that only allows a single message at a time to be displayed with an optional interval between /// displays. @MainActor final class DefaultDisplayCoordinator: DisplayCoordinator { private enum LockState { case unlocked case locked case unlocking } private var lockState: AirshipMainActorValue = AirshipMainActorValue(.unlocked) private let appStateTracker: any AppStateTrackerProtocol private let taskSleeper: any AirshipTaskSleeper private var unlockTask: Task? public var displayInterval: TimeInterval { didSet { if self.lockState.value == .unlocking { self.unlockTask?.cancel() startUnlockTask() } } } init( displayInterval: TimeInterval, appStateTracker: (any AppStateTrackerProtocol)? = nil, taskSleeper: any AirshipTaskSleeper = .shared ) { self.displayInterval = displayInterval self.appStateTracker = appStateTracker ?? AppStateTracker.shared self.taskSleeper = taskSleeper } var isReady: Bool { return lockState.value == .unlocked && self.appStateTracker.state == .active } @MainActor func messageWillDisplay(_ message: InAppMessage) { self.lockState.set(.locked) } @MainActor func messageFinishedDisplaying(_ message: InAppMessage) { self.startUnlockTask() } @MainActor private func startUnlockTask() { guard self.lockState.value != .unlocked else { return } self.lockState.set(.unlocking) self.unlockTask = Task { @MainActor in try? await self.taskSleeper.sleep(timeInterval: self.displayInterval) if (!Task.isCancelled) { self.lockState.set(.unlocked) } } } func waitForReady() async { while !isReady { if Task.isCancelled { return } for await state in self.lockState.updates { if (state == .unlocked) { break } } for await state in self.appStateTracker.stateUpdates { if (state == .active) { break } } } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Coordinators/DisplayCoordinator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Controls the display of an In-App message @MainActor protocol DisplayCoordinator: AnyObject, Sendable { var isReady: Bool { get } func messageWillDisplay(_ message: InAppMessage) func messageFinishedDisplaying(_ message: InAppMessage) func waitForReady() async } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Coordinators/DisplayCoordinatorManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol DisplayCoordinatorManagerProtocol: Sendable, AnyObject { @MainActor var displayInterval: TimeInterval { get set } func displayCoordinator(message: InAppMessage) -> any DisplayCoordinator } final class DisplayCoordinatorManager: DisplayCoordinatorManagerProtocol { private let immediateCoordinator: ImmediateDisplayCoordinator private let defaultCoordinator: DefaultDisplayCoordinator private let dataStore: PreferenceDataStore private static let displayIntervalKey: String = "UAInAppMessageManagerDisplayInterval" @MainActor var displayInterval: TimeInterval { get { self.dataStore.double(forKey: Self.displayIntervalKey, defaultValue: 0.0) } set { self.dataStore.setDouble(newValue, forKey: Self.displayIntervalKey) self.defaultCoordinator.displayInterval = newValue } } @MainActor init( dataStore: PreferenceDataStore, immediateCoordinator: ImmediateDisplayCoordinator? = nil, defaultCoordinator: DefaultDisplayCoordinator? = nil ) { self.dataStore = dataStore self.immediateCoordinator = immediateCoordinator ?? ImmediateDisplayCoordinator() self.defaultCoordinator = defaultCoordinator ?? DefaultDisplayCoordinator( displayInterval: dataStore.double(forKey: Self.displayIntervalKey, defaultValue: 0.0) ) } func displayCoordinator(message: InAppMessage) -> any DisplayCoordinator { guard !message.isEmbedded else { return immediateCoordinator } switch message.displayBehavior { case .immediate: return immediateCoordinator case .standard: return defaultCoordinator case .none: return defaultCoordinator } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Display Coordinators/ImmediateDisplayCoordinator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// A display coordinator that only requires the app to be active @MainActor final class ImmediateDisplayCoordinator: DisplayCoordinator { private let appStateTracker: any AppStateTrackerProtocol init( appStateTracker: (any AppStateTrackerProtocol)? = nil ) { self.appStateTracker = appStateTracker ?? AppStateTracker.shared } var isReady: Bool { return appStateTracker.state == .active } func messageWillDisplay(_ message: InAppMessage) { } func messageFinishedDisplaying(_ message: InAppMessage) { } func waitForReady() async { for await update in appStateTracker.stateUpdates { if Task.isCancelled { break } if update == .active { break } } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppActionRunner.swift ================================================ #if canImport(AirshipCore) public import AirshipCore #endif import Foundation /// Action runner for in-app experiences. Must be used in order to properly attribute custom events to the message. public protocol InAppActionRunner: Sendable { /// Runs an action. /// - Parameters: /// - actionName: The action name. /// - arguments: The action arguments. /// - Returns: Action result. @MainActor func run(actionName: String, arguments: ActionArguments) async -> ActionResult /// Runs actions asynchronously. /// - Parameters: /// - actions: The actions payload @MainActor func runAsync(actions: AirshipJSON) /// Runs actions. /// - Parameters: /// - actions: The actions payload @MainActor func run(actions: AirshipJSON) async } protocol InternalInAppActionRunner: InAppActionRunner, ThomasActionRunner { } final class DefaultInAppActionRunner: InternalInAppActionRunner { private let analytics: any InAppMessageAnalyticsProtocol private let trackPermissionResults: Bool init(analytics: any InAppMessageAnalyticsProtocol, trackPermissionResults: Bool) { self.analytics = analytics self.trackPermissionResults = trackPermissionResults } @MainActor func extendMetadata( _ metadata: inout [String: any Sendable], layoutContext: ThomasLayoutContext? = nil ) { if trackPermissionResults { let permissionReceiver: @Sendable ( AirshipPermission, AirshipPermissionStatus, AirshipPermissionStatus ) async -> Void = { [analytics] permission, start, end in await analytics.recordEvent( ThomasLayoutPermissionResultEvent( permission: permission, startingStatus: start, endingStatus: end ), layoutContext: layoutContext ) } metadata[PromptPermissionAction.resultReceiverMetadataKey] = permissionReceiver } metadata[AddCustomEventAction._inAppMetadata] = analytics.makeCustomEventContext( layoutContext: layoutContext ) } @MainActor public func run(actionName: String, arguments: ActionArguments) async -> ActionResult { var mutated = arguments self.extendMetadata(&mutated.metadata) return await ActionRunner.run(actionName: actionName, arguments: mutated) } @MainActor public func runAsync(actions: AirshipJSON) { var metadata: [String: any Sendable] = [:] self.extendMetadata(&metadata) Task { await self.run(actions: actions) } } @MainActor public func run(actions: AirshipJSON) async { var metadata: [String: any Sendable] = [:] self.extendMetadata(&metadata) await ActionRunner.run( actionsPayload: actions, situation: .automation, metadata: metadata ) } @MainActor public func runAsync(actions: AirshipJSON, layoutContext: ThomasLayoutContext) { var metadata: [String: any Sendable] = [:] self.extendMetadata(&metadata, layoutContext: layoutContext) Task { await ActionRunner.run( actionsPayload: actions, situation: .automation, metadata: metadata ) } } @MainActor public func run(actionName: String, arguments: ActionArguments, layoutContext: ThomasLayoutContext) async -> ActionResult { var args = arguments self.extendMetadata(&args.metadata, layoutContext: layoutContext) return await ActionRunner.run(actionName: actionName, arguments: arguments) } } protocol InAppActionRunnerFactoryProtocol: Sendable { func makeRunner(message: InAppMessage, analytics: any InAppMessageAnalyticsProtocol) -> any InternalInAppActionRunner } final class InAppActionRunnerFactory: InAppActionRunnerFactoryProtocol { func makeRunner(message: InAppMessage, analytics: any InAppMessageAnalyticsProtocol) -> any InternalInAppActionRunner { return DefaultInAppActionRunner( analytics: analytics, trackPermissionResults: message.isAirshipLayout ) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessage.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) public import AirshipCore #endif enum InAppMessageSource: String, Codable, Equatable, Sendable { case remoteData = "remote-data" case appDefined = "app-defined" case legacyPush = "legacy-push" } /// In-App Message public struct InAppMessage: Codable, Equatable, Sendable { /// Display behavior public enum DisplayBehavior: String, Codable, Equatable, Sendable { /// Immediate display, allows it to be displayed on top of other IAX case immediate /// Displays one at a time with display interval between displays case standard = "default" } /// The name. public var name: String /// Display content public var displayContent: InAppMessageDisplayContent { get { return displayContentWrapper.displayContent } set { displayContentWrapper = DisplayContentWrapper(displayContent: newValue, json: nil) } } // Workaround for iOS 26.0 encoding crash (FB#3472, June 2025). // TODO: Test and remove in SDK 21 if iOS 26.x SDKs have fixed the encoding issue. // The workaround avoids re-encoding AirshipLayout by caching the original JSON. private var displayContentWrapper: DisplayContentWrapper /// Source var source: InAppMessageSource? /// Any message extras. public var extras: AirshipJSON? /// Display actions. public var actions: AirshipJSON? /// If reporting is enabled or not for the message. public var isReportingEnabled: Bool? /// Display behavior public var displayBehavior: DisplayBehavior? /// Rendered locale var renderedLocale: AirshipJSON? enum CodingKeys: String, CodingKey { case name case extras = "extra" case actions case isReportingEnabled = "reporting_enabled" case displayBehavior = "display_behavior" case display case layout case displayType = "display_type" case renderedLocale = "rendered_locale" case source } /// In-app message constructor /// - Parameters: /// - name: Name of the message /// - displayContent: Content model to be displayed in the message /// - extras: Extras payload as JSON /// - actions: Actions to be executed by the message as JSON /// - isReportingEnabled: Reporting enabled flag /// - displayBehavior: Display behavior public init( name: String, displayContent: InAppMessageDisplayContent, extras: AirshipJSON? = nil, actions: AirshipJSON? = nil, isReportingEnabled: Bool? = nil, displayBehavior: DisplayBehavior? = nil ) { self.name = name self.displayContentWrapper = DisplayContentWrapper(displayContent: displayContent, json: nil) self.extras = extras self.actions = actions self.isReportingEnabled = isReportingEnabled self.displayBehavior = displayBehavior self.renderedLocale = nil self.source = .appDefined } init( name: String, displayContent: InAppMessageDisplayContent, displayContentJSON: AirshipJSON? = nil, source: InAppMessageSource?, extras: AirshipJSON? = nil, actions: AirshipJSON? = nil, isReportingEnabled: Bool? = nil, displayBehavior: DisplayBehavior? = nil, renderedLocale: AirshipJSON? = nil ) { self.name = name self.displayContentWrapper = DisplayContentWrapper(displayContent: displayContent, json: displayContentJSON) self.source = source self.extras = extras self.actions = actions self.isReportingEnabled = isReportingEnabled self.displayBehavior = displayBehavior self.renderedLocale = renderedLocale } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" let source = try container.decodeIfPresent(InAppMessageSource.self, forKey: .source) let extras = try container.decodeIfPresent(AirshipJSON.self, forKey: .extras) let actions = try container.decodeIfPresent(AirshipJSON.self, forKey: .actions) let isReportingEnabled = try container.decodeIfPresent(Bool.self, forKey: .isReportingEnabled) let displayBehavior = try container.decodeIfPresent(DisplayBehavior.self, forKey: .displayBehavior) let renderedLocale = try container.decodeIfPresent(AirshipJSON.self, forKey: .renderedLocale) let displayType = try container.decode(DisplayType.self, forKey: .displayType) var displayContent: InAppMessageDisplayContent! var displayContentJSON: AirshipJSON? switch (displayType) { case .banner: let banner = try container.decode(InAppMessageDisplayContent.Banner.self, forKey: .display) displayContent = .banner(banner) case .modal: let modal = try container.decode(InAppMessageDisplayContent.Modal.self, forKey: .display) displayContent = .modal(modal) case .fullscreen: let fullscreen = try container.decode(InAppMessageDisplayContent.Fullscreen.self, forKey: .display) displayContent = .fullscreen(fullscreen) case .custom: let custom = try container.decode(AirshipJSON.self, forKey: .display) displayContent = .custom(custom) case .html: let html = try container.decode(InAppMessageDisplayContent.HTML.self, forKey: .display) displayContent = .html(html) case .layout: displayContentJSON = try container.decode(AirshipJSON.self, forKey: .display) let wrapper = try container.decode(AirshipLayoutWrapper.self, forKey: .display) displayContent = .airshipLayout(wrapper.layout) } self.init( name: name, displayContent: displayContent, displayContentJSON: displayContentJSON, source: source, extras: extras, actions: actions, isReportingEnabled: isReportingEnabled, displayBehavior: displayBehavior, renderedLocale: renderedLocale ) } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.name, forKey: .name) try container.encodeIfPresent(self.source, forKey: .source) try container.encodeIfPresent(self.extras, forKey: .extras) try container.encodeIfPresent(self.actions, forKey: .actions) try container.encodeIfPresent(self.isReportingEnabled, forKey: .isReportingEnabled) try container.encodeIfPresent(self.isReportingEnabled, forKey: .isReportingEnabled) try container.encodeIfPresent(self.displayBehavior, forKey: .displayBehavior) try container.encodeIfPresent(self.renderedLocale, forKey: .renderedLocale) switch (self.displayContent) { case .banner(let banner): try container.encode(banner, forKey: .display) try container.encode(DisplayType.banner, forKey: .displayType) case .fullscreen(let fullscreen): try container.encode(fullscreen, forKey: .display) try container.encode(DisplayType.fullscreen, forKey: .displayType) case .modal(let modal): try container.encode(modal, forKey: .display) try container.encode(DisplayType.modal, forKey: .displayType) case .html(let html): try container.encode(html, forKey: .display) try container.encode(DisplayType.html, forKey: .displayType) case .custom(let custom): try container.encode(custom, forKey: .display) try container.encode(DisplayType.custom, forKey: .displayType) case .airshipLayout(let layout): if let json = displayContentWrapper.json { try container.encode(json, forKey: .display) } else { try container.encode(AirshipLayoutWrapper(layout: layout), forKey: .display) } try container.encode(DisplayType.layout, forKey: .displayType) } } private enum DisplayType: String, Codable { case banner case modal case fullscreen case custom case html case layout } } extension InAppMessage { var urlInfos: [URLInfo] { switch (self.displayContent) { case .banner(let content): return urlInfosForMedia(content.media) case .fullscreen(let content): return urlInfosForMedia(content.media) case .modal(let content): return urlInfosForMedia(content.media) case .html(let html): return [.web(url: html.url, requireNetwork: html.requiresConnectivity != false)] case .custom(_): return [] case .airshipLayout(let content): return content.urlInfos } } private func urlInfosForMedia(_ media: InAppMessageMediaInfo?) -> [URLInfo] { guard let media = media else { return [] } switch (media.type) { case .image: return [.image(url: media.url, prefetch: true)] case .video: return [.video(url: media.url)] case .youtube: return [.video(url: media.url)] case .vimeo: return [.video(url: media.url)] } } var isEmbedded: Bool { guard case .airshipLayout(let data) = self.displayContent else { return false } return data.isEmbedded } var isAirshipLayout: Bool { guard case .airshipLayout(_) = self.displayContent else { return false } return true } } fileprivate struct AirshipLayoutWrapper: Codable { var layout: AirshipLayout } fileprivate struct DisplayContentWrapper: Equatable { var displayContent: InAppMessageDisplayContent var json: AirshipJSON? init(displayContent: InAppMessageDisplayContent, json: AirshipJSON?) { self.displayContent = displayContent self.json = json } static func ==(lhs: DisplayContentWrapper, rhs: DisplayContentWrapper) -> Bool { return lhs.displayContent == rhs.displayContent } } /// These are just for view testing purposes extension InAppMessage { /// We return a window since we are implementing display /// - Note: for internal use only. :nodoc: @MainActor public func _display() async throws { let adapter = try AirshipLayoutDisplayAdapter(message: self, priority: 0, assets: EmptyAirshipCachedAssets()) #if os(macOS) let displayTarget = AirshipDisplayTarget() #else let displayTarget = AirshipDisplayTarget { try AirshipSceneManager.shared.lastActiveScene } #endif _ = try await adapter.display( displayTarget: displayTarget, analytics: LoggingInAppMessageAnalytics() ) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationExecutor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif final class InAppMessageAutomationExecutor: AutomationExecutorDelegate { typealias ExecutionData = PreparedInAppMessageData private let delegates: Delegates = Delegates() private let assetManager: any AssetCacheManagerProtocol private let analyticsFactory: any InAppMessageAnalyticsFactoryProtocol private let scheduleConditionsChangedNotifier: ScheduleConditionsChangedNotifier #if os(macOS) init( assetManager: any AssetCacheManagerProtocol, analyticsFactory: any InAppMessageAnalyticsFactoryProtocol, scheduleConditionsChangedNotifier: ScheduleConditionsChangedNotifier ) { self.assetManager = assetManager self.analyticsFactory = analyticsFactory self.scheduleConditionsChangedNotifier = scheduleConditionsChangedNotifier } #else private let sceneManager: any InAppMessageSceneManagerProtocol @MainActor weak var sceneDelegate: (any InAppMessageSceneDelegate)? { get { return sceneManager.delegate } set { sceneManager.delegate = newValue } } init( sceneManager: any InAppMessageSceneManagerProtocol, assetManager: any AssetCacheManagerProtocol, analyticsFactory: any InAppMessageAnalyticsFactoryProtocol, scheduleConditionsChangedNotifier: ScheduleConditionsChangedNotifier ) { self.sceneManager = sceneManager self.assetManager = assetManager self.analyticsFactory = analyticsFactory self.scheduleConditionsChangedNotifier = scheduleConditionsChangedNotifier } #endif @MainActor weak var displayDelegate: (any InAppMessageDisplayDelegate)? { get { return delegates.displayDelegate } set { delegates.displayDelegate = newValue } } @MainActor var onIsReadyToDisplay: (@MainActor @Sendable (InAppMessage, String) -> Bool)? { get { return delegates.onIsReadyToDisplay } set { delegates.onIsReadyToDisplay = newValue } } func isReady( data: PreparedInAppMessageData, preparedScheduleInfo: PreparedScheduleInfo ) -> ScheduleReadyResult { guard data.displayAdapter.isReady else { AirshipLogger.info("Schedule \(preparedScheduleInfo.scheduleID) display adapter not ready") Task { [scheduleConditionsChangedNotifier] in await data.displayAdapter.waitForReady() scheduleConditionsChangedNotifier.notify() } return .notReady } guard data.displayCoordinator.isReady else { AirshipLogger.info("Schedule \(preparedScheduleInfo.scheduleID) display coordinator not ready") Task { [scheduleConditionsChangedNotifier] in await data.displayCoordinator.waitForReady() scheduleConditionsChangedNotifier.notify() } return .notReady } var isReady: Bool? if let onDisplay = self.onIsReadyToDisplay { isReady = onDisplay( data.message, preparedScheduleInfo.scheduleID ) } else if let displayDelegate = self.displayDelegate { isReady = displayDelegate.isMessageReadyToDisplay( data.message, scheduleID: preparedScheduleInfo.scheduleID ) } guard isReady != false else { AirshipLogger.info("Schedule \(preparedScheduleInfo.scheduleID) InAppMessageDisplayDelegate not ready") return .notReady } return .ready } @MainActor func execute( data: PreparedInAppMessageData, preparedScheduleInfo: PreparedScheduleInfo ) async throws -> ScheduleExecuteResult { guard preparedScheduleInfo.additionalAudienceCheckResult else { AirshipLogger.info("Schedule \(preparedScheduleInfo.scheduleID) missed additional audience check") data.analytics.recordEvent( ThomasLayoutResolutionEvent.audienceExcluded(), layoutContext: nil ) return .finished } #if os(macOS) let displayTarget = AirshipDisplayTarget() #else let displayTarget = AirshipDisplayTarget { try self.sceneManager.scene(forMessage: data.message).scene } #endif // Display self.delegates.displayDelegate?.messageWillDisplay( data.message, scheduleID: preparedScheduleInfo.scheduleID ) data.displayCoordinator.messageWillDisplay(data.message) var result: ScheduleExecuteResult = .finished let experimentResult = preparedScheduleInfo.experimentResult if let experimentResult = experimentResult, experimentResult.isMatch { AirshipLogger.info("Schedule \(preparedScheduleInfo.scheduleID) part of experiment") data.analytics.recordEvent( ThomasLayoutResolutionEvent.control(experimentResult: experimentResult), layoutContext: nil ) } else { do { AirshipLogger.info("Displaying message \(preparedScheduleInfo.scheduleID)") let displayResult = try await data.displayAdapter.display(displayTarget: displayTarget, analytics: data.analytics) switch (displayResult) { case .cancel: result = .cancel case .finished: result = .finished } if let actions = data.message.actions { data.actionRunner.runAsync(actions: actions) } } catch { data.displayCoordinator.messageFinishedDisplaying(data.message) AirshipLogger.error("Failed to display message \(error)") result = .retry } } // Finished data.displayCoordinator.messageFinishedDisplaying(data.message) self.delegates.displayDelegate?.messageFinishedDisplaying( data.message, scheduleID: preparedScheduleInfo.scheduleID ) // Clean up assets if (result != .retry) { await self.assetManager.clearCache(identifier: preparedScheduleInfo.scheduleID) } return result } func interrupted(schedule: AutomationSchedule, preparedScheduleInfo: PreparedScheduleInfo) async -> InterruptedBehavior { guard case .inAppMessage(let message) = schedule.data else { return .finish } guard !message.isEmbedded else { return .retry } let analytics = await self.analyticsFactory.makeAnalytics( preparedScheduleInfo: preparedScheduleInfo, message: message ) analytics.recordEvent( ThomasLayoutResolutionEvent.interrupted(), layoutContext: nil ) await self.assetManager.clearCache(identifier: preparedScheduleInfo.scheduleID) return .finish } @MainActor func notifyDisplayConditionsChanged() { self.scheduleConditionsChangedNotifier.notify() } /// Delegates holder so I can keep the executor sendable private final class Delegates: Sendable { @MainActor weak var displayDelegate: (any InAppMessageDisplayDelegate)? @MainActor var onIsReadyToDisplay: (@MainActor @Sendable (InAppMessage, String) -> Bool)? } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageAutomationPreparer.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Any data needed by in-app message to handle displaying the message struct PreparedInAppMessageData: Sendable { var message: InAppMessage var displayAdapter: any DisplayAdapter var displayCoordinator: any DisplayCoordinator var analytics: any InAppMessageAnalyticsProtocol var actionRunner: any InAppActionRunner & ThomasActionRunner } final class InAppMessageAutomationPreparer: AutomationPreparerDelegate { typealias PrepareDataIn = InAppMessage typealias PrepareDataOut = PreparedInAppMessageData private let displayCoordinatorManager: any DisplayCoordinatorManagerProtocol private let displayAdapterFactory: any DisplayAdapterFactoryProtocol private let assetManager: any AssetCacheManagerProtocol private let analyticsFactory: any InAppMessageAnalyticsFactoryProtocol private let actionRunnerFactory: any InAppActionRunnerFactoryProtocol @MainActor public var displayInterval: TimeInterval { get { return displayCoordinatorManager.displayInterval } set { displayCoordinatorManager.displayInterval = newValue } } init( assetManager: any AssetCacheManagerProtocol, displayCoordinatorManager: any DisplayCoordinatorManagerProtocol, displayAdapterFactory: any DisplayAdapterFactoryProtocol = DisplayAdapterFactory(), analyticsFactory: any InAppMessageAnalyticsFactoryProtocol, actionRunnerFactory: any InAppActionRunnerFactoryProtocol = InAppActionRunnerFactory() ) { self.assetManager = assetManager self.displayCoordinatorManager = displayCoordinatorManager self.displayAdapterFactory = displayAdapterFactory self.analyticsFactory = analyticsFactory self.actionRunnerFactory = actionRunnerFactory } func prepare( data: InAppMessage, preparedScheduleInfo: PreparedScheduleInfo ) async throws -> PreparedInAppMessageData { let assets = try await self.prepareAssets( message: data, scheduleID: preparedScheduleInfo.scheduleID, skip: preparedScheduleInfo.additionalAudienceCheckResult == false || preparedScheduleInfo.experimentResult?.isMatch == true ) let displayCoordinator = self.displayCoordinatorManager.displayCoordinator(message: data) let analytics = await self.analyticsFactory.makeAnalytics( preparedScheduleInfo: preparedScheduleInfo, message: data ) let actionRunner = self.actionRunnerFactory.makeRunner(message: data, analytics: analytics) let displayAdapter = try await self.displayAdapterFactory.makeAdapter( args: DisplayAdapterArgs( message: data, assets: assets, priority: preparedScheduleInfo.priority, _actionRunner: actionRunner ) ) return PreparedInAppMessageData( message: data, displayAdapter: displayAdapter, displayCoordinator: displayCoordinator, analytics: analytics, actionRunner: actionRunner ) } func cancelled(scheduleID: String) async { AirshipLogger.trace("Execution cancelled \(scheduleID)") await self.assetManager.clearCache(identifier: scheduleID) } private func prepareAssets(message: InAppMessage, scheduleID: String, skip: Bool) async throws -> any AirshipCachedAssetsProtocol { // - prepare assets 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 } } AirshipLogger.trace("Preparing assets \(scheduleID): \(imageURLs)") return try await self.assetManager.cacheAssets( identifier: scheduleID, assets: imageURLs ) } @MainActor func setAdapterFactoryBlock( forType type: CustomDisplayAdapterType, factoryBlock: @escaping @Sendable (DisplayAdapterArgs) -> (any CustomDisplayAdapter)? ) { self.displayAdapterFactory.setAdapterFactoryBlock( forType: type, factoryBlock: { args in factoryBlock(args) } ) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageColor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// In-App message color public struct InAppMessageColor: Codable, Sendable, Equatable { /// Raw hex string - #AARRGGBB public let hexColorString: String /// Parsed swiftUI color public let color: Color /// In-app message color initializer /// - Parameter hexColorString: Color represented by hex string of the format #AARRGGBB public init(hexColorString: String) { self.hexColorString = hexColorString self.color = AirshipColor.resolveHexColor(hexColorString) ?? .clear } public init(from decoder: any Decoder) throws { let hexColorString = try decoder.singleValueContainer().decode(String.self) self.init(hexColorString: hexColorString) } public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(self.hexColorString) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageDisplayContent.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// In-App message display content public enum InAppMessageDisplayContent: Sendable, Equatable { /// Banner messages case banner(Banner) /// Fullscreen messages case fullscreen(Fullscreen) /// Modal messages case modal(Modal) /// Html messages case html(HTML) /// Custom messages case custom(AirshipJSON) /// Airship layout messages case airshipLayout(AirshipLayout) public func validate() -> Bool { switch self { case .banner(let layout): return layout.validate() case .fullscreen(let layout): return layout.validate() case .modal(let layout): return layout.validate() case .html(let layout): return layout.validate() case .custom(_): /// Don't validate custom layouts return true case .airshipLayout(let layout): return layout.validate() } } /// Banner display content public struct Banner: Codable, Sendable, Equatable { /// Banner layout templates public enum Template: String, Codable, Sendable { /// Media left case mediaLeft = "media_left" /// Media right case mediaRight = "media_right" } /// Banner placement public enum Placement: String, Codable, Sendable, Equatable { /// Top case top /// Bottom case bottom } /// The heading public var heading: InAppMessageTextInfo? /// The body public var body: InAppMessageTextInfo? /// The media public var media: InAppMessageMediaInfo? /// The buttons public var buttons: [InAppMessageButtonInfo]? /// The button layout type public var buttonLayoutType: InAppMessageButtonLayoutType? /// The template public var template: Template? /// The background color public var backgroundColor: InAppMessageColor? /// The dismiss button color public var dismissButtonColor: InAppMessageColor? /// The border radius public var borderRadius: Double? /// How long the banner displays public var duration: TimeInterval? /// Banner placement public var placement: Placement? /// Tap actions public var actions: AirshipJSON? enum CodingKeys: String, CodingKey { case actions case heading case body case media case buttons case buttonLayoutType = "button_layout" case template case backgroundColor = "background_color" case dismissButtonColor = "dismiss_button_color" case borderRadius = "border_radius" case duration case placement } /// Banner in-app message model initializer /// - Parameters: /// - heading: Model defining the message heading /// - body: Model defining the message body /// - media: Model defining the message media /// - buttons: Message button models /// - buttonLayoutType: Button layout model /// - template: Layout template defining text position relative to media /// - backgroundColor: Background color /// - dismissButtonColor: Dismiss button color /// - borderRadius: Border radius of the message body /// - duration: Duration before message is dismissed /// - placement: Placement of message on its parent /// - actions: Actions to execute on message tap public init( heading: InAppMessageTextInfo? = nil, body: InAppMessageTextInfo? = nil, media: InAppMessageMediaInfo? = nil, buttons: [InAppMessageButtonInfo]? = nil, buttonLayoutType: InAppMessageButtonLayoutType? = nil, template: Template? = nil, backgroundColor: InAppMessageColor? = nil, dismissButtonColor: InAppMessageColor? = nil, borderRadius: Double? = nil, duration: TimeInterval? = nil, placement: Placement? = nil, actions: AirshipJSON? = nil ) { self.heading = heading self.body = body self.media = media self.buttons = buttons self.buttonLayoutType = buttonLayoutType self.template = template self.backgroundColor = backgroundColor self.dismissButtonColor = dismissButtonColor self.borderRadius = borderRadius self.duration = duration self.placement = placement self.actions = actions } } /// Modal display content public struct Modal: Codable, Sendable, Equatable { /// Modal templates public enum Template: String, Codable, Sendable { /// Header, media, body case headerMediaBody = "header_media_body" /// Media, header, body case mediaHeaderBody = "media_header_body" /// Header, body, media case headerBodyMedia = "header_body_media" } /// The heading public var heading: InAppMessageTextInfo? /// The body public var body: InAppMessageTextInfo? /// The media public var media: InAppMessageMediaInfo? /// The footer public var footer: InAppMessageButtonInfo? /// The buttons public var buttons: [InAppMessageButtonInfo]? /// The button layout type public var buttonLayoutType: InAppMessageButtonLayoutType? /// The template public var template: Template? /// The background color public var backgroundColor: InAppMessageColor? /// The dismiss button color public var dismissButtonColor: InAppMessageColor? /// The border radius public var borderRadius: Double? /// If the modal can be displayed as fullscreen on small devices public var allowFullscreenDisplay: Bool? enum CodingKeys: String, CodingKey { case heading case body case media case footer case buttons case buttonLayoutType = "button_layout" case template case backgroundColor = "background_color" case dismissButtonColor = "dismiss_button_color" case borderRadius = "border_radius" case allowFullscreenDisplay = "allow_fullscreen_display" } /// Modal in-app message model initializer /// - Parameters: /// - heading: Model defining the message heading /// - body: Model defining the message body /// - media: Model defining the message media /// - footer: Model defining a footer button /// - buttons: Message button models /// - buttonLayoutType: Layout for buttons /// - template: Layout template defining relative position of heading, body and media /// - dismissButtonColor: Dismiss button color /// - backgroundColor: Background color /// - borderRadius: Border radius of the message body /// - allowFullscreenDisplay: Flag determining if the message can be displayed as fullscreen on small devices public init( heading: InAppMessageTextInfo? = nil, body: InAppMessageTextInfo? = nil, media: InAppMessageMediaInfo? = nil, footer: InAppMessageButtonInfo? = nil, buttons: [InAppMessageButtonInfo], buttonLayoutType: InAppMessageButtonLayoutType? = nil, template: Template, dismissButtonColor: InAppMessageColor? = nil, backgroundColor: InAppMessageColor? = nil, borderRadius: Double? = nil, allowFullscreenDisplay: Bool? = nil ) { self.heading = heading self.body = body self.media = media self.footer = footer self.buttons = buttons self.buttonLayoutType = buttonLayoutType self.template = template self.backgroundColor = backgroundColor self.dismissButtonColor = dismissButtonColor self.borderRadius = borderRadius self.allowFullscreenDisplay = allowFullscreenDisplay } } /// Fullscreen display content public struct Fullscreen: Codable, Sendable, Equatable { /// Fullscreen templates public enum Template: String, Codable, Sendable { /// Header, media, body case headerMediaBody = "header_media_body" /// Media, header, body case mediaHeaderBody = "media_header_body" /// Header, body, media case headerBodyMedia = "header_body_media" } /// The heading public var heading: InAppMessageTextInfo? /// The body public var body: InAppMessageTextInfo? /// The media public var media: InAppMessageMediaInfo? /// The footer public var footer: InAppMessageButtonInfo? /// The buttons public var buttons: [InAppMessageButtonInfo]? /// The button layout type public var buttonLayoutType: InAppMessageButtonLayoutType? /// The template public var template: Template? /// The background color public var backgroundColor: InAppMessageColor? /// The dismiss button color public var dismissButtonColor: InAppMessageColor? enum CodingKeys: String, CodingKey { case heading case body case media case footer case buttons case buttonLayoutType = "button_layout" case template case backgroundColor = "background_color" case dismissButtonColor = "dismiss_button_color" } /// Full screen in-app message model initializer /// - Parameters: /// - heading: Model defining the message heading /// - body: Model defining the message body /// - media: Model defining the message media /// - footer: Model defining a footer button /// - buttons: Message button models /// - buttonLayoutType: Layout for buttons /// - template: Layout template defining relative position of heading, body and media /// - dismissButtonColor: Dismiss button color /// - backgroundColor: Background color public init(heading: InAppMessageTextInfo? = nil, body: InAppMessageTextInfo? = nil, media: InAppMessageMediaInfo? = nil, footer: InAppMessageButtonInfo? = nil, buttons: [InAppMessageButtonInfo], buttonLayoutType: InAppMessageButtonLayoutType? = nil, template: Template, dismissButtonColor: InAppMessageColor? = nil, backgroundColor: InAppMessageColor? = nil ) { self.heading = heading self.body = body self.media = media self.footer = footer self.buttons = buttons self.buttonLayoutType = buttonLayoutType self.template = template self.backgroundColor = backgroundColor self.dismissButtonColor = dismissButtonColor } } /// HTML display content public struct HTML: Codable, Sendable, Equatable { /// The URL public var url: String /// The height of the manually sized HTML view public var height: Double? /// The width of the manually sized HTML view public var width: Double? /// Flag indicating if the HTML view should lock its aspect ratio when resizing to fit the screen public var aspectLock: Bool? /// Flag indicating if the content requires connectivity to display correctly public var requiresConnectivity: Bool? /// The dismiss button color public var dismissButtonColor: InAppMessageColor? /// The background color public var backgroundColor: InAppMessageColor? /// The border radius public var borderRadius: Double? /// If the html can be displayed as fullscreen on small devices public var allowFullscreen: Bool? // If the html should always display as fullscreen public var forceFullscreen: Bool? enum CodingKeys: String, CodingKey { case url case height case width case aspectLock = "aspect_lock" case requiresConnectivity = "require_connectivity" case backgroundColor = "background_color" case dismissButtonColor = "dismiss_button_color" case borderRadius = "border_radius" case allowFullscreen = "allow_fullscreen_display" case forceFullscreen = "force_fullscreen_display" } /// HTML in-app message model initializer /// - Parameters: /// - url: URL of content /// - height: Height of web view /// - width: Height of web view /// - aspectLock: Flag for locking aspect ratio /// - requiresConnectivity: Flag for determining if message can be displayed without connectivity /// - dismissButtonColor: Dismiss button color /// - backgroundColor: Background color /// - borderRadius: Border radius /// - allowFullscreen: Flag determining if the message can be displayed as fullscreen on small devices public init( url: String, height: Double? = nil, width: Double? = nil, aspectLock: Bool? = nil, requiresConnectivity: Bool? = nil, dismissButtonColor: InAppMessageColor? = nil, backgroundColor: InAppMessageColor? = nil, borderRadius: Double? = nil, allowFullscreen: Bool? = nil, forceFullscreen: Bool? = nil ) { self.url = url self.height = height self.width = width self.aspectLock = aspectLock self.requiresConnectivity = requiresConnectivity self.backgroundColor = backgroundColor self.dismissButtonColor = dismissButtonColor self.borderRadius = borderRadius self.allowFullscreen = allowFullscreen self.forceFullscreen = forceFullscreen } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageDisplayDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Message display delegate public protocol InAppMessageDisplayDelegate: AnyObject, Sendable{ /// Called to check if the message is ready to be displayed. This method will be called for /// every message that is pending display whenever a display condition changes. Use `notifyDisplayConditionsChanged` /// to notify whenever a condition changes to reevaluate the pending In-App messages. /// /// - Parameters: /// - message: The message /// - scheduleID: The schedule ID /// - Returns: `true` if the message is ready to display, `false` otherwise. @MainActor func isMessageReadyToDisplay(_ message: InAppMessage, scheduleID: String) -> Bool /// Called when a message will be displayed. /// - Parameters: /// - message: The message /// - scheduleID: The schedule ID @MainActor func messageWillDisplay(_ message: InAppMessage, scheduleID: String) /// Called when a message finished displaying /// - Parameters: /// - message: The message /// - scheduleID: The schedule ID @MainActor func messageFinishedDisplaying(_ message: InAppMessage, scheduleID: String) } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageEnvironment.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif @MainActor class InAppMessageEnvironment: ObservableObject { private let delegate: any InAppMessageViewDelegate let imageLoader: AirshipImageLoader #if !os(tvOS) let nativeBridgeExtension: (any NativeBridgeExtensionDelegate)? #endif let actionRunner: (any InAppActionRunner)? @Published var isDismissed = false @MainActor init( delegate: any InAppMessageViewDelegate, extensions: InAppMessageExtensions? = nil ) { self.delegate = delegate self.imageLoader = AirshipImageLoader(imageProvider: extensions?.imageProvider) #if !os(tvOS) self.nativeBridgeExtension = extensions?.nativeBridgeExtension #endif self.actionRunner = extensions?.actionRunner } private func tryDismiss(callback: @escaping () -> Void) { if !self.isDismissed { withAnimation { self.isDismissed = true } callback() } } @MainActor func onAppear() { self.delegate.onAppear() } /// Called when a button dismisses the in-app message /// - Parameters: /// - buttonInfo: The button info on the dismissing button. @MainActor func onButtonDismissed(buttonInfo: InAppMessageButtonInfo) { tryDismiss { self.delegate.onButtonDismissed(buttonInfo: buttonInfo) } } func runActions(actions: AirshipJSON?) { guard let actions = actions else { return } Task { await ActionRunner.run(actionsPayload: actions, situation: .automation, metadata: [:]) } } @MainActor func runActions(_ actions: AirshipJSON?) { guard let actions = actions else { return } guard let runner = actionRunner else { Task { await ActionRunner.run( actionsPayload: actions, situation: .automation, metadata: [:] ) } return } runner.runAsync(actions: actions) } @MainActor func runAction(_ actionName: String, arguments: ActionArguments) async -> ActionResult { guard let runner = actionRunner else { return await ActionRunner.run(actionName: actionName, arguments: arguments) } return await runner.run( actionName: actionName, arguments: arguments ) } /// Called when a message dismisses after the set timeout period @MainActor func onTimedOut() { tryDismiss { self.delegate.onTimedOut() } } /// Called when a message dismisses with the close button or banner drawer handle @MainActor func onUserDismissed() { tryDismiss { self.delegate.onUserDismissed() } } /// Called when a message is dismissed via a tap to the message body @MainActor func onMessageTapDismissed() { tryDismiss { self.delegate.onMessageTapDismissed() } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageSceneDelegate.swift ================================================ /* Copyright Airship and Contributors */ #if !os(macOS) import Foundation public import UIKit /// Scene delegate public protocol InAppMessageSceneDelegate: AnyObject { /// Called to get the scene for a given message. If no scene is provided, the default scene will be used. /// - Parameters: /// - message: The in-app message /// - Returns: A UIWindowScene @MainActor func sceneForMessage(_ message: InAppMessage) -> UIWindowScene? } #endif ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageSceneManager.swift ================================================ /* Copyright Airship and Contributors */ #if !os(macOS) import Foundation import UIKit #if canImport(AirshipCore) import AirshipCore #endif protocol InAppMessageSceneManagerProtocol: AnyObject, Sendable { @MainActor var delegate: (any InAppMessageSceneDelegate)? { get set } @MainActor func scene(forMessage: InAppMessage) throws -> any WindowSceneHolder } final class InAppMessageSceneManager: InAppMessageSceneManagerProtocol, Sendable { @MainActor weak var delegate: (any InAppMessageSceneDelegate)? private let sceneManger: any AirshipSceneManagerProtocol init(sceneManger: any AirshipSceneManagerProtocol) { self.sceneManger = sceneManger } @MainActor func scene(forMessage message: InAppMessage) throws -> any WindowSceneHolder { let scene = try self.delegate?.sceneForMessage(message) ?? sceneManger.lastActiveScene return DefaultWindowSceneHolder(scene: scene) } } protocol WindowSceneHolder: Sendable { @MainActor var scene: UIWindowScene { get } } @MainActor struct DefaultWindowSceneHolder: WindowSceneHolder { var scene: UIWindowScene } #endif ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageValidation.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif extension InAppMessage { func validate() -> Bool { if !self.displayContent.validate() { AirshipLogger.debug("Messages require valid display content") return false } return true } } extension InAppMessageDisplayContent.Banner { private static let maxButtons:Int = 2 func validate() -> Bool { if (self.heading?.text ?? "").isEmpty && (self.body?.text ?? "").isEmpty { AirshipLogger.debug("Banner must have either its body or heading defined.") return false } if self.buttons?.count ?? 0 > Self.maxButtons { AirshipLogger.debug("Banner allows a maximum of \(Self.maxButtons) buttons") return false } return true } } extension InAppMessageDisplayContent.Modal { func validate() -> Bool { if (self.heading?.text ?? "").isEmpty && (self.body?.text ?? "").isEmpty { AirshipLogger.debug("Modal display must have either its body or heading defined.") return false } return true } } extension InAppMessageDisplayContent.Fullscreen { func validate() -> Bool { if (self.heading?.text ?? "").isEmpty && (self.body?.text ?? "").isEmpty { AirshipLogger.debug("Full screen display must have either its body or heading defined.") return false } return true } } extension InAppMessageDisplayContent.HTML { func validate() -> Bool { if self.url.isEmpty { AirshipLogger.debug("HTML display must have a non-empty URL.") return false } return true } } extension InAppMessageTextInfo { func validate() -> Bool { if (self.text.isEmpty) { AirshipLogger.debug("In-app text infos require nonempty text") return false } return true } } extension InAppMessageButtonInfo { private static let minIdentifierLength:Int = 1 private static let maxIdentifierLength:Int = 100 func validate() -> Bool { if self.label.text.isEmpty { AirshipLogger.debug("In-app button infos require a nonempty label") return false } if identifier.count < Self.minIdentifierLength || identifier.count > Self.maxIdentifierLength { AirshipLogger.debug("In-app button infos require an identifier between [\(Self.minIdentifierLength), \(Self.maxIdentifierLength)] characters") return false } return true } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessageViewDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Delegate for receiving callback pertaining to in-app message lifecycle state public protocol InAppMessageViewDelegate { /// Called whenever the view appears @MainActor func onAppear() /// Called when a button dismisses the in-app message /// - Parameters: /// - buttonInfo: The button info on the dismissing button. @MainActor func onButtonDismissed(buttonInfo: InAppMessageButtonInfo) /// Called when a message dismisses after the set timeout period @MainActor func onTimedOut() /// Called when a message dismisses with the close button or banner drawer handle @MainActor func onUserDismissed() /// Called when a message is dismissed via a tap to the message body @MainActor func onMessageTapDismissed() } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/InAppMessaging.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// In-app messaging public protocol InAppMessaging: AnyObject, Sendable { /// Called when the Message is requested to be displayed. /// Return `true` if the message is ready to display, `false` otherwise. @MainActor var onIsReadyToDisplay: (@MainActor @Sendable (_ message: InAppMessage, _ scheduleID: String) -> Bool)? { get set } /// Theme manager @MainActor var themeManager: InAppAutomationThemeManager { get } /// Display interval @MainActor var displayInterval: TimeInterval { get set } /// Display delegate @MainActor var displayDelegate: (any InAppMessageDisplayDelegate)? { get set } #if !os(macOS) /// Scene delegate @MainActor var sceneDelegate: (any InAppMessageSceneDelegate)? { get set } #endif /// Sets a factory block for a custom display adapter. /// If the factory block returns a nil adapter, the default adapter will be used. /// /// - Parameters: /// - forType: The type /// - factoryBlock: The factory block @MainActor func setCustomAdapter( forType: CustomDisplayAdapterType, factoryBlock: @escaping @Sendable (DisplayAdapterArgs) -> (any CustomDisplayAdapter)? ) /// Notifies In-App messages that the display conditions should be reevaluated. /// This should only be called when state that was used to prevent a display with `InAppMessageDisplayDelegate` changes. @MainActor func notifyDisplayConditionsChanged() } final class DefaultInAppMessaging: InAppMessaging { let executor: InAppMessageAutomationExecutor let preparer: InAppMessageAutomationPreparer @MainActor let themeManager: InAppAutomationThemeManager = InAppAutomationThemeManager() @MainActor var displayInterval: TimeInterval { get { return preparer.displayInterval } set { preparer.displayInterval = newValue } } @MainActor var onIsReadyToDisplay: (@MainActor @Sendable (InAppMessage, String) -> Bool)? { get { return executor.onIsReadyToDisplay } set { executor.onIsReadyToDisplay = newValue } } @MainActor weak var displayDelegate: (any InAppMessageDisplayDelegate)? { get { return executor.displayDelegate } set { executor.displayDelegate = newValue } } #if !os(macOS) @MainActor weak var sceneDelegate: (any InAppMessageSceneDelegate)? { get { return executor.sceneDelegate } set { executor.sceneDelegate = newValue } } #endif @MainActor func setAdapterFactoryBlock( forType type: CustomDisplayAdapterType, factoryBlock: @escaping @Sendable (InAppMessage, any AirshipCachedAssetsProtocol) -> (any CustomDisplayAdapter)? ) { self.setCustomAdapter(forType: type) { args in factoryBlock(args.message, args.assets) } } @MainActor func setCustomAdapter( forType type: CustomDisplayAdapterType, factoryBlock: @escaping @Sendable (DisplayAdapterArgs) -> (any CustomDisplayAdapter)? ) { self.preparer.setAdapterFactoryBlock(forType: type, factoryBlock: factoryBlock) } @MainActor init( executor: InAppMessageAutomationExecutor, preparer: InAppMessageAutomationPreparer ) { self.executor = executor self.preparer = preparer } @MainActor func notifyDisplayConditionsChanged() { executor.notifyDisplayConditionsChanged() } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Info/InAppMessageButtonInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Button info public struct InAppMessageButtonInfo: Sendable, Codable, Equatable { /// Button behavior public enum Behavior: String, Sendable, Codable { /// Dismisses the message when tapped case dismiss /// Dismisses and cancels the message when tapped case cancel } /// Button identifier, used for reporting public let identifier: String /// Button label public var label: InAppMessageTextInfo /// Button actions public var actions: AirshipJSON? /// Button behavior public var behavior: Behavior? // Background color public var backgroundColor: InAppMessageColor? /// Border color public var borderColor: InAppMessageColor? /// Border radius in points public var borderRadius: Double? /// In-app message button model /// - Parameters: /// - identifier: Button identifier /// - label: Text model for the button text /// - actions: Actions for the button to execute /// - behavior: Behavior of the button on tap /// - backgroundColor: Background color /// - borderColor: Border color /// - borderRadius: Border radius public init( identifier: String, label: InAppMessageTextInfo, actions: AirshipJSON? = nil, behavior: Behavior? = nil, backgroundColor: InAppMessageColor? = nil, borderColor: InAppMessageColor? = nil, borderRadius: Double? = nil ) { self.identifier = identifier self.label = label self.actions = actions self.behavior = behavior self.backgroundColor = backgroundColor self.borderColor = borderColor self.borderRadius = borderRadius } enum CodingKeys: String, CodingKey { case identifier = "id" case label case actions case behavior case backgroundColor = "background_color" case borderColor = "border_color" case borderRadius = "border_radius" } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Info/InAppMessageButtonLayoutType.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Button layout type public enum InAppMessageButtonLayoutType: String, Codable, Sendable, Equatable { /// Stacked vertically case stacked /// Joined horizontally case joined /// Separated horizontally case separate } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Info/InAppMessageMediaInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Media info public struct InAppMessageMediaInfo: Sendable, Codable, Equatable { /// Media type public enum MediaType: String, Sendable, Codable { /// Youtube videos case youtube /// Vimeo videos case vimeo /// HTML video case video /// Image case image } /// The media's URL public var url: String /// The media's type public var type: MediaType /// Content description public var description: String? /// In-app message media model /// - Parameters: /// - url: URL from which to fetch the media /// - type: Media type /// - description: Content description for accessibility purposes public init( url: String, type: MediaType, description: String? = nil ) { self.url = url self.type = type self.description = description } enum CodingKeys: String, CodingKey { case url case type case description } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Info/InAppMessageTextInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// InAppMessage text info public struct InAppMessageTextInfo: Sendable, Codable, Equatable { /// Text styles public enum Style: String, Sendable, Codable { case bold = "bold" case italic = "italic" case underline = "underline" } /// Text alignment public enum Alignment: String, Sendable, Codable { case left = "left" case center = "center" case right = "right" } /// The display text public var text: String /// The text color public var color: InAppMessageColor? /// The font size public var size: Double? /// Font families public var fontFamilies: [String]? /// Alignment public var alignment: Alignment? /// Style public var style: [Style]? /// In-app message text model /// - Parameters: /// - text: Text /// - color: Color /// - size: Size /// - fontFamilies: Font families /// - alignment: Text alignment inside its own frame /// - style: Text style public init( text: String, color: InAppMessageColor? = nil, size: Double? = nil, fontFamilies: [String]? = nil, alignment: Alignment? = nil, style: [Style]? = nil ) { self.text = text self.color = color self.size = size self.fontFamilies = fontFamilies self.alignment = alignment self.style = style } enum CodingKeys: String, CodingKey { case text case color case size case fontFamilies = "font_family" case alignment case style } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Legacy/LegacyInAppAnalytics.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol LegacyInAppAnalyticsProtocol: Sendable { func recordReplacedEvent(scheduleID: String, replacementID: String) func recordDirectOpenEvent(scheduleID: String) } struct LegacyInAppAnalytics : LegacyInAppAnalyticsProtocol { let recorder: any ThomasLayoutEventRecorderProtocol func recordReplacedEvent(scheduleID: String, replacementID: String) { recorder.recordEvent( inAppEventData: ThomasLayoutEventData( event: LegacyResolutionEvent.replaced(replacementID: replacementID), context: nil, source: .airship, messageID: .legacy(identifier: scheduleID), renderedLocale: nil ) ) } func recordDirectOpenEvent(scheduleID: String) { recorder.recordEvent( inAppEventData: ThomasLayoutEventData( event: LegacyResolutionEvent.directOpen(), context: nil, source: .airship, messageID: .legacy(identifier: scheduleID), renderedLocale: nil ) ) } } struct LegacyResolutionEvent : ThomasLayoutEvent { let name = EventType.inAppResolution let data: (any Encodable & Sendable)? private init(data: (any Encodable & Sendable)?) { self.data = data } static func replaced(replacementID: String) -> LegacyResolutionEvent { return LegacyResolutionEvent( data: LegacyResolutionBody(type: .replaced, replacementID: replacementID) ) } static func directOpen() -> LegacyResolutionEvent { return LegacyResolutionEvent( data: LegacyResolutionBody(type: .directOpen) ) } fileprivate enum LegacyResolutionType: String, Encodable { case replaced case directOpen = "direct_open" } fileprivate struct LegacyResolutionBody: Encodable { var type: LegacyResolutionType var replacementID: String? enum CodingKeys: String, CodingKey { case type case replacementID = "replacement_id" } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Legacy/LegacyInAppMessage.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import UserNotifications #if canImport(AirshipCore) public import AirshipCore #endif /** * Model object representing in-app message data. */ public struct LegacyInAppMessage: Sendable, Equatable { /** * Enumeration of in-app message screen positions. */ public enum Position: String, Sendable { case top case bottom } /** * Enumeration of in-app message display types. */ public enum DisplayType: String, Sendable { case banner } ///--------------------------------------------------------------------------------------- /// @name Legacy In App Message Properties ///--------------------------------------------------------------------------------------- /** * The unique identifier for the message */ public let identifier: String ///--------------------------------------------------------------------------------------- /// @name Legacy In App Message Top Level Properties ///--------------------------------------------------------------------------------------- /** * The expiration date for the message. * Unless otherwise specified, defaults to 30 days from construction. */ public let expiry: Date /** * Optional key value extra. */ public let extra: AirshipJSON? ///--------------------------------------------------------------------------------------- /// @name Legacy In App Message Display Properties ///--------------------------------------------------------------------------------------- /** * The display type. Defaults to `LegacyInAppMessage.DisplayType.banner` * when built with the default class constructor. * When built from a payload with a missing or unidentified display type, * the message will be nil. */ public let displayType: DisplayType /** * The alert message. */ public let alert: String /** * The screen position. Defaults to `LegacyInAppMessage.Position.bottom`. */ public let position: Position /** * The amount of time to wait before automatically dismissing * the message. */ public let duration: TimeInterval /** * The primary color. hex */ public let primaryColor: String? /** * The secondary color hex. */ public let secondaryColor: String? ///--------------------------------------------------------------------------------------- /// @name Legacy In App Message Actions Properties ///--------------------------------------------------------------------------------------- /** * The button group (category) associated with the message. * This value will determine which buttons are present and their * localized titles. */ public let buttonGroup: String? /** * A dictionary mapping button group keys to dictionaries * mapping action names to action arguments. The relevant * action(s) will be run when the user taps the associated * button. */ public let buttonActions: [String: AirshipJSON]? /** * A dictionary mapping an action name to an action argument. * The relevant action will be run when the user taps or "clicks" * on the message. */ public let onClick: AirshipJSON? let campaigns: AirshipJSON? let messageType: String? /* // Default values unless otherwise specified self.displayType = UALegacyInAppMessageDisplayTypeBanner; self.expiry = [NSDate dateWithTimeIntervalSinceNow:kUADefaultInAppMessageExpiryInterval]; self.position = UALegacyInAppMessagePositionBottom; self.duration = kUADefaultInAppMessageDurationInterval; */ #if !os(tvOS) /** * An array of UNNotificationAction instances corresponding to the left-to-right order * of interactive message buttons. */ @MainActor var notificationActions: [UNNotificationAction]? { return self.buttonCategory?.actions } /** * A UNNotificationCategory instance, * corresponding to the button group of the message. * If no matching category is found, this property will be nil. */ @MainActor public var buttonCategory: UNNotificationCategory? { guard let group = buttonGroup else { return nil } return Airship.push.combinedCategories.first(where: { $0.identifier == group }) } #endif init?( payload: [String: Any], overrideId: String? = nil, overrideOnClick: AirshipJSON? = nil, date: any AirshipDateProtocol = AirshipDate.shared ) { guard let identifier = overrideId ?? (payload[ParseKey.identifier.rawValue] as? String), let displayInfo = payload[ParseKey.display.rawValue] as? [String: Any], let displayTypeRaw = displayInfo[ParseKey.Display.type.rawValue] as? String, let displayType = DisplayType(rawValue: displayTypeRaw), let alert = displayInfo[ParseKey.Display.alert.rawValue] as? String else { return nil } let wrapJson: (Any?) -> AirshipJSON? = { input in guard let input = input else { return nil } do { return try AirshipJSON.wrap(input) } catch { AirshipLogger.warn("failed to wrap \(String(describing: input)), \(error)") return nil } } self.identifier = identifier self.campaigns = wrapJson(payload[ParseKey.campaigns.rawValue]) self.messageType = payload[ParseKey.messageType.rawValue] as? String if let rawDate = payload[ParseKey.expiry.rawValue] as? String, let date = AirshipDateFormatter.date(fromISOString: rawDate) { self.expiry = date } else { self.expiry = date.now.advanced(by: Defaults.expiry) } self.extra = wrapJson(payload[ParseKey.extra.rawValue]) self.displayType = displayType self.alert = alert self.duration = displayInfo[ParseKey.Display.duration.rawValue] as? Double ?? Defaults.duration if let positionRaw = displayInfo[ParseKey.Display.position.rawValue] as? String, let position = Position(rawValue: positionRaw) { self.position = position } else { self.position = .bottom } self.primaryColor = displayInfo[ParseKey.Display.primaryColor.rawValue] as? String self.secondaryColor = displayInfo[ParseKey.Display.secondaryColor.rawValue] as? String if let actionsInfo = payload[ParseKey.actions.rawValue] as? [String: Any] { self.buttonGroup = actionsInfo[ParseKey.Action.buttonGroup.rawValue] as? String if let actions = actionsInfo[ParseKey.Action.buttonActions.rawValue] as? [String: Any] { self.buttonActions = actions.reduce(into: [String: AirshipJSON]()) { partialResult, record in if let json = wrapJson(record.value) { partialResult[record.key] = json } } } else { self.buttonActions = nil } self.onClick = overrideOnClick ?? wrapJson(actionsInfo[ParseKey.Action.onClick.rawValue]) } else { self.buttonGroup = nil self.buttonActions = nil self.onClick = overrideOnClick } } private enum ParseKey: String { case identifier = "identifier" case campaigns = "campaigns" case messageType = "message_type" case expiry = "expiry" case extra = "extra" case display = "display" case actions = "actions" enum Action: String { case buttonGroup = "button_group" case buttonActions = "button_actions" case onClick = "on_click" } enum Display: String { case type = "type" case position = "position" case alert = "alert" case duration = "duration" case primaryColor = "primary_color" case secondaryColor = "secondary_color" } } private enum Defaults { static let expiry: TimeInterval = 60 * 60 * 24 * 30 // 30 days in seconds static let duration: TimeInterval = 15 // seconds } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/Legacy/LegacyInAppMessaging.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(AirshipCore) import AirshipCore #endif public typealias MessageConvertor = @MainActor @Sendable (LegacyInAppMessage) -> AutomationSchedule? public typealias MessageExtender = @Sendable (inout InAppMessage) -> Void public typealias ScheduleExtender = @Sendable (inout AutomationSchedule) -> Void /// Legacy in-app messaging protocol public protocol LegacyInAppMessaging: AnyObject, Sendable { /// Optional message converter from a `LegacyInAppMessage` to an `AutomationSchedule` @MainActor var customMessageConverter: MessageConvertor? { get set } /// Optional message extender. @MainActor var messageExtender: MessageExtender? { get set } /// Optional schedule extender. @MainActor var scheduleExtender: ScheduleExtender? { get set } /// Sets whether legacy messages will display immediately upon arrival, instead of waiting /// until the following foreground. Defaults to `true`. @MainActor var displayASAPEnabled: Bool { get set } } protocol InternalLegacyInAppMessaging: LegacyInAppMessaging { #if !os(tvOS) func receivedNotificationResponse(_ response: UNNotificationResponse) async #endif func receivedRemoteNotification( _ notification: AirshipJSON // wrapped [AnyHashable : Any] ) async -> UABackgroundFetchResult } final class DefaultLegacyInAppMessaging: LegacyInAppMessaging, Sendable { private let dataStore: PreferenceDataStore private let analytics: any LegacyInAppAnalyticsProtocol private let automationEngine: any AutomationEngineProtocol @MainActor public var customMessageConverter: MessageConvertor? @MainActor public var messageExtender: MessageExtender? @MainActor public var scheduleExtender: ScheduleExtender? private let date: any AirshipDateProtocol init( analytics: any LegacyInAppAnalyticsProtocol, dataStore: PreferenceDataStore, automationEngine: any AutomationEngineProtocol, date: any AirshipDateProtocol = AirshipDate.shared ) { self.analytics = analytics self.automationEngine = automationEngine self.dataStore = dataStore self.date = date cleanUpOldData() } var pendingMessageID: String? { get { return dataStore.string(forKey: Keys.CurrentStorage.pendingMessageIds.rawValue) } set { dataStore.setObject(newValue, forKey: Keys.CurrentStorage.pendingMessageIds.rawValue) } } /** * Sets whether legacy messages will display immediately upon arrival, instead of waiting * until the following foreground. Defaults to `YES`. */ @MainActor var displayASAPEnabled: Bool = true private func cleanUpOldData() { self.dataStore.removeObject(forKey: Keys.LegacyStorage.pendingMessages.rawValue) self.dataStore.removeObject(forKey: Keys.LegacyStorage.autoDisplayMessage.rawValue) self.dataStore.removeObject(forKey: Keys.LegacyStorage.lastDisplayedMessageId.rawValue) } private func schedule(message: LegacyInAppMessage) async { let generator = await customMessageConverter ?? generateScheduleFor guard let schedule = await generator(message) else { AirshipLogger.error("Failed to convert legacy in-app automation: \(message)") return } if let pending = self.pendingMessageID { if await self.scheduleExists(identifier: pending) { AirshipLogger.debug("Pending in-app message replaced") self.analytics.recordReplacedEvent( scheduleID: pending, replacementID: schedule.identifier ) } await self.cancelSchedule(identifier: pending) } self.pendingMessageID = schedule.identifier do { try await self.automationEngine.upsertSchedules([schedule]) AirshipLogger.debug("LegacyInAppMessageManager - schedule is saved \(schedule)") } catch { AirshipLogger.error("Failed to schedule \(schedule)") } } private func scheduleExists(identifier: String) async -> Bool { do { return try await automationEngine.getSchedule(identifier: identifier) != nil } catch { AirshipLogger.debug("Failed to query schedule \(identifier), \(error)") return true } } private func cancelSchedule(identifier: String) async { do { return try await automationEngine.cancelSchedules(identifiers: [identifier]) } catch { AirshipLogger.debug("Failed to cancel schedule \(identifier), \(error)") } } @MainActor private func generateScheduleFor(message: LegacyInAppMessage) -> AutomationSchedule? { let primaryColor = InAppMessageColor(hexColorString: message.primaryColor ?? Defaults.primaryColor) let secondaryColor = InAppMessageColor(hexColorString: message.secondaryColor ?? Defaults.secondaryColor) #if !os(tvOS) let buttons = message .notificationActions? .prefix(Defaults.notificationButtonsCount) .map({ action in return InAppMessageButtonInfo( identifier: action.identifier, label: InAppMessageTextInfo( text: action.title, color: primaryColor, alignment: .left ), actions: message.buttonActions?[action.identifier], backgroundColor: secondaryColor, borderRadius: Defaults.borderRadius ) }) #else let buttons: [InAppMessageButtonInfo] = [] #endif let displayContent = InAppMessageDisplayContent.Banner( body: InAppMessageTextInfo(text: message.alert, color: secondaryColor), buttons: buttons, buttonLayoutType: .separate, backgroundColor: primaryColor, dismissButtonColor: secondaryColor, borderRadius: Defaults.borderRadius, duration: message.duration, placement: message.position.bannerPlacement, actions: message.onClick ) var inAppMessage = InAppMessage( name: message.alert, displayContent: .banner(displayContent), source: .legacyPush, extras: message.extra ) self.messageExtender?(&inAppMessage) // In terms of the scheduled message model, displayASAP means using an active session trigger. // Otherwise the closest analog to the v1 behavior is the foreground trigger. let trigger = self.displayASAPEnabled ? AutomationTrigger.activeSession(count: 1) : AutomationTrigger.foreground(count: 1) var schedule = AutomationSchedule( identifier: message.identifier, data: .inAppMessage(inAppMessage), triggers: [trigger], created: date.now, lastUpdated: date.now, end: message.expiry, campaigns: message.campaigns, messageType: message.messageType ) self.scheduleExtender?(&schedule) return schedule } } extension DefaultLegacyInAppMessaging: InternalLegacyInAppMessaging { #if !os(tvOS) func receivedNotificationResponse(_ response: UNNotificationResponse) async { let userInfo = response.notification.request.content.userInfo guard userInfo.keys.contains(Keys.incomingMessageKey.rawValue), let messageID = userInfo["_"] as? String, messageID == self.pendingMessageID else { return } self.pendingMessageID = nil if await self.scheduleExists(identifier: messageID) { AirshipLogger.debug("Pending in-app message replaced") self.analytics.recordDirectOpenEvent(scheduleID: messageID) } await self.cancelSchedule(identifier: messageID) } #endif func receivedRemoteNotification(_ notification: AirshipJSON) async -> UABackgroundFetchResult { guard let userInfo = notification.unWrap() as? [AnyHashable: Any], let payload = userInfo[Keys.incomingMessageKey.rawValue] as? [String: Any] else { return .noData } let overrideId = userInfo["_"] as? String let messageCenterAction: AirshipJSON? if let actionRaw = userInfo[Keys.messageCenterActionKey.rawValue] as? [String: Any], let action = try? AirshipJSON.wrap(actionRaw) { messageCenterAction = action } else if let messageId = userInfo[Keys.messageCenterActionKey.rawValue] as? String { messageCenterAction = .object([Keys.messageCenterActionKey.rawValue: .string(messageId)]) } else { messageCenterAction = nil } let message = LegacyInAppMessage( payload: payload, overrideId: overrideId, overrideOnClick: messageCenterAction, date: self.date ) if let message = message { await schedule(message: message) } return .noData } private enum Keys: String { enum LegacyStorage: String { // User defaults key for storing and retrieving pending messages case pendingMessages = "UAPendingInAppMessage" // User defaults key for storing and retrieving auto display enabled case autoDisplayMessage = "UAAutoDisplayInAppMessageDataStoreKey" // Legacy key for the last displayed message ID case lastDisplayedMessageId = "UALastDisplayedInAppMessageID" } enum CurrentStorage: String { // Data store key for storing and retrieving pending message IDs case pendingMessageIds = "UAPendingInAppMessageID" } case incomingMessageKey = "com.urbanairship.in_app" case messageCenterActionKey = "_uamid" } private enum Defaults { static let primaryColor = "#FFFFFF" static let secondaryColor = "#1C1C1C" static let borderRadius = 2.0 static let notificationButtonsCount = 2 } } fileprivate extension LegacyInAppMessage.Position { var bannerPlacement: InAppMessageDisplayContent.Banner.Placement { switch self { case .top: return .top case .bottom: return .bottom } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/BeveledLoadingView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI struct BeveledLoadingView: View { let opacity = 0.7 var body: some View { ZStack { RoundedRectangle(cornerSize: CGSize(width: 16, height: 16), style: .continuous) .frame(width: 100, height:100) .opacity(0.7) ProgressView() .progressViewStyle(CircularProgressViewStyle(tint:Color.white.opacity(0.7))) .scaleEffect(2) } } } #Preview { BeveledLoadingView() } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/ButtonGroup.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif private let defaultButtonMargin: CGFloat = 15 private let defaultFooterMargin: CGFloat = 0 private let buttonDefaultBorderWidth: CGFloat = 2 struct ViewHeightKey: PreferenceKey { typealias Value = CGFloat static let defaultValue = CGFloat.zero static func reduce(value: inout Value, nextValue: () -> Value) { value += nextValue() } } struct ButtonGroup: View { /// 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 @Binding private var isDisabled: Bool let layout: InAppMessageButtonLayoutType let buttons: [InAppMessageButtonInfo] let theme: InAppMessageTheme.Button init( isDisabled: Binding? = nil, layout: InAppMessageButtonLayoutType, buttons: [InAppMessageButtonInfo], theme: InAppMessageTheme.Button ) { self._isDisabled = isDisabled ?? Binding.constant(false) self.layout = layout self.buttons = buttons self.theme = theme } private func makeButtonView(buttonInfo: InAppMessageButtonInfo, roundedEdge: RoundedEdge = .all) -> some View { return ButtonView( buttonInfo: buttonInfo, roundedEdge: roundedEdge, relativeMinHeight: $buttonMinHeight, minHeight: theme.height, isDisabled: $isDisabled ) .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: theme.stackedSpacing) { ForEach(buttons, id: \.identifier) { button in makeButtonView(buttonInfo: button) } } .fixedSize(horizontal: false, vertical: true) /// Hug children in vertical axis case .joined: HStack(spacing: 0) { ForEach(Array(buttons.enumerated()), id: \.element.identifier) { index, button in if buttons.count > 1 { if index == 0 { // If first button of n buttons: only round leading edge makeButtonView(buttonInfo: button, roundedEdge: .leading) } else if index == buttons.count - 1 { // If last button of n buttons: only round trailing edge makeButtonView(buttonInfo: button, roundedEdge: .trailing) } else { // If middle button of n buttons: round trailing and leading edges makeButtonView(buttonInfo: button, roundedEdge: .none) } } else { // Round all button edges by default makeButtonView(buttonInfo: button) } } }.fixedSize(horizontal: false, vertical: true) /// Hug children in horizontal axis and veritcal axis case .separate: HStack(spacing: theme.separatedSpacing) { ForEach(buttons, id: \.identifier) { button in makeButtonView(buttonInfo: button) } }.fixedSize(horizontal: false, vertical: true) /// Hug children in horizontal axis and veritcal axis } } } struct ButtonView: View { @EnvironmentObject var environment: InAppMessageEnvironment @ScaledMetric var scaledPadding: CGFloat = 12 @Binding var isDisabled: Bool 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, minHeight: CGFloat = 33, isDisabled: Binding? = nil ) { self.buttonInfo = buttonInfo self.roundedEdge = roundedEdge _relativeMinHeight = relativeMinHeight ?? Binding.constant(CGFloat(0)) self.minHeight = minHeight _isDisabled = isDisabled ?? Binding.constant(false) } @ViewBuilder var buttonLabel: some View { TextView( textInfo: buttonInfo.label, textTheme: InAppMessageTheme.Text( letterSpacing: 0, lineSpacing: 0, padding: EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) ) ) } var body: some View { Button(action: onTap) { buttonLabel .padding(scaledPadding) .frame(maxWidth: .infinity, minHeight: max(relativeMinHeight, minHeight)) .background(buttonInfo.backgroundColor?.color) .roundEdge(radius: buttonInfo.borderRadius ?? 0, edge: roundedEdge, borderColor: buttonInfo.borderColor?.color ?? .clear, borderWidth: 2) #if os(macOS) .fixedSize() #endif } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) #if os(tvOS) .buttonStyle(.card) #elseif os(macOS) .buttonStyle(.plain) #endif } private func onTap() { if (!isDisabled) { environment.onButtonDismissed(buttonInfo: self.buttonInfo) environment.runActions(actions: self.buttonInfo.actions) } } } enum RoundedEdge { case none case leading case trailing case all } struct RoundEdgeModifier: ViewModifier { var radius: CGFloat var edge: RoundedEdge var borderColor: Color var borderWidth: CGFloat func body(content: Content) -> some View { content .clipShape(RoundedEdgeShape(radius: radius, edge: edge)) .overlay(RoundedEdgeShape(radius: radius, edge: edge) .stroke(borderColor, lineWidth: borderWidth)) } } struct RoundedEdgeShape: Shape { var radius: CGFloat var edge: RoundedEdge func path(in rect: CGRect) -> Path { var topLeading: CGFloat = 0 var bottomLeading: CGFloat = 0 var topTrailing: CGFloat = 0 var bottomTrailing: CGFloat = 0 switch edge { case .leading: topLeading = radius bottomLeading = radius case .trailing: topTrailing = radius bottomTrailing = radius case .all: topLeading = radius bottomLeading = radius topTrailing = radius bottomTrailing = radius case .none: break } return UnevenRoundedRectangle( topLeadingRadius: topLeading, bottomLeadingRadius: bottomLeading, bottomTrailingRadius: bottomTrailing, topTrailingRadius: topTrailing ).path(in: rect) } } extension View { func roundEdge(radius: CGFloat, edge: RoundedEdge, borderColor: Color = .clear, borderWidth: CGFloat = 0) -> some View { self.modifier(RoundEdgeModifier(radius: radius, edge: edge, borderColor: borderColor, borderWidth: borderWidth)) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/CloseButton.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI struct CloseButton: View { internal init( dismissIconImage: Image, dismissIconColor: Color, width: CGFloat? = nil, height: CGFloat? = nil, onTap: @escaping () -> () ) { self.dismissIconColor = dismissIconColor self.dismissIconImage = dismissIconImage self.width = width ?? 12 self.height = height ?? 12 self.onTap = onTap } let dismissIconColor: Color let dismissIconImage: Image let onTap: () -> () private let opacity: CGFloat = 0.25 private let defaultPadding: CGFloat = 24 private let height: CGFloat private let width: CGFloat private let tappableHeight: CGFloat = 44 private let tappableWidth: CGFloat = 44 /// 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 { dismissIconImage .foregroundColor(dismissIconColor) .frame(width: width, height: height) .padding(8) } var body: some View { Button(action: onTap) { dismissButtonImage .frame( width: max(tappableWidth, width), height: max(tappableHeight, height) ) } .accessibilityLabel("Dismiss") #if os(tvOS) .buttonStyle(.card) #endif } } #Preview { CloseButton(dismissIconImage: Image(systemName: "xmark"), dismissIconColor: .white, onTap: {}) .background(Color.green) } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/FullscreenView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif struct FullscreenView: View, Sendable { @EnvironmentObject var environment: InAppMessageEnvironment let displayContent: InAppMessageDisplayContent.Fullscreen let theme: InAppMessageTheme.Fullscreen @ViewBuilder private var headerView: some View { if let heading = displayContent.heading { TextView(textInfo: heading, textTheme: self.theme.header) .applyAlignment(placement: displayContent.heading?.alignment ?? .left) .accessibilityAddTraits(.isHeader) .accessibilityAddTraits(.isStaticText) } } @ViewBuilder private var bodyView: some View { if let body = displayContent.body { TextView(textInfo: body, textTheme: self.theme.body) .applyAlignment(placement: displayContent.body?.alignment ?? .left) .accessibilityAddTraits(.isStaticText) } } @ViewBuilder private var mediaView: some View { if let media = displayContent.media { if shouldRemoveMediaTopPadding { MediaView(mediaInfo: media, mediaTheme: theme.media) .padding(.top, -theme.padding.top) } else { MediaView(mediaInfo: media, mediaTheme: theme.media) } } } @ViewBuilder private var buttonsView: some View { if let buttons = displayContent.buttons, !buttons.isEmpty { ButtonGroup( layout: displayContent.buttonLayoutType ?? .stacked, buttons: buttons, theme: theme.buttons ) } } @ViewBuilder private var footerButton: some View { if let footer = displayContent.footer { ButtonView(buttonInfo: footer) } } var shouldRemoveMediaTopPadding: Bool { switch displayContent.template { case .headerMediaBody: displayContent.heading == nil case .headerBodyMedia: displayContent.heading == nil && displayContent.body == nil case .mediaHeaderBody, .none: true } } var body: some View { ScrollView { VStack(spacing:24) { switch displayContent.template { case .headerMediaBody: headerView mediaView bodyView case .headerBodyMedia: headerView bodyView mediaView case .mediaHeaderBody, .none: mediaView headerView bodyView } buttonsView footerButton } .padding(theme.padding) .background(Color.airshipTappableClear) } .addCloseButton( dismissIconResource: theme.dismissIconResource, dismissButtonColor: displayContent.dismissButtonColor?.color, width: theme.dismissIconWidth, height: theme.dismissIconHeight, onUserDismissed: { environment.onUserDismissed() } ) .addBackground( color: displayContent.backgroundColor?.color ?? Color.black ) .onAppear { self.environment.onAppear() } } } #Preview { let headingText = InAppMessageTextInfo(text: "this is header text") let bodyText = InAppMessageTextInfo(text: "this is body text") let displayContent = InAppMessageDisplayContent.Fullscreen(heading: headingText, body:bodyText, buttons: [], template: .headerMediaBody) return FullscreenView(displayContent: displayContent, theme: InAppMessageTheme.Fullscreen.defaultTheme) } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/HTMLView.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif import Combine struct HTMLView: View { #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 .publisher(for: UIDevice.orientationDidChangeNotification) .makeConnectable() .autoconnect() #endif @EnvironmentObject private var environment: InAppMessageEnvironment init(displayContent: InAppMessageDisplayContent.HTML, theme: InAppMessageTheme.HTML) { self.displayContent = displayContent self.theme = theme } var body: some View { let allowAspectLock = displayContent.width != nil && displayContent.height != nil && displayContent.aspectLock == true InAppMessageWebView(displayContent: displayContent, accessibilityLabel: "In-app web view") .airshipApplyIf(!theme.hideDismissIcon){ $0.addCloseButton( dismissIconResource: theme.dismissIconResource, dismissButtonColor: displayContent.dismissButtonColor?.color, width: theme.dismissIconWidth, height: theme.dismissIconHeight, onUserDismissed: { environment.onUserDismissed() } ) }.airshipApplyIf(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) }.airshipApplyIf(isModal && !allowAspectLock) { $0.cornerRadius(displayContent.borderRadius ?? 0) .parentClampingResize( maxWidth: min(theme.maxWidth, (displayContent.width ?? .infinity)), maxHeight: min(theme.maxHeight, (displayContent.height ?? .infinity)) ) .padding(theme.padding) .addBackground(color: .airshipShadowColor) }.airshipApplyIf(!isModal) { /// Add system background color by default - clear color will be parsed by the display content if it's set $0.addBackground(color: displayContent.backgroundColor?.color ?? AirshipColor.systemBackground) } .onAppear { self.environment.onAppear() } } var isModal: Bool { guard displayContent.forceFullscreen != true else { return false } 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, theme: InAppMessageTheme.HTML.defaultTheme) } #endif ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageBannerView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif struct InAppMessageBannerView: View { @ObservedObject var environment: InAppMessageEnvironment /// Used to transmit self sizing info to UIKit host @ObservedObject var bannerConstraints: InAppMessageBannerConstraints /// A state variable to prevent endless size refreshing @State private var lastSize: CGSize? @State private var isShowing: Bool = false @State private var swipeOffset: CGFloat = 0 @State private var isButtonTapsDisabled: Bool = false @StateObject var timer: AirshipObservableTimer var theme: InAppMessageTheme.Banner var onDismiss: () -> Void private let displayContent: InAppMessageDisplayContent.Banner private static let mediaMaxWidth: CGFloat = 120 private static let mediaMinHeight: CGFloat = 88 private static let mediaMaxHeight: CGFloat = 480 static let animationInOutDuration = 0.2 @ViewBuilder private var dissmisActionButton: some View { Button("ua_dismiss".airshipLocalizedString(fallback: "Dismiss")) { setShowing(state: false) { onDismiss() } } .accessibilityRemoveTraits(.isButton) } @ViewBuilder private var headerView: some View { if let heading = displayContent.heading { TextView(textInfo: heading, textTheme: self.theme.header) .fixedSize(horizontal: false, vertical: true) .accessibilityAddTraits(.isHeader) .accessibilityAddTraits(.isStaticText) .applyAlignment(placement: heading.alignment ?? .left) .accessibilityActions { dissmisActionButton } } } @ViewBuilder private var bodyView: some View { if let body = displayContent.body { TextView(textInfo: body, textTheme: self.theme.body) .fixedSize(horizontal: false, vertical: true) .accessibilityAddTraits(.isStaticText) .applyAlignment(placement: body.alignment ?? .left) .accessibilityActions { dissmisActionButton } } } @ViewBuilder private var mediaView: some View { if let media = displayContent.media { MediaView( mediaInfo: media, mediaTheme: self.theme.media ) .padding(.horizontal, -theme.media.padding.leading) .frame( maxWidth: Self.mediaMaxWidth, minHeight: Self.mediaMinHeight, maxHeight: Self.mediaMaxHeight ) .fixedSize(horizontal: false, vertical: true) } } @ViewBuilder private var buttonsView: some View { if let buttons = displayContent.buttons, !buttons.isEmpty { ButtonGroup( isDisabled: $isButtonTapsDisabled, layout: displayContent.buttonLayoutType ?? .stacked, buttons: buttons, theme: self.theme.buttons ) } } #if os(iOS) private var orientationChangePublisher = NotificationCenter.default .publisher(for: UIDevice.orientationDidChangeNotification) .makeConnectable() .autoconnect() #endif 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 self._timer = StateObject(wrappedValue: AirshipObservableTimer(duration: displayContent.duration)) } @ViewBuilder private var contentBody: some View { switch displayContent.template { case .mediaLeft, .none: HStack(alignment: .top, spacing: 16) { mediaView VStack(alignment: .center, spacing: 16) { headerView bodyView } } .accessibilityActions { dissmisActionButton } case .mediaRight: HStack(alignment: .top, spacing: 16) { VStack(alignment: .center, spacing: 16) { headerView bodyView } mediaView } .accessibilityActions { dissmisActionButton } } } @ViewBuilder private var nub: some View { let tabHeight: CGFloat = 4 let tabWidth: CGFloat = 36 let tabColor:Color = displayContent.dismissButtonColor?.color ?? Color.black.opacity(0.42) Capsule() .frame(width: tabWidth, height: tabHeight) .foregroundColor(tabColor) .accessibilityElement() .padding(.horizontal, 40) .padding(.vertical, 16) .contentShape(Rectangle()) .airshipFocusableCompat() .accessibilityLabel("ua_dismiss".airshipLocalizedString(fallback: "Dismiss")) .accessibilityAddTraits(.isButton) .accessibilityAction { onDismiss() } } @ViewBuilder private var messageBody: some View { let itemSpacing: CGFloat = 16 let messageContent = VStack(spacing: itemSpacing) { contentBody buttonsView } let body = VStack(spacing: 0) { if (displayContent.placement == .top) { messageContent nub } else { nub messageContent } } .padding(.horizontal, itemSpacing) .airshipGeometryGroupCompat() if let actions = displayContent.actions { Button( action: { if (!self.isButtonTapsDisabled) { environment.onUserDismissed() environment.runActions(actions: actions) } }, label: { body.background(Color.airshipTappableClear) } ).buttonStyle( InAppMessageCustomOpacityButtonStyle(pressedOpacity: theme.tapOpacity) ) } else { body } } private func setShowing(state:Bool, completion: (() -> Void)? = nil) { withAnimation(Animation.easeInOut(duration: InAppMessageBannerView.animationInOutDuration)) { self.isShowing = state } DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + InAppMessageBannerView.animationInOutDuration, execute: { completion?() }) } private var banner: some View { messageBody .frame(maxWidth: theme.maxWidth) .background( GeometryReader(content: { contentMetrics -> Color in let size = contentMetrics.size DispatchQueue.main.async { if self.lastSize != size { self.bannerConstraints.size = size self.lastSize = size } } return Color.airshipTappableClear }) ) .background( (displayContent.backgroundColor?.color ?? Color.white) .cornerRadius(displayContent.borderRadius ?? 0) .edgesIgnoringSafeArea(displayContent.placement == .top ? .top : .bottom) .shadow( color: theme.shadow.color, radius: theme.shadow.radius, x: theme.shadow.xOffset, y: theme.shadow.yOffset ) ) .showing(isShowing: isShowing) .padding(theme.padding) .airshipApplyTransitioningPlacement(isTopPlacement: displayContent.placement == .top) .offset(x: 0, y: swipeOffset) #if !os(tvOS) .simultaneousGesture(swipeGesture) #endif .accessibilityElement(children: .contain) .accessibilityAction(.escape) { setShowing(state: false) { onDismiss() } } .onAppear { setShowing(state: true) timer.onAppear() self.environment.onAppear() } .onDisappear { self.timer.onDisappear() } .airshipOnChangeOf(swipeOffset) { value in self.isButtonTapsDisabled = value != 0 self.timer.isPaused = value != 0 } .airshipOnChangeOf(environment.isDismissed) { _ in setShowing(state: false) { onDismiss() } } .onReceive(timer.$isExpired) { expired in if (expired) { self.environment.onUserDismissed() } } .frame(width: self.width) } var width: CGFloat { #if os(visionOS) return min(1280, theme.maxWidth) #elseif os(macOS) // On macOS, we typically use the main window's width or // a reasonable default if the window isn't attached yet. let screenWidth = NSScreen.main?.frame.width ?? 1024 return min(screenWidth, theme.maxWidth) #else min(UIScreen.main.bounds.size.width, theme.maxWidth) #endif } var body: some View { InAppMessageRootView(inAppMessageEnvironment: environment) { banner } } #if !os(tvOS) private var swipeGesture: some Gesture { let minSwipeDistance: CGFloat = if self.bannerConstraints.size.height > 0 { min(100.0, self.bannerConstraints.size.height * 0.5) } else { 100.0 } let placement = displayContent.placement ?? .bottom return DragGesture(minimumDistance: 10) .onChanged { gesture in withAnimation(.interpolatingSpring(stiffness: 300, damping: 20)) { let offset = gesture.translation.height let upwardSwipeTopPlacement = (placement == .top && offset < 0) let downwardSwipeBottomPlacement = (placement == .bottom && offset > 0) if upwardSwipeTopPlacement || downwardSwipeBottomPlacement { self.swipeOffset = gesture.translation.height } } } .onEnded { gesture in withAnimation(.interpolatingSpring(stiffness: 300, damping: 20)) { let offset = gesture.translation.height swipeOffset = offset let upwardSwipeTopPlacement = (placement == .top && offset < -minSwipeDistance) let downwardSwipeBottomPlacement = (placement == .bottom && offset > minSwipeDistance) if upwardSwipeTopPlacement || downwardSwipeBottomPlacement { self.environment.onUserDismissed() } else { /// Return to origin and do nothing self.swipeOffset = 0 } } } } #endif } fileprivate struct InAppMessageCustomOpacityButtonStyle: ButtonStyle { let pressedOpacity: Double func makeBody(configuration: Configuration) -> some View { configuration.label.opacity(configuration.isPressed ? pressedOpacity : 1.0) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageExtensions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Airship rendering engine extensions. /// - Note: for internal use only. :nodoc: struct InAppMessageExtensions { #if !os(tvOS) let nativeBridgeExtension: (any NativeBridgeExtensionDelegate)? #endif let imageProvider: (any AirshipImageProvider)? let actionRunner: (any InAppActionRunner)? } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageHostingController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif #if !os(watchOS) class InAppMessageHostingController : AirshipNativeHostingController where Content : View { var onDismiss: (() -> Void)? override init(rootView: Content) { super.init(rootView: rootView) #if os(macOS) self.view.wantsLayer = true self.view.layer?.backgroundColor = NSColor.clear.cgColor #else self.view.backgroundColor = .clear #endif } required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } #if os(macOS) override func viewDidDisappear() { super.viewDidDisappear() dismiss() } // macOS escape key handling is usually done via commands or // overriding cancelOperation in the view hierarchy. override func cancelOperation(_ sender: Any?) { dismiss() } #else override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) dismiss() } override func accessibilityPerformEscape() -> Bool { dismiss() return true } #endif private func dismiss() { self.onDismiss?() onDismiss = nil } #if !os(tvOS) && !os(macOS) /// Just to be explicit about what we expect from these hosting controllers override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return .all } override var shouldAutorotate: Bool { return true } #endif } #endif import Combine class InAppMessageBannerViewController: InAppMessageHostingController { private var centerXConstraint: NSLayoutConstraint? private var topConstraint: NSLayoutConstraint? private var bottomConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint? private var widthConstraint: NSLayoutConstraint? private let bannerConstraints: InAppMessageBannerConstraints private let placement: InAppMessageDisplayContent.Banner.Placement? private var subscription: AnyCancellable? init( rootView: InAppMessageBannerView, placement: InAppMessageDisplayContent.Banner.Placement?, bannerConstraints: InAppMessageBannerConstraints ) { self.bannerConstraints = bannerConstraints self.placement = placement super.init(rootView: rootView) } required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } #if os(macOS) override func viewDidAppear() { super.viewDidAppear() createBannerConstraints() handleBannerConstraints(size: self.bannerConstraints.size) subscription = bannerConstraints.$size.sink { [weak self] size in self?.handleBannerConstraints(size: size) } } override func viewWillDisappear() { subscription?.cancel() super.viewWillDisappear() } func createBannerConstraints() { guard let contentView = view.window?.contentView else { return } self.view.translatesAutoresizingMaskIntoConstraints = false if let window = self.view.window { centerXConstraint = self.view.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) topConstraint = self.view.topAnchor.constraint(equalTo: contentView.topAnchor) bottomConstraint = self.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) heightConstraint = self.view.heightAnchor.constraint(equalToConstant: self.bannerConstraints.size.height) widthConstraint = self.view.widthAnchor.constraint(equalToConstant: self.bannerConstraints.size.width) } } #else override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) createBannerConstraints() handleBannerConstraints(size: self.bannerConstraints.size) self.view.layoutIfNeeded() if UIAccessibility.isVoiceOverRunning { DispatchQueue.main.asyncAfter(deadline: .now() + InAppMessageBannerView.animationInOutDuration) { self.view.accessibilityViewIsModal = true UIAccessibility.post(notification: .screenChanged, argument: self.view) } } subscription = bannerConstraints.$size.sink { [weak self] size in self?.handleBannerConstraints(size: size) self?.view.layoutIfNeeded() } } override func viewWillDisappear(_ animated: Bool) { subscription?.cancel() super.viewWillDisappear(animated) } func createBannerConstraints() { self.view.translatesAutoresizingMaskIntoConstraints = false if let window = self.view.window { centerXConstraint = self.view.centerXAnchor.constraint(equalTo: window.centerXAnchor) topConstraint = self.view.topAnchor.constraint(equalTo: window.topAnchor) bottomConstraint = self.view.bottomAnchor.constraint(equalTo: window.bottomAnchor) heightConstraint = self.view.heightAnchor.constraint(equalToConstant: self.bannerConstraints.size.height) widthConstraint = self.view.widthAnchor.constraint(equalToConstant: self.bannerConstraints.size.width) } } #endif func handleBannerConstraints(size: CGSize) { // Ensure view is still in window hierarchy before updating constraints guard self.view.window != nil else { return } self.centerXConstraint?.isActive = true self.heightConstraint?.isActive = true self.widthConstraint?.isActive = true self.widthConstraint?.constant = size.width switch self.placement { case .top: self.topConstraint?.isActive = true self.bottomConstraint?.isActive = false self.heightConstraint?.constant = size.height + self.view.safeAreaInsets.top default: self.topConstraint?.isActive = false self.bottomConstraint?.isActive = true self.heightConstraint?.constant = size.height + self.view.safeAreaInsets.bottom } } } @MainActor class InAppMessageBannerConstraints: ObservableObject { @Published var size: CGSize init(size: CGSize) { self.size = size } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageModalView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif struct InAppMessageModalView: View { @EnvironmentObject var environment: InAppMessageEnvironment #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 @ViewBuilder private var headerView: some View { if let heading = displayContent.heading { TextView(textInfo: heading, textTheme: theme.header) .applyAlignment(placement: displayContent.heading?.alignment ?? .left) .accessibilityAddTraits(.isHeader) .accessibilityAddTraits(.isStaticText) } } @ViewBuilder private var bodyView: some View { if let body = displayContent.body { TextView(textInfo: body, textTheme: theme.body) .applyAlignment(placement: displayContent.body?.alignment ?? .left) .accessibilityAddTraits(.isStaticText) } } @ViewBuilder private var mediaView: some View { if let media = displayContent.media { if shouldRemoveMediaTopPadding { MediaView(mediaInfo: media, mediaTheme: theme.media) .padding(.top, -theme.padding.top) } else { MediaView(mediaInfo: media, mediaTheme: theme.media) } } } @ViewBuilder private var buttonsView: some View { if let buttons = displayContent.buttons, !buttons.isEmpty { ButtonGroup( layout: displayContent.buttonLayoutType ?? .stacked, buttons: buttons, theme: theme.buttons ) } } @ViewBuilder private var footerButton: some View { if let footer = displayContent.footer { ButtonView(buttonInfo: footer) } } #if os(iOS) private var orientationChangePublisher = NotificationCenter.default .publisher(for: UIDevice.orientationDidChangeNotification) .makeConnectable() .autoconnect() #endif init(displayContent: InAppMessageDisplayContent.Modal, theme: InAppMessageTheme.Modal) { self.displayContent = displayContent self.theme = theme } var shouldRemoveMediaTopPadding: Bool { switch displayContent.template { case .headerMediaBody: displayContent.heading == nil case .headerBodyMedia: displayContent.heading == nil && displayContent.body == nil case .mediaHeaderBody, .none: true } } @ViewBuilder private var content: some View { VStack(spacing: 24) { ScrollView { VStack(spacing: 24) { switch displayContent.template { case .headerMediaBody: headerView mediaView bodyView case .headerBodyMedia: headerView bodyView mediaView case .mediaHeaderBody, .none: if displayContent.media != nil { mediaView } headerView bodyView } } .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) } else { scrollViewContentSize = geo.size } } } return Color.clear } ) } .airshipApplyIf(isModal) { $0.frame(maxHeight: scrollViewContentSize.height) } VStack(spacing:24) { buttonsView footerButton } .padding(.leading, theme.padding.leading) .padding(.trailing, theme.padding.trailing) .padding(.bottom, theme.padding.bottom) } } var body: some View { content .addCloseButton( dismissIconResource: theme.dismissIconResource, dismissButtonColor: displayContent.dismissButtonColor?.color, width: theme.dismissIconWidth, height: theme.dismissIconHeight, onUserDismissed: { environment.onUserDismissed() } ) .background(displayContent.backgroundColor?.color ?? Color.black) .airshipApplyIf(isModal) { $0.cornerRadius(displayContent.borderRadius ?? 0) .parentClampingResize(maxWidth: theme.maxWidth, maxHeight: theme.maxHeight) .padding(theme.padding) .addBackground(color: .airshipShadowColor) } .airshipApplyIf(!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 } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageNativeBridgeExtension.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) import Foundation public import WebKit #if canImport(AirshipCore) public import AirshipCore #endif /// Airship native bridge extension for an InAppMessage public final class InAppMessageNativeBridgeExtension: NativeBridgeExtensionDelegate, Sendable { private let message: InAppMessage /// Airship native bridge extension initializer /// - Parameter message: In-app message public init(message: InAppMessage) { self.message = message } public func actionsMetadata( for command: JavaScriptCommand, webView: WKWebView ) -> [String: String] { return [:] } public func extendJavaScriptEnvironment( _ js: any JavaScriptEnvironmentProtocol, webView: WKWebView ) async { let extras = message.extras?.unWrap() as? [String : AnyHashable] js.add( "getMessageExtras", dictionary: extras ?? [:] ) } } #endif ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageRootView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif struct InAppMessageRootView: View { @ObservedObject var inAppMessageEnvironment: InAppMessageEnvironment let content: () -> Content init( inAppMessageEnvironment: InAppMessageEnvironment, @ViewBuilder content: @escaping () -> Content ) { self.inAppMessageEnvironment = inAppMessageEnvironment self.content = content } @ViewBuilder var body: some View { content() .environmentObject(inAppMessageEnvironment) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageViewUtils.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI extension View { @ViewBuilder func addBackground(color: Color) -> some View { ZStack { color.ignoresSafeArea(.all).zIndex(0) self.zIndex(1) } } @ViewBuilder func applyAlignment( placement: InAppMessageTextInfo.Alignment ) -> some View { switch placement { case .center: HStack { Spacer() self Spacer() } case .left: HStack { self Spacer() } case .right: HStack { Spacer() self } } } @ViewBuilder func aspectResize(width:Double? = nil, height:Double? = nil) -> some View { self.modifier(AspectResize(width:width, height:height)) } @ViewBuilder func parentClampingResize(maxWidth: CGFloat, maxHeight: CGFloat) -> some View { self.modifier(ParentClampingResize(maxWidth: maxWidth, maxHeight: maxHeight)) } @ViewBuilder func addCloseButton( dismissIconResource: String, dismissButtonColor: Color?, width: CGFloat? = nil, height: CGFloat? = nil, onUserDismissed: @escaping () -> Void ) -> some View { let dismissIconImage = InAppMessageTheme.dismissIcon(dismissIconResource) let defaultDismissColor = Color.black ZStack(alignment: .topTrailing) { // Align close button to the top trailing corner self.zIndex(0) CloseButton( dismissIconImage: dismissIconImage, dismissIconColor: dismissButtonColor ?? defaultDismissColor, width: width, height: height, onTap: onUserDismissed ) .zIndex(1) } } } struct CenteredGeometryReader: View { var content: (CGSize) -> Content init(@ViewBuilder content: @escaping (CGSize) -> Content) { self.content = content } var body: some View { GeometryReader { geo in let size = geo.size 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? 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) } } } /// Attempt to resize to specified size and clamp any size axis that exceeds parent size axis to said axis. struct ParentClampingResize: ViewModifier { var maxWidth: CGFloat var maxHeight: CGFloat func body(content: Content) -> some View { CenteredGeometryReader { parentSize in let parentWidth = parentSize.width let parentHeight = parentSize.height content.frame( maxWidth: min(parentWidth, maxWidth), maxHeight: min(parentHeight, maxHeight) ) } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/InAppMessageWebView.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) import Foundation import SwiftUI import WebKit #if canImport(AirshipCore) import AirshipCore #endif struct InAppMessageWebView: View { let displayContent: InAppMessageDisplayContent.HTML @State var isWebViewLoading: Bool = false let accessibilityLabel: String? @EnvironmentObject var environment: InAppMessageEnvironment var body: some View { ZStack { WKWebViewRepresentable( url: self.displayContent.url, nativeBridgeExtension: self.environment.nativeBridgeExtension, isWebViewLoading: self.$isWebViewLoading, accessibilityLabel: accessibilityLabel, onRunActions: { name, value, _ in return await environment.runAction(name, arguments: value) }, onDismiss: { environment.onUserDismissed() } ) .addBackground( /// Add system background color by default - clear color will be parsed by the display content if it's set color: displayContent.backgroundColor?.color ?? AirshipColor.systemBackground ) .zIndex(0) if self.isWebViewLoading { BeveledLoadingView() .zIndex(1) /// Necessary to set z index for animation to work .transition(.opacity) } } } } struct WKWebViewRepresentable: AirshipNativeViewRepresentable { #if os(macOS) typealias NSViewType = WKWebView func makeNSView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateNSView(_ nsView: WKWebView, context: Context) { updateView(nsView, context: context) } #else typealias UIViewType = WKWebView func makeUIView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateUIView(_ uiView: WKWebView, context: Context) { updateView(uiView, context: context) } #endif let url: String let nativeBridgeExtension: (any NativeBridgeExtensionDelegate)? @Binding var isWebViewLoading: Bool let accessibilityLabel: String? let onRunActions: @MainActor (String, ActionArguments, WKWebView) async -> ActionResult let onDismiss: () -> Void func makeWebView(context: Context) -> WKWebView { let webView = WKWebView() #if os(macOS) webView.setValue(false, forKey: "drawsBackground") webView.setAccessibilityElement(true) webView.setAccessibilityLabel(accessibilityLabel) #else webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear webView.isAccessibilityElement = true webView.accessibilityLabel = accessibilityLabel #endif webView.navigationDelegate = context.coordinator.nativeBridge if #available(iOS 16.4, *) { webView.isInspectable = Airship.isFlying && Airship.config.airshipConfig.isWebViewInspectionEnabled } if let url = URL(string: self.url) { updateLoading(true) webView.load(URLRequest(url: url)) } return webView } func makeCoordinator() -> Coordinator { Coordinator(self, actionRunner: BlockNativeBridgeActionRunner(onRun: onRunActions)) } func updateView(_ uiView: WKWebView, context: Context) {} func updateLoading(_ isWebViewLoading: Bool) { DispatchQueue.main.async { withAnimation { self.isWebViewLoading = isWebViewLoading } } } class Coordinator: NSObject, AirshipWKNavigationDelegate, JavaScriptCommandDelegate, NativeBridgeDelegate { private let parent: WKWebViewRepresentable private let challengeResolver: ChallengeResolver let nativeBridge: NativeBridge init(_ parent: WKWebViewRepresentable, actionRunner: any NativeBridgeActionRunner, resolver: ChallengeResolver = .shared) { self.parent = parent self.nativeBridge = NativeBridge(actionRunner: actionRunner) self.challengeResolver = resolver super.init() self.nativeBridge.nativeBridgeExtensionDelegate = self.parent.nativeBridgeExtension self.nativeBridge.forwardNavigationDelegate = self self.nativeBridge.javaScriptCommandDelegate = self self.nativeBridge.nativeBridgeDelegate = self } func webView( _ webView: WKWebView, didFinish navigation: WKNavigation! ) { parent.updateLoading(false) } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { parent.updateLoading(true) DispatchQueue.main.async { webView.reload() } } func webView( _ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error ) { parent.updateLoading(true) DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak webView] in webView?.reload() } } func webView( _ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await challengeResolver.resolve(challenge) } func performCommand(_ command: JavaScriptCommand, webView: WKWebView) -> Bool { return false } nonisolated func close() { DispatchQueue.main.async { self.parent.onDismiss() } } } } #endif ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/MediaView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if canImport(WebKit) import WebKit #endif #if canImport(AirshipCore) import AirshipCore #endif struct MediaView: View { @EnvironmentObject var environment: InAppMessageEnvironment var mediaInfo: InAppMessageMediaInfo var mediaTheme: InAppMessageTheme.Media var body: some View { switch mediaInfo.type { case .image: mediaImageView default: #if canImport(WebKit) webView #else EmptyView() #endif } } @ViewBuilder private var mediaImageView: some View { AirshipAsyncImage( url: mediaInfo.url, imageLoader: environment.imageLoader, image: { image, imageSize in image .resizable() .scaledToFit() }, placeholder: { ProgressView() } ) .padding(mediaTheme.padding) } #if canImport(WebKit) private var webView: some View { InAppMessageMediaWebView(mediaInfo: mediaInfo) .aspectRatio(16.0/9.0, contentMode: .fill) .frame(maxWidth: .infinity) .padding(mediaTheme.padding) } #endif } #if canImport(WebKit) struct InAppMessageMediaWebView: AirshipNativeViewRepresentable { #if os(macOS) typealias NSViewType = WKWebView func makeNSView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateNSView(_ nsView: WKWebView, context: Context) { updateView(nsView, context: context) } #else typealias UIViewType = WKWebView func makeUIView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateUIView(_ uiView: WKWebView, context: Context) { updateView(uiView, context: context) } #endif let mediaInfo: InAppMessageMediaInfo private var baseURL: URL? { let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.airship.sdk" return URL(string: "https://\(bundleIdentifier)") } func makeWebView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() #if os(macOS) let webView = WKWebView(frame: .zero, configuration: config) webView.setAccessibilityElement(true) webView.layer?.backgroundColor = .clear webView.setValue(false, forKey: "drawsBackground") // For transparency #else config.allowsInlineMediaPlayback = true config.allowsPictureInPictureMediaPlayback = true let webView = WKWebView(frame: .zero, configuration: config) webView.isAccessibilityElement = true webView.scrollView.isScrollEnabled = false webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear webView.scrollView.contentInsetAdjustmentBehavior = .never #endif webView.navigationDelegate = context.coordinator if #available(iOS 16.4, *) { webView.isInspectable = Airship.isFlying && Airship.config.airshipConfig.isWebViewInspectionEnabled } return webView } func updateView(_ uiView: WKWebView, context: Context) { switch mediaInfo.type { case .video: let htmlString = "" uiView.loadHTMLString(htmlString, baseURL: baseURL) case .youtube: guard var urlComponents = URLComponents(string: mediaInfo.url) else { return } urlComponents.query = "playsinline=1" if let url = urlComponents.url { uiView.load(URLRequest(url: url)) } case .vimeo: guard var urlComponents = URLComponents(string: mediaInfo.url) else { return } urlComponents.query = "playsinline=1" if let url = urlComponents.url { uiView.load(URLRequest(url: url)) } case .image: break // Do nothing for images } } func makeCoordinator() -> Coordinator { return Coordinator() } class Coordinator: NSObject, WKNavigationDelegate { let challengeResolver: ChallengeResolver init(resolver: ChallengeResolver = .shared) { self.challengeResolver = resolver } func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await challengeResolver.resolve(challenge) } } } #endif struct MediaInfo { let url: String let type: InAppMediaType } enum InAppMediaType { case video, youtube, image, vimeo } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/TextView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif struct TextView: View { let textInfo: InAppMessageTextInfo let textTheme: InAppMessageTheme.Text var body: some View { Text(textInfo.text) .foregroundColor(textInfo.color?.color ?? .primary) .multilineTextAlignment(alignment(for: textInfo.alignment)) .applyTextStyling(textInfo: textInfo) .applyTextTheme(textTheme) } private func alignment(for alignment: InAppMessageTextInfo.Alignment?) -> TextAlignment { switch alignment { case .left: return .leading case .center: return .center case .right: return .trailing case .none: return .center } } } extension View { func applyTextStyling(textInfo: InAppMessageTextInfo) -> some View { return self.modifier(TextStyleViewModifier(textInfo: textInfo)) } } struct TextStyleViewModifier: ViewModifier { @Environment(\.sizeCategory) var sizeCategory let textInfo: InAppMessageTextInfo @ViewBuilder func body(content: Content) -> some View { content.font( AirshipFont.resolveFont( size: textInfo.size ?? 14, families: textInfo.fontFamilies, isItalic: textInfo.style?.contains(.italic) ?? false, isBold: textInfo.style?.contains(.bold) ?? false ) ) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageTheme.swift ================================================ /* 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) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeAdditionalPadding.swift ================================================ /* 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) } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeBanner.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif public extension InAppMessageTheme { /// Banner in-app message theme struct Banner: Equatable, Sendable { /// 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 }() } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeButton.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif public extension InAppMessageTheme { static func dismissIcon(_ dismissIconResource: String?) -> Image { if let name = dismissIconResource, let customImage = AirshipNativeImage(named: name) { return Image(airshipNativeImage: customImage) } if let name = dismissIconResource, let systemImage = AirshipNativeImage.airshipSystemImage(name: name) { return Image(airshipNativeImage: systemImage) } return Image(systemName: "xmark") } /// Button in-app message theme struct Button: Equatable, Sendable { /// Button height public var height: Double /// Button spacing when stacked public var stackedSpacing: Double /// Button spacing when separated 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 } } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeFullscreen.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif public extension InAppMessageTheme { /// Fullscreen in-app message theme struct Fullscreen: Equatable, Sendable { /// 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 /// Dismiss icon width public var dismissIconWidth: CGFloat /// Dismiss icon height public var dismissIconHeight: CGFloat /// 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 self.dismissIconWidth = overrides.dismissIconWidth ?? self.dismissIconWidth self.dismissIconHeight = overrides.dismissIconHeight ?? self.dismissIconHeight } 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 dismissIconWidth: CGFloat? var dismissIconHeight: CGFloat? enum CodingKeys: String, CodingKey { case additionalPadding = "additionalPadding" case headerTheme = "headerStyle" case bodyTheme = "bodyStyle" case mediaTheme = "mediaStyle" case buttonTheme = "buttonStyle" case dismissIconResource = "dismissIconResource" case dismissIconWidth = "dismissIconWidth" case dismissIconHeight = "dismissIconHeight" } } 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: "ua_airship_dismiss", dismissIconWidth: 12, dismissIconHeight: 12 ) /// Overrides do { try theme.applyPlistIfExists(plistName: "UAInAppMessageFullScreenStyle") } catch { AirshipLogger.error("Unable to apply theme overrides \(error)") } return theme }() } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeHTML.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif public extension InAppMessageTheme { /// Html message theme struct HTML: Equatable, Sendable { /// Max width in points public var maxWidth: CGFloat /// Max height in points 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 /// Dismiss icon width public var dismissIconWidth: CGFloat /// Dismiss icon height public var dismissIconHeight: CGFloat /// 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.dismissIconWidth = overrides.dismissIconWidth ?? self.dismissIconWidth self.dismissIconHeight = overrides.dismissIconHeight ?? self.dismissIconHeight 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 dismissIconWidth: CGFloat? var dismissIconHeight: CGFloat? 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: "ua_airship_dismiss", dismissIconWidth: 12, dismissIconHeight: 12 ) /// Overrides do { try theme.applyPlistIfExists(plistName: "UAInAppMessageHTMLStyle") } catch { AirshipLogger.error("Unable to apply theme overrides \(error)") } return theme }() } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeManager.swift ================================================ /* 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: Sendable { /// 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 } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeMedia.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI public extension InAppMessageTheme { /// Media in-app message theme struct Media: Equatable, Sendable { /// 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 } } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeModal.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif public extension InAppMessageTheme { /// Modal in-app message theme struct Modal: Equatable, Sendable { /// 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 /// Dismiss icon width public var dismissIconWidth: CGFloat /// Dismiss icon height public var dismissIconHeight: CGFloat /// 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 self.dismissIconWidth = overrides.dismissIconWidth ?? self.dismissIconWidth self.dismissIconHeight = overrides.dismissIconHeight ?? self.dismissIconHeight } 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 dismissIconWidth: CGFloat? var dismissIconHeight: CGFloat? 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 dismissIconWidth = "dismissIconWidth" case dismissIconHeight = "dismissIconHeight" 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: "ua_airship_dismiss", dismissIconWidth: 12, dismissIconHeight: 12 ) /// Overrides do { try theme.applyPlistIfExists(plistName: "UAInAppMessageModalStyle") } catch { AirshipLogger.error("Unable to apply theme overrides \(error)") } return theme }() } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeShadow.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI /// For color utils #if canImport(AirshipCore) import AirshipCore #endif public extension InAppMessageTheme { /// Shadow in-app message theme struct Shadow: Equatable, Sendable { /// 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" } } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/InAppMessageThemeText.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI public extension InAppMessageTheme { /// Text in-app message theme struct Text: Equatable, Sendable { /// 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? } } } ================================================ FILE: Airship/AirshipAutomation/Source/InAppMessage/View/Theme/ThemeExtensions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI extension View { @ViewBuilder func applyTextTheme(_ textTheme: InAppMessageTheme.Text) -> some View { self.padding(textTheme.padding) .lineSpacing(textTheme.lineSpacing) .kerning(textTheme.letterSpacing) } } ================================================ FILE: Airship/AirshipAutomation/Source/Limits/FrequencyChecker.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) import AirshipCore #endif /// Protocol for checking and incrementing frequency limits (e.g. for in-app message display caps). public protocol FrequencyCheckerProtocol: Sendable { /// Whether the frequency limit has been exceeded. @MainActor var isOverLimit: Bool { get } /// Checks the limit and, if not over limit, increments the count. Call from main actor. /// - Returns: `true` if the increment was applied (under limit), `false` if over limit. @MainActor func checkAndIncrement() -> Bool } final class FrequencyChecker: FrequencyCheckerProtocol { private let isOverLimitBlock: @Sendable @MainActor () -> Bool private let checkAndIncrementBlock: @Sendable @MainActor () -> Bool var isOverLimit: Bool { return isOverLimitBlock() } init( isOverLimitBlock: @escaping @Sendable @MainActor () -> Bool, checkAndIncrementBlock: @escaping @Sendable @MainActor () -> Bool ) { self.isOverLimitBlock = isOverLimitBlock self.checkAndIncrementBlock = checkAndIncrementBlock } @MainActor func checkAndIncrement() -> Bool { return checkAndIncrementBlock() } } ================================================ FILE: Airship/AirshipAutomation/Source/Limits/FrequencyConstraint.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Represents a constraint on occurrences within a given time period. struct FrequencyConstraint: Equatable, Hashable, Sendable, Decodable { var identifier: String var range: TimeInterval var count: UInt fileprivate enum CodingKeys: String, CodingKey { case identifier = "id" case range = "range" case boundary = "boundary" case period = "period" } fileprivate enum Period: String, Decodable { case seconds case minutes case hours case days case weeks case months case years func toTimeInterval(_ value: Double) -> TimeInterval { switch (self) { case .seconds: return value case .minutes: return value * 60 case .hours: return value * 60 * 60 case .days: return value * 60 * 60 * 24 case .weeks: return value * 60 * 60 * 24 * 6 case .months: return value * 60 * 60 * 24 * 30 case .years: return value * 60 * 60 * 24 * 365 } } } init(identifier: String, range: TimeInterval, count: UInt) { self.identifier = identifier self.range = range self.count = count } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let identifier = try container.decode(String.self, forKey: .identifier) let periodRange = try container.decode(Double.self, forKey: .range) let period = try container.decode(Period.self, forKey: .period) let boundary = try container.decode(UInt.self, forKey: .boundary) self.init( identifier: identifier, range: period.toTimeInterval(periodRange), count: boundary ) } } ================================================ FILE: Airship/AirshipAutomation/Source/Limits/FrequencyLimitManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Manager protocol for keeping track of frequency limits and occurrence counts. protocol FrequencyLimitManagerProtocol: Sendable { /// Gets a frequency checker corresponding to the passed in constraints identifiers /// - Parameter constraintIDs: Constraint identifiers /// - Returns: The frequency checker instance @MainActor func getFrequencyChecker( constraintIDs: [String]? ) async throws -> any FrequencyCheckerProtocol func setConstraints(_ constraints: [FrequencyConstraint]) async throws } /// Manager for keeping track of frequency limits and occurrence counts. final class FrequencyLimitManager: FrequencyLimitManagerProtocol, Sendable { private let frequencyLimitStore: FrequencyLimitStore private let date: any AirshipDateProtocol private let storeQueue: AirshipSerialQueue private let emptyChecker = FrequencyChecker( isOverLimitBlock: { false }, checkAndIncrementBlock: { true } ) @MainActor private var fetchedConstraints: [String: ConstraintInfo] = [:] @MainActor private var pendingOccurrences: [Occurrence] = [] init( dataStore: FrequencyLimitStore, date: any AirshipDateProtocol = AirshipDate(), storeQueue: AirshipSerialQueue = AirshipSerialQueue() ) { self.frequencyLimitStore = dataStore self.date = date self.storeQueue = storeQueue } convenience init(config: RuntimeConfig) { self.init(dataStore: FrequencyLimitStore(config: config)) } @MainActor func getFrequencyChecker( constraintIDs: [String]? ) async throws -> any FrequencyCheckerProtocol { guard let constraintIDs = constraintIDs, !constraintIDs.isEmpty else { return emptyChecker } return try await storeQueue.run { await self.writePending() let fetched = await self.fetchedConstraints.keys let need = Set(constraintIDs).subtracting(fetched) if !need.isEmpty { let constraintInfos = try await self.frequencyLimitStore.fetchConstraints( Array(need) ) if (constraintInfos.count != need.count) { let missing = need.subtracting(constraintInfos.map { $0.constraint.identifier } ) throw AirshipErrors.error("Requested constraints \(constraintIDs) missing: \(missing)") } await self.updateFetchedConstraintInfos(constraintInfos) } return FrequencyChecker( isOverLimitBlock: { [weak self] in return self?.isOverLimit(constraintIDs: constraintIDs) ?? true }, checkAndIncrementBlock: { [weak self] in return self?.checkAndIncrement(constraintIDs: constraintIDs) ?? false } ) } } func setConstraints(data: Data) async throws { let constraints = try JSONDecoder().decode( [FrequencyConstraint].self, from: data ) try await setConstraints(constraints) } func setConstraints(_ constraints: [FrequencyConstraint]) async throws { try await self.storeQueue.run { await self.writePending() let existing = Set( try await self.frequencyLimitStore.fetchConstraints() .map { $0.constraint } ) let incomingIDs = Set(constraints.map { $0.identifier }) let upsert = constraints.filter { constraint in !existing.contains(constraint) } let delete = existing .filter { constraint in if (!incomingIDs.contains(constraint.identifier)) { return true } return constraints.contains { incoming in constraint.identifier == incoming.identifier && constraint.range != incoming.range } } .map { $0.identifier } try await self.frequencyLimitStore.deleteConstraints(delete) await self.removeFetchedConstraints(delete) for upsert in upsert { try await self.frequencyLimitStore.upsertConstraint(upsert) await self.updateFetchedConstraint(upsert) } } } @MainActor private func isOverLimit(constraintIDs: [String]) -> Bool { return constraintIDs.contains( where: { constraintID in guard let constraintInfo = self.fetchedConstraints[constraintID] else { return false } let constraint = constraintInfo.constraint let occurrences = constraintInfo.occurrences.sorted { l, r in l.timestamp <= r.timestamp } guard occurrences.count >= constraint.count else { return false } let timeStamp = occurrences[occurrences.count - Int(constraint.count)].timestamp let timeSinceOccurrence = self.date.now.timeIntervalSince(timeStamp) return timeSinceOccurrence <= constraint.range } ) } @MainActor private func checkAndIncrement(constraintIDs: [String]) -> Bool { guard !isOverLimit(constraintIDs: constraintIDs) else { return false } let now = self.date.now constraintIDs.forEach { constraintID in let occurrence = Occurrence(constraintID: constraintID, timestamp: now) self.fetchedConstraints[constraintID]?.occurrences.append(occurrence) self.pendingOccurrences.append(occurrence) } // Queue up a task to write pending Task { await self.storeQueue.runSafe { await self.writePending() } } return true } @MainActor private func updateFetchedConstraint(_ constraint: FrequencyConstraint) { self.fetchedConstraints[constraint.identifier]?.constraint = constraint } @MainActor private func removeFetchedConstraints(_ constraintIDs: [String]) { constraintIDs.forEach { constraintID in self.fetchedConstraints[constraintID] = nil } } @MainActor private func updateFetchedConstraintInfos(_ constraintInfos: [ConstraintInfo]) { constraintInfos.forEach { info in self.fetchedConstraints[info.constraint.identifier] = info } } func writePending() async { let pending = await popPendingOccurrences() do { try await self.frequencyLimitStore.saveOccurrences(pending) } catch { AirshipLogger.error("Failed to write pending: \(pending) \(error)") await appendPendingOccurrences(pending) } } @MainActor private func appendPendingOccurrences(_ pending: [Occurrence]) { self.pendingOccurrences.append(contentsOf: pending) } @MainActor private func popPendingOccurrences() -> [Occurrence] { let pending = self.pendingOccurrences self.pendingOccurrences = [] return pending } } ================================================ FILE: Airship/AirshipAutomation/Source/Limits/FrequencyLimitStore.swift ================================================ /* Copyright Airship and Contributors */ import CoreData import Foundation #if canImport(AirshipCore) import AirshipCore #endif enum FrequencyLimitStoreError: Error, Sendable { case coreDataUnavailable case coreDataError } actor FrequencyLimitStore { private let coreData: UACoreData? init( appKey: String, inMemory: Bool ) { let bundle = AirshipAutomationResources.bundle if let modelURL = bundle.url(forResource: "UAFrequencyLimits", withExtension:"momd") { self.coreData = UACoreData( name: "UAFrequencyLimits", modelURL: modelURL, inMemory: inMemory, stores: ["Frequency-limits-\(appKey).sqlite"] ) } else { self.coreData = nil } } init( config: RuntimeConfig ) { self.init( appKey: config.appCredentials.appKey, inMemory: false ) } init( coreData: UACoreData ) { self.coreData = coreData } // MARK: - // MARK: Public Data Access func fetchConstraints( _ constraintIDs: [String]? = nil ) async throws -> [ConstraintInfo] { guard let coreData = self.coreData else { throw FrequencyLimitStoreError.coreDataUnavailable } AirshipLogger.trace( "Fetching frequency limit constraints" ) return try await coreData.performWithResult { context in let result = try self.fetchConstraintsData( forIDs: constraintIDs, context: context ) return result.map { data in return self.makeInfo(data: data) } } } func deleteConstraints( _ constraintIDs: [String] ) async throws { guard let coreData = self.coreData else { throw FrequencyLimitStoreError.coreDataUnavailable } AirshipLogger.trace( "Deleting constraint IDs : \(constraintIDs)" ) try await coreData.perform { context in let constraints = try self.fetchConstraintsData( forIDs: constraintIDs, context: context) constraints.forEach { constraint in context.delete(constraint) } } } func saveOccurrences( _ occurrences: [Occurrence] ) async throws { guard let coreData = self.coreData else { throw FrequencyLimitStoreError.coreDataUnavailable } AirshipLogger.trace("Saving occurrences \(occurrences)") let map: [String: [Occurrence]] = Dictionary(grouping: occurrences, by: { $0.constraintID }) try await coreData.perform { context in try map.forEach { constraintID, occurrences in let constraintsData = try self.fetchConstraintsData( forIDs: [constraintID], context: context ) if let constraintData = constraintsData.first { try occurrences.forEach { occurrence in let occurrenceData = try self.makeOccurrenceData(context: context) occurrenceData.timestamp = occurrence.timestamp constraintData.occurrence.insert(occurrenceData) } } } } } func upsertConstraint( _ constraint: FrequencyConstraint ) async throws { guard let coreData = self.coreData else { throw FrequencyLimitStoreError.coreDataUnavailable } AirshipLogger.trace( "Update constraint : \(constraint.identifier)" ) try await coreData.perform { context in let result = try self.fetchConstraintsData( forIDs: [constraint.identifier], context: context ) let data = try (result.first ?? self.makeConstraintData(context: context)) data.identifier = constraint.identifier data.count = constraint.count data.range = constraint.range } } // MARK: - // MARK: Helpers fileprivate nonisolated func fetchConstraintsData( forIDs constraintIDs: [String]? = nil, context: NSManagedObjectContext ) throws -> [FrequencyConstraintData] { let request: NSFetchRequest = FrequencyConstraintData.fetchRequest() request.includesPropertyValues = true if let constraintIDs = constraintIDs { request.predicate = NSPredicate(format: "identifier IN %@", constraintIDs) } return try context.fetch(request) } fileprivate nonisolated func makeConstraintData( context: NSManagedObjectContext ) throws -> FrequencyConstraintData { guard let data = NSEntityDescription.insertNewObject( forEntityName: FrequencyConstraintData.frequencyConstraintDataEntity, into:context) as? FrequencyConstraintData else { throw FrequencyLimitStoreError.coreDataError } return data } fileprivate nonisolated func makeOccurrenceData( context:NSManagedObjectContext ) throws -> OccurrenceData { guard let data = NSEntityDescription.insertNewObject( forEntityName: OccurrenceData.occurrenceDataEntity, into:context ) as? OccurrenceData else { throw FrequencyLimitStoreError.coreDataError } return data } fileprivate nonisolated func makeInfo(data: FrequencyConstraintData) -> ConstraintInfo { return ConstraintInfo( constraint: FrequencyConstraint( identifier: data.identifier, range: data.range, count: data.count ), occurrences: data.occurrence.map({ occurrenceData in Occurrence( constraintID: data.identifier, timestamp: occurrenceData.timestamp ) }) ) } } struct ConstraintInfo: Hashable, Equatable, Sendable { var constraint: FrequencyConstraint var occurrences: [Occurrence] } /// Represents a constraint on occurrences within a given time period. /// @objc(UAFrequencyConstraintData) fileprivate class FrequencyConstraintData: NSManagedObject { static let frequencyConstraintDataEntity = "UAFrequencyConstraintData" @nonobjc class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: FrequencyConstraintData.frequencyConstraintDataEntity) } /// The constraint identifier. @NSManaged var identifier: String /// The time range. @NSManaged var range: TimeInterval /// The number of allowed occurrences. @NSManaged var count: UInt /// The occurrences @NSManaged var occurrence: Set } @objc(UAOccurrenceData) fileprivate class OccurrenceData: NSManagedObject { static let occurrenceDataEntity = "UAOccurrenceData" @nonobjc class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: OccurrenceData.occurrenceDataEntity) } /// The timestamp @NSManaged var timestamp: Date } ================================================ FILE: Airship/AirshipAutomation/Source/Limits/Occurrence.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct Occurrence: Sendable, Equatable, Hashable { let constraintID: String let timestamp: Date } ================================================ FILE: Airship/AirshipAutomation/Source/RemoteData/AutomationRemoteDataAccess.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine #if canImport(AirshipCore) import AirshipCore #endif /// Remote data access for automation protocol AutomationRemoteDataAccessProtocol: Sendable { var publisher: AnyPublisher { get } func isCurrent(schedule: AutomationSchedule) async -> Bool func requiresUpdate(schedule: AutomationSchedule) async -> Bool func waitFullRefresh(schedule: AutomationSchedule) async func bestEffortRefresh(schedule: AutomationSchedule) async -> Bool func notifyOutdated(schedule: AutomationSchedule) async func contactID(forSchedule schedule: AutomationSchedule) -> String? func source(forSchedule schedule: AutomationSchedule) -> RemoteDataSource? } final class AutomationRemoteDataAccess: AutomationRemoteDataAccessProtocol { private let remoteData: any RemoteDataProtocol private let network: any AirshipNetworkCheckerProtocol private static let remoteDataTypes = ["in_app_messages"] init( remoteData: any RemoteDataProtocol, network: any AirshipNetworkCheckerProtocol = AirshipNetworkChecker() ) { self.remoteData = remoteData self.network = network } var publisher: AnyPublisher { return remoteData.publisher(types: Self.remoteDataTypes) .map { payloads in InAppRemoteData.fromPayloads(payloads) } .eraseToAnyPublisher() } func isCurrent(schedule: AutomationSchedule) async -> Bool { guard isRemoteSchedule(schedule) else { return true } guard let remoteDataInfo = remoteDataInfo(forSchedule: schedule) else { return false } return await self.remoteData.isCurrent(remoteDataInfo: remoteDataInfo) } func requiresUpdate(schedule: AutomationSchedule) async -> Bool { guard isRemoteSchedule(schedule) else { return false } guard let remoteDataInfo = remoteDataInfo(forSchedule: schedule), await self.remoteData.isCurrent(remoteDataInfo: remoteDataInfo) else { return true } let source = remoteDataInfo.source switch(await remoteData.status(source: source)) { case .outOfDate: return true case .stale: return false case .upToDate: return false #if canImport(AirshipCore) @unknown default: return false #endif } } func waitFullRefresh(schedule: AutomationSchedule) async { guard isRemoteSchedule(schedule) else { return } let source = remoteDataInfo(forSchedule: schedule)?.source ?? .app await self.remoteData.waitRefresh(source: source) } func bestEffortRefresh(schedule: AutomationSchedule) async -> Bool { guard isRemoteSchedule(schedule) else { return true } guard let remoteDataInfo = remoteDataInfo(forSchedule: schedule), await remoteData.isCurrent(remoteDataInfo: remoteDataInfo) else { return false } let source = remoteDataInfo.source if await self.remoteData.status(source: source) == .upToDate { return true } // if we are connected wait for refresh attempt if (await network.isConnected) { await remoteData.waitRefreshAttempt(source: source) } return await remoteData.isCurrent(remoteDataInfo: remoteDataInfo) } func notifyOutdated(schedule: AutomationSchedule) async { if let remoteDataInfo = remoteDataInfo(forSchedule: schedule) { await self.remoteData.notifyOutdated(remoteDataInfo: remoteDataInfo) } } func contactID(forSchedule schedule: AutomationSchedule) -> String? { return remoteDataInfo(forSchedule: schedule)?.contactID } func source(forSchedule schedule: AutomationSchedule) -> RemoteDataSource? { guard self.isRemoteSchedule(schedule) else { return nil } return remoteDataInfo(forSchedule: schedule)?.source ?? .app } private func isRemoteSchedule(_ schedule: AutomationSchedule) -> Bool { if case .object(let map) = schedule.metadata { if map[InAppRemoteData.remoteInfoMetadataKey] != nil { return true } if map[InAppRemoteData.legacyRemoteInfoMetadataKey] != nil { return true } } // legacy way if case .inAppMessage(let message) = schedule.data { return message.source == .remoteData } return false } private func remoteDataInfo(forSchedule schedule: AutomationSchedule) -> RemoteDataInfo? { guard case .object(let map) = schedule.metadata else { return nil } guard let remoteInfoJson = map[InAppRemoteData.remoteInfoMetadataKey] else { return nil } do { if let json = remoteInfoJson.string { // 17.x and older let object = try AirshipJSON.from(json: json) return try object.decode() } else { return try remoteInfoJson.decode() } } catch { AirshipLogger.trace("Failed to parse remote info from schedule \(schedule) \(error)") } return nil } } struct InAppRemoteData: Sendable { static let legacyRemoteInfoMetadataKey: String = "com.urbanairship.iaa.REMOTE_DATA_METADATA" static let remoteInfoMetadataKey: String = "com.urbanairship.iaa.REMOTE_DATA_INFO"; struct Data: Decodable, Equatable { var schedules: [AutomationSchedule] var constraints: [FrequencyConstraint]? var failedSchedules: [FailedScheduleRecord] enum CodingKeys: String, CodingKey { case schedules = "in_app_messages" case constraints = "frequency_constraints" } init( schedules: [AutomationSchedule], constraints: [FrequencyConstraint]?, failedSchedules: [FailedScheduleRecord] = [] ) { self.schedules = schedules self.constraints = constraints self.failedSchedules = failedSchedules } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) var schedules: [AutomationSchedule] = [] var failedScheduleRecords: [FailedScheduleRecord] = [] let decodedSchedules = try container .decode([ScheduleDecodeResult].self, forKey: .schedules) for parsed in decodedSchedules { switch parsed { case .succeed(let result): schedules.append(result) case .failed(let schedule, let error): AirshipLogger.error("Failed to parse schedule \(error)") if let schedule = schedule { failedScheduleRecords.append( FailedScheduleRecord( identifier: schedule.identifier, createdDate: schedule.created ?? Date(), minSDKVersion: schedule.minSDKVersion ) ) } } } self.schedules = schedules self.failedSchedules = failedScheduleRecords self.constraints = try container.decodeIfPresent([FrequencyConstraint].self, forKey: .constraints) } } struct Payload { var data: Data var timestamp: Date var remoteDataInfo: RemoteDataInfo? } var payloads: [RemoteDataSource: Payload] /// Returns all schedule record that failed to parse across all payloads with their failure timestamps var failedSchedules: [FailedScheduleRecord] { payloads.values.flatMap { payload in payload.data.failedSchedules } } static func parsePayload(_ payload: RemoteDataPayload?) -> Payload? { guard let payload = payload else { return nil } do { let metadata = try AirshipJSON.wrap( [ legacyRemoteInfoMetadataKey: "", remoteInfoMetadataKey: payload.remoteDataInfo ] as [String: AnyHashable] ) var data: Data = try payload.data.decode() data.schedules.indices.forEach { i in data.schedules[i].metadata = metadata if case .inAppMessage(var message) = data.schedules[i].data { message.source = .remoteData data.schedules[i].data = .inAppMessage(message) } data.schedules[i].triggers.indices.forEach { j in let trigger = data.schedules[i].triggers[j] if (trigger.shouldBackFillIdentifier) { data.schedules[i].triggers[j] = trigger.backfilledIdentifier(executionType: .execution) } } if var delay = data.schedules[i].delay, var triggers = delay.cancellationTriggers { triggers.indices.forEach { j in let trigger = triggers[j] if (trigger.shouldBackFillIdentifier) { triggers[j] = trigger.backfilledIdentifier(executionType: .delayCancellation) } } delay.cancellationTriggers = triggers data.schedules[i].delay = delay } } return Payload( data: data, timestamp:payload.timestamp, remoteDataInfo: payload.remoteDataInfo ) } catch { AirshipLogger.error("Failed to parse app remote-data response. \(error)") } return nil } static func fromPayloads(_ payloads: [RemoteDataPayload]) -> InAppRemoteData { var parsed: [RemoteDataSource: Payload] = [:] payloads.forEach { payload in parsed[payload.remoteDataInfo?.source ?? .app] = parsePayload(payload) } return InAppRemoteData(payloads: parsed) } } /// A struct to extract just the ID, created date, and min SDK version from a schedule when full parsing fails fileprivate struct PartialSchedule: Decodable { let identifier: String let created: Date? let minSDKVersion: String? enum CodingKeys: String, CodingKey { case identifier = "id" case created case minSDKVersion = "min_sdk_version" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.identifier = try container.decode(String.self, forKey: .identifier) self.minSDKVersion = try container.decodeIfPresent(String.self, forKey: .minSDKVersion) if let createdString = try? container.decodeIfPresent(String.self, forKey: .created) { self.created = AirshipDateFormatter.date(fromISOString: createdString) } else { self.created = nil } } } fileprivate enum ScheduleDecodeResult: Decodable { case succeed(AutomationSchedule) case failed(PartialSchedule?, any Error) init(from decoder: any Decoder) throws { do { let schedule = try AutomationSchedule(from: decoder) self = .succeed(schedule) } catch { // Try to at least extract the ID and created date for tracking purposes if let container = try? decoder.singleValueContainer(), let partial = try? container.decode(PartialSchedule.self) { self = .failed(partial, error) } else { self = .failed(nil, error) } } } } ================================================ FILE: Airship/AirshipAutomation/Source/RemoteData/AutomationRemoteDataSubscriber.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @preconcurrency import Combine #if canImport(AirshipCore) import AirshipCore #endif /// Protocol for subscribing to remote data updates and syncing automation schedules. protocol AutomationRemoteDataSubscriberProtocol: Sendable { /// Starts listening for remote data updates and processing automation schedules. @MainActor func subscribe() /// Stops listening for remote data updates and cancels any in-flight processing. @MainActor func unsubscribe() } /// Subscribes to in-app remote data, applies frequency constraints, and syncs schedules with the automation engine. final class AutomationRemoteDataSubscriber: AutomationRemoteDataSubscriberProtocol, Sendable { private let sourceInfoStore: AutomationSourceInfoStore private let remoteDataAccess: any AutomationRemoteDataAccessProtocol private let engine: any AutomationEngineProtocol private let frequencyLimitManager: any FrequencyLimitManagerProtocol private let airshipSDKVersion: String @MainActor private var processTask: Task? private var updateStream: AsyncStream { AsyncStream { continuation in let cancellable = self.remoteDataAccess.publisher.sink { continuation.yield($0) } continuation.onTermination = { _ in cancellable.cancel() } } } /// Creates a remote data subscriber. /// - Parameters: /// - dataStore: Store for preference and source info. /// - remoteDataAccess: Source of in-app remote data updates. /// - engine: Engine used to upsert and stop automation schedules. /// - frequencyLimitManager: Manager for frequency constraints from remote data. /// - airshipSDKVersion: SDK version used for source info (defaults to current). init( dataStore: PreferenceDataStore, remoteDataAccess: any AutomationRemoteDataAccessProtocol, engine: any AutomationEngineProtocol, frequencyLimitManager: any FrequencyLimitManagerProtocol, airshipSDKVersion: String = AirshipVersion.version ) { self.sourceInfoStore = AutomationSourceInfoStore(dataStore: dataStore) self.remoteDataAccess = remoteDataAccess self.engine = engine self.frequencyLimitManager = frequencyLimitManager self.airshipSDKVersion = airshipSDKVersion } /// Starts subscribing to remote data and processing automation updates. @MainActor func subscribe() { if processTask != nil { return } let stream = self.updateStream self.processTask = Task { [weak self] in for await update in stream { guard !Task.isCancelled else { return } await self?.processConstraints(update) await self?.processAutomations(update) } } } /// Stops the subscription and cancels any ongoing processing task. @MainActor func unsubscribe() { processTask?.cancel() processTask = nil } private func processAutomations(_ data: InAppRemoteData) async { let currentSchedules: [AutomationSchedule] do { currentSchedules = try await engine.schedules } catch { AirshipLogger.error("Unable to process automations. Failed to query current schedules with error \(error)") return } for source in RemoteDataSource.allCases { let schedules = currentSchedules.filter { schedule in self.remoteDataAccess.source(forSchedule: schedule) == source } do { try await self.syncAutomations( payload: data.payloads[source], source: source, currentSchedules: schedules ) } catch { AirshipLogger.error("Failed to process \(source) automations \(error)") } } } private func syncAutomations( payload: InAppRemoteData.Payload?, source: RemoteDataSource, currentSchedules: [AutomationSchedule] ) async throws { let currentScheduleIDs = Set(currentSchedules.map { $0.identifier }) guard let payload = payload else { if !currentSchedules.isEmpty { try await engine.stopSchedules( identifiers: Array(currentScheduleIDs) ) } return } let contactID = payload.remoteDataInfo?.contactID let lastSourceInfo = self.sourceInfoStore.getSourceInfo( source: source, contactID: contactID ) let failureResolution = resolveFailedSchedules( lastSourceInfo: lastSourceInfo, currentFailures: payload.data.failedSchedules ) let currentSourceInfo = AutomationSourceInfo( remoteDataInfo: payload.remoteDataInfo, payloadTimestamp: payload.timestamp, airshipSDKVersion: airshipSDKVersion, failedSchedules: failureResolution.tracked ) guard lastSourceInfo != currentSourceInfo else { return } let payloadScheduleIDs = Set(payload.data.schedules.map { $0.identifier }) let schedulesToStop = currentSchedules.filter { !payloadScheduleIDs.contains($0.identifier) } if !schedulesToStop.isEmpty { try await engine.stopSchedules( identifiers: schedulesToStop.map { $0.identifier } ) } let schedulesToUpsert = payload.data.schedules.filter { schedule in if currentScheduleIDs.contains(schedule.identifier) { return true } if failureResolution.recovered.contains(schedule.identifier) { return true } return AutomationSchedule.isNewSchedule( created: schedule.created, minSDKVersion: schedule.minSDKVersion, sinceDate: lastSourceInfo?.payloadTimestamp ?? .distantPast, lastSDKVersion: lastSourceInfo?.airshipSDKVersion ) } if !schedulesToUpsert.isEmpty { try await engine.upsertSchedules(schedulesToUpsert) } self.sourceInfoStore.setSourceInfo( currentSourceInfo, source: source, contactID: contactID ) } private func resolveFailedSchedules( lastSourceInfo: AutomationSourceInfo?, currentFailures: [FailedScheduleRecord] ) -> (tracked: [FailedScheduleRecord], recovered: Set) { let previouslyFailed = Set(lastSourceInfo?.failedSchedules?.map { $0.identifier } ?? []) let currentlyFailed = Set(currentFailures.map { $0.identifier }) let recovered = previouslyFailed.subtracting(currentlyFailed) let stillFailing = lastSourceInfo?.failedSchedules?.filter { currentlyFailed.contains($0.identifier) } ?? [] let stillFailingIDs = Set(stillFailing.map { $0.identifier }) let lastPayloadTimestamp = lastSourceInfo?.payloadTimestamp ?? .distantPast let newlyFailed = currentFailures .filter { !stillFailingIDs.contains($0.identifier) } .filter { AutomationSchedule.isNewSchedule( created: $0.createdDate, minSDKVersion: $0.minSDKVersion, sinceDate: lastPayloadTimestamp, lastSDKVersion: lastSourceInfo?.airshipSDKVersion ) } return (tracked: stillFailing + newlyFailed, recovered: recovered) } private func processConstraints(_ data: InAppRemoteData) async { let constraints = RemoteDataSource.allCases .compactMap { source in data.payloads[source]?.data.constraints } .reduce([], +) do { try await frequencyLimitManager.setConstraints(constraints) } catch { AirshipLogger.error("Failed to process constraints \(error)") } } } ================================================ FILE: Airship/AirshipAutomation/Source/RemoteData/AutomationSourceInfoStore.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Stores information about a remote-data source used for scheduling final class AutomationSourceInfoStore: Sendable { let dataStore: PreferenceDataStore init(dataStore: PreferenceDataStore) { self.dataStore = dataStore } private static let sourceInfoKeyPrefix: String = "AutomationSourceInfo" func getSourceInfo(source: RemoteDataSource, contactID: String?) -> AutomationSourceInfo? { let key = makeInfoKey(source: source, contactID: contactID) if let info: AutomationSourceInfo = self.dataStore.safeCodable(forKey: key) { return info } return self.recoverSourceInfo(source: source, contactID: contactID) } func setSourceInfo( _ sourceInfo: AutomationSourceInfo, source: RemoteDataSource, contactID: String? ) { let key = makeInfoKey(source: source, contactID: contactID) self.dataStore.setSafeCodable(sourceInfo, forKey: key) } private func makeInfoKey(source: RemoteDataSource, contactID: String?) -> String { return if source == .contact { "\(Self.sourceInfoKeyPrefix).\(source).\(contactID ?? "")" } else { "\(Self.sourceInfoKeyPrefix).\(source)" } } private func recoverSourceInfo(source: RemoteDataSource, contactID: String?) -> AutomationSourceInfo? { let key = makeInfoKey(source: source, contactID: contactID) switch (source) { case .app: let lastSDKVersion = self.dataStore.string(forKey: LegacyAppKeys.lastSDKVersion) let lastPayloadTimestamp = self.dataStore.object(forKey: LegacyAppKeys.lastPayloadTimestamp) defer { self.dataStore.removeObject(forKey: LegacyAppKeys.lastMetadata) self.dataStore.removeObject(forKey: LegacyAppKeys.lastPayloadTimestamp) self.dataStore.removeObject(forKey: LegacyAppKeys.lastRemoteDataInfo) self.dataStore.removeObject(forKey: LegacyAppKeys.lastSDKVersion) } guard let lastPayloadTimestamp = lastPayloadTimestamp as? Date else { return nil } let sourceInfo = AutomationSourceInfo( remoteDataInfo: nil, payloadTimestamp: lastPayloadTimestamp, airshipSDKVersion: lastSDKVersion ) self.dataStore.setSafeCodable(sourceInfo, forKey: key) return sourceInfo case .contact: let lastSDKVersion = self.dataStore.string(forKey: LegacyContactKeys.lastSDKVersion(contactID)) let lastPayloadTimestamp = self.dataStore.object(forKey: LegacyContactKeys.lastPayloadTimestamp(contactID)) defer { self.dataStore.removeObject(forKey: LegacyContactKeys.lastPayloadTimestamp(contactID)) self.dataStore.removeObject(forKey: LegacyContactKeys.lastRemoteDataInfo(contactID)) self.dataStore.removeObject(forKey: LegacyContactKeys.lastSDKVersion(contactID)) } guard let lastPayloadTimestamp = lastPayloadTimestamp as? Date else { return nil } let sourceInfo = AutomationSourceInfo( remoteDataInfo: nil, payloadTimestamp: lastPayloadTimestamp, airshipSDKVersion: lastSDKVersion ) self.dataStore.setSafeCodable(sourceInfo, forKey: key) return sourceInfo #if canImport(AirshipCore) @unknown default: return nil #endif } } } /// Represents a schedule that failed to parse, with enough info to evaluate newness on retry. struct FailedScheduleRecord: Sendable, Codable, Equatable { let identifier: String let createdDate: Date let minSDKVersion: String? } struct AutomationSourceInfo: Sendable, Codable, Equatable { let remoteDataInfo: RemoteDataInfo? let payloadTimestamp: Date let airshipSDKVersion: String? /// Schedules that failed to parse, carried forward across syncs until /// they either parse successfully or are removed from remote data. var failedSchedules: [FailedScheduleRecord]? } fileprivate struct LegacyAppKeys { static let lastPayloadTimestamp = "UAInAppRemoteDataClient.LastPayloadTimeStamp" static let lastSDKVersion = "UAInAppRemoteDataClient.LastSDKVersion" static let lastRemoteDataInfo = "UAInAppRemoteDataClient.LastRemoteDataInfo" static let lastMetadata = "UAInAppRemoteDataClient.LastPayloadMetadata" } fileprivate struct LegacyContactKeys { private static let lastPayloadTimestampPrefix = "UAInAppRemoteDataClient.LastPayloadTimeStamp.Contact" private static let lastSDKVersionPrefix = "UAInAppRemoteDataClient.LastSDKVersion.Contact" private static let lastRemoteDataInfoPrefix = "UAInAppRemoteDataClient.LastRemoteDataInfo.Contact" static func lastPayloadTimestamp(_ contactID: String?) -> String { return "\(lastPayloadTimestampPrefix)\(contactID ?? "")" } static func lastSDKVersion(_ contactID: String?) -> String { return "\(lastSDKVersionPrefix)\(contactID ?? "")" } static func lastRemoteDataInfo(_ contactID: String?) -> String { return "\(lastRemoteDataInfoPrefix)\(contactID ?? "")" } } ================================================ FILE: Airship/AirshipAutomation/Source/RemoteData/DeferredScheduleResult.swift ================================================ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct DeferredScheduleResult: Sendable, Codable, Equatable { var isAudienceMatch: Bool var message: InAppMessage? var actions: AirshipJSON? enum CodingKeys: String, CodingKey { case isAudienceMatch = "audience_match" case message case actions } } ================================================ FILE: Airship/AirshipAutomation/Source/Utils/ActiveTimer.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif @MainActor final class ActiveTimer: AirshipTimerProtocol { private var isStarted: Bool = false private var isActive: Bool private var elapsedTime: TimeInterval = 0 private var startDate: Date? = nil private let notificationCenter: AirshipNotificationCenter private let dateFetcher: any AirshipDateProtocol init( appStateTracker: (any AppStateTrackerProtocol)? = nil, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, date: any AirshipDateProtocol = AirshipDate.shared ) { self.notificationCenter = notificationCenter self.dateFetcher = date let stateTracker = appStateTracker ?? AppStateTracker.shared self.isActive = stateTracker.state == .active notificationCenter.addObserver(self, selector: #selector(onApplicationBecomeActive), name: AppStateTracker.didBecomeActiveNotification) notificationCenter.addObserver(self, selector: #selector(onApplicationWillResignActive), name: AppStateTracker.willResignActiveNotification) } deinit { self.notificationCenter.removeObserver(self) } func start() { guard !self.isStarted else { return } if self.isActive { self.startDate = dateFetcher.now } self.isStarted = true } func stop() { guard self.isStarted else { return } self.elapsedTime += currentSessionTime() self.startDate = nil self.isStarted = false } private func currentSessionTime() -> TimeInterval { guard let date = self.startDate else { return 0 } return self.dateFetcher.now.timeIntervalSince(date) } @objc private func onApplicationBecomeActive() { self.isActive = true if self.isStarted, self.startDate == nil { self.startDate = dateFetcher.now } } @objc private func onApplicationWillResignActive() { self.isActive = false stop() } var time: TimeInterval { return self.elapsedTime + currentSessionTime() } } ================================================ FILE: Airship/AirshipAutomation/Source/Utils/AirshipAsyncSemaphore.swift ================================================ /* Copyright Airship and Contributors */ import Foundation actor AirshipAsyncSemaphore { private var value: Int private var waiters: [CheckedContinuation] = [] init(value: Int) { self.value = value } func withPermit(block: @Sendable () async throws -> T) async throws -> T { await self.wait() defer { signal() } return try await block() } private func wait() async { if value > 0 { value -= 1 return } await withCheckedContinuation { cont in waiters.append(cont) } } private func signal() { if let first = waiters.first { waiters.removeFirst() first.resume() } else { value += 1 } } } ================================================ FILE: Airship/AirshipAutomation/Source/Utils/AutomationActionRunner.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Action runner protocol AutomationActionRunnerProtocol: Sendable { func runActions(_ actions: AirshipJSON, situation: ActionSituation, metadata: [String: any Sendable]) async } /// Default action runner struct AutomationActionRunner: AutomationActionRunnerProtocol { func runActions(_ actions: AirshipJSON, situation: ActionSituation, metadata: [String: any Sendable]) async { await ActionRunner.run(actionsPayload: actions, situation: situation, metadata: metadata) } } ================================================ FILE: Airship/AirshipAutomation/Source/Utils/RetryingQueue.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// A concurrent queue that automatically retries an operation with a backoff. /// /// This queue manages a set of operations, ensuring that they are executed /// according to priority and concurrency limits. The queue's primary feature is its /// strict priority model: a higher-priority task will always be processed before /// a lower-priority task, even if that means a completed low-priority task must /// wait to return its result. /// /// Operations can be retried with an exponential backoff. Concurrency is limited /// by `maxConcurrentOperations`, and backpressure is applied via `maxPendingResults` /// to prevent too many completed operations from awaiting their turn to return. /// /// NOTE: For internal use only. :nodoc: actor RetryingQueue { /// Work state that persists across retries for a single operation. actor State { private var state: [String: any Sendable] = [:] /// Sets a value for a given key, allowing state to be preserved across retries. /// - Parameters: /// - value: The value to set. /// - key: The key. func setValue(_ value: (any Sendable)?, key: String) { self.state[key] = value } /// Gets the state for the given key. /// - Parameters: /// - key: The key. /// - Returns: The value if it exists, cast to the expected type. func value(key: String) -> R? { return self.state[key] as? R } } /// The result of an operation block. enum Result: Sendable { /// A successful result. /// - Parameters: /// - result: The value to return from the `run` method. /// - ignoreReturnOrder: If `true`, the result is returned immediately. If `false`, /// it waits for its turn based on priority. case success(result: T, ignoreReturnOrder: Bool = false) /// Indicates the operation should be retried after a specific delay. /// The next retry will use an exponential backoff based on this value. /// - Parameters: /// - retryAfter: The minimum amount of time to wait before retrying. case retryAfter(TimeInterval) /// Indicates the operation should be retried using the default exponential backoff. case retry } /// The internal status of a queued operation. private enum Status: Equatable { /// The operation is in the queue, waiting for its turn to run. case pendingRun /// The operation is currently executing. case running /// The operation has finished and is waiting for its turn to return the result. case pendingReturn /// The operation failed and is waiting for the retry delay to pass. case retrying } /// Internal state for tracking each operation. private struct OperationState { /// The priority of the operation. Lower numbers are higher priority. let priority: Int /// A unique ID for the operation. var id: UInt /// The current status of the operation. var status: Status = .pendingRun /// A continuation used to suspend and resume the operation's task. private var continuation: CheckedContinuation? = nil init(id: UInt, priority: Int) { self.id = id self.priority = priority } /// Resumes the operation's suspended task. mutating func continueWork() { self.continuation?.resume() self.continuation = nil } /// Sets the continuation to allow the operation's task to be suspended. mutating func setContinuation( _ continuation: CheckedContinuation ) { self.continuation = continuation } } /// Max number of operations to run simultaneously. private var maxConcurrentOperations: UInt /// The target maximum number of completed results waiting to be returned. This provides /// backpressure against the queue. This limit may be bypassed to allow a /// high-priority task to run, preventing a potential deadlock. private var maxPendingResults: UInt /// The initial delay for exponential backoff retries. private var initialBackOff: TimeInterval /// The maximum delay for exponential backoff retries. private var maxBackOff: TimeInterval /// A dictionary holding the state for all current operations. private var operationState: [UInt: OperationState] = [:] /// A counter to generate unique operation IDs. private var nextID: UInt = 1 private let taskSleeper: any AirshipTaskSleeper /// Queue id for logging private let id: String init( id: String, config: RemoteConfig.RetryingQueueConfig? = nil, taskSleeper: any AirshipTaskSleeper = .shared ) { self.id = id self.maxConcurrentOperations = config?.maxConcurrentOperations ?? 3 self.maxPendingResults = config?.maxPendingResults ?? 2 self.initialBackOff = config?.initialBackoff ?? 15 self.maxBackOff = config?.maxBackOff ?? 60 self.taskSleeper = taskSleeper } init( id: String, maxConcurrentOperations: UInt = 3, maxPendingResults: UInt = 2, initialBackOff: TimeInterval = 15, maxBackOff: TimeInterval = 60, taskSleeper: any AirshipTaskSleeper = .shared ) { self.id = id self.maxConcurrentOperations = max(1, maxConcurrentOperations) self.maxPendingResults = max(1, maxPendingResults) self.initialBackOff = max(1, initialBackOff) self.maxBackOff = max(initialBackOff, maxBackOff) self.taskSleeper = taskSleeper } /// Adds and runs an operation on the queue. /// /// This method returns only when the operation completes successfully. If the /// operation fails, it will be automatically retried according to the backoff configuration. /// The `async` task calling this method will be suspended until the operation can /// start and will remain suspended until it can return its final result. /// /// - Parameters: /// - name: The name of the operation, used for logging. /// - priority: The priority of the operation. Lower numbers are higher priority. /// - operation: The operation block to execute. It receives a `State` object /// to persist data across retries and must return a `Result`. /// - Returns: The successful result value of the operation. func run( name: String, priority: Int = 0, operation: @escaping @Sendable (State) async throws -> Result ) async -> T { let state = State() var nextBackOff = initialBackOff let operationID = addOperation(priority: priority) AirshipLogger.trace("Queue \(self.id) added: \(name) (priority: \(priority), id: \(operationID))") while(true) { AirshipLogger.trace("Queue \(self.id) waiting to start: \(name) (id: \(operationID))") await waitForStart(operationID: operationID) AirshipLogger.trace("Queue \(self.id) starting task for: \(name) (id: \(operationID))") let task: Task = Task { AirshipLogger.trace("Queue \(self.id) running operation for: \(name) (id: \(operationID))") return try await operation(state) } var result: Result do { result = try await task.value AirshipLogger.trace("Queue \(self.id) task finished for: \(name) (id: \(operationID))") } catch { AirshipLogger.trace("Queue \(self.id) task failed for: \(name) (id: \(operationID)). Error: \(error). Will retry.") result = .retry } switch(result) { case .success(let result, let ignoreReturnOrder): AirshipLogger.trace("Queue \(self.id) waiting to return success for: \(name) (id: \(operationID)). Ignore order: \(ignoreReturnOrder)") await waitForReturn(operationID: operationID, ignoreReturnOrder: ignoreReturnOrder) AirshipLogger.trace("Queue \(self.id) returning success for: \(name) (id: \(operationID))") return result case .retryAfter(let retryAfter): AirshipLogger.trace("Queue \(self.id) will retry after delay: \(name) (id: \(operationID)), delay: \(retryAfter)s") await waitForRetry(operationID: operationID, retryAfter: retryAfter) AirshipLogger.trace("Queue \(self.id) resuming after wait for: \(name) (id: \(operationID))") nextBackOff = min(maxBackOff, max(initialBackOff, retryAfter * 2)) case .retry: AirshipLogger.trace("Queue \(self.id) will retry with backoff: \(name) (id: \(operationID)), backoff: \(nextBackOff)s") await waitForRetry(operationID: operationID, retryAfter: nextBackOff) AirshipLogger.trace("Queue \(self.id) resuming after wait for: \(name) (id: \(operationID))") nextBackOff = min(maxBackOff, nextBackOff * 2) } } } /// Adds a new operation to the queue and returns its ID. private func addOperation(priority: Int) -> UInt { let state = OperationState(id: nextID, priority: priority) nextID += 1 self.operationState[state.id] = state return state.id } /// Handles the retry logic for a failed operation. /// The operation is moved to the `.retrying` state, other tasks are unblocked, /// and this task sleeps for the specified interval before re-queuing itself. private func waitForRetry(operationID: UInt, retryAfter: TimeInterval) async { self.operationState[operationID]?.status = .retrying // Unblock the next operation in the return queue, since this one is no longer returning. if let next = nextReturnID() { self.operationState[next]?.continueWork() } // Unblock the next operation waiting to start, since this one is no longer running. if let next = nextPendingOperationID() { self.operationState[next]?.continueWork() } try? await self.taskSleeper.sleep(timeInterval: retryAfter) if (retryAfter <= 0) { await Task.yield() } // Re-queue the operation to be run again. self.operationState[operationID]?.status = .pendingRun } /// Suspends the current task until it is its turn to start running. private func waitForStart(operationID: UInt) async { if (self.nextPendingOperationID() != operationID) { await withCheckedContinuation { continuation in self.setContinuation(continuation, operationID: operationID) } } self.operationState[operationID]?.status = .running } /// Suspends the current task until it is its turn to return the result. /// After returning, it cleans up and signals other waiting tasks. private func waitForReturn(operationID: UInt, ignoreReturnOrder: Bool) async { self.operationState[operationID]?.status = .pendingReturn // Unblock the next pending operation, as this one is no longer running. if let next = nextPendingOperationID() { self.operationState[next]?.continueWork() } // If strict order is required, wait until this is the highest-priority finished task. if (!ignoreReturnOrder && self.nextReturnID() != operationID) { await withCheckedContinuation { continuation in self.setContinuation(continuation, operationID: operationID) } } // Operation is complete, remove its state. self.operationState.removeValue(forKey: operationID) // Unblock the next operation in the return queue. if let next = nextReturnID() { self.operationState[next]?.continueWork() } // Unblock the next pending operation, as the pending results count has decreased. if let next = nextPendingOperationID() { self.operationState[next]?.continueWork() } } /// Determines the ID of the next operation that should be started. /// /// This function contains the core scheduling and deadlock-prevention logic. /// - Returns: The ID of the next operation to run, or `nil` if no operation can be started. private func nextPendingOperationID() -> UInt? { let running = self.operationState.values.filter { $0.status == .running } if (running.count >= self.maxConcurrentOperations) { return nil } // DEADLOCK PREVENTION: If the highest-priority item in the entire queue is // waiting to run, it might be blocked by a lower-priority item that is // `.pendingReturn`, which in turn is waiting for the high-priority item. // To break this circular dependency, we allow the high-priority item to // bypass the `maxPendingResults` check and run immediately. if let id = nextReturnID(), self.operationState[id]?.status == .pendingRun { return id } // BACKPRESSURE: If the deadlock condition isn't met, enforce the limit // on the number of completed operations waiting to return. let returning = self.operationState.values.filter { $0.status == .pendingReturn } if (returning.count >= self.maxPendingResults) { return nil } // STANDARD SELECTION: Return the highest-priority task that is pending to run. return self.operationState.values .filter { $0.status == .pendingRun } .sorted { $0.priority < $1.priority } .first?.id } /// Determines the ID of the operation that has the highest priority overall. /// /// This is used to enforce strict priority ordering. A lower-priority item that has /// finished (`.pendingReturn`) will be forced to wait if a higher-priority item /// exists, even if it's only just been added (`.pendingRun`). /// /// - Returns: The ID of the highest-priority active task. private func nextReturnID() -> UInt? { return self.operationState.values .filter { $0.status != .retrying } .sorted { $0.priority < $1.priority } .first?.id } /// Associates a continuation with an operation, allowing its task to be suspended. private func setContinuation( _ continuation: CheckedContinuation, operationID: UInt ) { self.operationState[operationID]?.setContinuation(continuation) } } ================================================ FILE: Airship/AirshipAutomation/Source/Utils/ScheduleConditionsChangedNotifier.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol ScheduleConditionsChangedNotifierProtocol: Sendable { @MainActor func notify() @MainActor func wait() async } /// NOTE: For internal use only. :nodoc: @MainActor final class ScheduleConditionsChangedNotifier: Sendable, ScheduleConditionsChangedNotifierProtocol { private var waiting: [CheckedContinuation] = [] @MainActor func notify() { waiting.forEach { continuation in continuation.resume() } waiting.removeAll() } @MainActor func wait() async { return await withCheckedContinuation { continuation in waiting.append(continuation) } } } ================================================ FILE: Airship/AirshipAutomation/Tests/Action Automation/ActionAutomationExecutorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore class ActionAutomationExecutorTest: XCTestCase { private let actionRunner: TestActionRunner = TestActionRunner() private var executor: ActionAutomationExecutor! private let preparedScheduleInfo = PreparedScheduleInfo(scheduleID: "some id", triggerSessionID: UUID().uuidString, priority: 0) private let actions = try! AirshipJSON.wrap(["some-action": "some-value"]) override func setUp() { self.executor = ActionAutomationExecutor(actionRunner: actionRunner) } func testExecute() async throws { let result = await self.executor.execute(data: actions, preparedScheduleInfo: preparedScheduleInfo) XCTAssertEqual(self.actionRunner.actions, actions) XCTAssertEqual(self.actionRunner.situation, .automation) XCTAssertTrue(self.actionRunner.metadata!.isEmpty) XCTAssertEqual(result, .finished) } func testIsReady() async throws { let result = await self.executor.isReady(data: actions, preparedScheduleInfo: preparedScheduleInfo) XCTAssertEqual(result, .ready) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Action Automation/ActionAutomationPreparerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class ActionPreparerTest: XCTestCase { private let preparer: ActionAutomationPreparer = ActionAutomationPreparer() private let actions = try! AirshipJSON.wrap(["some-action": "some-value"]) private let preparedScheduleInfo = PreparedScheduleInfo(scheduleID: "some id", triggerSessionID: UUID().uuidString, priority: 0) func testPrepare() async throws { let result = try await self.preparer.prepare(data: actions, preparedScheduleInfo: preparedScheduleInfo) XCTAssertEqual(actions, result) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Actions/CancelSchedulesActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class CancelSchedulesActionTest: XCTestCase { let automation = TestAutomationEngine() var action: CancelSchedulesAction! override func setUp() async throws { let dataStore = PreferenceDataStore(appKey: UUID().uuidString) let config = RuntimeConfig.testConfig() let inAppAutomation = await DefaultInAppAutomation( engine: automation, inAppMessaging: TestInAppMessaging(), legacyInAppMessaging: TestLegacyInAppMessaging(), remoteData: TestRemoteData(), remoteDataSubscriber: TestRemoteDataSubscriber(), dataStore: dataStore, privacyManager: TestPrivacyManager( dataStore: dataStore, config: config, defaultEnabledFeatures: .all), config: config) action = CancelSchedulesAction(overrideAutomation: inAppAutomation) } func testAcceptsArguments() async throws { let valid: [ActionSituation] = [ .foregroundPush, .backgroundPush, .manualInvocation, .webViewInvocation, .automation ] let rejected: [ActionSituation] = [ .launchedFromPush, .foregroundInteractiveButton, .backgroundInteractiveButton ] for situation in valid { let args = ActionArguments(value: AirshipJSON.null, situation: situation) let result = await action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejected { let args = ActionArguments(value: AirshipJSON.null, situation: situation) let result = await action.accepts(arguments: args) XCTAssertFalse(result) } } func testArguments() async throws { //should accept all var args = ActionArguments(value: try AirshipJSON.wrap("all"), situation: .automation) var result = try await action.perform(arguments: args) XCTAssertNil(result) //should fail other strings args = ActionArguments(value: try AirshipJSON.wrap("invalid"), situation: .automation) await assertThrowsAsync { _ = try await action.perform(arguments: args) } //should accept dictionaries with groups args = ActionArguments(value: try AirshipJSON.wrap(["groups": "test"]), situation: .automation) result = try await action.perform(arguments: args) XCTAssertNil(result) //should accept dictionaries with groups array args = ActionArguments(value: try AirshipJSON.wrap(["groups": ["test"]]), situation: .automation) result = try await action.perform(arguments: args) XCTAssertNil(result) //should accept dictionaries with ids args = ActionArguments(value: try AirshipJSON.wrap(["ids": "test"]), situation: .automation) result = try await action.perform(arguments: args) XCTAssertNil(result) //should accept dictionaries with ids array args = ActionArguments(value: try AirshipJSON.wrap(["ids": ["test"]]), situation: .automation) result = try await action.perform(arguments: args) XCTAssertNil(result) //should accept dictionaries with ids and groups args = ActionArguments(value: try AirshipJSON.wrap(["ids": ["test"], "groups": "test1"]), situation: .automation) result = try await action.perform(arguments: args) XCTAssertNil(result) //should fail if neither groups nor ids key found args = ActionArguments(value: try AirshipJSON.wrap(["key": "invalid"]), situation: .automation) await assertThrowsAsync { _ = try await action.perform(arguments: args) } } func assertThrowsAsync(_ block: () async throws -> Void) async { do { try await block() XCTFail() } catch { } } func testCancellAll() async throws { await automation.setSchedules([ AutomationSchedule(identifier: "action1", data: .actions(.null), triggers: []), AutomationSchedule(identifier: "action2", data: .actions(.null), triggers: []), AutomationSchedule(identifier: "message", data: .inAppMessage(InAppMessage(name: "test", displayContent: .custom(.null))), triggers: []) ]) var count = await automation.schedules.count XCTAssertEqual(3, count) _ = try await action.perform(arguments: ActionArguments(value: AirshipJSON.string("all"))) count = await automation.schedules.count XCTAssertEqual(1, count) let schedule = await automation.schedules.first XCTAssertEqual("message", schedule?.identifier) } func testCancelGroups() async throws { await automation.setSchedules([ AutomationSchedule(identifier: "group1", triggers: [], data: .actions(.null), group: "group-1"), AutomationSchedule(identifier: "group2", triggers: [], data: .actions(.null), group: "group-2"), AutomationSchedule(identifier: "group3", triggers: [], data: .actions(.null), group: "group-3"), ]) let count = await automation.schedules.count XCTAssertEqual(3, count) _ = try await action.perform( arguments: ActionArguments( value: ["groups": "group-1"])) var scheduleIds = await automation.schedules.map({ $0.identifier }) XCTAssertEqual(["group2", "group3"], scheduleIds) _ = try await action.perform( arguments: ActionArguments( value: ["groups": ["group-2", "group-3"]])) scheduleIds = await automation.schedules.map({ $0.identifier }) XCTAssert(scheduleIds.isEmpty) } func testCancelWithIds() async throws { await automation.setSchedules([ AutomationSchedule(identifier: "id-1", triggers: [], data: .actions(.null)), AutomationSchedule(identifier: "id-2", triggers: [], data: .actions(.null)), AutomationSchedule(identifier: "id-3", triggers: [], data: .actions(.null)), ]) let count = await automation.schedules.count XCTAssertEqual(3, count) _ = try await action.perform( arguments: ActionArguments( value: ["ids": "id-1"])) var scheduleIds = await automation.schedules.map({ $0.identifier }) XCTAssertEqual(["id-2", "id-3"], scheduleIds) _ = try await action.perform( arguments: ActionArguments( value: ["ids": ["id-2", "id-3"]])) scheduleIds = await automation.schedules.map({ $0.identifier }) XCTAssert(scheduleIds.isEmpty) } func testBothGroupsAndIds() async throws { await automation.setSchedules([ AutomationSchedule(identifier: "id-1", triggers: [], data: .actions(.null)), AutomationSchedule(identifier: "id-2", triggers: [], data: .actions(.null), group: "group") ]) let count = await automation.schedules.count XCTAssertEqual(2, count) _ = try await action.perform( arguments: ActionArguments( value: ["ids": "id-1", "groups": "group"])) let scheduleIds = await automation.schedules.map({ $0.identifier }) XCTAssert(scheduleIds.isEmpty) } } final class TestInAppMessaging: InAppMessaging, @unchecked Sendable { @MainActor var onIsReadyToDisplay: (@MainActor @Sendable (AirshipAutomation.InAppMessage, String) -> Bool)? @MainActor var themeManager: InAppAutomationThemeManager = InAppAutomationThemeManager() var displayInterval: TimeInterval = 0.0 var displayDelegate: InAppMessageDisplayDelegate? var sceneDelegate: InAppMessageSceneDelegate? func setAdapterFactoryBlock( forType: CustomDisplayAdapterType, factoryBlock: @escaping @Sendable (InAppMessage, AirshipCachedAssetsProtocol) -> CustomDisplayAdapter? ) { } func setCustomAdapter( forType: CustomDisplayAdapterType, factoryBlock: @escaping @Sendable (DisplayAdapterArgs) -> CustomDisplayAdapter? ) { } func notifyDisplayConditionsChanged() { } } final class TestLegacyInAppMessaging: InternalLegacyInAppMessaging, @unchecked Sendable { init(customMessageConverter: AirshipAutomation.MessageConvertor? = nil, messageExtender: AirshipAutomation.MessageExtender? = nil, scheduleExtender: AirshipAutomation.ScheduleExtender? = nil, displayASAPEnabled: Bool = true) { self.customMessageConverter = customMessageConverter self.messageExtender = messageExtender self.scheduleExtender = scheduleExtender self.displayASAPEnabled = displayASAPEnabled } func receivedNotificationResponse(_ response: UNNotificationResponse) async { } func receivedRemoteNotification(_ notification: AirshipJSON) async -> UABackgroundFetchResult { return .noData } var customMessageConverter: AirshipAutomation.MessageConvertor? var messageExtender: AirshipAutomation.MessageExtender? var scheduleExtender: AirshipAutomation.ScheduleExtender? var displayASAPEnabled: Bool } final class TestRemoteDataSubscriber: AutomationRemoteDataSubscriberProtocol { func subscribe() { } func unsubscribe() { } } ================================================ FILE: Airship/AirshipAutomation/Tests/Actions/LandingPageActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class LandingPageActionTest: XCTestCase { func testAcceptsArguments() async throws { let action = LandingPageAction() let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ] let rejectedSituations = [ ActionSituation.backgroundPush, ActionSituation.backgroundInteractiveButton, ] for situation in validSituations { let args = ActionArguments(value: AirshipJSON.null, situation: situation) let result = await action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(value: AirshipJSON.null, situation: situation) let result = await action.accepts(arguments: args) XCTAssertFalse(result) } } func testSimpleURLArg() async throws { let urlChecked = expectation(description: "url checked") let scheduled = expectation(description: "scheduled") let expectedMessage = InAppMessage( name: "Landing Page https://some-url", displayContent: .html( .init( url: "https://some-url", requiresConnectivity: false, borderRadius: 10 ) ), isReportingEnabled: false, displayBehavior: .immediate ) let action = LandingPageAction( borderRadius: 10, scheduleExtender: nil, allowListChecker: { url in XCTAssertEqual("https://some-url", url.absoluteString) urlChecked.fulfill() return true }, scheduler: { schedule in XCTAssertEqual(schedule.data, .inAppMessage(expectedMessage)) XCTAssertEqual(schedule.triggers.count, 1) XCTAssertEqual(schedule.triggers[0].type, EventAutomationTriggerType.activeSession.rawValue) XCTAssertEqual(schedule.triggers[0].goal, 1.0) XCTAssertTrue(schedule.bypassHoldoutGroups!) XCTAssertEqual(schedule.productID, "landing_page") XCTAssertEqual(schedule.queue, "landing_page") XCTAssertEqual(schedule.priority, Int.min) scheduled.fulfill() } ) let args = ActionArguments(value: "https://some-url", situation: .manualInvocation) let result = try await action.perform(arguments: args) XCTAssertNil(result) await self.fulfillment(of: [urlChecked, scheduled]) } func testDictionaryArgs() async throws { let urlChecked = expectation(description: "url checked") let scheduled = expectation(description: "scheduled") let expectedMessage = InAppMessage( name: "Landing Page https://some-url", displayContent: .html( .init( url: "https://some-url", height: 20.0, width: 10.0, aspectLock: true, requiresConnectivity: false, borderRadius: 10 ) ), isReportingEnabled: false, displayBehavior: .immediate ) let action = LandingPageAction( borderRadius: 10, scheduleExtender: nil, allowListChecker: { url in XCTAssertEqual("https://some-url", url.absoluteString) urlChecked.fulfill() return true }, scheduler: { schedule in XCTAssertEqual(schedule.data, .inAppMessage(expectedMessage)) XCTAssertEqual(schedule.triggers.count, 1) XCTAssertEqual(schedule.triggers[0].type, EventAutomationTriggerType.activeSession.rawValue) XCTAssertEqual(schedule.triggers[0].goal, 1.0) XCTAssertTrue(schedule.bypassHoldoutGroups!) XCTAssertEqual(schedule.productID, "landing_page") XCTAssertEqual(schedule.queue, "landing_page") XCTAssertEqual(schedule.priority, Int.min) scheduled.fulfill() } ) let argsJSON = """ { "url": "https://some-url", "width": 10.0, "height": 20.0, "aspect_lock": true } """ let args = ActionArguments(value: try AirshipJSON.from(json: argsJSON), situation: .manualInvocation) let result = try await action.perform(arguments: args) XCTAssertNil(result) await self.fulfillment(of: [urlChecked, scheduled]) } func testAppendSchema() async throws { let urlChecked = expectation(description: "url checked") let scheduled = expectation(description: "scheduled") let expectedMessage = InAppMessage( name: "Landing Page https://some-url", displayContent: .html( .init( url: "https://some-url", requiresConnectivity: false, borderRadius: 10 ) ), isReportingEnabled: false, displayBehavior: .immediate ) let action = LandingPageAction( borderRadius: 10, scheduleExtender: nil, allowListChecker: { url in XCTAssertEqual("https://some-url", url.absoluteString) urlChecked.fulfill() return true }, scheduler: { schedule in XCTAssertEqual(schedule.data, .inAppMessage(expectedMessage)) XCTAssertEqual(schedule.triggers.count, 1) XCTAssertEqual(schedule.triggers[0].type, EventAutomationTriggerType.activeSession.rawValue) XCTAssertEqual(schedule.triggers[0].goal, 1.0) XCTAssertTrue(schedule.bypassHoldoutGroups!) XCTAssertEqual(schedule.productID, "landing_page") XCTAssertEqual(schedule.priority, Int.min) scheduled.fulfill() } ) let args = ActionArguments(value: "some-url", situation: .manualInvocation) let result = try await action.perform(arguments: args) XCTAssertNil(result) await self.fulfillment(of: [urlChecked, scheduled]) } func testExtendSchedule() async throws { let urlChecked = expectation(description: "url checked") let scheduled = expectation(description: "scheduled") let expectedMessage = InAppMessage( name: "Landing Page https://some-url", displayContent: .html( .init( url: "https://some-url", requiresConnectivity: false, borderRadius: 20 ) ), isReportingEnabled: false, displayBehavior: .immediate ) let action = LandingPageAction( borderRadius: 10, scheduleExtender: { args, schedule in schedule.group = "some-group" guard case .inAppMessage(var message) = schedule.data else { return } guard case .html(var html) = message.displayContent else { return } html.borderRadius = 20.0 message.displayContent = .html(html) schedule.data = .inAppMessage(message) }, allowListChecker: { url in XCTAssertEqual("https://some-url", url.absoluteString) urlChecked.fulfill() return true }, scheduler: { schedule in XCTAssertEqual(schedule.data, .inAppMessage(expectedMessage)) XCTAssertEqual(schedule.triggers.count, 1) XCTAssertEqual(schedule.triggers[0].type, EventAutomationTriggerType.activeSession.rawValue) XCTAssertEqual(schedule.triggers[0].goal, 1.0) XCTAssertTrue(schedule.bypassHoldoutGroups!) XCTAssertEqual(schedule.productID, "landing_page") XCTAssertEqual(schedule.priority, Int.min) scheduled.fulfill() } ) let args = ActionArguments(value: "some-url", situation: .manualInvocation) let result = try await action.perform(arguments: args) XCTAssertNil(result) await self.fulfillment(of: [urlChecked, scheduled]) } func testRejectsURL() async throws { let expectation = expectation(description: "url checked") let action = LandingPageAction( borderRadius: 2, scheduleExtender: nil, allowListChecker: { url in XCTAssertEqual("https://some-url", url.absoluteString) expectation.fulfill() return false }, scheduler: { schedule in XCTFail("Should skip scheduling") } ) let args = ActionArguments(value: "https://some-url", situation: .manualInvocation) do { _ = try await action.perform(arguments: args) XCTFail("should throw") } catch {} await self.fulfillment(of: [expectation]) } func testReportingEnabled() async throws { let pushMetadata: AirshipJSON = ["_": "some-send-ID"] let scheduled = expectation(description: "scheduled") let expectedMessage = InAppMessage( name: "Landing Page https://some-url", displayContent: .html( .init( url: "https://some-url", requiresConnectivity: false, borderRadius: 10 ) ), isReportingEnabled: true, displayBehavior: .immediate ) let action = LandingPageAction( borderRadius: 10, scheduleExtender: nil, allowListChecker: { url in return true }, scheduler: { schedule in XCTAssertEqual(schedule.data, .inAppMessage(expectedMessage)) XCTAssertEqual(schedule.triggers.count, 1) XCTAssertEqual(schedule.triggers[0].type, EventAutomationTriggerType.activeSession.rawValue) XCTAssertEqual(schedule.triggers[0].goal, 1.0) XCTAssertTrue(schedule.bypassHoldoutGroups!) XCTAssertEqual(schedule.productID, "landing_page") XCTAssertEqual(schedule.priority, Int.min) XCTAssertEqual(schedule.identifier, "some-send-ID") scheduled.fulfill() } ) let args = ActionArguments( value: "https://some-url", situation: .manualInvocation, metadata: [ActionArguments.pushPayloadJSONMetadataKey: pushMetadata] ) let result = try await action.perform(arguments: args) XCTAssertNil(result) await self.fulfillment(of: [scheduled]) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Actions/ScheduleActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class ScheduleActionTest: XCTestCase { let automation = TestAutomationEngine() var action: ScheduleAction! override func setUp() async throws { let dataStore = PreferenceDataStore(appKey: UUID().uuidString) let config = RuntimeConfig.testConfig() let inAppAutomation = await DefaultInAppAutomation( engine: automation, inAppMessaging: TestInAppMessaging(), legacyInAppMessaging: TestLegacyInAppMessaging(), remoteData: TestRemoteData(), remoteDataSubscriber: TestRemoteDataSubscriber(), dataStore: dataStore, privacyManager: TestPrivacyManager( dataStore: dataStore, config: config, defaultEnabledFeatures: .all), config: config) action = ScheduleAction(overrideAutomation: inAppAutomation) } func testAcceptsArguments() async throws { let valid: [ActionSituation] = [ .foregroundPush, .backgroundPush, .manualInvocation, .webViewInvocation, .automation ] let rejected: [ActionSituation] = [ .launchedFromPush, .foregroundInteractiveButton, .backgroundInteractiveButton ] for situation in valid { let args = ActionArguments(value: AirshipJSON.null, situation: situation) let result = await action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejected { let args = ActionArguments(value: AirshipJSON.null, situation: situation) let result = await action.accepts(arguments: args) XCTAssertFalse(result) } } func testSchedule() async throws { let start = Date(timeIntervalSince1970: 1709138610) let end = Date(timeIntervalSince1970: 1709138610).advanced(by: 1) let json: AirshipJSON = [ "id": "test-id", "type": "actions", "group": "test-group", "limit": 1, "actions": ["action-name": "action-value"], "end": .string(AirshipDateFormatter.string(fromDate: end, format: .iso)), "start": .string(AirshipDateFormatter.string(fromDate: start, format: .iso)), "triggers": [ [ "type": "foreground", "goal": 2 ] ] ] var count = await automation.schedules.count XCTAssertEqual(0, count) let scheduleId = try await action.perform(arguments: ActionArguments(value: json)) XCTAssertEqual("test-id", scheduleId?.string) count = await automation.schedules.count XCTAssertEqual(1, count) let schedule = await automation.schedules.first XCTAssertEqual("test-id", schedule?.identifier) XCTAssertEqual("test-group", schedule?.group) XCTAssertEqual(1, schedule?.limit) XCTAssertEqual(end, schedule?.end) XCTAssertEqual(start, schedule?.start) XCTAssertEqual(1, schedule?.triggers.count) XCTAssertEqual(EventAutomationTriggerType.foreground.rawValue, schedule?.triggers.first?.type) XCTAssertEqual(2, schedule?.triggers.first?.goal) let actionJson: AirshipJSON switch schedule?.data { case .actions(let json): actionJson = json default: actionJson = .null } XCTAssertEqual(AirshipJSON.object(["action-name": "action-value"]), actionJson) } func testScheduleThrowsOnInvalidSource() async throws { do { _ = try await action.perform(arguments: ActionArguments(value: [:])) XCTFail() } catch { } } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/ApplicationMetricsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation @testable import AirshipCore final class ApplicationMetricsTest: XCTestCase { private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter( notificationCenter: NotificationCenter() ) private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private var privacyManager: TestPrivacyManager! private var metrics: ApplicationMetrics! override func setUp() async throws { self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: .testConfig(), defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) self.metrics = ApplicationMetrics( dataStore: self.dataStore, privacyManager: self.privacyManager, notificationCenter: self.notificationCenter, appVersion: "1.0.0" ) } func testAppVersionUpdated() throws { // Fresh install XCTAssertFalse(self.metrics.isAppVersionUpdated) // No change self.metrics = ApplicationMetrics( dataStore: self.dataStore, privacyManager: self.privacyManager, notificationCenter: self.notificationCenter, appVersion: "1.0.0" ) XCTAssertFalse(self.metrics.isAppVersionUpdated) // Update self.metrics = ApplicationMetrics( dataStore: self.dataStore, privacyManager: self.privacyManager, notificationCenter: self.notificationCenter, appVersion: "2.0.0" ) XCTAssertTrue(self.metrics.isAppVersionUpdated) } func testOptedOut() { // Update self.metrics = ApplicationMetrics( dataStore: self.dataStore, privacyManager: self.privacyManager, notificationCenter: self.notificationCenter, appVersion: "2.0.0" ) XCTAssertTrue(self.metrics.isAppVersionUpdated) self.privacyManager.enabledFeatures = [.analytics, .push] XCTAssertTrue(self.metrics.isAppVersionUpdated) self.privacyManager.enabledFeatures = [.inAppAutomation, .push] XCTAssertTrue(self.metrics.isAppVersionUpdated) self.privacyManager.enabledFeatures = .push XCTAssertFalse(self.metrics.isAppVersionUpdated) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/AudienceCheck/AdditionalAudienceCheckerResolverTest.swift ================================================ /* 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: "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: "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: "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: "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: "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: "default context", url: "https://test.url")) XCTAssert(result) date.offset += 2 do { result = try await resolver.resolve( deviceInfoProvider: deviceInfoProvider, additionalAudienceCheckOverrides: .init( bypass: false, context: "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: "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: "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: "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) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/AutomationScheduleDataTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation @testable import AirshipCore final class AutomationScheduleDataTest: XCTestCase { private let date: Date = Date() private let triggerInfo: TriggeringInfo = TriggeringInfo( context: nil, date: Date() ) private let preparedScheduleInfo = PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0) private var data: AutomationScheduleData! override func setUp() async throws { self.data = AutomationScheduleData( schedule: AutomationSchedule( identifier: "neat", triggers: [], data: .actions(.string("actions")) ), scheduleState: .idle, lastScheduleModifiedDate: self.date, scheduleStateChangeDate: self.date, executionCount: 0, triggerSessionID: UUID().uuidString ) } func testIsInState() throws { XCTAssertTrue(data.isInState([.idle])) XCTAssertFalse(data.isInState([])) XCTAssertFalse(data.isInState([.executing])) XCTAssertFalse(data.isInState([.executing, .finished, .prepared, .paused])) XCTAssertTrue(data.isInState([.idle, .executing, .finished, .prepared, .paused])) } func testIsActive() throws { // no start or end XCTAssertTrue(data.isActive(date: self.date)) // starts in the future self.data.schedule.start = self.date + 1 XCTAssertFalse(data.isActive(date: self.date)) // starts now self.data.schedule.start = self.date XCTAssertTrue(data.isActive(date: self.date)) // ends in the past self.data.schedule.end = self.date - 1 XCTAssertFalse(data.isActive(date: self.date)) // ends now self.data.schedule.end = self.date XCTAssertFalse(data.isActive(date: self.date)) // ends in the future self.data.schedule.end = self.date + 1 XCTAssertTrue(data.isActive(date: self.date)) } func testIsExpired() throws { // no end set XCTAssertFalse(data.isExpired(date: self.date)) // ends in the past self.data.schedule.end = self.date - 1 XCTAssertTrue(data.isExpired(date: self.date)) // ends now self.data.schedule.end = self.date XCTAssertTrue(data.isExpired(date: self.date)) // ends in the future self.data.schedule.end = self.date + 1 XCTAssertFalse(data.isExpired(date: self.date)) } func testOverLimitNotSetDefaultsTo1() throws { self.data.schedule.limit = nil self.data.executionCount = 0 XCTAssertFalse(data.isOverLimit) self.data.executionCount = 1 XCTAssertTrue(data.isOverLimit) } func testOverLimitUnlimited() throws { self.data.schedule.limit = 0 self.data.executionCount = 0 XCTAssertFalse(data.isOverLimit) self.data.executionCount = 1 XCTAssertFalse(data.isOverLimit) self.data.executionCount = 100 XCTAssertFalse(data.isOverLimit) } func testOverLimit() throws { self.data.schedule.limit = 10 self.data.executionCount = 0 XCTAssertFalse(data.isOverLimit) self.data.executionCount = 9 XCTAssertFalse(data.isOverLimit) self.data.executionCount = 10 XCTAssertTrue(data.isOverLimit) self.data.executionCount = 11 XCTAssertTrue(data.isOverLimit) } func testFinished() { self.data.triggerInfo = self.triggerInfo self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.finished(date: self.date + 100) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertNil(self.data.triggerInfo) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testIdle() { self.data.scheduleState = .finished self.data.triggerInfo = self.triggerInfo self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.idle(date: self.date + 100) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertNil(self.data.triggerInfo) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPaused() { self.data.triggerInfo = self.triggerInfo self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.paused(date: self.date + 100) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertNil(self.data.triggerInfo) XCTAssertEqual(self.data.scheduleState, .paused) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testUpdateStateFinishesOverLimit() { self.data.scheduleState = .idle self.data.executionCount = 1 self.data.schedule.limit = 1 self.data.updateState(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testUpdateStateExpired() { self.data.scheduleState = .idle self.data.schedule.end = self.date self.data.updateState(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testUpdateFinishedToIdle() { self.data.scheduleState = .finished self.data.updateState(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testUpdateStateFinished() { self.data.scheduleState = .idle self.data.updateState(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date) } func testPrepareCancelledPenalize() { self.data.schedule.limit = 2 self.data.scheduleState = .triggered self.data.prepareCancelled(date: self.date + 100, penalize: true) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.executionCount, 1) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPrepareCancelled() { self.data.scheduleState = .triggered self.data.prepareCancelled(date: self.date + 100, penalize: false) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.executionCount, 0) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPrepareCancelledOverLimit() { self.data.scheduleState = .triggered self.data.prepareCancelled(date: self.date + 100, penalize: true) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 1) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPrepareCancelledExpired() { self.data.schedule.limit = 2 self.data.scheduleState = .triggered self.data.schedule.end = self.date self.data.prepareCancelled(date: self.date + 100, penalize: true) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 1) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPrepareInterrupted() { self.data.scheduleState = .prepared self.data.prepareInterrupted(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .triggered) XCTAssertEqual(self.data.executionCount, 0) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testTriggeredScheduleInterrupted() { self.data.scheduleState = .triggered self.data.prepareInterrupted(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .triggered) XCTAssertEqual(self.data.executionCount, 0) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date) } func testPrepareInterruptedOverLimit() { self.data.schedule.limit = 1 self.data.executionCount = 1 self.data.scheduleState = .triggered self.data.prepareInterrupted(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPrepareInterruptedExpired() { self.data.scheduleState = .triggered self.data.schedule.end = self.date self.data.prepareInterrupted(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 0) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionCancelled() { self.data.scheduleState = .prepared self.data.executionCancelled(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.executionCount, 0) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionCancelledOverLimit() { self.data.schedule.limit = 1 self.data.executionCount = 1 self.data.scheduleState = .prepared self.data.executionCancelled(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionCancelledExpired() { self.data.scheduleState = .prepared self.data.schedule.end = self.date self.data.executionCancelled(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPrepared() { self.data.scheduleState = .triggered self.data.prepared(info: self.preparedScheduleInfo, date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .prepared) XCTAssertEqual(self.data.preparedScheduleInfo, self.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPreparedOverLimit() { self.data.schedule.limit = 1 self.data.executionCount = 1 self.data.scheduleState = .triggered self.data.prepared(info: self.preparedScheduleInfo, date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testPreparedExpired() { self.data.schedule.end = self.date self.data.scheduleState = .triggered self.data.prepared(info: self.preparedScheduleInfo, date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionSkipped() { self.data.schedule.limit = 2 self.data.executionCount = 1 self.data.scheduleState = .prepared self.data.executionSkipped(date: self.date + 100) XCTAssertEqual(self.data.executionCount, 1) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionSkippedOverLimit() { self.data.schedule.limit = 1 self.data.executionCount = 1 self.data.scheduleState = .prepared self.data.executionSkipped(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionSkippedExpired() { self.data.schedule.limit = 2 self.data.executionCount = 1 self.data.schedule.end = self.date self.data.scheduleState = .prepared self.data.executionSkipped(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInvalidated() { self.data.schedule.limit = 2 self.data.executionCount = 1 self.data.scheduleState = .prepared self.data.executionInvalidated(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .triggered) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInvalidatedOverLimit() { self.data.schedule.limit = 1 self.data.executionCount = 1 self.data.scheduleState = .prepared self.data.executionInvalidated(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInvalidatedExpired() { self.data.schedule.limit = 2 self.data.executionCount = 1 self.data.schedule.end = self.date self.data.scheduleState = .prepared self.data.executionInvalidated(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecuting() { self.data.executionCount = 1 self.data.scheduleState = .prepared self.data.executing(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .executing) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInterrupted() { self.data.schedule.limit = 3 self.data.executionCount = 1 self.data.scheduleState = .executing self.data.executionInterrupted(date: self.date + 100, retry: false) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.executionCount, 2) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInterruptedRetry() { self.data.schedule.limit = 3 self.data.executionCount = 1 self.data.schedule.interval = 10.0 self.data.scheduleState = .executing self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.schedule.end = self.date self.data.executionInterrupted(date: self.date + 100, retry: true) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 1) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInterruptedOverLimit() { self.data.schedule.limit = 2 self.data.executionCount = 1 self.data.schedule.interval = 10.0 self.data.scheduleState = .executing self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.executionInterrupted(date: self.date + 100, retry: false) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 2) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInterruptedExpired() { self.data.schedule.limit = 3 self.data.executionCount = 1 self.data.schedule.interval = 10.0 self.data.scheduleState = .executing self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.schedule.end = self.date self.data.executionInterrupted(date: self.date + 100, retry: true) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 1) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testExecutionInterruptedInterval() { self.data.schedule.limit = 3 self.data.executionCount = 1 self.data.scheduleState = .executing self.data.schedule.interval = 10.0 self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.executionInterrupted(date: self.date + 100, retry: false) XCTAssertEqual(self.data.scheduleState, .paused) XCTAssertEqual(self.data.executionCount, 2) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testFinishedExecuting() { self.data.schedule.limit = 3 self.data.executionCount = 1 self.data.scheduleState = .executing self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.finishedExecuting(date: self.date + 100) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleState, .idle) XCTAssertEqual(self.data.executionCount, 2) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testFinishedExecutingOverLimit() { self.data.schedule.limit = 2 self.data.executionCount = 1 self.data.schedule.interval = 10.0 self.data.scheduleState = .executing self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.finishedExecuting(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 2) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testFinishedExecutingExpired() { self.data.schedule.limit = 3 self.data.executionCount = 1 self.data.schedule.interval = 10.0 self.data.scheduleState = .executing self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.schedule.end = self.date self.data.finishedExecuting(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.executionCount, 2) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testFinishedExecutingInterval() { self.data.schedule.limit = 3 self.data.executionCount = 1 self.data.scheduleState = .executing self.data.schedule.interval = 10.0 self.data.preparedScheduleInfo = self.preparedScheduleInfo self.data.finishedExecuting(date: self.date + 100) XCTAssertEqual(self.data.scheduleState, .paused) XCTAssertEqual(self.data.executionCount, 2) XCTAssertNil(self.data.preparedScheduleInfo) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testShouldDelete() { XCTAssertFalse(self.data.shouldDelete(date: self.date)) self.data.scheduleState = .finished XCTAssertTrue(self.data.shouldDelete(date: self.date)) self.data.schedule.editGracePeriodDays = 10 XCTAssertFalse(self.data.shouldDelete(date: self.date)) XCTAssertFalse(self.data.shouldDelete(date: self.date + 10 * 60 * 60 * 24 - 1)) XCTAssertTrue(self.data.shouldDelete(date: self.date + 10 * 60 * 60 * 24)) } func testTriggered() { let previousTriggerSessionID = self.data.triggerSessionID let context = AirshipTriggerContext(type: "some-type", goal: 10.0, event: "event") self.data.triggered(triggerInfo: TriggeringInfo(context: context, date: self.date), date: self.date + 100) XCTAssertEqual(self.data.triggerInfo?.context, context) XCTAssertEqual(self.data.triggerInfo?.date, self.date) XCTAssertEqual(self.data.scheduleState, .triggered) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) XCTAssertNotEqual(self.data.triggerSessionID, previousTriggerSessionID) } func testTriggeredOverLimit() { self.data.schedule.limit = 1 self.data.executionCount = 1 let context = AirshipTriggerContext(type: "some-type", goal: 10.0, event: "event") self.data.triggered(triggerInfo: TriggeringInfo(context: context, date: self.date), date: self.date + 100) XCTAssertNil(self.data.triggerInfo) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } func testTriggeredExpired() { self.data.schedule.limit = 2 self.data.executionCount = 1 self.data.schedule.end = self.date let context = AirshipTriggerContext(type: "some-type", goal: 10.0, event: "event") self.data.triggered(triggerInfo: TriggeringInfo(context: context, date: self.date), date: self.date + 100) XCTAssertNil(self.data.triggerInfo) XCTAssertEqual(self.data.scheduleState, .finished) XCTAssertEqual(self.data.scheduleStateChangeDate, self.date + 100) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/AutomationScheduleTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore class AutomationScheduleTests: XCTestCase { func testParseActions() throws { let jsonString = """ { "id": "test_schedule", "triggers": [ { "type": "custom_event_count", "goal": 1, "id": "json-id" } ], "group": "test_group", "priority": 2, "limit": 5, "start": "2023-12-20T00:00:00Z", "end": "2023-12-21T00:00:00Z", "audience": {}, "delay": {}, "interval": 3600, "type": "actions", "actions": { "foo": "bar", }, "bypass_holdout_groups": true, "edit_grace_period": 7, "metadata": {}, "frequency_constraint_ids": ["constraint1", "constraint2"], "message_type": "test_type", "last_updated": "2023-12-20T12:30: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"])), triggers: [.event(EventAutomationTrigger(id: "json-id", type: .customEventCount, goal: 1.0))], created: Date(timeIntervalSince1970: 1703073600), lastUpdated: Date(timeIntervalSince1970: 1703075400), group: "test_group", priority: 2, limit: 5, start: Date(timeIntervalSince1970: 1703030400), end: Date(timeIntervalSince1970: 1703116800), audience: AutomationAudience(audienceSelector: DeviceAudienceSelector()), delay: AutomationDelay(), interval: 3600, bypassHoldoutGroups: true, editGracePeriodDays: 7, metadata: [:], frequencyConstraintIDs: ["constraint1", "constraint2"], messageType: "test_type", additionalAudienceCheckOverrides: .init(bypass: true, context: "json-context", url: "https://result.url") ) try verify(json: jsonString, expected: expectedSchedule) } func testParseDeferred() throws { let jsonString = """ { "id": "test_schedule", "triggers": [ { "type": "custom_event_count", "goal": 1, "id": "json-id" } ], "group": "test_group", "priority": 2, "limit": 5, "start": "2023-12-20T00:00:00Z", "end": "2023-12-21T00:00:00Z", "audience": { "new_user": true, "miss_behavior": "cancel" }, "delay": {}, "interval": 3600, "type": "deferred", "deferred": { "url": "some:url", "retry_on_timeout": true, "type": "in_app_message" }, "bypass_holdout_groups": true, "edit_grace_period": 7, "metadata": {}, "frequency_constraint_ids": ["constraint1", "constraint2"], "message_type": "test_type", "last_updated": "2023-12-20T12:30:00Z", "created": "2023-12-20T12:00:00Z" } """ let expectedSchedule = AutomationSchedule( identifier: "test_schedule", data: .deferred(DeferredAutomationData(url: URL(string:"some:url")!, retryOnTimeOut: true, type: .inAppMessage)), triggers: [.event(EventAutomationTrigger(id: "json-id", type: .customEventCount, goal: 1.0))], created: Date(timeIntervalSince1970: 1703073600), lastUpdated: Date(timeIntervalSince1970: 1703075400), group: "test_group", priority: 2, limit: 5, start: Date(timeIntervalSince1970: 1703030400), end: Date(timeIntervalSince1970: 1703116800), audience: AutomationAudience(audienceSelector: DeviceAudienceSelector(newUser: true), missBehavior: .cancel), delay: AutomationDelay(), interval: 3600, bypassHoldoutGroups: true, editGracePeriodDays: 7, metadata: [:], frequencyConstraintIDs: ["constraint1", "constraint2"], messageType: "test_type" ) try verify(json: jsonString, expected: expectedSchedule) } func testParseInAppMessage() throws { let jsonString = """ { "id": "test_schedule", "triggers": [ { "type": "custom_event_count", "goal": 1, "id": "json-id" } ], "group": "test_group", "priority": 2, "limit": 5, "start": "2023-12-20T00:00:00Z", "end": "2023-12-21T00:00:00Z", "audience": {}, "delay": {}, "interval": 3600, "type": "in_app_message", "message": { "source": "app-defined", "display" : { "cool": "story" }, "display_type" : "custom", "name" : "woot" }, "bypass_holdout_groups": true, "edit_grace_period": 7, "metadata": {}, "frequency_constraint_ids": ["constraint1", "constraint2"], "message_type": "test_type", "last_updated": "2023-12-20T12:30:00Z", "created": "2023-12-20T12:00:00Z" } """ let message = InAppMessage( name: "woot", displayContent: .custom( ["cool": "story"] ), source: .appDefined ) let expectedSchedule = AutomationSchedule( identifier: "test_schedule", data: .inAppMessage(message), triggers: [.event(EventAutomationTrigger(id: "json-id", type: .customEventCount, goal: 1.0))], created: Date(timeIntervalSince1970: 1703073600), lastUpdated: Date(timeIntervalSince1970: 1703075400), group: "test_group", priority: 2, limit: 5, start: Date(timeIntervalSince1970: 1703030400), end: Date(timeIntervalSince1970: 1703116800), audience: AutomationAudience(audienceSelector: DeviceAudienceSelector(), missBehavior: nil), delay: AutomationDelay(), interval: 3600, bypassHoldoutGroups: true, editGracePeriodDays: 7, metadata: [:], frequencyConstraintIDs: ["constraint1", "constraint2"], messageType: "test_type" ) try verify(json: jsonString, expected: expectedSchedule) } func testParseInAppMessageCompoundAudience() throws { let jsonString = """ { "id": "test_schedule", "triggers": [ { "type": "custom_event_count", "goal": 1, "id": "json-id" } ], "group": "test_group", "priority": 2, "limit": 5, "start": "2023-12-20T00:00:00Z", "end": "2023-12-21T00:00:00Z", "compound_audience": { "selector": { "type": "atomic", "audience": { "new_user": true } }, "miss_behavior": "skip" }, "delay": {}, "interval": 3600, "type": "in_app_message", "message": { "source": "app-defined", "display": { "cool": "story" }, "display_type": "custom", "name": "woot" }, "bypass_holdout_groups": true, "edit_grace_period": 7, "metadata": {}, "frequency_constraint_ids": [ "constraint1", "constraint2" ], "message_type": "test_type", "last_updated": "2023-12-20T12:30:00Z", "created": "2023-12-20T12:00:00Z" } """ let message = InAppMessage( name: "woot", displayContent: .custom( ["cool": "story"] ), source: .appDefined ) let expectedSchedule = AutomationSchedule( identifier: "test_schedule", data: .inAppMessage(message), triggers: [.event(EventAutomationTrigger(id: "json-id", type: .customEventCount, goal: 1.0))], created: Date(timeIntervalSince1970: 1703073600), lastUpdated: Date(timeIntervalSince1970: 1703075400), group: "test_group", priority: 2, limit: 5, start: Date(timeIntervalSince1970: 1703030400), end: Date(timeIntervalSince1970: 1703116800), audience: nil, compoundAudience: AutomationCompoundAudience( selector: .atomic(.init(newUser: true)), missBehavior: .skip ), delay: AutomationDelay(), interval: 3600, bypassHoldoutGroups: true, editGracePeriodDays: 7, metadata: [:], frequencyConstraintIDs: ["constraint1", "constraint2"], messageType: "test_type" ) try verify(json: jsonString, expected: expectedSchedule) } func verify(json: String, expected: AutomationSchedule) throws { let decoder = JSONDecoder() let encoder = JSONEncoder() let fromJSON = try decoder.decode(AutomationSchedule.self, from: json.data(using: .utf8)!) XCTAssertEqual(fromJSON, expected) let roundTrip = try decoder.decode(AutomationSchedule.self, from: try encoder.encode(fromJSON)) XCTAssertEqual(roundTrip, fromJSON) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/AutomationDelayProcessorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class AutomationDelayProcessorTest: XCTestCase { private let analytics: TestAnalytics = TestAnalytics() private let stateTracker: TestAppStateTracker = TestAppStateTracker() private let date: UATestDate = UATestDate() private let taskSleeper: TestTaskSleeper = TestTaskSleeper() private var processor: AutomationDelayProcessor! private var executionWindowProcessor: TestExecutionWindowProcessor! override func setUp() async throws { self.executionWindowProcessor = TestExecutionWindowProcessor() self.date.dateOverride = Date() self.processor = await AutomationDelayProcessor( analytics: analytics, appStateTracker: stateTracker, taskSleeper: taskSleeper, date: date, executionWindowProcessor: executionWindowProcessor ) } @MainActor func testProcess() async throws { let executionWindow = try ExecutionWindow( include: [ .weekly(daysOfWeek: [1]) ] ) let delay = AutomationDelay( seconds: 100.0, screens: ["screen1", "screen2"], regionID: "region1", appState: .foreground, executionWindow: executionWindow ) let finished = AirshipMainActorValue(false) let started = expectation(description: "delay started") let ended = expectation(description: "delay processed") let now = date.now Task { @MainActor [processor] in started.fulfill() await processor!.process(delay: delay, triggerDate: now) finished.set(true) ended.fulfill() } await self.fulfillment(of: [started]) XCTAssertFalse(finished.value) self.analytics.setScreen("screen1") self.analytics.setRegions(Set(["region1"])) self.analytics.setRegions(Set(["region1"])) self.stateTracker.currentState = .active self.executionWindowProcessor.onIsActive = { window in XCTAssertEqual(window, executionWindow) return true } await self.fulfillment(of: [ended]) XCTAssertTrue(finished.value) let sleeps = self.taskSleeper.sleeps XCTAssertEqual(sleeps, [100.0]) let processed = await self.executionWindowProcessor.getProcessed() XCTAssertEqual(processed, [executionWindow]) } @MainActor func testPreprocess() async throws { let executionWindow = try ExecutionWindow( include: [ .weekly(daysOfWeek: [1]) ] ) let delay = AutomationDelay( seconds: 100.0, screens: ["screen1", "screen2"], regionID: "region1", appState: .foreground, executionWindow: executionWindow ) let finished = AirshipMainActorValue(false) let ended = expectation(description: "delay processed") let now = date.now Task { @MainActor [processor] in try! await processor!.preprocess(delay: delay, triggerDate: now) finished.set(true) ended.fulfill() } await self.fulfillment(of: [ended]) XCTAssertTrue(finished.value) let sleeps = self.taskSleeper.sleeps XCTAssertEqual(sleeps, [70.0]) let processed = await self.executionWindowProcessor.getProcessed() XCTAssertEqual(processed, [executionWindow]) } @MainActor func testTaskSleep() async throws { let delay = AutomationDelay( seconds: 100.0 ) let ended = expectation(description: "delay processed") let now = date.now Task { @MainActor [processor] in await processor!.process(delay: delay, triggerDate: now) ended.fulfill() } await self.fulfillment(of: [ended]) let sleeps = self.taskSleeper.sleeps XCTAssertEqual(sleeps, [100.0]) } @MainActor func testRemainingSleep() async throws { let delay = AutomationDelay( seconds: 100.0 ) let ended = expectation(description: "delay processed") let now = date.now Task { @MainActor [processor] in await processor!.process(delay: delay, triggerDate: now - 50.0) ended.fulfill() } await self.fulfillment(of: [ended]) let sleeps = self.taskSleeper.sleeps XCTAssertEqual(sleeps, [50.0]) } @MainActor func testSkipSleep() async throws { let delay = AutomationDelay( seconds: 100.0 ) let ended = expectation(description: "delay processed") let now = date.now Task { @MainActor [processor] in await processor!.process(delay: delay, triggerDate: now - 100.0) ended.fulfill() } await self.fulfillment(of: [ended]) let sleeps = self.taskSleeper.sleeps XCTAssertEqual(sleeps, []) } @MainActor func testEmptyDelay() async throws { let delay = AutomationDelay() let ended = expectation(description: "delay processed") let now = date.now Task { @MainActor [processor] in await processor!.process(delay: delay, triggerDate: now - 100.0) ended.fulfill() } await self.fulfillment(of: [ended]) let sleeps = self.taskSleeper.sleeps XCTAssertEqual(sleeps, []) XCTAssertTrue(self.processor.areConditionsMet(delay: delay)) } @MainActor func testNilDelay() async throws { let ended = expectation(description: "delay processed") let now = date.now Task { @MainActor [processor] in await processor!.process(delay: nil, triggerDate: now - 100.0) ended.fulfill() } await self.fulfillment(of: [ended]) let sleeps = self.taskSleeper.sleeps XCTAssertEqual(sleeps, []) XCTAssertTrue(self.processor.areConditionsMet(delay: nil)) } @MainActor func testScreenConditions() async throws { let delay = AutomationDelay( screens: ["screen1", "screen2"] ) XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) self.analytics.setScreen("screen1") XCTAssertTrue(self.processor.areConditionsMet(delay: delay)) self.analytics.setScreen("screen3") XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) } @MainActor func testRegionCondition() async throws { let delay = AutomationDelay( regionID: "foo" ) XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) self.analytics.setRegions(Set(["foo", "baz"])) XCTAssertTrue(self.processor.areConditionsMet(delay: delay)) self.analytics.setRegions(Set(["bar", "baz"])) XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) } @MainActor func testForegroundAppState() async throws { let delay = AutomationDelay( appState: .foreground ) self.stateTracker.currentState = .background XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) self.stateTracker.currentState = .inactive XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) self.stateTracker.currentState = .active XCTAssertTrue(self.processor.areConditionsMet(delay: delay)) } @MainActor func testBackgroundAppState() async throws { let delay = AutomationDelay( appState: .background ) self.stateTracker.currentState = .background XCTAssertTrue(self.processor.areConditionsMet(delay: delay)) self.stateTracker.currentState = .inactive XCTAssertTrue(self.processor.areConditionsMet(delay: delay)) self.stateTracker.currentState = .active XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) } @MainActor func testExecutionWindow() async throws { let executionWindow = try ExecutionWindow( include: [ .weekly(daysOfWeek: [1]) ] ) let delay = AutomationDelay( executionWindow: executionWindow ) self.executionWindowProcessor.onIsActive = { _ in return false } XCTAssertFalse(self.processor.areConditionsMet(delay: delay)) self.executionWindowProcessor.onIsActive = { _ in return true } XCTAssertTrue(self.processor.areConditionsMet(delay: delay)) } } fileprivate actor TestExecutionWindowProcessor: ExecutionWindowProcessorProtocol { private var processed: [ExecutionWindow] = [] @MainActor var onIsActive: ((ExecutionWindow) -> Bool)? func process(window: ExecutionWindow) async throws { processed.append(window) } func getProcessed() -> [ExecutionWindow] { return processed } @MainActor func isActive(window: ExecutionWindow) -> Bool { return onIsActive?(window) ?? false } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEngineTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore @MainActor final class TestScheduleConditionsChangedNotifier: ScheduleConditionsChangedNotifierProtocol { var onNotify: (() -> Void)? var onWait: (() -> Void)? func notify() { onNotify?() } func wait() async { onWait?() } } final class AutomationEngineTest: XCTestCase { private var engine: AutomationEngine! private var dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var automationStore: AutomationStore! private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter( notificationCenter: NotificationCenter() ) private let actionPreparer: TestPreparerDelegate = TestPreparerDelegate() private let messagePreparer: TestPreparerDelegate = TestPreparerDelegate() private let remoteDataAccess: TestRemoteDataAccess = TestRemoteDataAccess() private var privacyManager: TestPrivacyManager! private let deferredResolver: TestDeferredResolver = TestDeferredResolver() private let experiments: TestExperimentDataProvider = TestExperimentDataProvider() private let frequencyLimits: TestFrequencyLimitManager = TestFrequencyLimitManager() private let audienceChecker: TestAudienceChecker = TestAudienceChecker() private var preparer: AutomationPreparer! private var eventFeed: AutomationEventFeed! private var executor: AutomationExecutor! private var messageExecutor: InAppMessageAutomationExecutor! private var delayProcessor: AutomationDelayProcessor! private var metrics: ApplicationMetrics! private let runtimeConfig: RuntimeConfig = .testConfig() private var scheduleConditionsChangedNotifier: TestScheduleConditionsChangedNotifier! @MainActor override func setUp() async throws { let history = DefaultAutomationEventsHistory(clock: UATestDate()) self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: runtimeConfig, defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) self.automationStore = AutomationStore(appKey: UUID().uuidString, inMemory: true) self.preparer = AutomationPreparer( actionPreparer: actionPreparer, messagePreparer: messagePreparer, deferredResolver: deferredResolver, frequencyLimits: frequencyLimits, audienceChecker: audienceChecker, experiments: experiments, remoteDataAccess: remoteDataAccess, config: self.runtimeConfig, additionalAudienceResolver: TestAdditionalAudienceResolver() ) let actionExecutor = ActionAutomationExecutor() let messageExecutor = TestInAppMessageAutomationExecutor() let executor = AutomationExecutor(actionExecutor: actionExecutor, messageExecutor: messageExecutor, remoteDataAccess: remoteDataAccess) let triggersProcessor = AutomationTriggerProcessor(store: automationStore, history: history) self.metrics = ApplicationMetrics( dataStore: dataStore, privacyManager: privacyManager, notificationCenter: self.notificationCenter, appVersion: "1.0.0" ) let analyticsFeed = AirshipAnalyticsFeed() { true } self.scheduleConditionsChangedNotifier = TestScheduleConditionsChangedNotifier() eventFeed = AutomationEventFeed(applicationMetrics: metrics, applicationStateTracker: AppStateTracker.shared, analyticsFeed: analyticsFeed) let analytics = TestAnalytics() let delayProcessor = AutomationDelayProcessor(analytics: analytics) self.engine = AutomationEngine( store: self.automationStore, executor: executor, preparer: self.preparer, scheduleConditionsChangedNotifier: scheduleConditionsChangedNotifier, eventFeed: eventFeed, triggersProcessor: triggersProcessor, delayProcessor: delayProcessor, eventsHistory: history ) } override func tearDown() async throws { await self.engine.stop() } func testStart() async throws { await self.engine.start() let startTask = await self.engine.startTask let listenTask = await self.engine.listenerTask XCTAssertNotNil(startTask) XCTAssertNotNil(listenTask) } func testStop() async throws { await self.engine.stop() let startTask = await self.engine.startTask let listenTask = await self.engine.listenerTask XCTAssertNil(startTask) XCTAssertNil(listenTask) } @MainActor func testSetEnginePaused() async throws { self.engine.setEnginePaused(true) XCTAssertTrue(self.engine.isEnginePaused.value) } @MainActor func testSetExecutionPaused() async throws { let onNotifyExpectation = expectation(description: "Schedule conditions changed notifiers should be notified when pause state changes.") self.scheduleConditionsChangedNotifier.onNotify = { onNotifyExpectation.fulfill() } self.engine.setExecutionPaused(true) XCTAssertTrue(self.engine.isExecutionPaused.value) self.engine.setExecutionPaused(false) XCTAssertFalse(self.engine.isExecutionPaused.value) await fulfillment(of: [onNotifyExpectation], timeout: 1) } func testStopSchedules() async throws { try await self.engine.upsertSchedules([AutomationSchedule(identifier: "test", triggers: [], data: .inAppMessage( InAppMessage( name: "test", displayContent: .custom(.string("test")) )))]) var schedule = try await self.engine.getSchedule(identifier: "test") XCTAssertNotNil(schedule) try await self.engine.stopSchedules(identifiers: ["test"]) schedule = try await self.engine.getSchedule(identifier: "test") XCTAssertNil(schedule) } func testUpsertSchedules() async throws { var schedule = try await self.engine.getSchedule(identifier: "test") XCTAssertNil(schedule) var storedSchedule = try await self.automationStore.getSchedule(scheduleID: "test") XCTAssertNil(storedSchedule) let beforeDate = AirshipDate().now try await self.engine.upsertSchedules([AutomationSchedule(identifier: "test", triggers: [], data: .inAppMessage( InAppMessage( name: "test", displayContent: .custom(.string("test")) )))]) schedule = try await self.engine.getSchedule(identifier: "test") storedSchedule = try await self.automationStore.getSchedule(scheduleID: "test") XCTAssertGreaterThan(storedSchedule!.lastScheduleModifiedDate, beforeDate) XCTAssertNotNil(schedule) } func testCancelSchedule() async throws { try await self.engine.upsertSchedules([AutomationSchedule(identifier: "test", triggers: [], data: .inAppMessage( InAppMessage( name: "test", displayContent: .custom(.string("test")) )))]) try await self.engine.cancelSchedules(identifiers: ["test"]) let schedule = try await self.engine.getSchedule(identifier: "test") 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 } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEventFeedTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation @testable import AirshipCore final class AutomationEventFeedTest: XCTestCase, @unchecked Sendable { private let date = UATestDate(offset: 0, dateOverride: Date()) private let datastore = PreferenceDataStore(appKey: UUID().uuidString) private var subject: AutomationEventFeed! private let analyticsFeed: AirshipAnalyticsFeed = AirshipAnalyticsFeed() { true } private let stateTracker: TestAppStateTracker = TestAppStateTracker() var iterator: AsyncStream.Iterator! override func setUp() async throws { let metrics = TestApplicationMetrics() metrics.versionUpdated = true subject = await AutomationEventFeed(applicationMetrics: metrics, applicationStateTracker: stateTracker, analyticsFeed: analyticsFeed) iterator = subject.feed.makeAsyncIterator() } func testFirstAttachProducesInitAndVersionUpdated() async throws { await subject.attach() let events = await takeNext(count: 2) let state = TriggerableState(versionUpdated: "test") XCTAssertEqual([AutomationEvent.event(type: .appInit), AutomationEvent.stateChanged(state: state)], events) } func testSubsequentAttachEmitsNoEvents() async throws { await subject.attach() var events = await takeNext(count: 3) await subject.attach() events = await takeNext() XCTAssert(events.isEmpty) await subject.detach().attach() events = await takeNext() XCTAssert(events.isEmpty) } @MainActor func testSupportedEvents() async throws { subject.attach() await takeNext(count: 3) stateTracker.currentState = .active var events = await takeNext(count: 2) 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.event(type: .background), events.first) verifyStateChange(event: events.last!, foreground: false, versionUpdated: "test") let trackScreenName = "test-screen" await analyticsFeed.notifyEvent(.screen(screen: trackScreenName)) var event = await takeNext().first XCTAssertEqual(AutomationEvent.event(type: .screen, data: .string(trackScreenName)), event) await analyticsFeed.notifyEvent(.analytics(eventType: .regionEnter, body: "some region data")) event = await takeNext().first XCTAssertEqual(AutomationEvent.event(type: .regionEnter, data: "some region data"), event) await analyticsFeed.notifyEvent(.analytics(eventType: .regionExit, body: "some region data")) event = await takeNext().first XCTAssertEqual(AutomationEvent.event(type: .regionExit, data: "some region data"), event) await analyticsFeed.notifyEvent(.analytics(eventType: .customEvent, body: "some data", value: 100)) event = await takeNext().first XCTAssertEqual(AutomationEvent.event(type: .customEventCount, data: "some data", value: 1), event) event = await takeNext().first XCTAssertEqual(AutomationEvent.event(type: .customEventValue, data: "some data", value: 100), event) await analyticsFeed.notifyEvent(.analytics(eventType: .featureFlagInteraction, body: "some data")) event = await takeNext().first XCTAssertEqual(AutomationEvent.event(type: .featureFlagInteraction, data: "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: "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.event(type: .customEventValue, data: .null, value: 10.0), event) } func testNoEventsIfNotAttached() async throws { var events = await takeNext() XCTAssert(events.isEmpty) await self.analyticsFeed.notifyEvent(.screen(screen: "foo")) events = await takeNext() XCTAssert(events.isEmpty) } func testNoEventsAfterDetach() async throws { await self.subject.attach() var events = await takeNext(count: 3) XCTAssert(events.count > 0) await subject.detach() await self.analyticsFeed.notifyEvent(.screen(screen: "foo")) events = await takeNext() XCTAssert(events.isEmpty) } func verifyStateChange(event: AutomationEvent, foreground: Bool, versionUpdated: String?, line: UInt = #line) { guard case .stateChanged(let state) = event else { XCTFail("invalid event", line: line) return } if (foreground) { XCTAssertNotNil(state.appSessionID) } else { XCTAssertNil(state.appSessionID) } XCTAssertEqual(versionUpdated, state.versionUpdated) } @discardableResult private func takeNext(count: UInt = 1, timeout: Int = 1) async -> [AutomationEvent] { let collectTask = Task { var result: [AutomationEvent] = [] var iterator = self.subject.feed.makeAsyncIterator() while result.count < count, !Task.isCancelled { if let next = await iterator.next() { result.append(next) } } return result } let cancel = DispatchWorkItem { collectTask.cancel() } DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: cancel) let result = await collectTask.result.get() cancel.cancel() return result } } class TestApplicationMetrics: ApplicationMetricsProtocol, @unchecked Sendable { var currentAppVersion: String? = "test" var versionUpdated = false var isAppVersionUpdated: Bool { return versionUpdated } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/AutomationEventsHistoryTest.swift ================================================ // Copyright Urban Airship and Contributors import Foundation import Testing @testable import AirshipAutomation @testable import AirshipCore struct AutomationEventsHistoryTest { let clock = UATestDate(dateOverride: Date()) private func makeSubject() -> DefaultAutomationEventsHistory { return DefaultAutomationEventsHistory( clock: clock ) } @Test func testAddAndRetrieveEvent() async { let subject = makeSubject() await subject.add(.event(type: .foreground)) let events = await subject.events #expect(events.count == 1) #expect(events.first == AutomationEvent.event(type: .foreground, data: nil, value: 1.0)) } @Test func testPrunesEventsOlderThanMaxDuration() async { let subject = makeSubject() // Add an event at the current time await subject.add(.event(type: .foreground)) // Advance time beyond the max duration (30 seconds in implementation) clock.advance(by: 31) let events = await subject.events #expect(events.count == 0, "Events older than maxDuration should be pruned") } @Test func testKeepsOnlyMostRecentMaxEvents() async { let subject = makeSubject() // Add 110 events; DefaultAutomationEventsHistory keeps last 100 let total = 110 for i in 0.. = TestExecutorDelegate() private let messageExecutor: TestExecutorDelegate = TestExecutorDelegate() private let remoteDataAccess: TestRemoteDataAccess = TestRemoteDataAccess() private let messageAnalyitics: TestInAppMessageAnalytics = TestInAppMessageAnalytics() private var executor: AutomationExecutor! private var preparedMessageData: PreparedInAppMessageData! @MainActor override func setUp() async throws { self.preparedMessageData = PreparedInAppMessageData( message: InAppMessage( name: "some name", displayContent: .custom(.string("custom")) ), displayAdapter: TestDisplayAdapter(), displayCoordinator: TestDisplayCoordinator(), analytics: messageAnalyitics, actionRunner: TestInAppActionRunner() ) self.executor = AutomationExecutor( actionExecutor: actionExecutor, messageExecutor: messageExecutor, remoteDataAccess: remoteDataAccess ) } func testMessageIsReady() async throws { let messageSchedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .inAppMessage(self.preparedMessageData), frequencyChecker: nil ) for readyResult in ScheduleReadyResult.allResults { self.messageExecutor.isReadyCalled = false self.messageExecutor.isReadyBlock = { data, info in XCTAssertEqual(.inAppMessage(data), messageSchedule.data) XCTAssertEqual(info, messageSchedule.info) return readyResult } let result = await self.executor.isReady( preparedSchedule: messageSchedule ) XCTAssertEqual(readyResult, result) XCTAssertTrue(messageExecutor.isReadyCalled) } } func testActionIsReady() async throws { let actionSchedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .actions(AirshipJSON.string("neat")), frequencyChecker: nil ) for readyResult in ScheduleReadyResult.allResults { self.actionExecutor.isReadyCalled = false self.actionExecutor.isReadyBlock = { data, info in XCTAssertEqual(.actions(data), actionSchedule.data) XCTAssertEqual(info, actionSchedule.info) return readyResult } let result = await self.executor.isReady( preparedSchedule: actionSchedule ) XCTAssertEqual(readyResult, result) XCTAssertTrue(actionExecutor.isReadyCalled) } } func testFrequencyChekerNotCheckedIfDelegateNotReady() async throws { let frequencyChecker = TestFrequencyChecker() let schedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .actions(AirshipJSON.string("neat")), frequencyChecker: frequencyChecker ) self.actionExecutor.isReadyBlock = { _, _ in return .notReady } frequencyChecker.checkAndIncrementBlock = { return false } let result = await self.executor.isReady( preparedSchedule: schedule ) XCTAssertEqual(result, .notReady) XCTAssertFalse(frequencyChecker.checkAndIncrementCalled) } func testFrequencyCheckerCheckFailed() async throws { let frequencyChecker = TestFrequencyChecker() let schedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .actions(AirshipJSON.string("neat")), frequencyChecker: frequencyChecker ) self.actionExecutor.isReadyBlock = { _, _ in return .ready } frequencyChecker.checkAndIncrementBlock = { return false } let result = await self.executor.isReady( preparedSchedule: schedule ) XCTAssertEqual(result, .skip) XCTAssertTrue(frequencyChecker.checkAndIncrementCalled) } func testFrequencyCheckerCheckSuccess() async throws { let frequencyChecker = TestFrequencyChecker() let schedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .actions(AirshipJSON.string("neat")), frequencyChecker: frequencyChecker ) frequencyChecker.checkAndIncrementBlock = { return true } self.actionExecutor.isReadyBlock = { _, _ in return .ready } let result = await self.executor.isReady( preparedSchedule: schedule ) XCTAssertEqual(result, .ready) XCTAssertTrue(frequencyChecker.checkAndIncrementCalled) XCTAssertTrue(actionExecutor.isReadyCalled) } func testIsValid() async throws { let automationSchedule = AutomationSchedule( identifier: "some schedule", triggers: [], data: .actions(AirshipJSON.null) ) self.remoteDataAccess.isCurrentBlock = { schedule in XCTAssertEqual(schedule, automationSchedule) return true } let result = await self.executor.isValid(schedule: automationSchedule) XCTAssertTrue(result) } func testIsValidFals() async throws { let automationSchedule = AutomationSchedule( identifier: "some schedule", triggers: [], data: .actions(AirshipJSON.null) ) self.remoteDataAccess.isCurrentBlock = { schedule in XCTAssertEqual(schedule, automationSchedule) return false } let result = await self.executor.isValid(schedule: automationSchedule) XCTAssertFalse(result) } func testExecuteActions() async throws { let actionSchedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .actions(AirshipJSON.string("neat")), frequencyChecker: nil ) self.actionExecutor.executeBlock = { data, info in XCTAssertEqual(.actions(data), actionSchedule.data) XCTAssertEqual(info, actionSchedule.info) return .finished } let result = await self.executor.execute(preparedSchedule: actionSchedule) XCTAssertTrue(actionExecutor.executeCalled) XCTAssertEqual(result, .finished) } func testExecuteMessage() async throws { let messageSchedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .inAppMessage(self.preparedMessageData), frequencyChecker: nil ) self.messageExecutor.executeBlock = { data, info in XCTAssertEqual(.inAppMessage(data), messageSchedule.data) XCTAssertEqual(info, messageSchedule.info) return .finished } let result = await self.executor.execute(preparedSchedule: messageSchedule) XCTAssertTrue(messageExecutor.executeCalled) XCTAssertEqual(result, .finished) } func testExecuteDelegateThrows() async throws { let messageSchedule = PreparedSchedule( info: PreparedScheduleInfo(scheduleID: UUID().uuidString, triggerSessionID: UUID().uuidString, priority: 0), data: .inAppMessage(self.preparedMessageData), frequencyChecker: nil ) self.messageExecutor.executeBlock = { data, info in throw AirshipErrors.error("Failed") } let result = await self.executor.execute(preparedSchedule: messageSchedule) XCTAssertTrue(messageExecutor.executeCalled) XCTAssertEqual(result, .retry) } func testInterruptedAction() async throws { let automationSchedule = AutomationSchedule( identifier: "some schedule", triggers: [], data: .actions(AirshipJSON.string("neat")) ) let preparedScheduleInfo = PreparedScheduleInfo(scheduleID: "some schedule", triggerSessionID: UUID().uuidString, priority: 0) self.actionExecutor.interruptedBlock = { info in XCTAssertEqual(info, preparedScheduleInfo) return .retry } let result = await self.executor.interrupted( schedule: automationSchedule, preparedScheduleInfo: preparedScheduleInfo ) XCTAssertTrue(self.actionExecutor.interruptCalled) XCTAssertEqual(result, .retry) } func testInterruptedMessage() async throws { let automationSchedule = AutomationSchedule( identifier: "some schedule", triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ) ) let preparedScheduleInfo = PreparedScheduleInfo(scheduleID: "some schedule", triggerSessionID: UUID().uuidString, priority: 0) self.messageExecutor.interruptedBlock = { info in XCTAssertEqual(info, preparedScheduleInfo) return .finish } let result = await self.executor.interrupted( schedule: automationSchedule, preparedScheduleInfo: preparedScheduleInfo ) XCTAssertTrue(self.messageExecutor.interruptCalled) XCTAssertEqual(result, .finish) } } fileprivate final class TestExecutorDelegate: AutomationExecutorDelegate, @unchecked Sendable { typealias ExecutionData = T var isReadyCalled: Bool = false var isReadyBlock: (@Sendable (T, PreparedScheduleInfo) -> ScheduleReadyResult)? var executeCalled: Bool = false var executeBlock: (@Sendable (T, PreparedScheduleInfo) async throws -> ScheduleExecuteResult)? var interruptCalled: Bool = false var interruptedBlock: (@Sendable (PreparedScheduleInfo) async -> InterruptedBehavior)? @MainActor func isReady( data: T, preparedScheduleInfo: PreparedScheduleInfo ) -> ScheduleReadyResult { isReadyCalled = true return self.isReadyBlock!(data, preparedScheduleInfo) } @MainActor func execute(data: T, preparedScheduleInfo: PreparedScheduleInfo) async throws -> ScheduleExecuteResult { executeCalled = true return try await self.executeBlock!(data, preparedScheduleInfo) } func interrupted(schedule: AutomationSchedule, preparedScheduleInfo: PreparedScheduleInfo) async -> InterruptedBehavior { interruptCalled = true return await self.interruptedBlock!(preparedScheduleInfo) } } extension ScheduleReadyResult { static var allResults: [ScheduleReadyResult] { return [.ready, .notReady, .invalidate, .skip] } } final class TestInAppActionRunner: InternalInAppActionRunner, @unchecked Sendable { var singleActions: [(String, ActionArguments, ThomasLayoutContext?)] = [] var actionPayloads: [(AirshipJSON, ThomasLayoutContext?)] = [] func runAsync(actions: AirshipJSON, layoutContext: ThomasLayoutContext) { actionPayloads.append((actions, layoutContext)) } func run(actionName: String, arguments: ActionArguments, layoutContext: ThomasLayoutContext) async -> ActionResult { singleActions.append((actionName, arguments, layoutContext)) return .error(AirshipErrors.error("not implemented")) } func run(actionName: String, arguments: ActionArguments) async -> ActionResult { singleActions.append((actionName, arguments, nil)) return .error(AirshipErrors.error("not implemented")) } func runAsync(actions: AirshipJSON) { actionPayloads.append((actions, nil)) } func run(actions: AirshipJSON) async { actionPayloads.append((actions, nil)) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/AutomationPreparerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class AutomationPreparerTest: XCTestCase { private let actionPreparer: TestPreparerDelegate = TestPreparerDelegate() private let messagePreparer: TestPreparerDelegate = TestPreparerDelegate() private let remoteDataAccess: TestRemoteDataAccess = TestRemoteDataAccess() private let deferredResolver: TestDeferredResolver = TestDeferredResolver() private let experiments: TestExperimentDataProvider = TestExperimentDataProvider() 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 let runtimeConfig: RuntimeConfig = .testConfig() @MainActor override func setUp() async throws { self.preparedMessageData = PreparedInAppMessageData( message: InAppMessage( name: "some name", displayContent: .custom(.string("custom")) ), displayAdapter: TestDisplayAdapter(), displayCoordinator: TestDisplayCoordinator(), analytics: TestInAppMessageAnalytics(), actionRunner: TestInAppActionRunner() ) self.preparer = AutomationPreparer( actionPreparer: actionPreparer, messagePreparer: messagePreparer, deferredResolver: deferredResolver, frequencyLimits: frequencyLimits, audienceChecker: audienceChecker, experiments: experiments, 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 { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ) ) self.remoteDataAccess.requiresUpdateBlock = { schedule in XCTAssertEqual(automationSchedule, schedule) return true } self.remoteDataAccess.waitFullRefreshBlock = { schedule in XCTAssertEqual(automationSchedule, schedule) } let prepareResult = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(prepareResult.isInvalidate) } func testBestEfforRefreshNotCurrent() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ) ) self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { schedule in XCTAssertEqual(automationSchedule, schedule) return false } let prepareResult = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(prepareResult.isInvalidate) } func testAudienceMismatchSkip() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .skip ), compoundAudience: .init( selector: .atomic(.init(newUser: true)), missBehavior: .cancel) ) self.remoteDataAccess.contactIDBlock = { _ in return nil } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { audience, created, _ in XCTAssertEqual( audience, CompoundDeviceAudienceSelector.combine( compoundSelector: automationSchedule.compoundAudience!.selector, deviceSelector: automationSchedule.audience!.audienceSelector ) ) XCTAssertEqual(created, automationSchedule.created) return .miss } let prepareResult = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) /// Uses compound selector if available XCTAssertTrue(prepareResult.isCancelled) } func testAudienceMismatchPenalize() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ) ) self.remoteDataAccess.contactIDBlock = { _ in return nil } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { audience, created, _ in XCTAssertEqual(audience, .atomic(automationSchedule.audience!.audienceSelector)) XCTAssertEqual(created, automationSchedule.created) return .miss } let prepareResult = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(prepareResult.isPenalize) } func testAudienceCheckFirst() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .skip ), compoundAudience: .init( selector: .atomic(.init(newUser: true)), missBehavior: .cancel ) ) self.remoteDataAccess.contactIDBlock = { _ in return nil } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { audience, created, _ in XCTAssertEqual( audience, CompoundDeviceAudienceSelector.combine( compoundSelector: automationSchedule.compoundAudience!.selector, deviceSelector: automationSchedule.audience!.audienceSelector ) ) XCTAssertEqual(created, automationSchedule.created) return .miss } let prepareResult = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(prepareResult.isCancelled) } func testCompoundAudienceCheck() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), audience: nil, compoundAudience: .init( selector: .atomic(.init(newUser: true)), missBehavior: .cancel) ) 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 XCTAssertEqual(audience, automationSchedule.compoundAudience?.selector) XCTAssertEqual(created, automationSchedule.created) return .miss } let prepareResult = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(prepareResult.isCancelled) } func testAudienceMismatchCancel() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .cancel ) ) self.remoteDataAccess.contactIDBlock = { _ in return nil } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { audience, created, _ in XCTAssertEqual(audience, .atomic(automationSchedule.audience!.audienceSelector)) XCTAssertEqual(created, automationSchedule.created) return .miss } let prepareResult = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(prepareResult.isCancelled) } func testContactIDAudienceChecks() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, triggers: [], data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ) ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, provider in let contactID = await provider.stableContactInfo.contactID XCTAssertEqual("contact ID", contactID) return .miss } let _ = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) } func testPrepareMessage() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", frequencyConstraintIDs: ["constraint"] ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let checker = TestFrequencyChecker() await self.frequencyLimits.setCheckerBlock { _ in return checker } let preparedData = self.preparedMessageData! self.messagePreparer.prepareBlock = { message, info in XCTAssertEqual(.inAppMessage(message), automationSchedule.data) XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, info.campaigns) XCTAssertEqual("contact ID", info.contactID) return preparedData } let triggerSessionID = UUID().uuidString let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: triggerSessionID ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertEqual(automationSchedule.identifier, prepared.info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, prepared.info.campaigns) XCTAssertEqual(prepared.data, .inAppMessage(preparedData)) XCTAssertEqual(triggerSessionID, prepared.info.triggerSessionID) XCTAssertNotNil(prepared.frequencyChecker) } func testPrepareMessageCheckerError() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", frequencyConstraintIDs: ["constraint"] ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } await self.frequencyLimits.setCheckerBlock { _ in throw AirshipErrors.error("Failed") } let triggerSessionID = UUID().uuidString let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: triggerSessionID ) XCTAssertTrue(result.isSkipped) } 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 = { _, _, _ in return .match } 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, body: nil, media: nil, buttons: nil, buttonLayoutType: .stacked, template: .mediaLeft, backgroundColor: InAppMessageColor(hexColorString: ""), dismissButtonColor: InAppMessageColor(hexColorString: ""), borderRadius: 5, duration: 100.0, placement: .top ) let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .inAppMessage( InAppMessage(name: "name", displayContent: .banner(invalidBanner)) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", frequencyConstraintIDs: ["constraint"] ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let checker = TestFrequencyChecker() await self.frequencyLimits.setCheckerBlock { _ in return checker } let preparedData = self.preparedMessageData! self.messagePreparer.prepareBlock = { message, info in XCTAssertEqual(.inAppMessage(message), automationSchedule.data) XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, info.campaigns) XCTAssertEqual("contact ID", info.contactID) return preparedData } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(result.isSkipped) } func testPrepareActions() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .actions( AirshipJSON.string("actions payload") ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", frequencyConstraintIDs: ["constraint"] ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let checker = TestFrequencyChecker() await self.frequencyLimits.setCheckerBlock { _ in return checker } self.actionPreparer.prepareBlock = { actions, info in XCTAssertEqual(.actions(actions), automationSchedule.data) XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, info.campaigns) XCTAssertEqual("contact ID", info.contactID) return actions } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertEqual(automationSchedule.identifier, prepared.info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, prepared.info.campaigns) XCTAssertEqual(prepared.data, .actions(AirshipJSON.string("actions payload"))) XCTAssertNotNil(prepared.frequencyChecker) } func testPrepareDeferredActions() async throws { let actions = try! AirshipJSON.wrap(["some": "action"]) let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .deferred( DeferredAutomationData( url: URL(string: "example://")!, retryOnTimeOut: false, type: .actions ) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", frequencyConstraintIDs: ["constraint"] ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let checker = TestFrequencyChecker() await self.frequencyLimits.setCheckerBlock { _ in return checker } await self.deferredResolver.onData { request in let data = try! AirshipJSON.wrap([ "audience_match": true, "actions": actions ]).toData() return .success(data) } self.actionPreparer.prepareBlock = { actionsPayload, info in XCTAssertEqual(actionsPayload, actions) XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, info.campaigns) XCTAssertEqual("contact ID", info.contactID) return actions } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertEqual(automationSchedule.identifier, prepared.info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, prepared.info.campaigns) XCTAssertEqual(prepared.data, .actions(actions)) XCTAssertNotNil(prepared.frequencyChecker) } func testPrepareDeferredMessage() async throws { let message = InAppMessage( name: "some name", displayContent: .custom(.string("custom")), source: .remoteData ) let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .deferred( DeferredAutomationData( url: URL(string: "example://")!, retryOnTimeOut: false, type: .inAppMessage ) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", frequencyConstraintIDs: ["constraint"] ) self.remoteDataAccess.contactIDBlock = { _ in return self.deviceInfoProvider.stableContactInfo.contactID } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let checker = TestFrequencyChecker() await self.frequencyLimits.setCheckerBlock { _ in return checker } let expectedRequest = DeferredRequest( url: URL(string: "example://")!, channelID: self.deviceInfoProvider.channelID, contactID: self.deviceInfoProvider.stableContactInfo.contactID, triggerContext: triggerContext, locale: deviceInfoProvider.locale, notificationOptIn: deviceInfoProvider.isUserOptedInPushNotifications ) await self.deferredResolver.onData { request in XCTAssertEqual(request, expectedRequest) let data = try! AirshipJSON.wrap([ "audience_match": true, "message": message ]).toData() return .success(data) } let preparedData = self.preparedMessageData! self.messagePreparer.prepareBlock = { inAppMessage, info in XCTAssertEqual(inAppMessage, message) XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, info.campaigns) XCTAssertEqual(self.deviceInfoProvider.stableContactInfo.contactID, info.contactID) return preparedData } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertEqual(automationSchedule.identifier, prepared.info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, prepared.info.campaigns) XCTAssertNotNil(prepared.frequencyChecker) } func testPrepareDeferredAudienceMismatchResult() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .deferred( DeferredAutomationData( url: URL(string: "example://")!, retryOnTimeOut: false, type: .inAppMessage ) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .skip ) ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } await self.deferredResolver.onData { request in let data = try! AirshipJSON.wrap([ "audience_match": false ]).toData() return .success(data) } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) XCTAssertTrue(result.isSkipped) } func testExperiements() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", frequencyConstraintIDs: ["constraint"] ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let checker = TestFrequencyChecker() await self.frequencyLimits.setCheckerBlock { _ in return checker } let preparedData = self.preparedMessageData! self.messagePreparer.prepareBlock = { message, info in XCTAssertEqual(.inAppMessage(message), automationSchedule.data) XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, info.campaigns) XCTAssertEqual("contact ID", info.contactID) return preparedData } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertEqual(automationSchedule.identifier, prepared.info.scheduleID) XCTAssertEqual(automationSchedule.campaigns, prepared.info.campaigns) XCTAssertEqual(prepared.data, .inAppMessage(preparedData)) XCTAssertNotNil(prepared.frequencyChecker) } func testExperiments() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns", messageType: "some message type" ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let experimentResult = ExperimentResult( channelID: "some channel", contactID: "contact ID", isMatch: true, reportingMetadata: [AirshipJSON.string("reporting")] ) self.experiments.onEvaluate = { info, provider in let contactID = await provider.stableContactInfo.contactID XCTAssertEqual( info, MessageInfo( messageType: automationSchedule.messageType!, campaigns: automationSchedule.campaigns ) ) XCTAssertEqual(contactID, "contact ID") return experimentResult } let preparedData = self.preparedMessageData! self.messagePreparer.prepareBlock = { message, info in XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(experimentResult, info.experimentResult) return preparedData } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertEqual(experimentResult, prepared.info.experimentResult) } func testExperimentsDefaultMessageType() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), campaigns: "campaigns" ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } let experimentResult = ExperimentResult( channelID: "some channel", contactID: "contact ID", isMatch: true, reportingMetadata: [AirshipJSON.string("reporting")] ) self.experiments.onEvaluate = { info, provider in let contactID = await provider.stableContactInfo.contactID XCTAssertEqual( info, MessageInfo( messageType: "transactional", campaigns: automationSchedule.campaigns ) ) XCTAssertEqual(contactID, "contact ID") return experimentResult } let preparedData = self.preparedMessageData! self.messagePreparer.prepareBlock = { message, info in XCTAssertEqual(automationSchedule.identifier, info.scheduleID) XCTAssertEqual(experimentResult, info.experimentResult) return preparedData } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertEqual(experimentResult, prepared.info.experimentResult) } func testByPassExperiments() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .inAppMessage( InAppMessage(name: "name", displayContent: .custom(.null)) ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), bypassHoldoutGroups: true, campaigns: "campaigns", messageType: "some message type" ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } self.experiments.onEvaluate = { info, provider in XCTFail() return nil } let preparedData = self.preparedMessageData! self.messagePreparer.prepareBlock = { message, info in XCTAssertNil(info.experimentResult) return preparedData } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertNil(prepared.info.experimentResult) } func testByPassExperimentsActions() async throws { let automationSchedule = AutomationSchedule( identifier: UUID().uuidString, data: .actions( AirshipJSON.string("payload") ), triggers: [], created: Date(), lastUpdated: Date(), audience: AutomationAudience( audienceSelector: DeviceAudienceSelector(), missBehavior: .penalize ), bypassHoldoutGroups: false, // even if false campaigns: "campaigns", messageType: "some message type" ) self.remoteDataAccess.contactIDBlock = { _ in return "contact ID" } self.remoteDataAccess.requiresUpdateBlock = { _ in return false } self.remoteDataAccess.bestEffortRefreshBlock = { _ in return true } self.audienceChecker.onEvaluate = { _, _, _ in return .match } self.experiments.onEvaluate = { info, provider in XCTFail() return nil } self.actionPreparer.prepareBlock = { actions, info in XCTAssertNil(info.experimentResult) return actions } let result = await self.preparer.prepare( schedule: automationSchedule, triggerContext: triggerContext, triggerSessionID: UUID().uuidString ) guard case .prepared(let prepared) = result else { XCTFail() return } XCTAssertNil(prepared.info.experimentResult) } } final class TestPreparerDelegate: AutomationPreparerDelegate, @unchecked Sendable { typealias PrepareDataIn = In typealias PrepareDataOut = Out var cancelledCalled: Bool = false var cancelledBlock: (@Sendable (String) async -> Void)? func cancelled(scheduleID: String) async { cancelledCalled = true await cancelledBlock!(scheduleID) } var prepareCalled: Bool = false var prepareBlock: (@Sendable (In, PreparedScheduleInfo) async -> Out)? func prepare(data: In, preparedScheduleInfo: PreparedScheduleInfo) async throws -> Out { prepareCalled = true return await prepareBlock!(data, preparedScheduleInfo) } } extension SchedulePrepareResult { var isInvalidate: Bool { switch (self) { case .invalidate: return true default: return false } } var isPrepared: Bool { switch (self) { case .prepared(_): return true default: return false } } var isCancelled: Bool { switch (self) { case .cancel: return true default: return false } } var isSkipped: Bool { switch (self) { case .skip: return true default: return false } } var isPenalize: Bool { switch (self) { case .penalize: return true default: return false } } } final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unchecked Sendable { var sdkVersion: String = "1.0.0" var isAirshipReady: Bool = false var tags: Set = Set() var isChannelCreated: Bool = true var channelID: String = UUID().uuidString var locale: Locale = Locale.current var appVersion: String? var permissions: [AirshipCore.AirshipPermission : AirshipCore.AirshipPermissionStatus] = [:] var isUserOptedInPushNotifications: Bool = false var analyticsEnabled: Bool = false var installDate: Date = Date() var stableContactInfo: StableContactInfo init(contactID: String = UUID().uuidString) { self.stableContactInfo = StableContactInfo(contactID: contactID) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/AutomationStoreTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class AutomationStoreTest: XCTestCase { private let store: AutomationStore = AutomationStore( appKey: UUID().uuidString, inMemory: true ) func testUpsertNewSchedules() async throws { let data = ["foo": makeSchedule(identifer: "foo"), "bar": makeSchedule(identifer: "bar")] let result = try await self.store.upsertSchedules(scheduleIDs: ["foo", "bar"]) { identifier, existing in XCTAssertNil(existing) return data[identifier]! } XCTAssertEqual(result, [data["foo"], data["bar"]]) } func testUpsertMixedSchedules() async throws { let original = ["foo": makeSchedule(identifer: "foo"), "bar": makeSchedule(identifer: "bar")] var result = try await self.store.upsertSchedules(scheduleIDs: ["foo", "bar"]) { identifier, existing in XCTAssertNil(existing) return original[identifier]! } XCTAssertEqual(result, [original["foo"], original["bar"]]) var updated = original updated["baz"] = makeSchedule(identifer: "baz") updated["foo"]?.scheduleState = .finished result = try await self.store.upsertSchedules(scheduleIDs: ["foo", "bar", "baz"]) { [updated] identifier, existing in if let existing = existing { XCTAssertTrue(existing.equalsIgnoringLastModified(original[identifier]!)) } return updated[identifier]! } XCTAssertEqual(result, [updated["foo"], updated["bar"], updated["baz"]]) } func testUpdate() async throws { let originalFoo = makeSchedule(identifer: "foo") _ = try await self.store.upsertSchedules(scheduleIDs: ["foo"]) { identifier, existing in return originalFoo } let triggerInfo = TriggeringInfo( context: AirshipTriggerContext(type: "foo", goal: 10.0, event: "event"), date: Date.distantPast ) let preparedInfo = PreparedScheduleInfo( scheduleID: "full", productID: "some product", campaigns: "campaigns", contactID: "some contact", experimentResult: ExperimentResult( channelID: "some channel", contactID: "some contact", isMatch: true, reportingMetadata: [AirshipJSON.string("reporing")] ), triggerSessionID: "some trigger session id", priority: 0 ) let date = Date() let result = try await self.store.updateSchedule(scheduleID: "foo") { data in data.executionCount = 100 data.triggerInfo = triggerInfo data.schedule.group = "bar" data.preparedScheduleInfo = preparedInfo data.scheduleState = .paused data.scheduleStateChangeDate = date } var expected = originalFoo expected.schedule.group = "bar" expected.executionCount = 100 expected.triggerInfo = triggerInfo expected.preparedScheduleInfo = preparedInfo expected.scheduleStateChangeDate = date expected.scheduleState = .paused XCTAssert(result!.equalsIgnoringLastModified(expected)) } func testUpsertFullData() async throws { var schedule = self.makeSchedule(identifer: "full") schedule.triggerInfo = TriggeringInfo( context: AirshipTriggerContext(type: "foo", goal: 10.0, event: "event"), date: Date.distantPast ) schedule.preparedScheduleInfo = PreparedScheduleInfo( scheduleID: "full", productID: "some product", campaigns: "campaigns", contactID: "some contact", experimentResult: ExperimentResult( channelID: "some channel", contactID: "some contact", isMatch: true, reportingMetadata: [AirshipJSON.string("reporing")] ), triggerSessionID: "some trigger session id", priority: 0 ) let batchUpsertResult = try await self.store.upsertSchedules(scheduleIDs: ["full"]) { [schedule] identifier, existing in return schedule } XCTAssertEqual(batchUpsertResult.count, 1) let fetchResult = try await self.store.getSchedule(scheduleID: "full") XCTAssertNotNil(fetchResult) XCTAssertGreaterThanOrEqual(fetchResult!.lastScheduleModifiedDate, batchUpsertResult[0].lastScheduleModifiedDate) } func testUpdateDoesNotExist() async throws { let result = try await self.store.updateSchedule(scheduleID: "baz") { data in XCTFail() } XCTAssertNil(result) } func testGetSchedules() async throws { let original = ["foo": makeSchedule(identifer: "foo"), "bar": makeSchedule(identifer: "bar")] let _ = try await self.store.upsertSchedules(scheduleIDs: ["foo", "bar"]) { identifier, existing in return original[identifier]! } let foo = try await self.store.getSchedule(scheduleID: "foo") XCTAssertTrue(foo!.equalsIgnoringLastModified(original["foo"]!)) let bar = try await self.store.getSchedule(scheduleID: "bar") XCTAssertTrue(bar!.equalsIgnoringLastModified(original["bar"]!)) let doesNotExist = try await self.store.getSchedule(scheduleID: "doesNotExist") XCTAssertNil(doesNotExist) } func testGetSchedulesByGroup() async throws { let original = [ "foo": makeSchedule(identifer: "foo", group: "groupA"), "bar": makeSchedule(identifer: "bar"), "baz": makeSchedule(identifer: "baz", group: "groupA") ] let _ = try await self.store.upsertSchedules(scheduleIDs: ["foo", "bar", "baz"]) { identifier, existing in return original[identifier]! } let groupA = try await self.store.getSchedules(group: "groupA").sorted { l, r in return l.schedule.identifier > r.schedule.identifier } XCTAssertTrue([original["foo"]!, original["baz"]!].equalsIgnoringLastModified(groupA)) } func testDeleteIdentifiers() async throws { let original = [ "foo": makeSchedule(identifer: "foo", group: "groupA"), "bar": makeSchedule(identifer: "bar"), "baz": makeSchedule(identifer: "baz", group: "groupA") ] let _ = try await self.store.upsertSchedules(scheduleIDs: ["foo", "bar", "baz"]) { identifier, existing in return original[identifier]! } try await self.store.deleteSchedules(scheduleIDs: ["foo", "doesNotExist"]) let remaining = try await self.store.getSchedules().sorted { l, r in return l.schedule.identifier > r.schedule.identifier } XCTAssertTrue([original["baz"]!, original["bar"]!].equalsIgnoringLastModified(remaining)) } func testDeleteGroup() async throws { let original = [ "foo": makeSchedule(identifer: "foo", group: "groupA"), "bar": makeSchedule(identifer: "bar", group: "groupB"), "baz": makeSchedule(identifer: "baz", group: "groupA") ] let _ = try await self.store.upsertSchedules(scheduleIDs: ["foo", "bar", "baz"]) { identifier, existing in return original[identifier]! } try await self.store.deleteSchedules(group: "groupA") let remaining = try await self.store.getSchedules().sorted { l, r in return l.schedule.identifier > r.schedule.identifier } XCTAssertTrue([original["bar"]!].equalsIgnoringLastModified(remaining)) } func testAssociatedData() async throws { let associatedData = try AirshipJSON.string("some data").toData() var schedule = self.makeSchedule(identifer: "bar") schedule.associatedData = associatedData let _ = try await self.store.upsertSchedules(scheduleIDs: ["bar"]) { [schedule] identifier, existing in return schedule } let fromStore = try await self.store.getAssociatedData(scheduleID: "bar") XCTAssertEqual(fromStore, associatedData) } func testAssociatedDataNull() async throws { let schedule = self.makeSchedule(identifer: "bar") let _ = try await self.store.upsertSchedules(scheduleIDs: ["bar"]) { [schedule] identifier, existing in return schedule } let fromStore = try await self.store.getAssociatedData(scheduleID: "bar") XCTAssertNil(fromStore) } func testAssociatedNoSchedule() async throws { let fromStore = try await self.store.getAssociatedData(scheduleID: "bar") XCTAssertNil(fromStore) } func testIsCurrent() async throws { let schedule = makeSchedule(identifer: "test") let _ = try await self.store.upsertSchedules(scheduleIDs: ["test"]) { identifier, existing in return schedule } let fullSchedule = try await self.store.getSchedule(scheduleID: "test")! var isCurrent = try await self.store.isCurrent( scheduleID: "test", lastScheduleModifiedDate: fullSchedule.lastScheduleModifiedDate, scheduleState: .idle ) XCTAssertTrue(isCurrent) isCurrent = try await self.store.isCurrent( scheduleID: "test", lastScheduleModifiedDate: fullSchedule.lastScheduleModifiedDate, scheduleState: .paused ) XCTAssertFalse(isCurrent) isCurrent = try await self.store.isCurrent( scheduleID: "test", lastScheduleModifiedDate: fullSchedule.lastScheduleModifiedDate.addingTimeInterval(1), scheduleState: .idle ) XCTAssertFalse(isCurrent) } func testIsCurrentNoSchedule() async throws { let isCurrent = try await self.store.isCurrent( scheduleID: "fake identifier", lastScheduleModifiedDate: Date(), scheduleState: .paused ) XCTAssertFalse(isCurrent) } private func makeSchedule(identifer: String, group: String? = nil) -> AutomationScheduleData { let schedule = AutomationSchedule( identifier: identifer, data: .inAppMessage( InAppMessage( name: "some name", displayContent: .custom(.string("Custom")) ) ), triggers: [], created: Date.distantPast, group: group ) return AutomationScheduleData( schedule: schedule, scheduleState: .idle, lastScheduleModifiedDate: .distantPast, scheduleStateChangeDate: Date.distantPast, executionCount: 0, triggerSessionID: UUID().uuidString ) } } extension [AutomationScheduleData] { func equalsIgnoringLastModified(_ other: [AutomationScheduleData]) -> Bool { guard count == other.count else { return false } return zip(self, other).allSatisfy { $0.equalsIgnoringLastModified($1) } } } extension AutomationScheduleData { func equalsIgnoringLastModified(_ other: AutomationScheduleData) -> Bool { schedule == other.schedule && scheduleState == other.scheduleState && scheduleStateChangeDate == other.scheduleStateChangeDate && executionCount == other.executionCount && triggerInfo == other.triggerInfo && preparedScheduleInfo == other.preparedScheduleInfo && associatedData == other.associatedData && triggerSessionID == other.triggerSessionID } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/AutomationTriggerProcessorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class AutomationTriggerProcessorTest: XCTestCase, @unchecked Sendable { private let date: UATestDate = UATestDate(offset: 0, dateOverride: Date()) private let store: TestTriggerStore = TestTriggerStore() private var processor: AutomationTriggerProcessor! private var history: (any AutomationEventsHistory)! override func setUp() async throws { self.history = DefaultAutomationEventsHistory(clock: date) self.processor = AutomationTriggerProcessor(store: store, history: self.history, date: date) } func testRestoreSchedule() async throws { self.store.stored = [ TriggerData( scheduleID: "unused-schedule-id", triggerID: "unused-trigger-id", count: 0, children: [:] ) ] let trigger = AutomationTrigger.event(.init(id: "trigger-id", type: .activeSession, goal: 1)) XCTAssertEqual(1, self.store.stored.count) try await restoreSchedules(trigger: trigger) XCTAssertEqual(0, self.store.stored.count) await self.processor.processEvent(.stateChanged(state: TriggerableState(appSessionID: "foreground"))) let result = await takeNext().first XCTAssertEqual("schedule-id", result?.scheduleID) XCTAssertEqual(TriggerExecutionType.execution, result?.triggerExecutionType) XCTAssertEqual(TriggeringInfo( context: AirshipTriggerContext( type: "active_session", goal: 1.0, event: .null), date: self.date.now), result?.triggerInfo) } func testUpdateTriggersResendsStatus() async throws { let trigger = AutomationTrigger.event(.init(id: "trigger-id", type: .activeSession, goal: 1)) await self.processor.processEvent(.stateChanged(state: TriggerableState())) try await restoreSchedules(trigger: trigger) await self.processor.processEvent(.stateChanged(state: TriggerableState(appSessionID: "foreground"))) await self.processor.updateScheduleState(scheduleID: "schedule-id", state: .idle) let result = await takeNext(count: 2).last XCTAssertEqual("schedule-id", result?.scheduleID) XCTAssertEqual(TriggerExecutionType.execution, result?.triggerExecutionType) XCTAssertEqual(TriggeringInfo( context: AirshipTriggerContext( type: "active_session", goal: 1.0, event: .null), date: self.date.now), result?.triggerInfo) } func testCancelSchedule() async throws { try await restoreSchedules() await self.processor.processEvent(.event(type: .appInit)) XCTAssertEqual( TriggerData( scheduleID: "schedule-id", triggerID: "default-trigger", count: 1, children: [:] ), self.store.stored.last ) await self.processor.cancel(scheduleIDs: ["schedule-id"]) XCTAssert(self.store.stored.isEmpty) await self.processor.processEvent(.event(type: .appInit)) let result = await takeNext() XCTAssert(result.isEmpty) } func testCancelWithGroup() async throws { let trigger = AutomationTrigger.event(.init(id: "trigger-id-2", type: .appInit, goal: 2)) let schedule = defaultSchedule(trigger: trigger, group: "test-group") try await self.processor.restoreSchedules([schedule]) await self.processor.processEvent(.event(type: .appInit)) XCTAssertEqual( TriggerData( scheduleID: "schedule-id", triggerID: "trigger-id-2", count: 1, children: [:] ), self.store.stored.last ) await self.processor.cancel(group: "test-group") XCTAssert(self.store.stored.isEmpty) await self.processor.processEvent(.event(type: .appInit)) let result = await takeNext() XCTAssert(result.isEmpty) } func testProcessEventEmitsResults() async throws { let trigger = AutomationTrigger.event(.init(id: "trigger-id", type: .appInit, goal: 1)) try await restoreSchedules(trigger: trigger) await self.processor.processEvent(.event(type: .appInit)) XCTAssertEqual( TriggerData( scheduleID: "schedule-id", triggerID: "trigger-id", count: 0, children: [:] ), self.store.stored.last ) let result = await takeNext() XCTAssertNotNil(result) } @MainActor func testProcessEventEmitsNothingOnPause() async throws { let trigger = AutomationTrigger.event(.init(id: "trigger-id", type: .appInit, goal: 1)) try await restoreSchedules(trigger: trigger) await self.processor.processEvent(.event(type: .appInit)) var result = await takeNext() XCTAssertNotNil(result) await self.processor.processEvent(.event(type: .appInit)) result = await takeNext() XCTAssertNotNil(result) self.processor.setPaused(true) await self.processor.processEvent(.event(type: .appInit)) result = await takeNext() XCTAssert(result.isEmpty) } func testReplayEvents() async { let triggerOld = AutomationTrigger.event(.init(id: "trigger-id", type: .appInit, goal: 2)) let oldSchedule = defaultSchedule(trigger: triggerOld) let event = AutomationEvent.event(type: .appInit) await self.processor.updateSchedules([oldSchedule]) await self.processor.processEvent(event) await self.history.add(event) let triggerNew = AutomationTrigger.event(.init(id: "new-trigger-id", type: .appInit, goal: 1)) let scheduleNew = AutomationScheduleData( schedule: AutomationSchedule( identifier: "new-schedule-id", data: .actions(.null), triggers: [triggerNew], group: nil ), scheduleState: .idle, lastScheduleModifiedDate: self.date.now, scheduleStateChangeDate: self.date.now, executionCount: 0, triggerSessionID: UUID().uuidString ) await self.processor.updateSchedules([oldSchedule, scheduleNew]) let results = await takeNext() XCTAssertEqual(results.count, 1) XCTAssertEqual("new-schedule-id", results.first?.scheduleID) XCTAssertEqual(TriggerExecutionType.execution, results.first?.triggerExecutionType) } private func restoreSchedules(trigger: AutomationTrigger? = nil) async throws { let trigger = trigger ?? AutomationTrigger.event(.init(id: "default-trigger", type: .appInit, goal: 2)) let schedule = defaultSchedule(trigger: trigger) try await self.processor.restoreSchedules([schedule]) } private func defaultSchedule(trigger: AutomationTrigger, group: String? = nil) -> AutomationScheduleData { return AutomationScheduleData( schedule: AutomationSchedule( identifier: "schedule-id", data: .actions(.null), triggers: [trigger], group: group ), scheduleState: .idle, lastScheduleModifiedDate: self.date.now, scheduleStateChangeDate: self.date.now, executionCount: 0, triggerSessionID: UUID().uuidString ) } @discardableResult private func takeNext(count: UInt = 1, timeout: Int = 1) async -> [TriggerResult] { let collectTask = Task { var result: [TriggerResult] = [] var iterator = await self.processor.triggerResults.makeAsyncIterator() while result.count < count, !Task.isCancelled { if let next = await iterator.next() { result.append(next) } } return result } let cancel = DispatchWorkItem { collectTask.cancel() } DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeout), execute: cancel) let result = await collectTask.result.get() cancel.cancel() return result } } final class TestTriggerStore: TriggerStoreProtocol, @unchecked Sendable { var stored: [TriggerData] = [] func getTrigger(scheduleID: String, triggerID: String) async throws -> AirshipAutomation.TriggerData? { return stored.first(where: { $0.triggerID == triggerID && $0.scheduleID == scheduleID }) } func upsertTriggers(_ triggers: [TriggerData]) async throws { let incomingIDs = triggers.map { $0.triggerID } stored.removeAll { incomingIDs.contains($0.triggerID) } stored.append(contentsOf: triggers) } func deleteTriggers(excludingScheduleIDs: Set) async throws { stored.removeAll(where: { !excludingScheduleIDs.contains($0.scheduleID) }) } func deleteTriggers(scheduleIDs: [String]) async throws { stored.removeAll(where: { scheduleIDs.contains($0.scheduleID) }) } func deleteTriggers(scheduleID: String, triggerIDs: Set) async throws { stored.removeAll { $0.scheduleID == scheduleID && triggerIDs.contains($0.triggerID) } } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/ExecutionWindowProcessorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class ExecutionWindowProcessorTest: XCTestCase { fileprivate struct Evaluated : Equatable, Sendable{ let window: ExecutionWindow let date: Date } private let date: UATestDate = UATestDate(dateOverride: Date()) private let taskSleeper: TestTaskSleeper = TestTaskSleeper() private let notificationCenter: NotificationCenter = NotificationCenter() private var processor: ExecutionWindowProcessor! private let window: ExecutionWindow = try! ExecutionWindow(include: [.weekly(daysOfWeek: [1])]) private var evaluatedWindows: AirshipAtomicValue<[Evaluated]> = .init([]) private var onResult: AirshipAtomicValue<(() throws -> ExecutionWindowResult)?> = .init(nil) override func setUpWithError() throws { processor = ExecutionWindowProcessor( taskSleeper: taskSleeper, date: date, notificationCenter: notificationCenter, onEvaluate: { window, date in self.evaluatedWindows.update { t in var mutated = t mutated.append(Evaluated(window: window, date: date)) return mutated } return try self.onResult.value!() } ) } @MainActor func testIsAvailable() throws { onResult.value = { throw AirshipErrors.error("Error!") } XCTAssertFalse(processor.isActive(window: window)) onResult.value = { return .retry(100) } XCTAssertFalse(processor.isActive(window: window)) onResult.value = { return .now } XCTAssertTrue(processor.isActive(window: window)) let evaluated = Evaluated(window: window, date: date.now) XCTAssertEqual(evaluatedWindows.value, [evaluated, evaluated, evaluated]) } func testProcessError() async throws { let setup = expectation(description: "setup") let task = Task { await self.fulfillment(of: [setup]) await processor.process(window: window) } taskSleeper.onSleep = { _ in task.cancel() } onResult.value = { throw AirshipErrors.error("Error!") } setup.fulfill() await task.value XCTAssertEqual(taskSleeper.sleeps, [24.0 * 60 * 60]) let evaluated = Evaluated(window: window, date: date.now) XCTAssertEqual(evaluatedWindows.value, [evaluated]) } func testProcessRetry() async throws { let setup = expectation(description: "setup") let task = Task { await self.fulfillment(of: [setup]) await processor.process(window: window) } taskSleeper.onSleep = { _ in task.cancel() } onResult.value = { .retry(100.0) } setup.fulfill() await task.value XCTAssertEqual(taskSleeper.sleeps, [100.0]) let evaluated = Evaluated(window: window, date: date.now) XCTAssertEqual(evaluatedWindows.value, [evaluated]) } func testLocaleChangeRechecks() async throws { let setup = expectation(description: "setup") let task = Task { await self.fulfillment(of: [setup]) await processor.process(window: window) } taskSleeper.onSleep = { sleeps in if sleeps.count == 1 { // Actually sleep on the first one to avoid a busy loop try await Task.sleep(nanoseconds: 1000000) } else { task.cancel() } } onResult.value = { .retry(1000.0) } setup.fulfill() notificationCenter.post(name: .NSSystemTimeZoneDidChange, object: nil) await task.value XCTAssertEqual(taskSleeper.sleeps, [1000.0, 1000.0]) let evaluated = Evaluated(window: window, date: date.now) XCTAssertEqual(evaluatedWindows.value, [evaluated, evaluated]) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/PreparedScheduleInfoTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation final class PreparedScheduleInfoTest: XCTestCase { func testMissingTriggerSessionID() throws { let json = """ { "scheduleID": "some schedule" } """ let info = try JSONDecoder().decode( PreparedScheduleInfo.self, from: json.data(using: .utf8)! ) XCTAssertEqual("some schedule", info.scheduleID) XCTAssertFalse(info.triggerSessionID.isEmpty) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Automation/Engine/PreparedTriggerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class PreparedTriggerTest: XCTestCase { let date = UATestDate(offset: 0, dateOverride: Date()) func testScheduleDatesUpdate() { var trigger = EventAutomationTrigger(type: .appInit, goal: 1) let instance = makeTrigger(trigger: .event(trigger)) XCTAssertNil(instance.startDate) XCTAssertNil(instance.endDate) XCTAssertEqual(0, instance.priority) trigger.goal = 3 instance.update(trigger: .event(trigger), startDate: date.now, endDate: date.now, priority: 3) XCTAssertEqual(date.now, instance.startDate) XCTAssertEqual(date.now, instance.endDate) XCTAssertEqual(3, instance.priority) XCTAssertEqual(.event(trigger), instance.trigger) } func testActivateTrigger() { let initialState = TriggerData( scheduleID: "test", triggerID: "trigger-id", count: 1, children: [:] ) let execution = makeTrigger(type: .execution, state: initialState) XCTAssertFalse(execution.isActive) execution.activate() XCTAssert(execution.isActive) XCTAssertEqual(initialState, execution.triggerData) let cancellation = makeTrigger(type: .delayCancellation, state: initialState) XCTAssertFalse(cancellation.isActive) cancellation.activate() XCTAssert(cancellation.isActive) XCTAssertEqual(0, cancellation.triggerData.count) } func testDiable() { let instance = makeTrigger() XCTAssertFalse(instance.isActive) instance.activate() XCTAssert(instance.isActive) instance.disable() XCTAssertFalse(instance.isActive) } func testProcessEventHappyPath() throws { let trigger = EventAutomationTrigger(type: .appInit, goal: 2) let instance = makeTrigger(trigger: .event(trigger), type: .execution) instance.activate() XCTAssertEqual(0, instance.triggerData.count) var result = instance.process(event: .event(type: .appInit)) XCTAssertEqual(1, result?.triggerData.count) XCTAssertNil(result?.triggerResult) result = instance.process(event: .event(type: .appInit)) XCTAssertEqual(0, result?.triggerData.count) let report = try XCTUnwrap(result?.triggerResult) XCTAssertEqual("test-schedule", report.scheduleID) XCTAssertEqual(TriggerExecutionType.execution, report.triggerExecutionType) XCTAssertEqual(AirshipTriggerContext(type: "app_init", goal: 2, event: .null), report.triggerInfo.context) XCTAssertEqual(date.now, report.triggerInfo.date) } func testProcessEventDoesNothing() { let trigger = EventAutomationTrigger(type: .appInit, goal: 1) let instance = makeTrigger(trigger: .event(trigger)) XCTAssertNil(instance.process(event: .event(type: .appInit))) instance.activate() instance.update( trigger: .event(trigger), startDate: self.date.now.advanced(by: 1), endDate: nil, priority: 0 ) XCTAssertNil(instance.process(event: .event(type: .appInit))) instance.update( trigger: .event(trigger), startDate: nil, endDate: nil, priority: 0 ) XCTAssertNotNil(instance.process(event: .event(type: .appInit))) } func testProcessEventDoesNothingForInvalidEventType() { let trigger = EventAutomationTrigger(type: .background, goal: 1) let instance = makeTrigger(trigger: .event(trigger)) instance.activate() XCTAssertNil(instance.process(event: .event(type: .foreground))) XCTAssertNotNil(instance.process(event: .event(type: .background))) } func testEventProcessingTypes() { let check: (EventAutomationTriggerType, AutomationEvent) -> TriggerData? = { type, event in let trigger = EventAutomationTrigger(type: type, goal: 3) let instance = self.makeTrigger(trigger: .event(trigger)) instance.activate() let result = instance.process(event: event) return result?.triggerData } for eventType in EventAutomationTriggerType.allCases { let event = AutomationEvent.event(type: eventType, data: .null) XCTAssertEqual(1, check(eventType, event)?.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)) } func testCompoundAndTrigger() throws { let trigger = AutomationTrigger.compound( .init( id: "compound", type: .and, goal: 2, children: [ .init(trigger: .event(.init(id: "foreground", type: .foreground, goal: 1))), .init(trigger: .event(.init(id: "init", type: .appInit, goal: 1))) ] ) ) let instance = makeTrigger(trigger: trigger) instance.activate() var state = instance.process(event: .event(type: .background)) XCTAssertNil(state?.triggerResult) state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) var foreground = try XCTUnwrap(state?.triggerData.children["foreground"]) XCTAssertEqual(1, foreground.count) var appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) /// Children reset once they are all triggered foreground = try XCTUnwrap(state?.triggerData.children["foreground"]) XCTAssertEqual(0, foreground.count) appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) state = instance.process(event: .event(type: .appInit)) XCTAssertNotNil(state?.triggerResult) } func testCompoundAndComplexTrigger() throws { let trigger = AutomationTrigger.compound( .init( id: "compound", type: .and, goal: 2, children: [ .init(trigger: .event(.init(id: "foreground", type: .foreground, goal: 1)), resetOnIncrement: true), .init(trigger: .event(.init(id: "init", type: .appInit, goal: 1)), resetOnIncrement: true) ] ) ) let instance = makeTrigger(trigger: trigger) instance.activate() var state = instance.process(event: .event(type: .background)) XCTAssertNil(state?.triggerResult) state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) var foreground = try XCTUnwrap(state?.triggerData.children["foreground"]) XCTAssertEqual(1, foreground.count) var appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(1, state?.triggerData.count) foreground = try XCTUnwrap(state?.triggerData.children["foreground"]) XCTAssertEqual(0, foreground.count) //1 because reset on increment is false appinit = try XCTUnwrap(state?.triggerData.children["init"]) XCTAssertEqual(0, appinit.count) _ = instance.process(event: .event(type: .appInit)) state = instance.process(event: .event(type: .foreground)) XCTAssertNotNil(state?.triggerResult) } func testCompoundOrTrigger() throws { let trigger = AutomationTrigger.compound( CompoundAutomationTrigger( id: "simple-or", type: .or, goal: 2, children: [ .init(trigger: .event(EventAutomationTrigger(id: "foreground", type: .foreground, goal: 2)), resetOnIncrement: true), .init(trigger: .event(EventAutomationTrigger(id: "init", type: .appInit, goal: 2)), resetOnIncrement: true), ])) let instance = makeTrigger(trigger: trigger) instance.activate() 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: .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: .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: .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: .event(type: .foreground)) XCTAssertEqual(0, state?.triggerData.count) XCTAssertNotNil(state?.triggerResult) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) } func testCompoundComplexOrTrigger() throws { let trigger = AutomationTrigger.compound( CompoundAutomationTrigger( id: "complex-or", type: .or, goal: 2, children: [ .init(trigger: .event(EventAutomationTrigger(id: "foreground", type: .foreground, goal: 2)), resetOnIncrement: true), .init(trigger: .event(EventAutomationTrigger(id: "init", type: .appInit, goal: 2))), ])) let instance = makeTrigger(trigger: trigger) instance.activate() 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: .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: .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: .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: .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: .event(type: .foreground)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 1) } func testCompoundChainTrigger() { let trigger = AutomationTrigger.compound(CompoundAutomationTrigger( id: "simple-chain", type: .chain, goal: 2, children: [ .init(trigger: .event(EventAutomationTrigger(id: "foreground", type: .foreground, goal: 2)), isSticky: true), .init(trigger: .event(EventAutomationTrigger(id: "init", type: .appInit, goal: 2))), ])) let instance = makeTrigger(trigger: trigger) instance.activate() 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: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData) state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData.count) 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: .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: .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: .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: .event(type: .appInit)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 2) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) } func testCompoundChainTriggerWithChildState() throws { let trigger = AutomationTrigger.compound(CompoundAutomationTrigger( id: "state-child-chain", type: .chain, goal: 1, children: [ .init(trigger: .event(EventAutomationTrigger(id: "custom-event", type: .customEventValue, goal: 1)), isSticky: true), .init(trigger: .activeSession(count: 1)), ])) let instance = makeTrigger(trigger: trigger) instance.activate() var state = instance.process(event: .stateChanged(state: TriggerableState(appSessionID: "test"))) XCTAssertNil(state?.triggerResult) state = instance.process(event: .event(type: .customEventValue, data: .null, value: 1)) XCTAssertNotNil(state?.triggerResult) } func testCompoundComplexChainTrigger() { let trigger = AutomationTrigger.compound(CompoundAutomationTrigger( id: "complex-chain", type: .chain, goal: 2, children: [ .init(trigger: .event(EventAutomationTrigger(id: "foreground", type: .foreground, goal: 2))), .init(trigger: .event(EventAutomationTrigger(id: "init", type: .appInit, goal: 2))), ])) let instance = makeTrigger(trigger: trigger) instance.activate() 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: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData) 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: .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: .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: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertNil(state?.triggerData) 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: .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: .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: .event(type: .appInit)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) assertChildDataCount(parent: state?.triggerData, triggerID: "foreground", count: 0) assertChildDataCount(parent: state?.triggerData, triggerID: "init", count: 0) } func testComplexTrigger() { let trigger = AutomationTrigger.compound( CompoundAutomationTrigger( id: "complex-trigger", type: .and, goal: 1, children: [ .init(trigger: AutomationTrigger.compound( CompoundAutomationTrigger( id: "foreground-or-init", type: .or, goal: 1, children: [ .init(trigger: .event(EventAutomationTrigger(id: "foreground", type: .foreground, goal: 1))), .init(trigger: .event(EventAutomationTrigger(id: "init", type: .appInit, goal: 1))) ]) )), .init(trigger: AutomationTrigger.compound( CompoundAutomationTrigger( id: "chain-screen-background", type: .chain, goal: 1, children: [ .init(trigger: .event(EventAutomationTrigger(id: "screen", type: .screen, goal: 1))), .init(trigger: .event(EventAutomationTrigger(id: "background", type: .background, goal: 1))) ]) )) ])) let instance = makeTrigger(trigger: trigger) instance.activate() var state = instance.process(event: .event(type: .foreground)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) state = instance.process(event: .event(type: .screen)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) state = instance.process(event: .event(type: .appInit)) XCTAssertNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) state = instance.process(event: .event(type: .background)) XCTAssertNotNil(state?.triggerResult) XCTAssertEqual(0, state?.triggerData.count) } private func assertChildDataCount(parent: TriggerData?, triggerID: String, count: Double, line: UInt = #line) { XCTAssertEqual(count, parent?.children[triggerID]?.count, line: line) } private func makeTrigger(trigger: AutomationTrigger? = nil, type: TriggerExecutionType = .execution, startDate: Date? = nil, endDate: Date? = nil, state: TriggerData? = nil) -> PreparedTrigger { let trigger = trigger ?? AutomationTrigger.event(.init(type: .appInit, goal: 1)) return PreparedTrigger( scheduleID: "test-schedule", trigger: trigger, type: type, startDate: startDate, endDate: endDate, triggerData: state, priority: 0, date: date ) } } ================================================ FILE: Airship/AirshipAutomation/Tests/ExecutionWindowTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class ExectutionWindowTest: XCTestCase { private var defaultTimeZone: TimeZone = TimeZone(secondsFromGMT: 0)! private var calendar: Calendar { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = defaultTimeZone return calendar } // Jan 1, 2024 leap year! private var referenceDate: Date { calendar.date( from: DateComponents( year: 2024, month: 1, day: 1, hour: 0, minute: 0, second: 0 ) )! } func testCodable() throws { let json = """ { "include": [ { "type": "weekly", "days_of_week": [1,2,3,4,5], "time_range": { "start_hour": 8, "start_minute": 30, "end_hour": 5, "end_minute": 59 } }, ], "exclude": [ { "type": "daily", "time_range": { "start_hour": 12, "start_minute": 0, "end_hour": 13, "end_minute": 0 }, "time_zone": { "type": "local" } }, { "type": "monthly", "months": [12], "days_of_month": [24, 31] }, { "type": "monthly", "months": [1], "days_of_month": [1] } ] } """ let expected = try ExecutionWindow( include: [ .weekly(daysOfWeek: [1,2,3,4,5], timeRange: .init(startHour: 8, startMinute: 30, endHour: 5, endMinute: 59)) ], exclude: [ .daily(timeRange: .init(startHour: 12, startMinute: 0, endHour: 13, endMinute: 0), timeZone: .local), .monthly(months: [12], daysOfMonth: [24, 31]), .monthly(months: [1], daysOfMonth: [1]), ] ) try verify(json: json, expected: expected) } func testDaily() throws { let json = """ { "include": [ { "type": "daily", "time_range": { "start_hour": 12, "start_minute": 1, "end_hour": 13, "end_minute": 2 }, "time_zone": { "type": "utc" } }, ] } """ let expected = try ExecutionWindow( include: [ .daily(timeRange: .init(startHour: 12, startMinute: 1, endHour: 13, endMinute: 2), timeZone: .utc) ] ) try verify(json: json, expected: expected) } func testInvalidDaily() throws { let json = """ { "include": [ { "type": "daily" }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testInvalidTimeRange() throws { let json = """ { "include": [ { "type": "daily", "time_range": { startMinute: -1, startHour: 12, endMinute: 0, endHour: 1 }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testInvalidTimeZoneType() throws { let json = """ { "include": [ { "type": "daily", "time_range": { "start_hour": 12, "start_minute": 1, "end_hour": 13, "end_minute": 2 }, "time_zone": { "type": "something" } }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testTimeZoneIdentifiers() throws { let json = """ { "include": [ { "type": "daily", "time_range": { "start_hour": 12, "start_minute": 1, "end_hour": 13, "end_minute": 2 }, "time_zone": { "type": "identifiers", "identifiers": ["America/Los_Angeles", "Africa/Abidjan"], "on_failure": "skip", } }, ] } """ let expected = try ExecutionWindow( include: [ .daily( timeRange: .init(startHour: 12, startMinute: 1, endHour: 13, endMinute: 2), timeZone: .identifiers(["America/Los_Angeles", "Africa/Abidjan"], onFailure: .skip) ) ] ) try verify(json: json, expected: expected) } func testMonthly() throws { let json = """ { "include": [ { "type": "monthly", "months": [1, 12], "days_of_month": [1, 31] }, ] } """ let expected = try ExecutionWindow( include: [ .monthly(months: [1, 12], daysOfMonth: [1, 31]) ] ) try verify(json: json, expected: expected) } func testMonthlyOnlyMonths() throws { let json = """ { "include": [ { "type": "monthly", "months": [1, 12] }, ] } """ let expected = try ExecutionWindow( include: [ .monthly(months: [1, 12]) ] ) try verify(json: json, expected: expected) } func testMonthlyOnlyDays() throws { let json = """ { "include": [ { "type": "monthly", "days_of_month": [1, 31] }, ] } """ let expected = try ExecutionWindow( include: [ .monthly(daysOfMonth: [1, 31]) ] ) try verify(json: json, expected: expected) } func testInvalidMonthly() throws { let json = """ { "include": [ { "type": "monthly" }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testInvalidMonthlyEmpty() throws { let json = """ { "include": [ { "type": "monthly", "days_of_month": [], "months": [] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testInvalidMonthlyMonthsBelow1() throws { let json = """ { "include": [ { "type": "monthly", "months": [0] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testInvalidMonthlyMonthAbove12() throws { let json = """ { "include": [ { "type": "monthly", "months": [13] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testInvalidMonthlyDaysAbove31() throws { let json = """ { "include": [ { "type": "monthly", "days_of_month": [32] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testInvalidMonthlyDaysBelow1() throws { let json = """ { "include": [ { "type": "monthly", "days_of_month": [0] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testWeekly() throws { let json = """ { "include": [ { "type": "weekly", "days_of_week": [1, 7] }, ] } """ let expected = try ExecutionWindow( include: [ .weekly(daysOfWeek: [1, 7]) ] ) try verify(json: json, expected: expected) } func testWeeklyInvalidEmptyDaysOfWeek() throws { let json = """ { "include": [ { "type": "weekly", "days_of_week": [] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testWeeklyInvalidEmptyDaysOfWeekBelow1() throws { let json = """ { "include": [ { "type": "weekly", "days_of_week": [0] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testWeeklyInvalidEmptyDaysOfAbove7() throws { let json = """ { "include": [ { "type": "weekly", "days_of_week": [8] }, ] } """ do { _ = try JSONDecoder().decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTFail("Should throw") } catch {} } func testReturnNowOnMatch() throws { let window = try ExecutionWindow( include: [ .daily(timeRange: .init(startHour: 0, endHour: 23)) ], exclude: [] ) XCTAssertEqual(windowAvailibility(window, date: referenceDate), .now) } func testEmptyWindow() throws { let window = try ExecutionWindow() XCTAssertEqual(windowAvailibility(window, date: Date()), .now) } func testIncludeTimeRangeSameStartAndEnd() throws { var date = referenceDate let window = try ExecutionWindow( include: [ .daily(timeRange: .init(startHour: 0, endHour: 0)) ] ) XCTAssertEqual(windowAvailibility(window, date: referenceDate), .now) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.days - 1.seconds)) date -= 2.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.seconds)) } func testIncludeTimeRange() throws { var date = referenceDate let window = try ExecutionWindow( include: [ .daily( timeRange: .init( startHour: 3, endHour: 4 ) ) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(3.hours)) date += 3.hours XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.hours XCTAssertEqual(windowAvailibility(window, date: date), .retry(23.hours)) } func testExcludeTimeRangeSameStartAndEnd() throws { var date = referenceDate let window = try ExecutionWindow( exclude: [ .daily(timeRange: .init(startHour: 0, endHour: 0)) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.seconds)) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) date -= 2.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) } func testExcludeEndOfTimeRange() throws { var date = referenceDate + 3.hours let window = try ExecutionWindow( exclude: [ .daily( timeRange: .init( startHour: 3, endHour: 0 ) ) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(21.hours)) date += 21.hours XCTAssertEqual(windowAvailibility(window, date: date), .now) } func testExcludeTimeRangeWrap() throws { var date = calendar.date( from: DateComponents( year: 2024, month: 1, day: 1, hour: 23, minute: 0, second: 0 ) )! let window = try ExecutionWindow( exclude: [ .daily( timeRange: .init( startHour: 23, endHour: 1 ) ) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(2.hours)) date += 1.hours XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.hours)) date += 1.hours XCTAssertEqual(windowAvailibility(window, date: date), .now) } func testIncludeAndExcludeSameRule() throws { let date = referenceDate + 3.hours let window = try ExecutionWindow( include: [ .daily( timeRange: .init( startHour: 3, endHour: 0 ) ) ], exclude: [ .daily( timeRange: .init( startHour: 3, endHour: 0 ) ) ] ) let startNextDay = calendar.startOfDay(for: date + 1.days) let delay = startNextDay.timeIntervalSince(date) XCTAssertEqual(windowAvailibility(window, date: date), .retry(delay)) } func testIncludeWeekly() throws { var date = calendar.date(bySetting: .weekday, value: 4, of: referenceDate)! let window = try ExecutionWindow( include: [ .weekly(daysOfWeek: [3, 5]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.days)) date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(4.days)) date += 3.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.days)) date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .now) } func testIncludeWeeklyTimeRange() throws { var date = calendar.date(bySetting: .weekday, value: 4, of: referenceDate)! let window = try ExecutionWindow( include: [ .weekly( daysOfWeek: [3, 5], timeRange: .init( startHour: 3, endHour: 0 ) ) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.days + 3.hours)) date += 1.days + 3.hours - 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.seconds)) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 21.hours - 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(4.days + 3.hours)) } func testIncludeWeeklyTimeRangeWithTimeZone() throws { var date = calendar.date(bySetting: .weekday, value: 4, of: referenceDate)! let window = try ExecutionWindow( include: [ .weekly( daysOfWeek: [3, 5], timeRange: .init( startHour: 3, endHour: 0 ), timeZone: .secondsFromGMT(Int(1.hours)) ) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.days + 2.hours)) date += 1.days + 2.hours - 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.seconds)) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 21.hours - 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(4.days + 3.hours)) } func testExcludeWeeklyTimeRange() throws { var date = calendar.date(bySetting: .weekday, value: 4, of: referenceDate)! let window = try ExecutionWindow( exclude: [ .weekly( daysOfWeek: [3, 5], timeRange: .init( startHour: 3, endHour: 0 ) ) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.days + 3.hours - 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(21.hours)) date += 21.hours - 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.seconds)) date += 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) } func testIncludeMonthly() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( include: [ .monthly(months: [2, 4], daysOfMonth: [15, 10]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(40.days)) date += 40.days XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(4.days)) date += 4.days XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(54.days)) date += 55.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(4.days)) date += 5.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(300.days)) } func testMonthlyNoMonthsAfterDay() throws { let date = calendar.date(bySetting: .day, value: 16, of: referenceDate)! let window = try ExecutionWindow( include: [ .monthly(daysOfMonth: [15]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(30.days)) } func testMonthlyNextMonth() throws { // Feb 16 var date = calendar.date(bySetting: .month, value: 2, of: referenceDate)! date = calendar.date(bySetting: .day, value: 16, of: date)! let window = try ExecutionWindow( include: [ .monthly(months: [1], daysOfMonth: [15]), .monthly(months: [3], daysOfMonth: [2, 3]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(15.days)) } func testMonthlyNextMonthNoDays() throws { // Feb 16 var date = calendar.date(bySetting: .month, value: 2, of: referenceDate)! date = calendar.date(bySetting: .day, value: 16, of: date)! let window = try ExecutionWindow( include: [ .monthly(months: [1, 3]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(14.days)) } func testMonthlyNextYear() throws { // Feb 15 var date = calendar.date(bySetting: .month, value: 2, of: referenceDate)! date = calendar.date(bySetting: .day, value: 15, of: date)! let window = try ExecutionWindow( include: [ .monthly(months: [1], daysOfMonth: [14]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(334.days)) } func testIncludeMonthlyWithTimeZone() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( include: [ .monthly( months: [2, 4], daysOfMonth: [15, 10], timeZone: .secondsFromGMT(Int(7.hours)) ) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(40.days - 7.hours)) date += 40.days - 7.hours XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(4.days)) date += 4.days XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(54.days)) date += 55.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(4.days)) date += 5.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(300.days)) } func testImpossibleMonthlyInclude() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( include: [ // can't happen .monthly(months: [2], daysOfMonth: [31], timeRange: .init(startHour: 5, endHour: 23)) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(Date.distantFuture.timeIntervalSince(referenceDate) + 5.hours)) } func testMonthlySkipsInvalidMonths() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( include: [ // can't happen .monthly(months: [2, 10], daysOfMonth: [31]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(304.days)) } func testImpossibleMonthlyExclude() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( exclude: [ // can't happen .monthly(months: [2], daysOfMonth: [31]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .now) } func testMonthlyWithoutMonths() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( include: [ .monthly(daysOfMonth: [31]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(30.days)) date += 31.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(30.days)) } func testMonthlyWithOnlyMonths() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( include: [ .monthly(months: [10, 12]) ] ) XCTAssertEqual(windowAvailibility(window, date: date), .retry(274.days)) date += 274.days XCTAssertEqual(windowAvailibility(window, date: date), .now) for _ in 0..<30 { date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .now) } date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(30.days)) } func testEmptyMonthlyIncludeThrows() throws { do { _ = try ExecutionWindow( include: [ .monthly() ] ) XCTFail("Should throw") } catch {} } func testEmptyMonthlyExcludeThrows() throws { do { _ = try ExecutionWindow( exclude: [ .monthly() ] ) XCTFail("Should throw") } catch {} } func testComplexRule() throws { var date = calendar.date(bySetting: .month, value: 1, of: referenceDate)! date = calendar.date(bySetting: .day, value: 1, of: date)! let window = try ExecutionWindow( include: [ .daily( timeRange: .init(startHour: 1, endHour: 2), timeZone: .secondsFromGMT(Int(1.hours)) ), .weekly( daysOfWeek: [5], timeRange: .init( startHour: 3, endHour: 5 ), timeZone: .utc ), .monthly(months: [2, 4], daysOfMonth: [2], timeRange: .init(startHour: 10, endHour: 22)) ], exclude: [ .monthly(months: [1, 3, 5, 7, 9, 11]) ] ) // Exclude monthly without days is only 1 day at a time XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.days)) for _ in 0..<30 { date += 1.days XCTAssertEqual(windowAvailibility(window, date: date), .retry(1.days)) } // Feb 1 date += 1.days // Timezone offset for the daily rule is 1, so its makes it [0-1] XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.hours // 2 hour until weekly rule for DOW 5 XCTAssertEqual(windowAvailibility(window, date: date), .retry(2.hours)) date += 2.hours XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 2.hours // 19 hours until the daily rule again XCTAssertEqual(windowAvailibility(window, date: date), .retry(19.hours)) date += 19.hours XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.hours // 9 hours until the monthly rule XCTAssertEqual(windowAvailibility(window, date: date), .retry(9.hours)) date += 9.hours XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 12.hours - 1.seconds XCTAssertEqual(windowAvailibility(window, date: date), .now) date += 1.seconds // 2 hour until the daily rule again XCTAssertEqual(windowAvailibility(window, date: date), .retry(2.hours)) } func testTransitionOutOfDST() throws { // Sun March 10 2024 we transition from PDT to PST self.defaultTimeZone = TimeZone(identifier: "America/Los_Angeles")! let midnightOf = calendar.date( from: DateComponents( timeZone: self.defaultTimeZone, year: 2024, month: 3, day: 10, hour: 0, minute: 0, second: 0 ) )! // Sun March 10 2024 let transition = calendar.date( from: DateComponents( timeZone: self.defaultTimeZone, year: 2024, month: 3, day: 10, hour: 3, minute: 0, second: 0 ) )! let window = try ExecutionWindow( include: [ .daily( timeRange: .init( startHour: 2, endHour: 4 ) ) ] ) // 12:00 PST XCTAssertEqual(windowAvailibility(window, date: midnightOf), .retry(2.hours)) // 3:00 PDT XCTAssertEqual(windowAvailibility(window, date: transition), .now) // 4:00 PDT XCTAssertEqual(windowAvailibility(window, date: transition + 1.hours), .retry(22.hours)) } func testTransitionToDST() throws { // Sun Nov 3 2024 we transition from PST to PDT self.defaultTimeZone = TimeZone(identifier: "America/Los_Angeles")! let midnightOf = calendar.date( from: DateComponents( timeZone: self.defaultTimeZone, year: 2024, month: 11, day: 3, hour: 0, minute: 0, second: 0 ) )! // Sun March 10 2024 let transition = calendar.date( from: DateComponents( timeZone: self.defaultTimeZone, year: 2024, month: 3, day: 10, hour: 1, minute: 0, second: 0 ) )! let window = try ExecutionWindow( include: [ .daily( timeRange: .init( startHour: 2, endHour: 4 ) ) ] ) // 12:00 PDT XCTAssertEqual(windowAvailibility(window, date: midnightOf), .retry(3.hours)) // 1:00 PST XCTAssertEqual(windowAvailibility(window, date: transition), .retry(1.hours)) // 2:00 PDT XCTAssertEqual(windowAvailibility(window, date: transition + 1.hours), .now) } func testErrorTimeZoneIdentifiersFailed() throws { let window = try ExecutionWindow( include: [ .daily( timeRange: .init( startHour: 3, endHour: 0 ), timeZone: .identifiers(["Does not exist"], onFailure: .error) ) ] ) do { _ = try window.nextAvailability(date: referenceDate) XCTFail("Should throw") } catch {} } func testSkipTimeZoneIdentifiersFailed() throws { let window = try ExecutionWindow( include: [ .daily( timeRange: .init( startHour: 0, endHour: 10 ), timeZone: .identifiers(["Does not exist"], onFailure: .skip) ) ] ) let result = try window.nextAvailability(date: referenceDate) XCTAssertEqual(result, .now) } private func windowAvailibility(_ window: ExecutionWindow, date: Date) -> ExecutionWindowResult { try! window.nextAvailability(date: date, currentTimeZone: defaultTimeZone) } func verify(json: String, expected: ExecutionWindow, line: UInt = #line) throws { let decoder = JSONDecoder() let encoder = JSONEncoder() let fromJSON = try decoder.decode(ExecutionWindow.self, from: json.data(using: .utf8)!) XCTAssertEqual(fromJSON, expected, line: line) let roundTrip = try decoder.decode(ExecutionWindow.self, from: try encoder.encode(fromJSON)) XCTAssertEqual(roundTrip, fromJSON, line: line) } } extension Date { func local(calendar: Calendar) -> Date { return self.advanced(by: Double(calendar.timeZone.secondsFromGMT())) } static func fromMidnight(seconds: TimeInterval, calendar: Calendar) -> Date { return Date(timeIntervalSince1970: 1704060000 + seconds - 3600) } } fileprivate extension ExecutionWindow.TimeZone { static func secondsFromGMT(_ seconds: Int) -> ExecutionWindow.TimeZone { let timeZoneID = TimeZone(secondsFromGMT: seconds)!.identifier return .identifiers([timeZoneID], onFailure: .error) } } fileprivate extension Int { var days: TimeInterval { return TimeInterval(self) * 60 * 60 * 24 } var hours: TimeInterval { TimeInterval(self) * 60 * 60 } var minutes: TimeInterval { TimeInterval(self) * 60 } var seconds: TimeInterval { TimeInterval(self) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Analytics/DefaultInAppDisplayImpressionRuleProviderTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class DefaultInAppDisplayImpressionRuleProviderTest: XCTestCase { let provider = DefaultInAppDisplayImpressionRuleProvider() func testCustomMessage() throws { let rule = provider.impressionRules( for: InAppMessage(name: "woot", displayContent: .custom(.string("neat"))) ) XCTAssertEqual(rule, .once) } func testFullscreenMessage() throws { let rule = provider.impressionRules( for: InAppMessage( name: "woot", displayContent: .fullscreen(.init(buttons: [], template: .headerBodyMedia)) ) ) XCTAssertEqual(rule, .once) } func testModalMessage() throws { let rule = provider.impressionRules( for: InAppMessage( name: "woot", displayContent: .modal(.init(buttons: [], template: .headerBodyMedia)) ) ) XCTAssertEqual(rule, .once) } func testBannerMessage() throws { let rule = provider.impressionRules( for: InAppMessage( name: "woot", displayContent: .banner(.init(buttons: [], template: .mediaLeft)) ) ) XCTAssertEqual(rule, .once) } func testModalThomas() throws { let airshipLayout = """ { "version":1, "presentation":{ "type":"modal", "default_placement":{ "size":{ "width":"50%", "height":"50%" } } }, "view":{ "type":"container", "items":[] } } """ let rule = provider.impressionRules( for: InAppMessage( name: "woot", displayContent: .airshipLayout( try! JSONDecoder().decode(AirshipLayout.self, from: airshipLayout.data(using: .utf8)!) ) ) ) XCTAssertEqual(rule, .once) } func testBannerThomas() throws { let airshipLayout = """ { "version":1, "presentation":{ "type":"banner", "default_placement":{ "position": "top", "size":{ "width":"50%", "height":"50%" } } }, "view":{ "type":"container", "items":[] } } """ let rule = provider.impressionRules( for: InAppMessage( name: "woot", displayContent: .airshipLayout( try! JSONDecoder().decode(AirshipLayout.self, from: airshipLayout.data(using: .utf8)!) ) ) ) XCTAssertEqual(rule, .once) } func testEmbeddedThomas() throws { let airshipLayout = """ { "version":1, "presentation":{ "type":"embedded", "embedded_id":"home_banner", "default_placement":{ "size":{ "width":"50%", "height":"50%" } } }, "view":{ "type":"container", "items":[] } } """ let rule = provider.impressionRules( for: InAppMessage( name: "woot", displayContent: .airshipLayout( try! JSONDecoder().decode(AirshipLayout.self, from: airshipLayout.data(using: .utf8)!) ) ) ) XCTAssertEqual(rule, .interval(1800.0)) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Analytics/InAppMessageAnalyticsTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipAutomation @testable import AirshipCore import Foundation @MainActor struct InAppMessageAnalyticsTest { private let eventRecorder = EventRecorder() private let historyStore = TestDisplayHistoryStore() private let preparedInfo = PreparedScheduleInfo( scheduleID: UUID().uuidString, productID: UUID().uuidString, campaigns: AirshipJSON.string(UUID().uuidString), contactID: UUID().uuidString, experimentResult: ExperimentResult( channelID: UUID().uuidString, contactID: UUID().uuidString, isMatch: true, reportingMetadata: [AirshipJSON.string(UUID().uuidString)] ), reportingContext: AirshipJSON.string(UUID().uuidString), triggerSessionID: UUID().uuidString, priority: 0 ) @Test func testSource() async throws { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .remoteData ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory() ) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) let data = eventRecorder.eventData.first! let expectedID = ThomasLayoutEventMessageID.airship( identifier: self.preparedInfo.scheduleID, campaigns: self.preparedInfo.campaigns ) #expect(data.messageID == expectedID) #expect(data.source == .airship) } @Test func testAppDefined() async throws { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .appDefined ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory() ) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) let data = eventRecorder.eventData.first! let expectedID = ThomasLayoutEventMessageID.appDefined( identifier: self.preparedInfo.scheduleID ) #expect(data.messageID == expectedID) #expect(data.source == .appDefined) } @Test func testLegacyMessageID() async throws { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory() ) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) let data = eventRecorder.eventData.first! let expectedID = ThomasLayoutEventMessageID.legacy( identifier: self.preparedInfo.scheduleID ) #expect(data.messageID == expectedID) #expect(data.source == .airship) } @Test func testData() async throws { let thomasLayoutContext = ThomasLayoutContext( pager: ThomasLayoutContext.Pager( identifier: UUID().uuidString, pageIdentifier: UUID().uuidString, pageIndex: 1, completed: false, count: 2 ), button: ThomasLayoutContext.Button(identifier: UUID().uuidString), form: ThomasLayoutContext.Form( identifier: UUID().uuidString, submitted: true, type: UUID().uuidString, responseType: UUID().uuidString ) ) let expectedContext = ThomasLayoutEventContext.makeContext( reportingContext: preparedInfo.reportingContext, experimentsResult: preparedInfo.experimentResult, layoutContext: thomasLayoutContext, displayContext: .init( triggerSessionID: preparedInfo.triggerSessionID, isFirstDisplay: true, isFirstDisplayTriggerSessionID: true ) ) let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory() ) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: thomasLayoutContext) let data = self.eventRecorder.eventData.first! #expect(data.context == expectedContext) #expect(data.renderedLocale == AirshipJSON.string("rendered locale")) #expect(data.event.name == EventType.customEvent) } @Test func testSingleImpression() async throws { let date = UATestDate(offset: 0, dateOverride: Date()) let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory(), date: date ) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) let impression = eventRecorder.lastRecordedImpression! #expect(preparedInfo.scheduleID == impression.entityID) #expect(AirshipMeteredUsageType.inAppExperienceImpression == impression.usageType) #expect(preparedInfo.productID == impression.product) #expect(preparedInfo.reportingContext == impression.reportingContext) #expect(date.now == impression.timestamp) #expect(preparedInfo.contactID == impression.contactID) eventRecorder.lastRecordedImpression = nil analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) #expect(eventRecorder.lastRecordedImpression == nil) let displayHistory = await self.historyStore.get( scheduleID: preparedInfo.scheduleID ) #expect(displayHistory.lastImpression?.date == date.now) #expect(displayHistory.lastImpression?.triggerSessionID == preparedInfo.triggerSessionID) } @Test func testImpressionInterval() async throws { let date = UATestDate(offset: 0, dateOverride: Date()) let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .interval(10.0), eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory(), date: date ) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) let impression = eventRecorder.lastRecordedImpression! #expect(preparedInfo.scheduleID == impression.entityID) #expect(AirshipMeteredUsageType.inAppExperienceImpression == impression.usageType) #expect(preparedInfo.productID == impression.product) #expect(preparedInfo.reportingContext == impression.reportingContext) #expect(date.now == impression.timestamp) #expect(preparedInfo.contactID == impression.contactID) eventRecorder.lastRecordedImpression = nil analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) #expect(eventRecorder.lastRecordedImpression == nil) var displayHistory = await self.historyStore.get( scheduleID: preparedInfo.scheduleID ) #expect(displayHistory.lastImpression?.date == date.now) #expect(displayHistory.lastImpression?.triggerSessionID == preparedInfo.triggerSessionID) date.offset += 9.9 analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) #expect(eventRecorder.lastRecordedImpression == nil) date.offset += 0.1 analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) assert(eventRecorder.lastRecordedImpression != nil) displayHistory = await self.historyStore.get( scheduleID: preparedInfo.scheduleID ) #expect(displayHistory.lastImpression?.date == date.now) #expect(displayHistory.lastImpression?.triggerSessionID == preparedInfo.triggerSessionID) } @Test func testReportingDisabled() async throws { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, isReportingEnabled: false, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory() ) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) assert(self.eventRecorder.eventData.isEmpty) // impressions are still recorded assert(eventRecorder.lastRecordedImpression != nil) } @Test func testDisplayUpdatesHistory() async { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, isReportingEnabled: true, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory() ) var displayHistory = await self.historyStore.get( scheduleID: preparedInfo.scheduleID ) #expect(displayHistory.lastDisplay?.triggerSessionID == nil) displayHistory = await self.historyStore.get( scheduleID: preparedInfo.scheduleID ) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) } @Test func testDisplayContextNewIAA() async { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, isReportingEnabled: true, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory() ) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) let firstDisplayContext = ThomasLayoutEventContext.Display( triggerSessionID: preparedInfo.triggerSessionID, isFirstDisplay: true, isFirstDisplayTriggerSessionID: true ) let secondDisplayContext = ThomasLayoutEventContext.Display( triggerSessionID: preparedInfo.triggerSessionID, isFirstDisplay: false, isFirstDisplayTriggerSessionID: false ) let expected = [ // event before a display firstDisplayContext, // first display firstDisplayContext, // event after display firstDisplayContext, // second display secondDisplayContext, // event after display secondDisplayContext ] let displayContexts = self.eventRecorder.eventData.map { $0.context!.display } #expect(displayContexts == expected) } @Test func testDisplayContextPreviouslyDisplayIAX() async { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, isReportingEnabled: true, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory( lastDisplay: .init(triggerSessionID: UUID().uuidString) ) ) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) let firstDisplayContext = ThomasLayoutEventContext.Display( triggerSessionID: preparedInfo.triggerSessionID, isFirstDisplay: false, isFirstDisplayTriggerSessionID: true ) let secondDisplayContext = ThomasLayoutEventContext.Display( triggerSessionID: preparedInfo.triggerSessionID, isFirstDisplay: false, isFirstDisplayTriggerSessionID: false ) let expected = [ // event before a display firstDisplayContext, // first display firstDisplayContext, // event after display firstDisplayContext, // second display secondDisplayContext, // event after display secondDisplayContext ] let displayContexts = self.eventRecorder.eventData.map { $0.context!.display } #expect(displayContexts == expected) } @Test func testDisplayContextSameTriggerSessionID() async { let analytics = InAppMessageAnalytics( preparedScheduleInfo: preparedInfo, message: InAppMessage( name: "name", displayContent: .custom(.string("custom")), source: .legacyPush, isReportingEnabled: true, renderedLocale: AirshipJSON.string("rendered locale") ), displayImpressionRule: .once, eventRecorder: eventRecorder, historyStore: historyStore, displayHistory: MessageDisplayHistory( lastDisplay: .init(triggerSessionID: preparedInfo.triggerSessionID) ) ) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) analytics.recordEvent(TestThomasLayoutEvent(), layoutContext: nil) let firstDisplayContext = ThomasLayoutEventContext.Display( triggerSessionID: preparedInfo.triggerSessionID, isFirstDisplay: false, isFirstDisplayTriggerSessionID: false ) let secondDisplayContext = ThomasLayoutEventContext.Display( triggerSessionID: preparedInfo.triggerSessionID, isFirstDisplay: false, isFirstDisplayTriggerSessionID: false ) let expected = [ // event before a display firstDisplayContext, // first display firstDisplayContext, // event after display firstDisplayContext, // second display secondDisplayContext, // event after display secondDisplayContext ] let displayContexts = self.eventRecorder.eventData.map { $0.context!.display } #expect(displayContexts == expected) } } final class EventRecorder: ThomasLayoutEventRecorderProtocol, @unchecked Sendable { var lastRecordedImpression: AirshipMeteredUsageEvent? var eventData: [ThomasLayoutEventData] = [] func recordEvent(inAppEventData: ThomasLayoutEventData) { eventData.append(inAppEventData) } func recordImpressionEvent(_ event: AirshipMeteredUsageEvent) { lastRecordedImpression = event } } final class TestDisplayHistoryStore: MessageDisplayHistoryStoreProtocol, @unchecked Sendable { var stored: [String: MessageDisplayHistory] = [:] func set(_ history: MessageDisplayHistory, scheduleID: String) { stored[scheduleID] = history } func get(scheduleID: String) async -> MessageDisplayHistory { return stored[scheduleID] ?? MessageDisplayHistory() } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Assets/AssetCacheManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore @testable import AirshipAutomation final class AssetCacheManagerTest: XCTestCase { class TestAssetDownloader: AssetDownloader, @unchecked Sendable { var downloadResult: Result? var downloadDelaySeconds: TimeInterval = 0 var customDownloadHandler: ((URL) async throws -> URL)? func downloadAsset(remoteURL: URL) async throws -> URL { // Simulate a network delay if downloadDelaySeconds > 0 { let delayNanoseconds = UInt64(downloadDelaySeconds * 1_000_000_000) // Convert seconds to nanoseconds try await Task.sleep(nanoseconds: delayNanoseconds) } if let customHandler = customDownloadHandler { return try await customHandler(remoteURL) } switch downloadResult { case .success(let url): return url case .failure(let error): throw error case .none: fatalError("Download result wasn't set.") } } } class TestAssetFileManager: AssetFileManager, @unchecked Sendable { var onEnsureCacheRootDirectory: ((_ rootPathComponent: String) -> URL)? var onEnsureDirectory: ((_ identifier: String) -> URL)? var onMoveAsset: ((_ tempURL: URL, _ cacheURL: URL) throws -> ())? var onAssetItemExists: ((_ cacheURL: URL) -> Bool )? var onClearAssets: ((_ cacheURL: URL) -> ())? var rootDirectory: URL? func assetItemExists(at cacheURL: URL) -> Bool { return self.onAssetItemExists?(cacheURL) ?? false } func ensureCacheDirectory(identifier: String) throws -> URL { if onEnsureDirectory == nil { XCTFail("Testing block onEnsureDirectory testing block must be implemented and return a URL") } return self.onEnsureDirectory!(identifier) } func ensureCacheRootDirectory(rootPathComponent: String) throws -> URL { if onEnsureCacheRootDirectory == nil { XCTFail("Testing block onEnsureCacheRootDirectory must be implemented and return a URL") } return self.onEnsureCacheRootDirectory!(rootPathComponent) } func moveAsset(from tempURL: URL, to cacheURL: URL) throws { try self.onMoveAsset?(tempURL, cacheURL) } func clearAssets(cacheURL: URL) throws { self.onClearAssets?(cacheURL) } } /// Tests that calling cache assets on two remote URLs will result in a file move to the correct directory with those two assets func testCacheTwoAssets() async throws { let downloader = TestAssetDownloader() downloader.downloadResult = .success(URL(fileURLWithPath: "/temp/asset")) let assetRemoteURL1 = URL(string:"http://airship.com/asset1")! let assetRemoteURL2 = URL(string:"http://airship.com/asset2")! let testScheduleIdentifier = "test-schedule-id" let expectedRootPathComponent = "com.urbanairship.iamassetcache" let expectedAsset1Filename = assetRemoteURL1.assetFilename let expectedAsset2Filename = assetRemoteURL2.assetFilename let expectedRootCacheDirectory = URL(fileURLWithPath:"test-user-cache/\(expectedRootPathComponent)/") let expectedCacheDirectory = expectedRootCacheDirectory.appendingPathComponent(testScheduleIdentifier, isDirectory: true) let expectedFile1URL = expectedCacheDirectory.appendingPathComponent(assetRemoteURL1.assetFilename, isDirectory:false) let expectedFile2URL = expectedCacheDirectory.appendingPathComponent(assetRemoteURL2.assetFilename, isDirectory:false) let fileManager = TestAssetFileManager() var shouldExist = false fileManager.onEnsureCacheRootDirectory = { rootPathComponent in /// Check root path component is used for the root directory XCTAssertEqual(rootPathComponent, expectedRootPathComponent) return expectedRootCacheDirectory } fileManager.onEnsureDirectory = { identifier in /// Check cache directory is the root path + expected schedule identifier XCTAssertEqual(identifier, expectedCacheDirectory.lastPathComponent) return expectedCacheDirectory } fileManager.onAssetItemExists = { url in /// If we're checking the status of the cache directory if expectedCacheDirectory == url { return true } /// If we're checking the status of file 1 if expectedFile1URL == url, shouldExist { return true } /// If we're checking the status of file 2 if expectedFile2URL == url, shouldExist { return true } if shouldExist { XCTFail() } return false } let asset1MovedToCache = expectation(description: "Test asset 1 moved to cache") let asset2MovedToCache = expectation(description: "Test asset 2 moved to cache") fileManager.onMoveAsset = { tempURL, cachedURL in if expectedAsset1Filename == cachedURL.lastPathComponent { asset1MovedToCache.fulfill() } if expectedAsset2Filename == cachedURL.lastPathComponent { asset2MovedToCache.fulfill() } } let manager = AssetCacheManager(assetDownloader: downloader, assetFileManager: fileManager) await manager.clearCache(identifier: testScheduleIdentifier) do { let cachedAssets = try await manager.cacheAssets(identifier: testScheduleIdentifier, assets: [assetRemoteURL1.path, assetRemoteURL2.path]) shouldExist = true XCTAssertTrue(cachedAssets.isCached(remoteURL: assetRemoteURL1)) XCTAssertTrue(cachedAssets.isCached(remoteURL: assetRemoteURL2)) XCTAssertEqual(cachedAssets.cachedURL(remoteURL: assetRemoteURL1), expectedFile1URL) XCTAssertEqual(cachedAssets.cachedURL(remoteURL: assetRemoteURL2), expectedFile2URL) await fulfillment(of: [asset1MovedToCache, asset2MovedToCache], timeout: 1) } catch { XCTFail("Caching assets should succeed: \(error)") } } func testClearCacheDuringActiveDownload() async throws { let downloader = TestAssetDownloader() downloader.downloadResult = .success(URL(fileURLWithPath: "/temp/asset")) downloader.downloadDelaySeconds = 0.5 // Set a delay to ensure clearCache is called while mock downloading is in progress let fileManager = TestAssetFileManager() fileManager.onEnsureCacheRootDirectory = { rootPathComponent in return URL(fileURLWithPath: "/path/to/cache") } fileManager.onEnsureDirectory = { url in return URL(fileURLWithPath: "/path/to/cache/identifier") } let manager = AssetCacheManager(assetDownloader: downloader, assetFileManager: fileManager) let identifier = "testIdentifier" // Start caching assets in a separate task to allow it to run in parallel let cacheTask = Task { try await manager.cacheAssets(identifier: identifier, assets: ["http://airship.com/asset"]) } // Give the cacheTask a moment to start be assigned to the task map try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds // Clear the cache while the caching task is still in progress await manager.clearCache(identifier: identifier) // Verify that the caching task was canceled var isCancelled = false do { _ = try await cacheTask.result.get() } catch { if (error as? CancellationError) != nil { isCancelled = true } else { XCTFail("Expected a CancellationError, but received: \(error)") } } XCTAssertTrue(isCancelled, "The caching task should be canceled after clearing the cache.") } /// Tests that duplicate URLs in the assets array are deduplicated before processing func testCacheDuplicateAssets() async throws { let downloader = TestAssetDownloader() downloader.downloadResult = .success(URL(fileURLWithPath: "/temp/asset")) let assetRemoteURL = URL(string:"http://airship.com/duplicate-asset")! let testScheduleIdentifier = "test-duplicate-schedule-id" let expectedRootPathComponent = "com.urbanairship.iamassetcache" let expectedRootCacheDirectory = URL(fileURLWithPath:"test-user-cache/\(expectedRootPathComponent)/") let expectedCacheDirectory = expectedRootCacheDirectory.appendingPathComponent(testScheduleIdentifier, isDirectory: true) let expectedFileURL = expectedCacheDirectory.appendingPathComponent(assetRemoteURL.assetFilename, isDirectory:false) let fileManager = TestAssetFileManager() var downloadCount = 0 var moveCount = 0 // Track how many times download is called downloader.customDownloadHandler = { remoteURL in downloadCount += 1 return URL(fileURLWithPath: "/temp/asset-\(downloadCount)") } fileManager.onEnsureCacheRootDirectory = { _ in return expectedRootCacheDirectory } fileManager.onEnsureDirectory = { _ in return expectedCacheDirectory } fileManager.onAssetItemExists = { url in if expectedCacheDirectory == url { return true } // First check returns false, subsequent checks return true return moveCount > 0 && url == expectedFileURL } fileManager.onMoveAsset = { tempURL, cachedURL in moveCount += 1 XCTAssertEqual(cachedURL, expectedFileURL) } let manager = AssetCacheManager(assetDownloader: downloader, assetFileManager: fileManager) // Pass the same URL three times (simulating the bug scenario) let duplicateAssets = [assetRemoteURL.absoluteString, assetRemoteURL.absoluteString, assetRemoteURL.absoluteString] do { let cachedAssets = try await manager.cacheAssets(identifier: testScheduleIdentifier, assets: duplicateAssets) // Should only download and move once despite duplicate URLs XCTAssertEqual(downloadCount, 1, "Should only download once for duplicate URLs") XCTAssertEqual(moveCount, 1, "Should only move once for duplicate URLs") XCTAssertTrue(cachedAssets.isCached(remoteURL: assetRemoteURL)) XCTAssertEqual(cachedAssets.cachedURL(remoteURL: assetRemoteURL), expectedFileURL) } catch { XCTFail("Caching duplicate assets should succeed: \(error)") } } /// Tests that concurrent caching of the same asset handles race conditions gracefully // TODO: This test needs to be redesigned to properly test the race condition handling func disabled_testConcurrentCachingSameAsset() async throws { let downloader = TestAssetDownloader() downloader.downloadDelaySeconds = 0.1 // Add small delay to increase chance of race condition let assetRemoteURL = URL(string:"http://airship.com/concurrent-asset")! let testScheduleIdentifier1 = "test-concurrent-schedule-1" let testScheduleIdentifier2 = "test-concurrent-schedule-2" let fileManager = TestAssetFileManager() var moveAttempts = 0 let moveAttemptsSemaphore = AirshipLock() fileManager.rootDirectory = URL(fileURLWithPath: "/test-cache") fileManager.onEnsureDirectory = { identifier in return URL(fileURLWithPath: "/test-cache/\(identifier)") } var cachedFiles = Set() fileManager.onAssetItemExists = { url in // Return true for directories if url.path.contains("/test-cache/test-concurrent-schedule") && !url.path.contains(".") { return true } // Check if file has been cached return cachedFiles.contains(url.path) } var firstMoveCompleted = false fileManager.onMoveAsset = { tempURL, cachedURL in moveAttemptsSemaphore.lock() defer { moveAttemptsSemaphore.unlock() } moveAttempts += 1 // Simulate the race condition - second attempt fails with "file exists" if moveAttempts == 2 && firstMoveCompleted { throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) } if moveAttempts == 1 { firstMoveCompleted = true cachedFiles.insert(cachedURL.path) } } downloader.customDownloadHandler = { _ in return URL(fileURLWithPath: "/temp/asset-\(UUID().uuidString)") } let manager = AssetCacheManager(assetDownloader: downloader, assetFileManager: fileManager) // Start two concurrent caching operations for the same asset async let cache1 = manager.cacheAssets(identifier: testScheduleIdentifier1, assets: [assetRemoteURL.absoluteString]) async let cache2 = manager.cacheAssets(identifier: testScheduleIdentifier2, assets: [assetRemoteURL.absoluteString]) do { // Both should succeed despite potential race condition let (result1, result2) = try await (cache1, cache2) XCTAssertNotNil(result1) XCTAssertNotNil(result2) // At least 2 move attempts should have been made moveAttemptsSemaphore.lock() let finalMoveAttempts = moveAttempts moveAttemptsSemaphore.unlock() XCTAssertGreaterThanOrEqual(finalMoveAttempts, 1, "Should have attempted to move at least once") } catch { XCTFail("Concurrent caching should handle race conditions gracefully: \(error)") } } } fileprivate extension URL { var assetFilename: String { return AirshipUtils.sha256Hash(input: self.path) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Assets/DefaultAssetDownloaderTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import Foundation #if canImport(AirshipCore) import AirshipCore #endif final class TestAssetDownloaderSession: AssetDownloaderSession, @unchecked Sendable { var nextData: Data? var nextError: Error? var nextResponse: URLResponse? func autoResumingDataTask(with url: URL, completion: @escaping (Data?, URLResponse?, Error?) -> Void) -> AirshipCancellable { completion(nextData, nextResponse, nextError) return CancellableValueHolder() { _ in } } } @testable import AirshipAutomation final class DefaultAssetDownloaderTest: XCTestCase { var downloader: DefaultAssetDownloader! var mockSession: TestAssetDownloaderSession! let testURL = URL(string: "https://airship.com/whatever")! override func setUpWithError() throws { try super.setUpWithError() mockSession = TestAssetDownloaderSession() downloader = DefaultAssetDownloader(session: mockSession) } func testDownloadAssetDataMatches() async throws { let expectedData = Data("Cool story".utf8) mockSession.nextData = expectedData let tempURL = try await downloader.downloadAsset(remoteURL: testURL) let downloadedData = try Data(contentsOf: tempURL) XCTAssertTrue(FileManager.default.fileExists(atPath: tempURL.path), "Downloaded file should exist at the temp URL") XCTAssertEqual(downloadedData, expectedData, "Downloaded data at the temp URL should match the expected data.") } override func tearDownWithError() throws { let fileManager = FileManager.default let tempFileURL = fileManager.temporaryDirectory.appendingPathComponent(testURL.lastPathComponent) if fileManager.fileExists(atPath: tempFileURL.path) { try fileManager.removeItem(at: tempFileURL) } downloader = nil mockSession = nil try super.tearDownWithError() } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Assets/DefaultAssetFileManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation final class DefaultAssetFileManagerTest: XCTestCase { func testEnsureCacheRootDirectory() { let rootPathComponent = "testCacheRoot" let assetManager = DefaultAssetFileManager(rootPathComponent: rootPathComponent) let fileManager = FileManager.default let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! let expectedCacheRootDirectory = cacheDirectory.appendingPathComponent(rootPathComponent, isDirectory: true) /// Ensure the initial state is clean try? fileManager.removeItem(at: expectedCacheRootDirectory) /// Test when nothing is there XCTAssertEqual(assetManager.rootDirectory, expectedCacheRootDirectory, "The method did not return the expected URL when the directory was not present initially.") /// Remove root and create a file in its place try? fileManager.removeItem(at: expectedCacheRootDirectory) fileManager.createFile(atPath: expectedCacheRootDirectory.path, contents: Data("TestData".utf8), attributes: nil) /// Test when a file is in the directory XCTAssertEqual(assetManager.rootDirectory, expectedCacheRootDirectory, "The method did not return the expected URL when a file was present at the directory location.") var isDir: ObjCBool = false XCTAssertTrue(fileManager.fileExists(atPath: expectedCacheRootDirectory.path, isDirectory: &isDir) && isDir.boolValue, "A directory was not created in place of the file.") } func testEnsureCacheDirectory() { let rootPathComponent = "testCacheRoot" let testIdentifier = "testIdentifier" let assetManager = DefaultAssetFileManager(rootPathComponent: rootPathComponent) let fileManager = FileManager.default let cacheDirectory = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first! let expectedCacheRootDirectory = cacheDirectory.appendingPathComponent(rootPathComponent, isDirectory: true) let expectedCacheDirectory = expectedCacheRootDirectory.appendingPathComponent(testIdentifier, isDirectory: true) XCTAssertEqual(try? assetManager.ensureCacheDirectory(identifier: testIdentifier), expectedCacheDirectory) } func testClearAssetsSuccess() throws { let rootPathComponent = "testCacheRoot" let assetManager = DefaultAssetFileManager(rootPathComponent: rootPathComponent) let cacheURL = FileManager.default.temporaryDirectory.appendingPathComponent("testAssets") let identifier = "testIdentifier" let assetsPath = cacheURL.appendingPathComponent(identifier) try? FileManager.default.createDirectory(at: assetsPath, withIntermediateDirectories: true) FileManager.default.createFile(atPath: assetsPath.appendingPathComponent("file1").path, contents: Data(), attributes: nil) try assetManager.clearAssets(cacheURL: cacheURL) let directoryExists: Bool = FileManager.default.fileExists(atPath: assetsPath.path) XCTAssertFalse(directoryExists, "Not all assets were cleared for the identifier.") /// Cleanup try? FileManager.default.removeItem(at: cacheURL) } func testMoveAssetSuccess() { let rootPathComponent = "testCacheRoot" let assetManager = DefaultAssetFileManager(rootPathComponent: rootPathComponent) let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("tempFile") let cacheURL = FileManager.default.temporaryDirectory.appendingPathComponent("cacheFile") FileManager.default.createFile(atPath: tempURL.path, contents: Data("TestData".utf8), attributes: nil) do { try assetManager.moveAsset(from: tempURL, to: cacheURL) XCTAssertTrue(FileManager.default.fileExists(atPath: cacheURL.path), "The file was not successfully moved to the cache URL.") XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path), "The temp was not successfully cleaned up after being moved to the cache URL.") } catch { XCTFail("Failed to move asset: \(error)") } /// Cleanup try? FileManager.default.removeItem(at: tempURL) try? FileManager.default.removeItem(at: cacheURL) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/DefaultInAppActionRunnerTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore @testable import AirshipAutomation @MainActor struct DefaultInAppActionRunnerTest { private let analytics: TestInAppMessageAnalytics = TestInAppMessageAnalytics() @Test func testCustomEventContext() { let layoutContext = ThomasLayoutContext( button: ThomasLayoutContext.Button(identifier: "bar") ) let customEventContext = InAppCustomEventContext( id: ThomasLayoutEventMessageID.appDefined(identifier: "foo"), context: ThomasLayoutEventContext() ) analytics.onMakeCustomEventContext = { lc in #expect(layoutContext == lc) return customEventContext } let runner = DefaultInAppActionRunner(analytics: analytics, trackPermissionResults: true) var metadata: [String: Sendable] = [:] runner.extendMetadata(&metadata, layoutContext: layoutContext) #expect(customEventContext == metadata[AddCustomEventAction._inAppMetadata] as? InAppCustomEventContext) } @Test func testCustomEventContextNilLayoutContext() { let customEventContext = InAppCustomEventContext( id: ThomasLayoutEventMessageID.appDefined(identifier: "foo"), context: ThomasLayoutEventContext() ) analytics.onMakeCustomEventContext = { lc in #expect(lc == nil) return customEventContext } let runner = DefaultInAppActionRunner(analytics: analytics, trackPermissionResults: true) var metadata: [String: Sendable] = [:] runner.extendMetadata(&metadata, layoutContext: nil) #expect(customEventContext == metadata[AddCustomEventAction._inAppMetadata] as? InAppCustomEventContext) } @Test func testTrackPermissionResults() async throws { let layoutContext = ThomasLayoutContext( button: ThomasLayoutContext.Button(identifier: "bar") ) analytics.onMakeCustomEventContext = { _ in return nil } let runner = DefaultInAppActionRunner(analytics: analytics, trackPermissionResults: true) var metadata: [String: Sendable] = [:] runner.extendMetadata(&metadata, layoutContext: layoutContext) let resultReceiver = metadata[PromptPermissionAction.resultReceiverMetadataKey] as! PermissionResultReceiver await resultReceiver(.displayNotifications, .granted, .granted) try verifyEvents( [ ( ThomasLayoutPermissionResultEvent( permission: .displayNotifications, startingStatus: .granted, endingStatus: .granted ), layoutContext ) ] ) } @Test func testTrackPermissionResultsNoContext() async throws { analytics.onMakeCustomEventContext = { _ in return nil } let runner = DefaultInAppActionRunner(analytics: analytics, trackPermissionResults: true) var metadata: [String: Sendable] = [:] runner.extendMetadata(&metadata, layoutContext: nil) let resultReceiver = metadata[PromptPermissionAction.resultReceiverMetadataKey] as! PermissionResultReceiver await resultReceiver(.displayNotifications, .granted, .granted) try verifyEvents( [ ( ThomasLayoutPermissionResultEvent( permission: .displayNotifications, startingStatus: .granted, endingStatus: .granted ), nil ) ] ) } @Test func testTrackPermissionRusultsDisabled() async { analytics.onMakeCustomEventContext = { _ in return nil } let runner = DefaultInAppActionRunner(analytics: analytics, trackPermissionResults: false) var metadata: [String: Sendable] = [:] runner.extendMetadata(&metadata, layoutContext: nil) let resultReceiver = metadata[PromptPermissionAction.resultReceiverMetadataKey] as? PermissionResultReceiver #expect(resultReceiver == nil) } private func verifyEvents( _ expected: [(ThomasLayoutEvent, ThomasLayoutContext?)], sourceLocation: SourceLocation = #_sourceLocation ) throws { #expect(expected.count == self.analytics.events.count, sourceLocation: sourceLocation) try expected.indices.forEach { index in let expectedEvent = expected[index] let actual = analytics.events[index] #expect(actual.0.name == expectedEvent.0.name, sourceLocation: sourceLocation) let actualData = try AirshipJSON.wrap(actual.0.data) let expectedData = try AirshipJSON.wrap(expectedEvent.0.data) #expect(actualData == expectedData, sourceLocation: sourceLocation) #expect(actual.1 == expectedEvent.1, sourceLocation: sourceLocation) } } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Display Adapter/AirshipLayoutDisplayAdapterTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class AirshipLayoutDisplayAdapterTest: XCTestCase { private let networkChecker: TestNetworkChecker = TestNetworkChecker() private let assets: TestCachedAssets = TestCachedAssets() func testIsReadyNoAssets() async throws { let message = InAppMessage( name: "no assets", displayContent: .banner(.init()) ) XCTAssertTrue(message.urlInfos.isEmpty) let adapter = try makeAdapter(message) await networkChecker.setConnected(false) let isReady = await adapter.isReady XCTAssertTrue(isReady) } func testIsReadyImageAsset() async throws { let message = InAppMessage( name: "image assets", displayContent: .banner( .init(media: .init(url: "some-url", type: .image)) ) ) let adapter = try makeAdapter(message) await networkChecker.setConnected(false) var isReady = await adapter.isReady XCTAssertFalse(isReady) self.assets.cached.append(URL(string: "some-url")!) isReady = await adapter.isReady XCTAssertTrue(isReady) self.assets.cached.removeAll() isReady = await adapter.isReady XCTAssertFalse(isReady) await networkChecker.setConnected(true) isReady = await adapter.isReady XCTAssertTrue(isReady) } func testIsReadyVideoAsset() async throws { let message = InAppMessage( name: "video assets", displayContent: .banner( .init(media: .init(url: "some-url", type: .video)) ) ) let adapter = try makeAdapter(message) // Caching is not checked for videos self.assets.cached.append(URL(string: "some-url")!) await networkChecker.setConnected(false) var isReady = await adapter.isReady XCTAssertFalse(isReady) await networkChecker.setConnected(true) isReady = await adapter.isReady XCTAssertTrue(isReady) } func testIsReadyHTMLAsset() async throws { let message = InAppMessage( name: "video assets", displayContent: .html( .init(url: "some-url") ) ) let adapter = try makeAdapter(message) // Caching is not checked for html self.assets.cached.append(URL(string: "some-url")!) await networkChecker.setConnected(false) var isReady = await adapter.isReady XCTAssertFalse(isReady) await networkChecker.setConnected(true) isReady = await adapter.isReady XCTAssertTrue(isReady) } func testWaitForReadyNetwork() async throws { let message = InAppMessage( name: "video assets", displayContent: .html( .init(url: "some-url") ) ) let adapter = try makeAdapter(message) let waitingReady = expectation(description: "waiting is ready") let isReady = expectation(description: "is ready") Task { waitingReady.fulfill() await adapter.waitForReady() isReady.fulfill() } await self.fulfillment(of: [waitingReady]) Task { [networkChecker] in await networkChecker.setConnected(true) } await self.fulfillment(of: [isReady]) } private func makeAdapter( _ message: InAppMessage ) throws -> AirshipLayoutDisplayAdapter { return try AirshipLayoutDisplayAdapter( message: message, priority: 0, assets: self.assets, networkChecker: self.networkChecker ) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Display Adapter/CustomDisplayAdapterWrapperTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class CustomDisplayAdapterWrapperTest: XCTestCase { private let testAdapter: TestCustomDisplayAdapter = TestCustomDisplayAdapter() private var wrapper: CustomDisplayAdapterWrapper! override func setUp() async throws { self.wrapper = CustomDisplayAdapterWrapper(adapter: testAdapter) } func testIsReady() async { await self.testAdapter.setReady(true) var isReady = await self.wrapper.isReady XCTAssertTrue(isReady) await self.testAdapter.setReady(false) isReady = await self.wrapper.isReady XCTAssertFalse(isReady) } func testWaitForReady() async { await self.testAdapter.setReady(false) let waitingReady = expectation(description: "waiting is ready") let isReady = expectation(description: "is ready") Task { [wrapper] in waitingReady.fulfill() await wrapper!.waitForReady() isReady.fulfill() } await self.fulfillment(of: [waitingReady]) Task { [testAdapter] in await testAdapter.setReady(true) } await self.fulfillment(of: [isReady]) } } fileprivate final class TestCustomDisplayAdapter: CustomDisplayAdapter { private let _isReady: AirshipMainActorValue = AirshipMainActorValue(false) @MainActor func setReady(_ ready: Bool) { _isReady.set(ready) } @MainActor var isReady: Bool { return _isReady.value } @MainActor func waitForReady() async { for await isReady in _isReady.updates { if (isReady) { return } } } func display(scene: UIWindowScene) async -> CustomDisplayResolution { // Cant test this due ot the scene return .userDismissed } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Display Adapter/DisplayAdapterFactoryTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class DisplayAdapterFactoryTest: XCTestCase { private let factory: DisplayAdapterFactory = DisplayAdapterFactory() private let assets: TestCachedAssets = TestCachedAssets() func testAirshipAdapter() async throws { try await verifyAirshipAdapter( displayContent: .modal(.init(buttons: [], template: .headerBodyMedia)) ) try await verifyAirshipAdapter( displayContent: .banner(.init(buttons: [], template: .mediaLeft)) ) try await verifyAirshipAdapter( displayContent: .fullscreen(.init(buttons: [], template: .headerBodyMedia)) ) try await verifyAirshipAdapter( displayContent: .html(.init(url: "some url")) ) let airshipLayout = """ { "version":1, "presentation":{ "type":"embedded", "embedded_id":"home_banner", "default_placement":{ "size":{ "width":"50%", "height":"50%" } } }, "view":{ "type":"container", "items":[] } } """ try await verifyAirshipAdapter( displayContent: .airshipLayout( try! JSONDecoder().decode(AirshipLayout.self, from: airshipLayout.data(using: .utf8)!) ) ) } func testCustomAdapters() async throws { try await verifyCustomAdapter( forType: .modal, displayContent: .modal(.init(buttons: [], template: .headerBodyMedia)) ) try await verifyCustomAdapter( forType: .banner, displayContent: .banner(.init(buttons: [], template: .mediaLeft)) ) try await verifyCustomAdapter( forType: .fullscreen, displayContent: .fullscreen(.init(buttons: [], template: .headerBodyMedia)) ) try await verifyCustomAdapter( forType: .html, displayContent: .html(.init(url: "some url")) ) try await verifyCustomAdapter( forType: .custom, displayContent: .custom(.string("custom")) ) } func testCustomThrowsNoAdapter() async throws { let message = InAppMessage( name: "Airship layout", displayContent: .custom(.string("custom")) ) do { let _ = try await factory.makeAdapter( args: DisplayAdapterArgs( message: message, assets: assets, priority: 0, _actionRunner: TestInAppActionRunner() ) ) XCTFail("Wrong adapter") } catch {} } private func verifyAirshipAdapter( displayContent: InAppMessageDisplayContent, line: UInt = #line ) async throws { let message = InAppMessage( name: "", displayContent: displayContent ) let adapter = try await factory.makeAdapter( args: DisplayAdapterArgs( message: message, assets: assets, priority: 0, _actionRunner: TestInAppActionRunner() ) ) guard adapter as? AirshipLayoutDisplayAdapter != nil else { XCTFail("Wrong adapter", line: line) return } } private func verifyCustomAdapter( forType type: CustomDisplayAdapterType, displayContent: InAppMessageDisplayContent, line: UInt = #line ) async throws { let message = InAppMessage( name: "", displayContent: displayContent ) let assets = self.assets let adapter = TestCustomDisplayAdapter() await factory.setAdapterFactoryBlock(forType: type) { args in guard let incomingAssets = args.assets as? TestCachedAssets, incomingAssets === assets, message == args.message else { XCTFail("Invalid args", line: line) return nil } return adapter } let result = try await factory.makeAdapter( args: DisplayAdapterArgs( message: message, assets: assets, priority: 0, _actionRunner: TestInAppActionRunner() ) ) guard let wrappedAdapter = result as? CustomDisplayAdapterWrapper, let unwrapped = wrappedAdapter.adapter as? TestCustomDisplayAdapter, unwrapped === adapter else { XCTFail("Wrong adapter", line: line) return } } } fileprivate final class TestCustomDisplayAdapter: CustomDisplayAdapter, Sendable { @MainActor var isReady: Bool { return true } func waitForReady() async { } @MainActor func display(scene: UIWindowScene) async -> CustomDisplayResolution { return .timedOut } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Display Adapter/InAppMessageDisplayListenerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore class InAppMessageDisplayListenerTests: XCTestCase { private let analytics: TestInAppMessageAnalytics = TestInAppMessageAnalytics() private var listener: InAppMessageDisplayListener! private let result: AirshipMainActorValue = AirshipMainActorValue(nil) private var timer: TestActiveTimer! @MainActor override func setUp() { self.timer = TestActiveTimer() listener = InAppMessageDisplayListener(analytics: analytics, timer: timer) { [result] displayResult in result.set(displayResult) } } @MainActor func testOnAppear() async { XCTAssertFalse(timer.isStarted) listener.onAppear() verifyEvents([ThomasLayoutDisplayEvent()]) XCTAssertTrue(timer.isStarted) listener.onAppear() verifyEvents([ThomasLayoutDisplayEvent(), ThomasLayoutDisplayEvent()]) XCTAssertNil(self.result.value) } @MainActor func testOnButtonDismissed() { self.timer.start() self.timer.time = 10 let buttonInfo = InAppMessageButtonInfo( identifier: "button id", label: .init(text: "button label"), behavior: .dismiss ) listener.onButtonDismissed(buttonInfo: buttonInfo) verifyEvents( [ ThomasLayoutResolutionEvent.buttonTap( identifier: "button id", description: "button label", displayTime: 10 ) ] ) XCTAssertFalse(timer.isStarted) XCTAssertEqual(self.result.value, .finished) } @MainActor func testOnButtonCancel() { self.timer.start() self.timer.time = 10 let buttonInfo = InAppMessageButtonInfo( identifier: "button id", label: .init(text: "button label"), behavior: .cancel ) listener.onButtonDismissed(buttonInfo: buttonInfo) verifyEvents( [ ThomasLayoutResolutionEvent.buttonTap( identifier: "button id", description: "button label", displayTime: 10 ) ] ) XCTAssertFalse(timer.isStarted) XCTAssertEqual(self.result.value, .cancel) } @MainActor func testOnTimedOut() { self.timer.start() self.timer.time = 3 listener.onTimedOut() verifyEvents([ThomasLayoutResolutionEvent.timedOut(displayTime: 3)]) XCTAssertFalse(timer.isStarted) XCTAssertEqual(self.result.value, .finished) } @MainActor func testOnUserDismissed() { self.timer.start() self.timer.time = 3 listener.onUserDismissed() verifyEvents([ThomasLayoutResolutionEvent.userDismissed(displayTime: 3)]) XCTAssertFalse(timer.isStarted) XCTAssertEqual(self.result.value, .finished) } @MainActor func testOnMessageTapDismissed() { self.timer.start() self.timer.time = 2 listener.onMessageTapDismissed() verifyEvents([ThomasLayoutResolutionEvent.messageTap(displayTime: 2)]) XCTAssertEqual(self.result.value, .finished) } private func verifyEvents(_ expected: [ThomasLayoutEvent], line: UInt = #line) { XCTAssertEqual(expected.count, self.analytics.events.count, line: line) expected.indices.forEach { index in let expectedEvent = expected[index] let event = analytics.events[index].0 XCTAssertEqual(event.name, expectedEvent.name, line: line) XCTAssertEqual(try! AirshipJSON.wrap(event.data), try! AirshipJSON.wrap(expectedEvent.data), line: line) } } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Display Coordinators/DefaultDisplayCoordinatorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class DefaultDisplayCoordinatorTest: XCTestCase { private let stateTracker: TestAppStateTracker = TestAppStateTracker() private var displayCoordinator: DefaultDisplayCoordinator! private let taskSleeper: TestTaskSleeper = TestTaskSleeper() let fooSchedule = InAppMessage(name: "foo", displayContent: .custom(.string("foo"))) @MainActor override func setUp() async throws { displayCoordinator = DefaultDisplayCoordinator( displayInterval: 10.0, appStateTracker: self.stateTracker, taskSleeper: self.taskSleeper ) } @MainActor func testIsReady() throws { self.stateTracker.currentState = .active XCTAssertTrue(self.displayCoordinator.isReady) self.stateTracker.currentState = .background XCTAssertFalse(self.displayCoordinator.isReady) self.stateTracker.currentState = .inactive XCTAssertFalse(self.displayCoordinator.isReady) } @MainActor func testIsReadyLocking() async throws { self.stateTracker.currentState = .active XCTAssertTrue(self.displayCoordinator.isReady) self.displayCoordinator.messageWillDisplay(fooSchedule) XCTAssertFalse(self.displayCoordinator.isReady) self.displayCoordinator.messageFinishedDisplaying(fooSchedule) await self.displayCoordinator.waitForReady() XCTAssertTrue(self.displayCoordinator.isReady) XCTAssertEqual([10], self.taskSleeper.sleeps) } @MainActor func testWaitForReady() async throws { self.stateTracker.currentState = .background let ready = Task { [displayCoordinator] in await displayCoordinator!.waitForReady() } self.stateTracker.currentState = .active await ready.value } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Display Coordinators/DisplayCoordinatorManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class DisplayCoordinatorManagerTest: XCTestCase { private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var manager: DisplayCoordinatorManager! @MainActor override func setUp() async throws { manager = DisplayCoordinatorManager(dataStore: dataStore) } func testDefaultAdapter() throws { let message = InAppMessage(name: "", displayContent: .custom(.string(""))) let adapter = manager.displayCoordinator(message: message) XCTAssertNotNil(adapter as? DefaultDisplayCoordinator) } func testDefaultAdapterEmbedded() throws { let airshipLayout = """ { "version":1, "presentation":{ "type":"embedded", "embedded_id":"home_banner", "default_placement":{ "size":{ "width":"50%", "height":"50%" } } }, "view":{ "type":"container", "items":[] } } """ let message = InAppMessage( name: "", displayContent: .airshipLayout( try! JSONDecoder().decode(AirshipLayout.self, from: airshipLayout.data(using: .utf8)!) ) ) let adapter = manager.displayCoordinator(message: message) XCTAssertNotNil(adapter as? ImmediateDisplayCoordinator) } func testStandardBehavior() throws { let message = InAppMessage( name: "", displayContent: .custom(.string("")), displayBehavior: .standard ) let adapter = manager.displayCoordinator(message: message) XCTAssertNotNil(adapter as? DefaultDisplayCoordinator) } func testImmediateBehavior() throws { let message = InAppMessage( name: "", displayContent: .custom(.string("")), displayBehavior: .immediate ) let adapter = manager.displayCoordinator(message: message) XCTAssertNotNil(adapter as? ImmediateDisplayCoordinator) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/Display Coordinators/ImmediateDisplayCoordinatorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class ImmediateDisplayCoordinatorTest: XCTestCase { private let stateTracker: TestAppStateTracker = TestAppStateTracker() private var displayCoordinator: ImmediateDisplayCoordinator! @MainActor override func setUp() async throws { displayCoordinator = ImmediateDisplayCoordinator( appStateTracker: self.stateTracker ) } @MainActor func testIsReady() throws { self.stateTracker.currentState = .active XCTAssertTrue(self.displayCoordinator.isReady) self.stateTracker.currentState = .background XCTAssertFalse(self.displayCoordinator.isReady) self.stateTracker.currentState = .inactive XCTAssertFalse(self.displayCoordinator.isReady) } @MainActor func testWaitForReady() async throws { self.stateTracker.currentState = .background let ready = Task { [displayCoordinator] in await displayCoordinator!.waitForReady() } self.stateTracker.currentState = .active await ready.value } @MainActor func testDisplayMultiple() throws { self.stateTracker.currentState = .active let foo = InAppMessage(name: "foo", displayContent: .custom(.string("foo"))) let bar = InAppMessage(name: "bar", displayContent: .custom(.string("bar"))) self.displayCoordinator.messageWillDisplay(foo) XCTAssertTrue(self.displayCoordinator.isReady) self.displayCoordinator.messageWillDisplay(bar) XCTAssertTrue(self.displayCoordinator.isReady) self.displayCoordinator.messageFinishedDisplaying(foo) XCTAssertTrue(self.displayCoordinator.isReady) self.displayCoordinator.messageFinishedDisplaying(bar) XCTAssertTrue(self.displayCoordinator.isReady) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageAutomationExecutorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class InAppMessageAutomationExecutorTest: XCTestCase { private let sceneManager: TestSceneManager = TestSceneManager() private let assetManager: TestAssetManager = TestAssetManager() private let analyticsFactory: TestAnalyticsFactory = TestAnalyticsFactory() private var conditionsChangedNotifier: ScheduleConditionsChangedNotifier! private let analytics: TestInAppMessageAnalytics = TestInAppMessageAnalytics() private let actionRunner: TestInAppActionRunner = TestInAppActionRunner() private var displayAdapter: TestDisplayAdapter! private let preparedInfo: PreparedScheduleInfo = PreparedScheduleInfo( scheduleID: UUID().uuidString, productID: UUID().uuidString, campaigns: .string(UUID().uuidString), contactID: UUID().uuidString, reportingContext: .string(UUID().uuidString), triggerSessionID: UUID().uuidString, priority: 0 ) private var displayCoordinator: TestDisplayCoordinator! private var preparedData: PreparedInAppMessageData! private var executor: InAppMessageAutomationExecutor! @MainActor override func setUp() async throws { self.displayAdapter = TestDisplayAdapter() self.conditionsChangedNotifier = ScheduleConditionsChangedNotifier() self.displayCoordinator = TestDisplayCoordinator() self.preparedData = PreparedInAppMessageData( message: InAppMessage( name: "", displayContent: .custom(.string("")), actions: "actions payload" ), displayAdapter: self.displayAdapter, displayCoordinator: self.displayCoordinator, analytics: analytics, actionRunner: actionRunner ) self.executor = InAppMessageAutomationExecutor( sceneManager: sceneManager, assetManager: assetManager, analyticsFactory: analyticsFactory, scheduleConditionsChangedNotifier: conditionsChangedNotifier ) self.analyticsFactory.setOnMake { _, _ in return self.analytics } } @MainActor func testIsReady() { self.displayAdapter.isReady = true self.displayCoordinator.isReady = true XCTAssertEqual( self.executor.isReady(data: preparedData, preparedScheduleInfo: preparedInfo), .ready ) } @MainActor func testNotReadyAdapter() { self.displayAdapter.isReady = false self.displayCoordinator.isReady = true XCTAssertEqual( self.executor.isReady(data: preparedData, preparedScheduleInfo: preparedInfo), .notReady ) } @MainActor func testNotReadyCoordinator() { self.displayAdapter.isReady = true self.displayCoordinator.isReady = false XCTAssertEqual( self.executor.isReady(data: preparedData, preparedScheduleInfo: preparedInfo), .notReady ) } @MainActor func testIsReadyDelegate() { self.displayAdapter.isReady = true self.displayCoordinator.isReady = true let delegate = TestDisplayDelegate() delegate.onIsReady = { [preparedData, preparedInfo] message, scheduleID in XCTAssertEqual(message, preparedData!.message) XCTAssertEqual(scheduleID, preparedInfo.scheduleID) return true } self.executor.displayDelegate = delegate XCTAssertEqual( self.executor.isReady(data: preparedData, preparedScheduleInfo: preparedInfo), .ready ) delegate.onIsReady = { [preparedData, preparedInfo] message, scheduleID in XCTAssertEqual(message, preparedData!.message) XCTAssertEqual(scheduleID, preparedInfo.scheduleID) return false } XCTAssertEqual( self.executor.isReady(data: preparedData, preparedScheduleInfo: preparedInfo), .notReady ) } func testInterrupted() async throws { let schedule = AutomationSchedule( identifier: preparedInfo.scheduleID, triggers: [], data: .inAppMessage(preparedData.message) ) _ = await self.executor.interrupted(schedule: schedule, preparedScheduleInfo: preparedInfo) let cleared = await self.assetManager.cleared XCTAssertEqual([self.preparedInfo.scheduleID], cleared) XCTAssertEqual(analytics.events.first!.0.name, ThomasLayoutResolutionEvent.interrupted().name) } @MainActor func testExecute() async throws { self.displayAdapter.onDisplay = { [preparedData] displayTarget, incomingAnalytics in XCTAssertTrue(preparedData!.analytics === incomingAnalytics) return .finished } let result = try await self.executor.execute(data: preparedData, preparedScheduleInfo: preparedInfo) XCTAssertTrue(self.displayAdapter.displayed) XCTAssertEqual(result, .finished) } @MainActor func testExecuteInControlGroup() async throws { let scene = TestScene() self.sceneManager.onScene = { [preparedData] message in XCTAssertEqual(message, preparedData!.message) return scene } let experimentResult = ExperimentResult( channelID: "some channel", contactID: "some contact", isMatch: true, reportingMetadata: [] ) var preparedInfo = preparedInfo preparedInfo.experimentResult = experimentResult let result = try await self.executor.execute(data: preparedData, preparedScheduleInfo: preparedInfo) XCTAssertEqual(analytics.events.first!.0.name, ThomasLayoutResolutionEvent.control(experimentResult: experimentResult).name) XCTAssertFalse(self.displayAdapter.displayed) XCTAssertEqual(result, .finished) XCTAssertTrue(self.actionRunner.actionPayloads.isEmpty) } @MainActor func testExecuteDisplayAdapter() async throws { let delegate = TestDisplayDelegate() self.executor.displayDelegate = delegate delegate.onWillDisplay = { [preparedData, preparedInfo] message, scheduleID in XCTAssertEqual(message, preparedData!.message) XCTAssertEqual(scheduleID, preparedInfo.scheduleID) } delegate.onFinishedDisplaying = { [preparedData, preparedInfo] message, scheduleID in XCTAssertEqual(message, preparedData!.message) XCTAssertEqual(scheduleID, preparedInfo.scheduleID) } self.sceneManager.onScene = { _ in return TestScene() } self.displayAdapter.onDisplay = { _, _ in XCTAssertTrue(delegate.onWillDisplayCalled) XCTAssertFalse(delegate.onFinishedDisplayingCalled) return .finished } let result = try await self.executor.execute(data: preparedData, preparedScheduleInfo: preparedInfo) XCTAssertTrue(delegate.onWillDisplayCalled) XCTAssertTrue(delegate.onWillDisplayCalled) XCTAssertTrue(self.displayAdapter.displayed) XCTAssertEqual(result, .finished) } @MainActor func testExecuteDisplayException() async throws { let scene = TestScene() self.sceneManager.onScene = { [preparedData] message in XCTAssertEqual(message, preparedData!.message) return scene } let analytics = TestInAppMessageAnalytics() self.analyticsFactory.onMake = { [preparedData, preparedInfo] incomingInfo, incomingMessage in XCTAssertEqual(incomingInfo, preparedInfo) XCTAssertEqual(incomingMessage, preparedData!.message) return analytics } self.displayAdapter.onDisplay = { incomingScene, incomingAnalytics in throw AirshipErrors.error("Failed") } let result = try await self.executor.execute(data: preparedData, preparedScheduleInfo: preparedInfo) XCTAssertTrue(self.displayAdapter.displayed) XCTAssertEqual(result, .retry) 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, ThomasLayoutResolutionEvent.audienceExcluded().name) XCTAssertFalse(self.displayAdapter.displayed) XCTAssertEqual(result, .finished) XCTAssertTrue(self.actionRunner.actionPayloads.isEmpty) } @MainActor func testDisplayTargetNoScene() async throws { self.sceneManager.onScene = { _ in throw AirshipErrors.error("Fail") } self.displayAdapter.onDisplay = { displayTarget, _ in _ = try displayTarget.sceneProvider() return .cancel } let result = try await self.executor.execute(data: preparedData, preparedScheduleInfo: preparedInfo) XCTAssertEqual(result, .retry) } @MainActor func testExecuteCancel() async throws { self.displayAdapter.onDisplay = { [preparedData] displayTarget, incomingAnalytics in XCTAssertTrue(preparedData!.analytics === incomingAnalytics) return .cancel } let result = try await self.executor.execute(data: preparedData, preparedScheduleInfo: preparedInfo) XCTAssertTrue(self.displayAdapter.displayed) XCTAssertEqual(result, .cancel) XCTAssertEqual(self.actionRunner.actionPayloads.first!.0, self.preparedData.message.actions) } } fileprivate final class TestDisplayDelegate: InAppMessageDisplayDelegate, @unchecked Sendable { @MainActor var onIsReady: ((InAppMessage, String) -> Bool)? @MainActor var onWillDisplay: ((InAppMessage, String) -> Void)? @MainActor var onWillDisplayCalled: Bool = false @MainActor var onFinishedDisplaying: ((InAppMessage, String) -> Void)? @MainActor var onFinishedDisplayingCalled: Bool = false @MainActor func isMessageReadyToDisplay(_ message: InAppMessage, scheduleID: String) -> Bool { return self.onIsReady!(message, scheduleID) } @MainActor func messageWillDisplay(_ message: InAppMessage, scheduleID: String) { self.onWillDisplay!(message, scheduleID) self.onWillDisplayCalled = true } @MainActor func messageFinishedDisplaying(_ message: InAppMessage, scheduleID: String) { self.onFinishedDisplaying!(message, scheduleID) self.onFinishedDisplayingCalled = true } } fileprivate final class TestScene: WindowSceneHolder { var scene: UIWindowScene { fatalError("not able to create a window scene") } } fileprivate final class TestSceneManager: InAppMessageSceneManagerProtocol, @unchecked Sendable { var delegate: InAppMessageSceneDelegate? @MainActor var onScene: ((InAppMessage) throws -> TestScene)? func scene(forMessage: InAppMessage) throws -> WindowSceneHolder { return try self.onScene!(forMessage) } } final class TestAnalyticsFactory: InAppMessageAnalyticsFactoryProtocol, @unchecked Sendable { func makeAnalytics(preparedScheduleInfo: PreparedScheduleInfo, message: InAppMessage) async -> any InAppMessageAnalyticsProtocol { return await self.onMake!(preparedScheduleInfo, message) } @MainActor var onMake: ((PreparedScheduleInfo, InAppMessage) async -> InAppMessageAnalyticsProtocol)? @MainActor func setOnMake(onMake: @escaping @Sendable (PreparedScheduleInfo, InAppMessage) -> InAppMessageAnalyticsProtocol) { self.onMake = onMake } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageAutomationPreparerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class InAppMessageAutomationPreparerTest: XCTestCase { private let displayCoordinatorManager: TestDisplayCoordinatorManager = TestDisplayCoordinatorManager() private let displayAdapterFactory: TestDisplayAdapterFactory = TestDisplayAdapterFactory() private let assetManager: TestAssetManager = TestAssetManager() private let analyticsFactory: TestAnalyticsFactory = TestAnalyticsFactory() private let analytics: TestInAppMessageAnalytics = TestInAppMessageAnalytics() private let actionRunnerFactory: TestInAppActionRunnerFactory = TestInAppActionRunnerFactory() private var preparer: InAppMessageAutomationPreparer! private let message: InAppMessage = InAppMessage( name: "", displayContent: .banner(.init(media: .init(url: "some-url", type: .image))) ) private let preparedScheduleInfo: PreparedScheduleInfo = PreparedScheduleInfo( scheduleID: UUID().uuidString, campaigns: "campigns", contactID: UUID().uuidString, experimentResult: nil, triggerSessionID: UUID().uuidString, priority: 0 ) override func setUp() async throws { await analyticsFactory.setOnMake { [analytics] _, _ in return analytics } self.preparer = InAppMessageAutomationPreparer( assetManager: assetManager, displayCoordinatorManager: displayCoordinatorManager, displayAdapterFactory: displayAdapterFactory, analyticsFactory: analyticsFactory, actionRunnerFactory: actionRunnerFactory ) actionRunnerFactory.onMake = { _, _ in return TestInAppActionRunner() } } func testPrepare() async throws { let runner = TestInAppActionRunner() actionRunnerFactory.onMake = { _, _ in return runner } let cachedAssets = TestCachedAssets() await self.assetManager.setOnCache { [preparedScheduleInfo] identifier, assets in XCTAssertEqual(identifier, preparedScheduleInfo.scheduleID) XCTAssertEqual(["some-url"], assets) return cachedAssets } let displayCoordinator = await TestDisplayCoordinator() self.displayCoordinatorManager.onCoordinator = { [message] incoming in XCTAssertEqual(message, incoming) return displayCoordinator } let displayAdapter = await TestDisplayAdapter() self.displayAdapterFactory.onMake = { [message] args in XCTAssertEqual(message, args.message) let incomingAssets = args.assets as? TestCachedAssets XCTAssertTrue(incomingAssets === cachedAssets) return displayAdapter } let results = try await self.preparer.prepare(data: message, preparedScheduleInfo: preparedScheduleInfo) XCTAssertEqual(self.message, results.message) XCTAssertTrue(displayCoordinator === results.displayCoordinator) XCTAssertTrue(displayAdapter === (results.displayAdapter as? TestDisplayAdapter)) XCTAssertTrue(runner === (results.actionRunner as? TestInAppActionRunner)) } func testPrepareFailedAssets() async throws { let displayCoordinator = await TestDisplayCoordinator() let adapter = await TestDisplayAdapter() self.displayCoordinatorManager.onCoordinator = { _ in return displayCoordinator } self.displayAdapterFactory.onMake = { _ in return adapter } await self.assetManager.setOnCache { identifier, assets in throw AirshipErrors.error("failed") } do { _ = try await self.preparer.prepare(data: message, preparedScheduleInfo: preparedScheduleInfo) XCTFail("should throw") } catch {} } func testPrepareFailedAdapter() async throws { let displayCoordinator = await TestDisplayCoordinator() self.displayCoordinatorManager.onCoordinator = { _ in return displayCoordinator } self.displayAdapterFactory.onMake = { _ in throw AirshipErrors.error("failed") } await self.assetManager.setOnCache { _, _ in return TestCachedAssets() } do { _ = try await self.preparer.prepare(data: message, preparedScheduleInfo: preparedScheduleInfo) XCTFail("should throw") } catch {} } func testCancelled() async throws { let scheduleID = UUID().uuidString await self.preparer.cancelled(scheduleID: scheduleID) let cleared = await self.assetManager.cleared XCTAssertEqual(cleared, [scheduleID]) } } fileprivate final class TestDisplayCoordinatorManager: DisplayCoordinatorManagerProtocol, @unchecked Sendable { var displayInterval: TimeInterval = 0.0 var onCoordinator: ((InAppMessage) -> DisplayCoordinator)? func displayCoordinator(message: InAppMessage) -> DisplayCoordinator { self.onCoordinator!(message) } } fileprivate final class TestDisplayAdapterFactory: DisplayAdapterFactoryProtocol, @unchecked Sendable { var onMake: ((DisplayAdapterArgs) throws -> DisplayAdapter)? func setAdapterFactoryBlock(forType: CustomDisplayAdapterType, factoryBlock: @escaping @Sendable (DisplayAdapterArgs) -> (any CustomDisplayAdapter)?) { } func makeAdapter(args: DisplayAdapterArgs) throws -> any DisplayAdapter { return try self.onMake!(args) } } final class TestInAppActionRunnerFactory: InAppActionRunnerFactoryProtocol, @unchecked Sendable { var onMake: ((InAppMessage, InAppMessageAnalyticsProtocol) -> InternalInAppActionRunner)? func makeRunner(message: InAppMessage, analytics: any InAppMessageAnalyticsProtocol) -> any InternalInAppActionRunner { return self.onMake!(message, analytics) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageContentValidationTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class InAppMessageContentValidationTest: XCTestCase { private var validHeading: InAppMessageTextInfo! private var validBody: InAppMessageTextInfo! private var validMedia: InAppMessageMediaInfo! // Assuming invalid media would have an invalid URL or type, but keeping it simple here private var validButtonLabel: InAppMessageTextInfo! private var validButton: InAppMessageButtonInfo! private let validText = "Valid Text" private let validIdentifier = "d17a055c-ed67-4101-b65f-cd28b5904c84" private let validURL = "some://image.png" private let validColor = InAppMessageColor(hexColorString: "#ffffff") private let validFontFam = ["sans-serif"] private var emptyHeading: InAppMessageTextInfo! private var emptyBody: InAppMessageTextInfo! private var emptyMedia: InAppMessageMediaInfo! private var emptyButtonLabel: InAppMessageTextInfo! private var emptyButton: InAppMessageButtonInfo! private var validVideoMedia: InAppMessageMediaInfo! private var validYoutubeMedia: InAppMessageMediaInfo! override func setUp() { super.setUp() // Valid components validHeading = InAppMessageTextInfo(text: validText, color: validColor, size: 22.0, fontFamilies: validFontFam, alignment: .center) validBody = InAppMessageTextInfo(text: validText, color: validColor, size: 16.0, fontFamilies: validFontFam, alignment: .center) validMedia = InAppMessageMediaInfo(url: validURL, type: .image, description: validText) validButtonLabel = InAppMessageTextInfo(text: validText, color: validColor, size: 10, fontFamilies: validFontFam, style: [.bold]) validButton = InAppMessageButtonInfo(identifier: validIdentifier, label: validButtonLabel, actions: [:], backgroundColor: validColor, borderColor: validColor, borderRadius: 2) // Empty components emptyHeading = InAppMessageTextInfo(text: "", color: validColor, size: 22.0, fontFamilies: validFontFam, alignment: .center) emptyBody = InAppMessageTextInfo(text: "", color: validColor, size: 16.0, fontFamilies: validFontFam, alignment: .center) emptyMedia = InAppMessageMediaInfo(url: "", type: .image, description: "") emptyButtonLabel = InAppMessageTextInfo(text: "", color: validColor, size: 10, fontFamilies: validFontFam, style: [.bold]) emptyButton = InAppMessageButtonInfo(identifier: "", label: validButtonLabel, actions: [:], backgroundColor: validColor, borderColor: validColor, borderRadius: 2) validVideoMedia = InAppMessageMediaInfo(url: validURL, type: .video, description: validText) validYoutubeMedia = InAppMessageMediaInfo(url: validURL, type: .video, description: validText) } func testBanner() { let valid = InAppMessageDisplayContent.Banner( heading: validHeading, body: validBody, media: validMedia, buttons: [validButton], buttonLayoutType: .stacked, template: .mediaLeft, backgroundColor: validColor, dismissButtonColor: validColor, borderRadius: 5, duration: 100.0, placement: .top ) XCTAssertTrue(valid.validate()) } func testInvalidBanner() { /// No heading or body let noHeaderOrBodyContent = InAppMessageDisplayContent.Banner( heading: nil, body: nil, media: validMedia, buttons: [validButton], buttonLayoutType: .stacked, template: .mediaLeft, backgroundColor: validColor, dismissButtonColor: validColor, borderRadius: 5, duration: 100.0, placement: .top ) let tooManyButtons = InAppMessageDisplayContent.Banner( heading: validHeading, body: validBody, media: validYoutubeMedia, buttons: [validButton, validButton, validButton], buttonLayoutType: .stacked, template: .mediaLeft, backgroundColor: validColor, dismissButtonColor: validColor, borderRadius: 5, duration: 100.0, placement: .top ) XCTAssertFalse(noHeaderOrBodyContent.validate()) XCTAssertFalse(tooManyButtons.validate()) } func testModal() { let valid = InAppMessageDisplayContent.Modal( heading: validHeading, body: validBody, media: validMedia, footer: validButton, buttons: [validButton], buttonLayoutType: .stacked, template: .mediaHeaderBody, dismissButtonColor: validColor, backgroundColor: validColor, borderRadius: 5, allowFullscreenDisplay: true ) XCTAssertTrue(valid.validate()) } func testInvalidModal() { let emptyHeadingAndBody = InAppMessageDisplayContent.Modal( heading: emptyHeading, body: emptyBody, media: validMedia, footer: validButton, buttons: [validButton], buttonLayoutType: .stacked, template: .mediaHeaderBody, dismissButtonColor: validColor, backgroundColor: validColor, borderRadius: 5, allowFullscreenDisplay: true ) let tooManyButtons = InAppMessageDisplayContent.Modal( heading: emptyHeading, body: emptyBody, media: validMedia, footer: validButton, buttons: [validButton, validButton, validButton], buttonLayoutType: .stacked, template: .mediaHeaderBody, dismissButtonColor: validColor, backgroundColor: validColor, borderRadius: 5, allowFullscreenDisplay: true ) XCTAssertFalse(tooManyButtons.validate()) XCTAssertFalse(emptyHeadingAndBody.validate()) } func testFullscreen() { let valid = InAppMessageDisplayContent.Fullscreen( heading: validHeading, body: validBody, media: validMedia, footer: validButton, buttons: [validButton], buttonLayoutType: .stacked, template: .mediaHeaderBody, dismissButtonColor: validColor, backgroundColor: validColor ) XCTAssertTrue(valid.validate()) } func testInvalidFullscreen() { let emptyHeadingAndBody = InAppMessageDisplayContent.Fullscreen( heading: emptyHeading, body: emptyBody, media: validMedia, footer: validButton, buttons: [validButton, validButton, validButton, validButton, validButton, validButton], buttonLayoutType: .stacked, template: .mediaHeaderBody, dismissButtonColor: validColor, backgroundColor: validColor) XCTAssertFalse(emptyHeadingAndBody.validate()) } func testHTML() { let valid = InAppMessageDisplayContent.HTML( url: validURL, height: 100, width: 100, aspectLock: true, requiresConnectivity: true, dismissButtonColor: validColor, backgroundColor: validColor, borderRadius: 5, allowFullscreen: true ) XCTAssertTrue(valid.validate()) } func testInvalidHTML() { let emptyURL = InAppMessageDisplayContent.HTML( url: "", height: 100, width: 100, aspectLock: true, requiresConnectivity: true, dismissButtonColor: validColor, backgroundColor: validColor, borderRadius: 5, allowFullscreen: true ) XCTAssertFalse(emptyURL.validate()) } func testTextInfo() { XCTAssertTrue(validHeading.validate()) XCTAssertTrue(validBody.validate()) XCTAssertFalse(emptyHeading.validate()) XCTAssertFalse(emptyBody.validate()) } func testButtonInfo() { XCTAssertTrue(validButton.validate()) XCTAssertFalse(emptyButton.validate()) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class InAppMessageTest: XCTestCase { func testBanner() throws { let json = """ { "source": "remote-data", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "body" : { "alignment" : "center", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "Big body" }, "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#63aff2", "border_color" : "#63aff2", "border_radius" : 2, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#ffffff", "font_family" : [ "sans-serif" ], "size" : 10, "style" : [ "bold" ], "text" : "Touch it" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "sans-serif" ], "size" : 22, "text" : "Boom" }, "media" : { "description" : "Image", "type" : "image", "url" : "some://image" }, "template" : "media_left", "placement" : "top", "duration" : 100.0 }, "display_type" : "banner", "name" : "woot" } """ let expected = InAppMessage( name: "woot", displayContent: .banner( .init( heading: .init( text: "Boom", color: .init(hexColorString: "#63aff2"), size: 22.0, fontFamilies: ["sans-serif"], alignment: .center ), body: .init( text: "Big body", color: .init(hexColorString: "#000000"), size: 16.0, fontFamilies: ["sans-serif"], alignment: .center ), media: .init( url: "some://image", type: .image, description: "Image" ), buttons: [ .init( identifier: "d17a055c-ed67-4101-b65f-cd28b5904c84", label: .init( text: "Touch it", color: .init(hexColorString: "#ffffff"), size: 10, fontFamilies: ["sans-serif"], style: [.bold] ), actions: [:], backgroundColor: .init(hexColorString: "#63aff2"), borderColor: .init(hexColorString: "#63aff2"), borderRadius: 2 ) ], buttonLayoutType: .stacked, template: .mediaLeft, backgroundColor: .init(hexColorString: "#ffffff"), dismissButtonColor: .init(hexColorString: "#000000"), borderRadius: 5, duration: 100.0, placement: .top ) ), source: .remoteData ) try verify(json: json, expected: expected) } func testModal() throws { let json = """ { "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "body" : { "alignment" : "center", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "Big body" }, "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#63aff2", "border_color" : "#63aff2", "border_radius" : 2, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#ffffff", "font_family" : [ "sans-serif" ], "size" : 10, "style" : [ "bold" ], "text" : "Touch it" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "sans-serif" ], "size" : 22, "text" : "Boom" }, "media" : { "description" : "Image", "type" : "image", "url" : "some://image" }, "template" : "media_header_body", }, "display_type" : "modal", "name" : "woot" } """ let expected = InAppMessage( name: "woot", displayContent: .modal( .init( heading: .init( text: "Boom", color: .init(hexColorString: "#63aff2"), size: 22.0, fontFamilies: ["sans-serif"], alignment: .center ), body: .init( text: "Big body", color: .init(hexColorString: "#000000"), size: 16.0, fontFamilies: ["sans-serif"], alignment: .center ), media: .init( url: "some://image", type: .image, description: "Image" ), buttons: [ .init( identifier: "d17a055c-ed67-4101-b65f-cd28b5904c84", label: .init( text: "Touch it", color: .init(hexColorString: "#ffffff"), size: 10, fontFamilies: ["sans-serif"], style: [.bold] ), actions: [:], backgroundColor: .init(hexColorString: "#63aff2"), borderColor: .init(hexColorString: "#63aff2"), borderRadius: 2 ) ], buttonLayoutType: .stacked, template: .mediaHeaderBody, dismissButtonColor: .init(hexColorString: "#000000"), backgroundColor: .init(hexColorString: "#ffffff"), borderRadius: 5, allowFullscreenDisplay: true ) ), source: .appDefined ) try verify(json: json, expected: expected) } func testFullscreen() throws { let json = """ { "source": "app-defined", "display" : { "background_color" : "#ffffff", "body" : { "alignment" : "center", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "Big body" }, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#63aff2", "border_color" : "#63aff2", "border_radius" : 2, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#ffffff", "font_family" : [ "sans-serif" ], "size" : 10, "style" : [ "bold" ], "text" : "Touch it" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "sans-serif" ], "size" : 22, "text" : "Boom" }, "media" : { "description" : "Image", "type" : "image", "url" : "some://image" }, "template" : "media_header_body", }, "display_type" : "fullscreen", "name" : "woot" } """ let expected = InAppMessage( name: "woot", displayContent: .fullscreen( .init( heading: .init( text: "Boom", color: .init(hexColorString: "#63aff2"), size: 22.0, fontFamilies: ["sans-serif"], alignment: .center ), body: .init( text: "Big body", color: .init(hexColorString: "#000000"), size: 16.0, fontFamilies: ["sans-serif"], alignment: .center ), media: .init( url: "some://image", type: .image, description: "Image" ), buttons: [ .init( identifier: "d17a055c-ed67-4101-b65f-cd28b5904c84", label: .init( text: "Touch it", color: .init(hexColorString: "#ffffff"), size: 10, fontFamilies: ["sans-serif"], style: [.bold] ), actions: [:], backgroundColor: .init(hexColorString: "#63aff2"), borderColor: .init(hexColorString: "#63aff2"), borderRadius: 2 ) ], buttonLayoutType: .stacked, template: .mediaHeaderBody, dismissButtonColor: .init(hexColorString: "#000000"), backgroundColor: .init(hexColorString: "#ffffff") ) ), source: .appDefined ) try verify(json: json, expected: expected) } func testHTML() throws { let json = """ { "display" : { "allow_fullscreen_display" : false, "background_color" : "#00000000", "border_radius" : 5, "dismiss_button_color" : "#000000", "url" : "some://url" }, "display_type" : "html", "name" : "Thanks page" } """ let expected = InAppMessage( name: "Thanks page", displayContent: .html( .init( url: "some://url", dismissButtonColor: .init(hexColorString: "#000000"), backgroundColor: .init(hexColorString: "#00000000"), borderRadius: 5.0, allowFullscreen: false ) ), source: nil ) try verify(json: json, expected: expected) } func testCustom() throws { let json = """ { "source": "app-defined", "display" : { "cool": "story" }, "display_type" : "custom", "name" : "woot" } """ let expected = InAppMessage( name: "woot", displayContent: .custom( ["cool": "story"] ), source: .appDefined ) try verify(json: json, expected: expected) } func testAirshipLayout() throws { let airshipLayout = """ { "version":1, "presentation":{ "type":"embedded", "embedded_id":"home_banner", "default_placement":{ "size":{ "width":"50%", "height":"50%" } } }, "view":{ "type":"container", "items":[] } } """ let json = """ { "source": "remote-data", "display" : { "layout": \(airshipLayout) }, "display_type" : "layout", "name" : "Airship layout" } """ let expectedLayout = try! JSONDecoder().decode(AirshipLayout.self, from: airshipLayout.data(using: .utf8)!) let expected = InAppMessage( name: "Airship layout", displayContent: .airshipLayout( expectedLayout ), source: .remoteData ) try verify(json: json, expected: expected) } func testNamePropertyDefaultsToEmptyString() throws { let json = """ { "source": "app-defined", "display" : { "cool": "story" }, "display_type" : "custom", } """ let expected = InAppMessage( name: "", displayContent: .custom( ["cool": "story"] ), source: .appDefined ) try verify(json: json, expected: expected) } func verify(json: String, expected: InAppMessage) throws { let decoder = JSONDecoder() let encoder = JSONEncoder() let fromJSON = try decoder.decode(InAppMessage.self, from: json.data(using: .utf8)!) XCTAssertEqual(fromJSON, expected) let roundTrip = try decoder.decode(InAppMessage.self, from: try encoder.encode(fromJSON)) XCTAssertEqual(roundTrip, fromJSON) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/InAppMessageThemeTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore final class InAppMessageThemeTest: XCTestCase { private var testBundle: Bundle! override func setUpWithError() throws { testBundle = Bundle(for: type(of: self)) } 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.shadow.radius) XCTAssertEqual(29, bannerTheme.shadow.xOffset) XCTAssertEqual(30, bannerTheme.shadow.yOffset) XCTAssertEqual("003100".airshipToColor() , bannerTheme.shadow.color) } 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() 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() throws { var htmlTheme = InAppMessageTheme.HTML.defaultTheme try htmlTheme.applyPlist(plistName: "Valid-UAInAppMessageHTMLStyle", bundle: testBundle) XCTAssertTrue(htmlTheme.hideDismissIcon == true) // 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 when plist parsing fails the theme is equivalent to its default values func testBannerDefaults() { var theme = InAppMessageTheme.Banner.defaultTheme try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) XCTAssertEqual(theme, InAppMessageTheme.Banner.defaultTheme) } /// Test when plist parsing fails the theme is equivalent to its default values func testModalDefaults() { var theme = InAppMessageTheme.Modal.defaultTheme try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) XCTAssertEqual(theme, InAppMessageTheme.Modal.defaultTheme) } /// Test when plist parsing fails the theme is equivalent to its default values func testFullscreenDefaults() { var theme = InAppMessageTheme.Fullscreen.defaultTheme try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) XCTAssertEqual(theme, InAppMessageTheme.Fullscreen.defaultTheme) } /// Test when plist parsing fails the theme is equivalent to its default values func testHTMLDefaults() { var theme = InAppMessageTheme.HTML.defaultTheme try? theme.applyPlist(plistName: "Non-existent plist name", bundle: testBundle) XCTAssertEqual(theme, InAppMessageTheme.HTML.defaultTheme) } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessage/View/InAppMessageNativeBridgeExtensionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation import AirshipCore import WebKit final class InAppMessageNativeBridgeExtensionTest: XCTestCase { func testExtras() async throws { let message = InAppMessage( name: "some name", displayContent: .custom("custom"), extras: ["cool": "value"] ) let jsProtocol = TestJSProtocol() let bridgeExtension = InAppMessageNativeBridgeExtension(message: message) await bridgeExtension.extendJavaScriptEnvironment(jsProtocol, webView: WKWebView()) XCTAssertEqual(jsProtocol.getters, ["getMessageExtras": message.extras]) } func testExtrasWrongType() async throws { let message = InAppMessage( name: "some name", displayContent: .custom("custom"), extras: "value" ) let jsProtocol = TestJSProtocol() let bridgeExtension = InAppMessageNativeBridgeExtension(message: message) await bridgeExtension.extendJavaScriptEnvironment(jsProtocol, webView: WKWebView()) XCTAssertEqual(jsProtocol.getters, ["getMessageExtras": .object([:])]) } func testExtrasMissing() async throws { let message = InAppMessage( name: "some name", displayContent: .custom(.string("custom")), extras: nil ) let jsProtocol = TestJSProtocol() let bridgeExtension = InAppMessageNativeBridgeExtension(message: message) await bridgeExtension.extendJavaScriptEnvironment(jsProtocol, webView: WKWebView()) XCTAssertEqual(jsProtocol.getters, ["getMessageExtras": .object([:])]) } } fileprivate final class TestJSProtocol: JavaScriptEnvironmentProtocol, @unchecked Sendable { var getters: [String: AirshipJSON] = [:] func add(_ getter: String, string: String?) { getters[getter] = try! AirshipJSON.wrap(string) } func add(_ getter: String, number: Double?) { getters[getter] = try! AirshipJSON.wrap(number) } func add(_ getter: String, dictionary: [AnyHashable : Any]?) { getters[getter] = try! AirshipJSON.wrap(dictionary) } func build() async -> String { return "" } } ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessaging/Invalid-UAInAppMessageBannerStyle.plist ================================================ headerStyle letterSpacing lineHeight 2018-04-18T18:56:01Z bodyStyle letterSpacing 2018-04-18T18:55:52Z lineHeight 0 buttonStyle buttonHeight 0 stackedButtonSpacing 0 additionalPadding top bottom leading 0 trailing 0 ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessaging/Invalid-UAInAppMessageFullScreenStyle.plist ================================================ headerStyle letterSpacing lineHeight 2018-04-18T18:56:01Z bodyStyle letterSpacing 2018-04-18T18:55:52Z lineHeight 0 buttonStyle buttonHeight 0 stackedButtonSpacing 0 additionalPadding top bottom leading 0 trailing 0 dismissIconResource 2018-04-24T22:29:07Z ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessaging/Invalid-UAInAppMessageModalStyle.plist ================================================ headerStyle letterSpacing lineHeight 2018-04-18T18:56:01Z bodyStyle letterSpacing 2018-04-18T18:55:52Z lineHeight 0 buttonStyle buttonHeight 0 stackedButtonSpacing 0 additionalPadding top bottom leading 0 trailing 0 dismissIconResource 2018-04-24T22:29:07Z ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessaging/Valid-UAInAppMessageBannerStyle.plist ================================================ additionalPadding top 1 bottom 2 leading 3 trailing 4 maxWidth 26 tapOpacity 27 headerStyle letterSpacing 5 lineSpacing 6 additionalPadding top 7 bottom 8 leading 9 trailing 10 bodyStyle letterSpacing 11 lineSpacing 12 additionalPadding top 13 bottom 14 leading 15 trailing 16 mediaStyle additionalPadding top 17 bottom 18 leading 19 trailing 20 buttonStyle buttonHeight 21 additionalPadding top 22 bottom 23 leading 24 trailing 25 shadowStyle colorHex 003100 radius 28 xOffset 29 yOffset 30 ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessaging/Valid-UAInAppMessageFullScreenStyle.plist ================================================ additionalPadding top 1 bottom 2 leading 3 trailing 4 headerStyle letterSpacing 5 lineSpacing 6 additionalPadding top 7 bottom 8 leading 9 trailing 10 bodyStyle letterSpacing 11 lineSpacing 12 additionalPadding top 13 bottom 14 leading 15 trailing 16 mediaStyle additionalPadding top 17 bottom 18 leading 19 trailing 20 buttonStyle buttonHeight 21 stackedButtonSpacing 22 separatedButtonSpacing 23 additionalPadding top 24 bottom 25 leading 26 trailing 27 dismissIconResource testDismissIconResourceName ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessaging/Valid-UAInAppMessageHTMLStyle.plist ================================================ hideDismissIcon additionalPadding top 1 bottom 2 leading 3 trailing 4 dismissIconResource testDismissIconResourceName maxWidth 28 maxHeight 29 ================================================ FILE: Airship/AirshipAutomation/Tests/InAppMessaging/Valid-UAInAppMessageModalStyle.plist ================================================ additionalPadding top 1 bottom 2 leading 3 trailing 4 headerStyle letterSpacing 5 lineSpacing 6 additionalPadding top 7 bottom 8 leading 9 trailing 10 bodyStyle letterSpacing 11 lineSpacing 12 additionalPadding top 13 bottom 14 leading 15 trailing 16 mediaStyle additionalPadding top 17 bottom 18 leading 19 trailing 20 buttonStyle buttonHeight 21 stackedButtonSpacing 22 separatedButtonSpacing 23 additionalPadding top 24 bottom 25 leading 26 trailing 27 dismissIconResource testDismissIconResourceName maxWidth 28 maxHeight 29 ================================================ FILE: Airship/AirshipAutomation/Tests/Legacy/LegacyInAppAnalyticsTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipAutomation @testable import AirshipCore @MainActor struct LegacyInAppAnalyticsTest { private let recoder: EventRecorder = EventRecorder() private let analytics: LegacyInAppAnalytics! init() { self.analytics = LegacyInAppAnalytics(recorder: recoder) } @Test func testDirectOpen() throws { self.analytics.recordDirectOpenEvent(scheduleID: "some schedule") let eventData = try #require(recoder.eventData.first) #expect(eventData.context == nil) #expect(eventData.renderedLocale == nil) #expect(eventData.messageID == .legacy(identifier: "some schedule")) #expect(eventData.source == .airship) let expectedJSON = """ { "type":"direct_open" } """ #expect(eventData.event.name.reportingName == "in_app_resolution") let expected = try AirshipJSON.from(json: expectedJSON) let actual = try eventData.event.bodyJSON #expect(actual == expected) } @Test func testReplaced() throws { self.analytics.recordReplacedEvent(scheduleID: "some schedule", replacementID: "replacement id") let eventData = try #require(recoder.eventData.first) #expect(eventData.context == nil) #expect(eventData.renderedLocale == nil) #expect(eventData.messageID == .legacy(identifier: "some schedule")) #expect(eventData.source == .airship) let expectedJSON = """ { "type":"replaced", "replacement_id": "replacement id" } """ #expect(eventData.event.name.reportingName == "in_app_resolution") let expected = try AirshipJSON.from(json: expectedJSON) let actual = try eventData.event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Legacy/LegacyInAppMessageTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation @testable import AirshipCore final class LegacyInAppMessageTest: XCTestCase { let date = UATestDate(offset: 0, dateOverride: Date()) func testParseMinPayload() { let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] let message = LegacyInAppMessage(payload: payload, date: date)! XCTAssertNil(message.campaigns) XCTAssertNil(message.messageType) XCTAssertEqual(60 * 60 * 24 * 30, message.expiry.timeIntervalSince(date.now)) XCTAssertEqual(15, message.duration) XCTAssertNil(message.extra) XCTAssertEqual(LegacyInAppMessage.DisplayType.banner, message.displayType) XCTAssertEqual(LegacyInAppMessage.Position.bottom, message.position) XCTAssertNil(message.primaryColor) XCTAssertNil(message.secondaryColor) XCTAssertNil(message.buttonGroup) XCTAssertNil(message.buttonActions) XCTAssertNil(message.onClick) } func testParseMaxPayload() { date.offset = 1 let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert", "position": "top", "primary_color": "#ABCDEF", "secondary_color": "#FEDCBA", "duration": 100.0, ], "extra": ["extra_value": "some text"], "expiry": AirshipDateFormatter.string(fromDate: date.now, format: .isoDelimitter), "actions": [ "on_click": ["onclick": "action"], "button_group": "button group", "button_actions": ["name": ["test": "json"]], ], "campaigns": ["test-campaing": "json"], "message_type": "test-message" ] let message = LegacyInAppMessage(payload: payload, date: date)! XCTAssertEqual(try! AirshipJSON.wrap(["test-campaing": "json"]), message.campaigns) XCTAssertEqual("test-message", message.messageType) XCTAssertEqual( AirshipDateFormatter.string(fromDate: date.now, format: .isoDelimitter), AirshipDateFormatter.string(fromDate: message.expiry, format: .isoDelimitter) ) XCTAssertEqual(100, message.duration) XCTAssertEqual(try! AirshipJSON.wrap(["extra_value": "some text"]), message.extra) XCTAssertEqual(LegacyInAppMessage.DisplayType.banner, message.displayType) XCTAssertEqual(LegacyInAppMessage.Position.top, message.position) XCTAssertEqual("#ABCDEF", message.primaryColor) XCTAssertEqual("#FEDCBA", message.secondaryColor) XCTAssertEqual("button group", message.buttonGroup) XCTAssertEqual(["name": try! AirshipJSON.wrap(["test": "json"])], message.buttonActions) XCTAssertEqual(try! AirshipJSON.wrap(["onclick": "action"]), message.onClick) } func testOverrideId() { let payload: [String : Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] let overridId = "override" let message = LegacyInAppMessage(payload: payload, overrideId: overridId)! XCTAssertEqual(overridId, message.identifier) } func testOverrideOnClick() { let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] let overridJson = try! AirshipJSON.wrap(["test": "json"]) let message = LegacyInAppMessage(payload: payload, overrideOnClick: overridJson)! XCTAssertEqual(overridJson, message.onClick) } func testMissingRequiredFields() { var payload: [String: Any] = [ "display": [ "type": "banner", "alert": "test alert" ] ] XCTAssertNil(LegacyInAppMessage(payload: payload)) payload = [ "identifier": "test-id", "display": [ "alert": "test alert" ] ] XCTAssertNil(LegacyInAppMessage(payload: payload)) payload = [ "identifier": "test-id", "display": [ "type": "banner", ] ] XCTAssertNil(LegacyInAppMessage(payload: payload)) payload = [ "identifier": "test-id", "display": [ "type": "invalid", "alert": "test alert" ] ] XCTAssertNil(LegacyInAppMessage(payload: payload)) } } extension Dictionary { func toNsDictionary() -> NSDictionary { return NSDictionary(dictionary: self) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Legacy/LegacyInAppMessagingTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation @testable import AirshipCore final class LegacyInAppMessagingTest: XCTestCase { private let analytics = TestLegacyAnalytics() private let engine = TestAutomationEngine() private let datastore = PreferenceDataStore(appKey: UUID().uuidString) private let date = UATestDate(offset: 0, dateOverride: Date()) private var airshipTestInstance: TestAirshipInstance! private var subject: DefaultLegacyInAppMessaging! @MainActor override func setUp() async throws { airshipTestInstance = TestAirshipInstance() let push = TestPush() push.combinedCategories = NotificationCategories.defaultCategories() airshipTestInstance.components = [push] airshipTestInstance.makeShared() createSubject() } override func tearDown() { TestAirshipInstance.clearShared() } private func createSubject() { subject = DefaultLegacyInAppMessaging( analytics: analytics, dataStore: datastore, automationEngine: engine, date: date ) } func testOldDataCleanedUpOnInit() { let keys = ["UAPendingInAppMessage", "UAAutoDisplayInAppMessageDataStoreKey", "UALastDisplayedInAppMessageID"] keys.forEach { key in datastore.setObject("\(key)-test-value", forKey: key) } keys.forEach { key in XCTAssert(datastore.keyExists(key)) } createSubject() keys.forEach { key in XCTAssertFalse(datastore.keyExists(key)) } } func testPendingMessageStorage() { XCTAssertNil(subject.pendingMessageID) subject.pendingMessageID = "message-id" XCTAssertEqual("message-id", subject.pendingMessageID) } func testAsapFlagStorage() async { var value = await subject.displayASAPEnabled XCTAssertTrue(value) await MainActor.run { [messaging = self.subject!] in messaging.displayASAPEnabled = false } value = await subject.displayASAPEnabled XCTAssertFalse(value) } func testNotificationResponseCancelsPendingMessage() async throws { let pendingMessageID = "pending" XCTAssertNil(subject.pendingMessageID) await assertLastCancalledScheduleIDEquals(nil) subject.pendingMessageID = pendingMessageID let response = try UNNotificationResponse.with(userInfo: [ "com.urbanairship.in_app": [], "_": pendingMessageID ]) await subject.receivedNotificationResponse(response) await assertLastCancalledScheduleIDEquals(pendingMessageID) XCTAssertNil(subject.pendingMessageID) } func testNotificationResponseRecordsDirectOpen() async throws { let pendingMessageID = "pending" subject.pendingMessageID = pendingMessageID let response = try UNNotificationResponse.with(userInfo: [ "com.urbanairship.in_app": [], "_": pendingMessageID ]) await subject.receivedNotificationResponse(response) XCTAssertEqual([pendingMessageID], self.analytics.directOpen) } func testNotificationResponseDoesNothingOnIdMismatch() async throws { XCTAssertNil(subject.pendingMessageID) await assertLastCancalledScheduleIDEquals(nil) subject.pendingMessageID = "mismatched" let response = try UNNotificationResponse.with(userInfo: [ "com.urbanairship.in_app": [], "_": "pendingMessageID" ]) await subject.receivedNotificationResponse(response) XCTAssertEqual("mismatched", subject.pendingMessageID) await assertLastCancalledScheduleIDEquals(nil) } func testNotificationResponseDoesNothingIfNoPending() async throws { XCTAssertNil(subject.pendingMessageID) await assertLastCancalledScheduleIDEquals(nil) let response = try UNNotificationResponse.with(userInfo: [ "com.urbanairship.in_app": [], "_": "pendingMessageID" ]) await subject.receivedNotificationResponse(response) XCTAssertNil(subject.pendingMessageID) await assertLastCancalledScheduleIDEquals(nil) } func testReceiveRemoteNotificationSchedulesMessageWithDefaults() async throws { let messageId = "test-id" let payload: [String: Any] = [ "identifier": messageId, "display": [ "type": "banner", "alert": "test alert" ] ] await assertLastCancalledScheduleIDEquals(nil) await assertEmptySchedules() subject.pendingMessageID = "some-pending" let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap(["com.urbanairship.in_app": payload]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedule = try await requireFirstSchedule() await assertLastCancalledScheduleIDEquals("some-pending") XCTAssertEqual(messageId, subject.pendingMessageID) XCTAssertEqual(messageId, schedule.identifier) XCTAssertEqual(1, schedule.triggers.count) guard case .event(let trigger) = schedule.triggers.first else { XCTFail() return } XCTAssertEqual(1.0, trigger.goal) XCTAssertNil(trigger.predicate) XCTAssertEqual(EventAutomationTriggerType.activeSession, trigger.type) XCTAssertEqual(date.now, schedule.created) let month: TimeInterval = 60 * 60 * 24 * 30.0 XCTAssertEqual(schedule.end, date.now + month) XCTAssertNil(schedule.campaigns) XCTAssertNil(schedule.messageType) let inAppMessage: InAppMessage switch schedule.data { case .inAppMessage(let message): inAppMessage = message default: fatalError("unsupported schedule data") } XCTAssertEqual("test alert", inAppMessage.name) XCTAssertEqual(InAppMessageSource.legacyPush, inAppMessage.source) XCTAssertNil(inAppMessage.extras) let banner: InAppMessageDisplayContent.Banner switch inAppMessage.displayContent { case .banner(let model): banner = model default: fatalError("unsupported display content") } XCTAssertEqual("test alert", banner.body?.text) XCTAssertEqual("#1C1C1C", banner.body?.color?.hexColorString) XCTAssertEqual(InAppMessageButtonLayoutType.separate, banner.buttonLayoutType) XCTAssertEqual("#FFFFFF", banner.backgroundColor?.hexColorString) XCTAssertEqual("#1C1C1C", banner.dismissButtonColor?.hexColorString) XCTAssertEqual(2, banner.borderRadius) XCTAssertEqual(15, banner.duration) XCTAssertEqual(InAppMessageDisplayContent.Banner.Placement.bottom, banner.placement) XCTAssertEqual(nil, banner.actions) XCTAssertNil(banner.buttons) } func testReceiveNotificationRecordsReplacement() async throws { subject.pendingMessageID = "some-pending" let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap(["com.urbanairship.in_app": payload]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) XCTAssertEqual("some-pending", self.analytics.replaced.first!.0) XCTAssertEqual("test-id", self.analytics.replaced.first!.1) } func testReceiveRemoteNotificationSchedulesMessage() async throws { let messageId = "test-id" let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert", "position": "top", "duration": 100.0, "primary_color": "#ABCDEF", "secondary_color": "#FEDCBA", ], "extra": ["extra_value": "some text"], "expiry": AirshipDateFormatter.string(fromDate: date.now, format: .isoDelimitter), "actions": [ "on_click": ["onclick": "action"], "button_group": "ua_shop_now_share", "button_actions": ["shop_now": ["test": "json"], "share": ["test-2": "json-2"]], ], "campaigns": ["test-campaing": "json"], "message_type": "test-message" ] await assertLastCancalledScheduleIDEquals(nil) await assertEmptySchedules() subject.pendingMessageID = "some-pending" let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap(["com.urbanairship.in_app": payload]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedule = try await requireFirstSchedule() await assertLastCancalledScheduleIDEquals("some-pending") XCTAssertEqual(messageId, subject.pendingMessageID) XCTAssertEqual(messageId, schedule.identifier) XCTAssertEqual(1, schedule.triggers.count) guard case .event(let trigger) = schedule.triggers.first else { XCTFail() return } XCTAssertEqual(1.0, trigger.goal) XCTAssertNil(trigger.predicate) XCTAssertEqual(EventAutomationTriggerType.activeSession, trigger.type) XCTAssertEqual(date.now, schedule.created) let timeDiff = schedule.end?.timeIntervalSince(date.now) ?? 0 XCTAssert(fabs(timeDiff) < 1) XCTAssertEqual(try! AirshipJSON.wrap(["test-campaing": "json"]), schedule.campaigns) XCTAssertEqual("test-message", schedule.messageType) let inAppMessage: InAppMessage switch schedule.data { case .inAppMessage(let message): inAppMessage = message default: fatalError("unsupported schedule data") } XCTAssertEqual("test alert", inAppMessage.name) XCTAssertEqual(InAppMessageSource.legacyPush, inAppMessage.source) XCTAssertEqual(try! AirshipJSON.wrap(["extra_value": "some text"]), inAppMessage.extras) let banner: InAppMessageDisplayContent.Banner switch inAppMessage.displayContent { case .banner(let model): banner = model default: fatalError("unsupported display content") } XCTAssertEqual("test alert", banner.body?.text) XCTAssertEqual("#FEDCBA", banner.body?.color?.hexColorString) XCTAssertEqual(InAppMessageButtonLayoutType.separate, banner.buttonLayoutType) XCTAssertEqual("#ABCDEF", banner.backgroundColor?.hexColorString) XCTAssertEqual("#FEDCBA", banner.dismissButtonColor?.hexColorString) XCTAssertEqual(2, banner.borderRadius) XCTAssertEqual(100, banner.duration) XCTAssertEqual(InAppMessageDisplayContent.Banner.Placement.top, banner.placement) XCTAssertEqual(try! AirshipJSON.wrap(["onclick": "action"]), banner.actions) let buttons = try! XCTUnwrap(banner.buttons) XCTAssertEqual(2, buttons.count) let shopNowButton = buttons[0] XCTAssertEqual("shop_now", shopNowButton.identifier) XCTAssertEqual("Shop Now", shopNowButton.label.text) XCTAssertEqual("#ABCDEF", shopNowButton.label.color?.hexColorString) XCTAssertEqual(try! AirshipJSON.wrap(["test": "json"]), shopNowButton.actions) XCTAssertEqual("#FEDCBA", shopNowButton.backgroundColor?.hexColorString) XCTAssertEqual(2, shopNowButton.borderRadius) let share = buttons[1] XCTAssertEqual("share", share.identifier) XCTAssertEqual("Share", share.label.text) XCTAssertEqual("#ABCDEF", share.label.color?.hexColorString) XCTAssertEqual(try! AirshipJSON.wrap(["test-2": "json-2"]), share.actions) XCTAssertEqual("#FEDCBA", share.backgroundColor?.hexColorString) XCTAssertEqual(2, share.borderRadius) } func testTriggertIsLessAgressiveIfNotDisplayAsap() async throws { let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] await MainActor.run { [messaging = self.subject!] in messaging.displayASAPEnabled = false } let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap(["com.urbanairship.in_app": payload]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedule = try await requireFirstSchedule() guard case .event(let trigger) = schedule.triggers.first else { XCTFail() return } XCTAssertEqual(1.0, trigger.goal) XCTAssertNil(trigger.predicate) XCTAssertEqual(EventAutomationTriggerType.foreground, trigger.type) } func testCustomMessageConverter() async throws { let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] let overridenId = "converter override id" await MainActor.run { [messaging = self.subject!] in messaging.customMessageConverter = { input in return AutomationSchedule( identifier: overridenId, triggers: [], data: .inAppMessage(InAppMessage(name: "overriden", displayContent: .banner(InAppMessageDisplayContent.Banner())))) } } let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap(["com.urbanairship.in_app": payload]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedule = try await requireFirstSchedule() XCTAssertEqual(overridenId, schedule.identifier) } func testMessageExtenderFunction() async throws { let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] let extendedMessageName = "extended message name" await MainActor.run { [messaging = self.subject!] in messaging.messageExtender = { input in input.name = extendedMessageName } } let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap(["com.urbanairship.in_app": payload]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedule = try await requireFirstSchedule() let inAppMessage: InAppMessage switch schedule.data { case .inAppMessage(let message): inAppMessage = message default: fatalError("unsupported schedule data") } XCTAssertEqual(extendedMessageName, inAppMessage.name) } func testScheduleExtendFunction() async throws { let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] await MainActor.run { [messaging = self.subject!] in messaging.scheduleExtender = { input in input.limit = 10 } } let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap(["com.urbanairship.in_app": payload]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedule = try await requireFirstSchedule() XCTAssertEqual(10, schedule.limit) } func testReceiveRemoteIgnoresNonlegacyMessages() async throws { await assertLastCancalledScheduleIDEquals(nil) await assertEmptySchedules() subject.pendingMessageID = "some-pending" let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap([:]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) await assertLastCancalledScheduleIDEquals(nil) await assertEmptySchedules() XCTAssertEqual("some-pending", subject.pendingMessageID) } func testReceiveRemoteNotificationHandlesMessageIdOverride() async throws { let messageId = "overriden" let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] await assertEmptySchedules() let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap([ "com.urbanairship.in_app": payload, "_": messageId ]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedules = await engine.schedules XCTAssertTrue(schedules.contains(where: { $0.identifier == messageId })) XCTAssertEqual(messageId, subject.pendingMessageID) } func testReceiveRemoteNotificationOverridesOnClick() async throws { let payload: [String: Any] = [ "identifier": "test-id", "display": [ "type": "banner", "alert": "test alert" ] ] await assertEmptySchedules() let onClickJson = try AirshipJSON.wrap(["onclick": "overriden"]) let result = await subject.receivedRemoteNotification( try! AirshipJSON.wrap([ "com.urbanairship.in_app": payload, "_uamid": onClickJson.unWrap()! ]) ) XCTAssertEqual(UABackgroundFetchResult.noData, result) let schedule = try await requireFirstSchedule() switch schedule.data { case .inAppMessage(let message): switch message.displayContent { case .banner(let banner): XCTAssertEqual(onClickJson, banner.actions) default: fatalError("unsupported display content") } default: fatalError("unsupported schedule data type") } } private func requireFirstSchedule(line: UInt = #line) async throws -> AutomationSchedule { let schedule = await engine.schedules.first return try XCTUnwrap(schedule) } private func assertEmptySchedules(line: UInt = #line) async { let schedules = await engine.schedules XCTAssert(schedules.isEmpty) } private func assertLastCancalledScheduleIDEquals(_ value: String?) async { let lastCancelledScheduleId = await engine.cancelledSchedules.last XCTAssertEqual(lastCancelledScheduleId, value) } } private final class KeyedArchiver: NSKeyedArchiver { // override func decodeObject(of classes: [AnyClass]?, forKey key: String) -> Any? { // return "" // } override func decodeObject(forKey _: String) -> Any { "" } override func decodeInt64(forKey key: String) -> Int64 { 0 } } private extension UNNotificationResponse { static func with( userInfo: [AnyHashable: Any], actionIdentifier: String = UNNotificationDefaultActionIdentifier ) throws -> UNNotificationResponse { let content = UNMutableNotificationContent() content.userInfo = userInfo let request = UNNotificationRequest( identifier: "", content: content, trigger: nil ) let coder = KeyedArchiver(requiringSecureCoding: false) let notification = try XCTUnwrap(UNNotification(coder: coder)) notification.setValue(request, forKey: "request") let response = try XCTUnwrap(UNNotificationResponse(coder: coder)) response.setValue(notification, forKey: "notification") response.setValue(actionIdentifier, forKey: "actionIdentifier") coder.finishEncoding() return response } } fileprivate final class TestLegacyAnalytics: LegacyInAppAnalyticsProtocol, @unchecked Sendable { var replaced: [(String, String)] = [] var directOpen: [String] = [] func recordReplacedEvent(scheduleID: String, replacementID: String) { replaced.append((scheduleID, replacementID)) } func recordDirectOpenEvent(scheduleID: String) { directOpen.append(scheduleID) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Limits/FrequencyLimitManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation @testable import AirshipCore final class FrequencyLimitManagerTest: AirshipBaseTest { private var manager: FrequencyLimitManager! private let date: UATestDate = UATestDate(offset: 0, dateOverride: Date(timeIntervalSince1970: 0)) private let store: FrequencyLimitStore = FrequencyLimitStore( appKey: UUID().uuidString, inMemory: true ) override func setUpWithError() throws { self.manager = FrequencyLimitManager( dataStore: self.store, date: self.date ) } @MainActor func testGetCheckerNoLimits() async throws { let frequencyChecker = try await self.manager.getFrequencyChecker(constraintIDs: []) XCTAssertNotNil(frequencyChecker) XCTAssertTrue(frequencyChecker.checkAndIncrement()) XCTAssertFalse(frequencyChecker.isOverLimit) } @MainActor func testSingleChecker() async throws { let constraint = FrequencyConstraint( identifier: "foo", range: 10, count: 2 ) try await self.manager.setConstraints([constraint]) var constraints = try await self.store.fetchConstraints() XCTAssertEqual(constraints.count, 1); let startDate = Date(timeIntervalSince1970: 0) self.date.dateOverride = startDate let frequencyChecker = try await self.manager.getFrequencyChecker(constraintIDs: ["foo"]) constraints = try await self.store.fetchConstraints() XCTAssertEqual(constraints.count, 1) XCTAssertFalse(frequencyChecker.isOverLimit) XCTAssertTrue(frequencyChecker.checkAndIncrement()) self.date.offset = 1 XCTAssertFalse(frequencyChecker.isOverLimit) XCTAssertTrue(frequencyChecker.checkAndIncrement()) // We should now be over the limit XCTAssertTrue(frequencyChecker.isOverLimit) XCTAssertFalse(frequencyChecker.checkAndIncrement()) // After the range has passed we should no longer be over the limit self.date.offset = 11 XCTAssertFalse(frequencyChecker.isOverLimit) // One more increment should push us back over the limit XCTAssertTrue(frequencyChecker.checkAndIncrement()) XCTAssertTrue(frequencyChecker.isOverLimit) await self.manager.writePending() let occurrences = try await self.store.fetchConstraints(["foo"]) .first! .occurrences .map { occurence in occurence.timestamp.timeIntervalSince1970 } // We should only have three occurrences, since the last check and increment should be a no-op XCTAssertEqual(Set([0, 1, 11]), Set(occurrences)) } @MainActor func testMultipleCheckers() async throws { let constraint = FrequencyConstraint( identifier: "foo", range: 10, count: 2 ) try await self.manager.setConstraints([constraint]) let checker1 = try await self.manager.getFrequencyChecker(constraintIDs: ["foo"]) let checker2 = try await self.manager.getFrequencyChecker(constraintIDs: ["foo"]) let constraints = try await self.store.fetchConstraints() XCTAssertEqual(constraints.count, 1) XCTAssertFalse(checker1.isOverLimit) XCTAssertFalse(checker2.isOverLimit) XCTAssertTrue(checker1.checkAndIncrement()) self.date.offset = 1 XCTAssertTrue(checker2.checkAndIncrement()) // We should now be over the limit XCTAssertTrue(checker1.isOverLimit) XCTAssertTrue(checker2.isOverLimit) // After the range has passed we should no longer be over the limit self.date.offset = 11 XCTAssertFalse(checker1.isOverLimit) XCTAssertFalse(checker2.isOverLimit) // The first check and increment should succeed, and the next should put us back over the limit again XCTAssertTrue(checker1.checkAndIncrement()) self.date.offset = 1 XCTAssertFalse(checker2.checkAndIncrement()) await self.manager.writePending() let occurrences = try await self.store.fetchConstraints(["foo"]) .first! .occurrences .map { occurence in occurence.timestamp.timeIntervalSince1970 } // We should only have three occurrences, since the last check and increment should be a no-op XCTAssertEqual(Set([0, 1, 11]), Set(occurrences)) } @MainActor func testMultipleConstraints() async throws { let constraint1 = FrequencyConstraint( identifier: "foo", range: 10, count: 2 ) let constraint2 = FrequencyConstraint( identifier: "bar", range: 2, count: 1 ) try await self.manager.setConstraints([constraint1, constraint2]) let checker = try await self.manager.getFrequencyChecker(constraintIDs: ["foo", "bar"]) XCTAssertFalse(checker.isOverLimit) var result = checker.checkAndIncrement() XCTAssertTrue(result) self.date.offset = 1 // We should now be violating constraint 2 XCTAssertTrue(checker.isOverLimit) result = checker.checkAndIncrement() XCTAssertFalse(result) self.date.offset = 3 // We should no longer be violating constraint 2 XCTAssertFalse(checker.isOverLimit) result = checker.checkAndIncrement() XCTAssertTrue(result) // We should now be violating constraint 1 self.date.offset = 9 XCTAssertTrue(checker.isOverLimit) result = checker.checkAndIncrement() XCTAssertFalse(result) // We should now be violating neither constraint self.date.offset = 11 XCTAssertFalse(checker.isOverLimit) // One more increment should hit the limit result = checker.checkAndIncrement() XCTAssertTrue(result) XCTAssertTrue(checker.isOverLimit) } @MainActor func testConstraintRemovedMidCheck() async throws { let constraint1 = FrequencyConstraint( identifier: "foo", range: 10, count: 2 ) let constraint2 = FrequencyConstraint( identifier: "bar", range: 20, count: 2 ) try await self.manager.setConstraints([constraint1, constraint2]) let checker = try await self.manager.getFrequencyChecker(constraintIDs: ["foo", "bar"]) try await self.manager.setConstraints( [ FrequencyConstraint( identifier: "bar", range: 10, count: 10 ) ] ) XCTAssertTrue(checker.checkAndIncrement()) self.date.offset = 1 XCTAssertTrue(checker.checkAndIncrement()) self.date.offset = 1 XCTAssertTrue(checker.checkAndIncrement()) await self.manager.writePending() // Foo should not exist let fooInfo = try await self.store.fetchConstraints(["foo"]) XCTAssertEqual(fooInfo.count, 0) // Bar should have the two occurences let barInfo = try await self.store.fetchConstraints(["bar"]) XCTAssertEqual(barInfo.first?.occurrences.count, 3); } @MainActor func testUpdateConstraintRangeClearsOccurrences() async throws { try await self.manager.setConstraints( [ FrequencyConstraint( identifier: "foo", range: 10, count: 2 ) ] ) let checker = try await self.manager.getFrequencyChecker(constraintIDs: ["foo"]) _ = checker.checkAndIncrement() await self.manager.writePending() try await self.manager.setConstraints( [ FrequencyConstraint( identifier: "foo", range: 20, count: 2 ) ] ) await self.manager.writePending() let fooInfo = try await self.store.fetchConstraints(["foo"]) XCTAssertEqual(fooInfo.first?.occurrences.count, 0); } func testUpdateConstraintCountDoesNotClearCount() async throws { try await self.manager.setConstraints( [ FrequencyConstraint( identifier: "foo", range: 10, count: 2 ) ] ) let checker = try await self.manager.getFrequencyChecker(constraintIDs: ["foo"]) let result = await checker.checkAndIncrement() XCTAssertTrue(result) try await self.manager.setConstraints( [ FrequencyConstraint( identifier: "foo", range: 10, count: 3 ) ] ) await self.manager.writePending() let fooInfo = try await self.store.fetchConstraints(["foo"]) XCTAssertEqual(fooInfo.first?.occurrences.count, 1); } } ================================================ FILE: Airship/AirshipAutomation/Tests/RemoteData/AutomationRemoteDataAccessTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore @testable import AirshipAutomation final class AutomationRemoteDataAccessTest: XCTestCase { private let remoteData: TestRemoteData = TestRemoteData() private let networkChecker: TestNetworkChecker = TestNetworkChecker() private var subject: AutomationRemoteDataAccess! override func setUpWithError() throws { subject = AutomationRemoteDataAccess( remoteData: remoteData, network: networkChecker ) } func testIsCurrentTrue() async { let info = makeRemoteDataInfo() let schedule = makeSchedule(remoteDataInfo: info) remoteData.isCurrent = true let isCurrent = await subject.isCurrent(schedule: schedule) XCTAssertTrue(isCurrent) } func testIsCurrentFalse() async { let info = makeRemoteDataInfo() let schedule = makeSchedule(remoteDataInfo: info) remoteData.isCurrent = false let isCurrent = await subject.isCurrent(schedule: schedule) XCTAssertFalse(isCurrent) } func testIsCurrentNilRemoteDataInfo() async { let schedule = makeSchedule(remoteDataInfo: nil) remoteData.isCurrent = true let isCurrent = await subject.isCurrent(schedule: schedule) XCTAssertFalse(isCurrent) } func testRequiresUpdateUpToDate() async { let info = makeRemoteDataInfo(.app) let schedule = makeSchedule(remoteDataInfo: info) remoteData.isCurrent = true remoteData.status[.app] = .upToDate let requiresUpdate = await subject.requiresUpdate(schedule: schedule) XCTAssertFalse(requiresUpdate) } func testRequiresUpdateStale() async { let info = makeRemoteDataInfo(.app) let schedule = makeSchedule(remoteDataInfo: info) remoteData.isCurrent = true remoteData.status[.app] = .stale let requiresUpdate = await subject.requiresUpdate(schedule: schedule) XCTAssertFalse(requiresUpdate) } func testRequiresUpdateOutOfDate() async { let info = makeRemoteDataInfo(.app) let schedule = makeSchedule(remoteDataInfo: info) remoteData.isCurrent = true remoteData.status[.app] = .outOfDate let requiresUpdate = await subject.requiresUpdate(schedule: schedule) XCTAssertTrue(requiresUpdate) } func testRequiresUpdateNotCurrent() async { let info = makeRemoteDataInfo(.app) let schedule = makeSchedule(remoteDataInfo: info) remoteData.isCurrent = false remoteData.status[.app] = .upToDate let requiresUpdate = await subject.requiresUpdate(schedule: schedule) XCTAssertTrue(requiresUpdate) } func testRequiresUpdateNilRemoteDataInfo() async { remoteData.isCurrent = false remoteData.status[.app] = .upToDate let schedule = makeSchedule(remoteDataInfo: nil) let requiresUpdate = await subject.requiresUpdate(schedule: schedule) XCTAssertTrue(requiresUpdate) } func testRequiresUpdateRightSource() async { remoteData.isCurrent = true remoteData.status[.app] = .outOfDate remoteData.status[.contact] = .upToDate let requiresUpdateContact = await subject.requiresUpdate( schedule: makeSchedule(remoteDataInfo: makeRemoteDataInfo(.contact)) ) XCTAssertFalse(requiresUpdateContact) let requiresUpdateApp = await subject.requiresUpdate( schedule: makeSchedule(remoteDataInfo: makeRemoteDataInfo(.app)) ) XCTAssertTrue(requiresUpdateApp) } func testWaitForFullRefresh() async { let info = makeRemoteDataInfo(.contact) let schedule = makeSchedule(remoteDataInfo: info) let expectation = XCTestExpectation() self.remoteData.waitForRefreshBlock = { source, maxTime in XCTAssertEqual(source, .contact) XCTAssertNil(maxTime) expectation.fulfill() } await subject.waitFullRefresh(schedule: schedule) await self.fulfillment(of: [expectation]) } func testWaitForFullRefreshNilInfo() async { let expectation = XCTestExpectation() self.remoteData.waitForRefreshBlock = { source, maxTime in XCTAssertEqual(source, .app) XCTAssertNil(maxTime) expectation.fulfill() } let schedule = makeSchedule(remoteDataInfo: nil) await subject.waitFullRefresh(schedule: schedule) await self.fulfillment(of: [expectation]) } func testBestEffortRefresh() async { await self.networkChecker.setConnected(true) remoteData.isCurrent = true let info = makeRemoteDataInfo(.contact) self.remoteData.status[.contact] = .stale let schedule = makeSchedule(remoteDataInfo: info) let expectation = XCTestExpectation() self.remoteData.waitForRefreshAttemptBlock = { source, maxTime in XCTAssertEqual(source, .contact) XCTAssertNil(maxTime) expectation.fulfill() } let result = await subject.bestEffortRefresh(schedule: schedule) await self.fulfillment(of: [expectation]) XCTAssertTrue(result) } func testBestEffortRefreshNotCurrentAfterAttempt() async { await self.networkChecker.setConnected(true) remoteData.isCurrent = true let info = makeRemoteDataInfo(.contact) self.remoteData.status[.contact] = .stale let schedule = makeSchedule(remoteDataInfo: info) let expectation = XCTestExpectation() self.remoteData.waitForRefreshAttemptBlock = { source, maxTime in self.remoteData.isCurrent = false expectation.fulfill() } let result = await subject.bestEffortRefresh(schedule: schedule) await self.fulfillment(of: [expectation]) XCTAssertFalse(result) } func testBestEffortRefreshNotCurrentReturnsNil() async { await self.networkChecker.setConnected(true) remoteData.isCurrent = false let info = makeRemoteDataInfo(.contact) let schedule = makeSchedule(remoteDataInfo: info) self.remoteData.status[.contact] = .stale self.remoteData.waitForRefreshAttemptBlock = { _, _ in XCTFail() } let result = await subject.bestEffortRefresh(schedule: schedule) XCTAssertFalse(result) } func testBestEffortRefreshNotConnected() async { await self.networkChecker.setConnected(false) remoteData.isCurrent = true let info = makeRemoteDataInfo(.contact) let schedule = makeSchedule(remoteDataInfo: info) self.remoteData.status[.contact] = .stale self.remoteData.waitForRefreshAttemptBlock = { _, _ in XCTFail() } let result = await subject.bestEffortRefresh(schedule: schedule) XCTAssertTrue(result) } func testNotifyOutdated() async { let info = makeRemoteDataInfo(.contact) let schedule = makeSchedule(remoteDataInfo: info) await self.subject.notifyOutdated(schedule: schedule) XCTAssertEqual(self.remoteData.notifiedOutdatedInfos, [info]) } func testRemoteDataInfoIgnoresInvalidSchedules() throws { let validSchedule = """ { "id": "test_schedule", "triggers": [ { "type": "custom_event_count", "goal": 1, "id": "json-id" } ], "group": "test_group", "priority": 2, "limit": 5, "start": "2023-12-20T00:00:00Z", "end": "2023-12-21T00:00:00Z", "audience": {}, "delay": {}, "interval": 3600, "type": "actions", "actions": { "foo": "bar", }, "bypass_holdout_groups": true, "edit_grace_period": 7, "metadata": {}, "frequency_constraint_ids": ["constraint1", "constraint2"], "message_type": "test_type", "last_updated": "2023-12-20T12:30:00Z", "created": "2023-12-20T12:00:00Z" } """ let invalidSchedule = """ { "priority": 2, "limit": 5, "start": "2023-12-20T00:00:00Z", "end": "2023-12-21T00:00:00Z", "audience": {}, "delay": {}, "interval": 3600, "type": "actions", "actions": { "foo": "bar", }, "bypass_holdout_groups": true, "edit_grace_period": 7, "metadata": {}, "frequency_constraint_ids": ["constraint1", "constraint2"], "message_type": "test_type", "last_updated": "2023-12-20T12:30:00Z", "created": "2023-12-20T12:00:00Z" } """ let dataJson = try AirshipJSON.from(json: "{\"in_app_messages\": [\(validSchedule), \(invalidSchedule)]}") let payload = RemoteDataPayload( type: "schedule_test", timestamp: Date(), data: dataJson, remoteDataInfo: nil) let decoded: InAppRemoteData.Data = try payload.data.decode() XCTAssertEqual(1, decoded.schedules.count) XCTAssertEqual("test_schedule", decoded.schedules.first?.identifier) // Invalid schedule without ID can't be tracked XCTAssertTrue(decoded.failedSchedules.isEmpty) } func testRemoteDataInfoTracksFailedSchedules() throws { let validSchedule = """ { "id": "valid_schedule", "triggers": [ { "type": "custom_event_count", "goal": 1, "id": "json-id" } ], "type": "actions", "actions": { "foo": "bar" } } """ // Invalid schedule WITH an ID and created date (missing required triggers) let invalidScheduleWithID = """ { "id": "failed_schedule_id", "created": "2023-12-20T12:00:00Z", "type": "actions", "actions": { "foo": "bar" } } """ let dataJson = try AirshipJSON.from(json: "{\"in_app_messages\": [\(validSchedule), \(invalidScheduleWithID)]}") let payload = RemoteDataPayload( type: "schedule_test", timestamp: Date(), data: dataJson, remoteDataInfo: nil) let decoded: InAppRemoteData.Data = try payload.data.decode() XCTAssertEqual(1, decoded.schedules.count) XCTAssertEqual("valid_schedule", decoded.schedules.first?.identifier) // Failed schedule with ID should be tracked XCTAssertEqual(decoded.failedSchedules.map { $0.identifier}, ["failed_schedule_id"]) // Verify created date is captured as createdDate let expectedCreatedDate = AirshipDateFormatter.date(fromISOString: "2023-12-20T12:00:00Z") XCTAssertEqual(decoded.failedSchedules.first?.createdDate, expectedCreatedDate) } func testFromPayloadsAggregatesFailedSchedules() throws { let validSchedule = """ { "id": "valid_schedule", "triggers": [ { "type": "custom_event_count", "goal": 1, "id": "json-id" } ], "type": "actions", "actions": { "foo": "bar" } } """ // Invalid schedule WITH an ID and created date (missing required triggers) let invalidScheduleWithID = """ { "id": "failed_schedule_id", "created": "2023-12-20T12:00:00Z", "type": "actions", "actions": { "foo": "bar" } } """ let dataJson = try AirshipJSON.from(json: "{\"in_app_messages\": [\(validSchedule), \(invalidScheduleWithID)]}") let remoteDataInfo = RemoteDataInfo( url: URL(string: "https://airship.test")!, lastModifiedTime: nil, source: .app ) let payload = RemoteDataPayload( type: "in_app_messages", timestamp: Date(), data: dataJson, remoteDataInfo: remoteDataInfo) let inAppRemoteData = InAppRemoteData.fromPayloads([payload]) // Verify aggregate failedSchedules on InAppRemoteData XCTAssertEqual(inAppRemoteData.failedSchedules.map { $0.identifier}, ["failed_schedule_id"]) // Verify created date is captured as createdDate let expectedCreatedDate = AirshipDateFormatter.date(fromISOString: "2023-12-20T12:00:00Z") XCTAssertEqual(inAppRemoteData.failedSchedules.first?.createdDate, expectedCreatedDate) } private func makeSchedule(remoteDataInfo: RemoteDataInfo?) -> AutomationSchedule { return AutomationSchedule( identifier: UUID().uuidString, data: .actions(AirshipJSON.null), triggers: [], created: Date(), lastUpdated: Date(), metadata: try! AirshipJSON.wrap([ "com.urbanairship.iaa.REMOTE_DATA_INFO": remoteDataInfo ]) ) } private func makeRemoteDataInfo(_ source: RemoteDataSource = .app) -> RemoteDataInfo { return RemoteDataInfo( url: URL(string: "https://airship.test")!, lastModifiedTime: nil, source: source ) } } ================================================ FILE: Airship/AirshipAutomation/Tests/RemoteData/AutomationRemoteDataSubscriberTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore @testable import AirshipAutomation final class AutomationRemoteDataSubscriberTest: XCTestCase { private let remoteDataAccess: TestRemoteDataAccess = TestRemoteDataAccess() private let engine: TestAutomationEngine = TestAutomationEngine() private let frequencyLimits: TestFrequencyLimitManager = TestFrequencyLimitManager() private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var subscriber: AutomationRemoteDataSubscriber! override func setUp() async throws { self.subscriber = AutomationRemoteDataSubscriber( dataStore: dataStore, remoteDataAccess: remoteDataAccess, engine: engine, frequencyLimitManager: frequencyLimits ) } func testSchedulingAutomations() async throws { let appSchedules = makeSchedules(source: .app) let contactSchedules = makeSchedules(source: .contact) let data = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: appSchedules, constraints: [] ), timestamp: Date() ), .contact: .init( data: .init( schedules: contactSchedules, constraints: [] ), timestamp: Date() ) ] ) await self.subscriber.subscribe() let appExpectation = expectation(description: "schedules saved") let contactExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { schedules in if (schedules == appSchedules) { appExpectation.fulfill() } else if (schedules == contactSchedules) { contactExpectation.fulfill() } else { XCTFail() } } self.remoteDataAccess.updatesSubject.send(data) await self.fulfillment(of: [appExpectation, contactExpectation]) } func testEmptyPayloadStopsSchedules() async throws { let appSchedules = makeSchedules(source: .app) await self.engine.setSchedules(appSchedules) let emptyData = InAppRemoteData( payloads: [:] ) await self.subscriber.subscribe() let stopExpectation = expectation(description: "schedules stopped") await self.engine.setOnStop { schedules in XCTAssertEqual(schedules, appSchedules) stopExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(emptyData) await self.fulfillment(of: [stopExpectation]) } func testIgnoreSchedulesNoLongerScheduled() async throws { await self.subscriber.subscribe() let date = Date() let firstUpdateSchedules = makeSchedules(source: .app, count: 4) let firstUpdate = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: firstUpdateSchedules, constraints: [] ), timestamp: date, remoteDataInfo: RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) ) ] ) let firstUpdateExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { schedules in XCTAssertEqual(schedules, firstUpdateSchedules) firstUpdateExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(firstUpdate) await self.fulfillment(of: [firstUpdateExpectation]) await self.engine.setSchedules(firstUpdateSchedules) let secondUpdateSchedules = firstUpdateSchedules + makeSchedules(source: .app, count: 4, created: date) let secondUpdate = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: secondUpdateSchedules, constraints: [] ), timestamp: date + 100.0, remoteDataInfo: RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) ) ] ) let secondUpdateExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { schedules in // Should still be the first update schedules since the second updates are older XCTAssertEqual(schedules, firstUpdateSchedules) secondUpdateExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(secondUpdate) await self.fulfillment(of: [secondUpdateExpectation]) } func testOlderSchedulesMinSDKVersion() async throws { self.subscriber = AutomationRemoteDataSubscriber( dataStore: dataStore, remoteDataAccess: remoteDataAccess, engine: engine, frequencyLimitManager: frequencyLimits, airshipSDKVersion: "1.0.0" ) await self.subscriber.subscribe() let date = Date() let firstUpdateSchedules = makeSchedules(source: .app, count: 4) let firstUpdate = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: firstUpdateSchedules, constraints: [] ), timestamp: date, remoteDataInfo: RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) ) ] ) let firstUpdateExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { schedules in XCTAssertEqual(schedules, firstUpdateSchedules) firstUpdateExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(firstUpdate) await self.fulfillment(of: [firstUpdateExpectation]) await self.subscriber.unsubscribe() // Update sdk version self.subscriber = AutomationRemoteDataSubscriber( dataStore: dataStore, remoteDataAccess: remoteDataAccess, engine: engine, frequencyLimitManager: frequencyLimits, airshipSDKVersion: "2.0.0" ) await self.subscriber.subscribe() await self.engine.setSchedules(firstUpdateSchedules) let secondUpdateSchedules = firstUpdateSchedules + makeSchedules( source: .app, count: 4, minSDKVersion: "2.0.0", created: date ) let secondUpdate = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: secondUpdateSchedules, constraints: [] ), timestamp: date + 100.0 ) ] ) let secondUpdateExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { schedules in XCTAssertEqual(schedules, secondUpdateSchedules) secondUpdateExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(secondUpdate) await self.fulfillment(of: [secondUpdateExpectation]) } func testSamePayloadSkipsAutomations() async throws { await self.subscriber.subscribe() let date = Date() let schedules = makeSchedules(source: .app, count: 4) let update = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: schedules, constraints: [] ), timestamp: date, remoteDataInfo: RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) ) ] ) let expecation = expectation(description: "schedules saved") await self.engine.setOnUpsert { scheduled in XCTAssertEqual(scheduled, schedules) expecation.fulfill() } self.remoteDataAccess.updatesSubject.send(update) self.remoteDataAccess.updatesSubject.send(update) await self.fulfillment(of: [expecation]) } func testRemoteDataInfoChangeUpdatesSchedules() async throws { await self.subscriber.subscribe() let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) let date = Date() let schedules = try makeSchedules(source: .app, count: 4).map { schedule in var mutable = schedule mutable.metadata = try AirshipJSON.wrap(remoteDataInfo) return mutable } let firstExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { scheduled in XCTAssertEqual(scheduled, schedules) firstExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: schedules, constraints: [] ), timestamp: date, remoteDataInfo: remoteDataInfo ) ] )) await self.fulfillment(of: [firstExpectation]) await self.engine.setSchedules(schedules) let updatedRemoteDataInfo = RemoteDataInfo( url: URL(string: "some-other-url")!, lastModifiedTime: nil, source: .app ) let updatedSchedules = try schedules.map { schedule in var mutable = schedule mutable.metadata = try AirshipJSON.wrap(updatedRemoteDataInfo) return mutable } let secondExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { scheduled in XCTAssertEqual(scheduled, updatedSchedules) secondExpectation.fulfill() } // udpate again with different remote-data info self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: updatedSchedules, constraints: [] ), timestamp: date, remoteDataInfo: updatedRemoteDataInfo ) ] )) await self.fulfillment(of: [secondExpectation]) } func testPayloadDateChangeAutomations() async throws { await self.subscriber.subscribe() let date = Date() let schedules = makeSchedules(source: .app, count: 4) let firstExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { scheduled in XCTAssertEqual(scheduled, schedules) firstExpectation.fulfill() } let remoteDateInfo = RemoteDataInfo( url: URL(string: "some-other-url")!, lastModifiedTime: nil, source: .app ) self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: schedules, constraints: [] ), timestamp: date, remoteDataInfo: remoteDateInfo ) ] )) await self.fulfillment(of: [firstExpectation]) await self.engine.setSchedules(schedules) let secondExpectation = expectation(description: "schedules saved") await self.engine.setOnUpsert { scheduled in XCTAssertEqual(scheduled, schedules) secondExpectation.fulfill() } // update again with different date self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: schedules, constraints: [] ), timestamp: date + 1, remoteDataInfo: remoteDateInfo ) ] )) await self.fulfillment(of: [secondExpectation]) } func testConstraints() async throws { let appConstraints = [ FrequencyConstraint(identifier: "foo", range: 100, count: 10), FrequencyConstraint(identifier: "bar", range: 100, count: 10) ] let contactConstraints = [ FrequencyConstraint(identifier: "foo", range: 1, count: 1), FrequencyConstraint(identifier: "baz", range: 1, count: 1) ] let data = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [], constraints: appConstraints ), timestamp: Date() ), .contact: .init( data: .init( schedules: [], constraints: contactConstraints ), timestamp: Date() ), ] ) await self.subscriber.subscribe() let expectation = expectation(description: "constraints saved") await self.frequencyLimits.setOnConstraints { constraints in XCTAssertEqual(constraints, appConstraints + contactConstraints) expectation.fulfill() } self.remoteDataAccess.updatesSubject.send(data) await self.fulfillment(of: [expectation]) } // MARK: - Failed schedule tracking tests func testFailedScheduleCarriedForwardAndRetriedOnSDKUpdate() async throws { let date = Date() let scheduleA = makeSchedule(source: .app, created: date) let failedB = FailedScheduleRecord( identifier: "failed_schedule_B", createdDate: date, minSDKVersion: nil ) // First sync (SDK 1.0.0): A succeeds, B fails self.subscriber = AutomationRemoteDataSubscriber( dataStore: dataStore, remoteDataAccess: remoteDataAccess, engine: engine, frequencyLimitManager: frequencyLimits, airshipSDKVersion: "1.0.0" ) await self.subscriber.subscribe() let firstExpectation = expectation(description: "first sync upsert") await self.engine.setOnUpsert { schedules in XCTAssertEqual(schedules, [scheduleA]) firstExpectation.fulfill() } let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [scheduleA], constraints: [], failedSchedules: [failedB] ), timestamp: date, remoteDataInfo: remoteDataInfo ) ] )) await self.fulfillment(of: [firstExpectation]) // Now simulate SDK update: recreate subscriber with new version await self.subscriber.unsubscribe() await self.engine.setSchedules([scheduleA]) self.subscriber = AutomationRemoteDataSubscriber( dataStore: dataStore, remoteDataAccess: remoteDataAccess, engine: engine, frequencyLimitManager: frequencyLimits, airshipSDKVersion: "2.0.0" ) await self.subscriber.subscribe() // Second sync: B now parses successfully let scheduleB = makeSchedule( source: .app, identifier: "failed_schedule_B", created: date ) let secondExpectation = expectation(description: "retry sync upsert") await self.engine.setOnUpsert { schedules in let ids = Set(schedules.map { $0.identifier }) XCTAssertTrue(ids.contains("failed_schedule_B")) secondExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [scheduleA, scheduleB], constraints: [], failedSchedules: [] ), timestamp: date, remoteDataInfo: remoteDataInfo ) ] )) await self.fulfillment(of: [secondExpectation]) } func testFailedScheduleNowParsesOnServerFix() async throws { let date = Date() let scheduleA = makeSchedule(source: .app, created: date) let failedB = FailedScheduleRecord( identifier: "failed_schedule_B", createdDate: date, minSDKVersion: nil ) await self.subscriber.subscribe() let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) // First sync: A succeeds, B fails let firstExpectation = expectation(description: "first sync") await self.engine.setOnUpsert { schedules in XCTAssertEqual(schedules.map { $0.identifier }, [scheduleA.identifier]) firstExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [scheduleA], constraints: [], failedSchedules: [failedB] ), timestamp: date, remoteDataInfo: remoteDataInfo ) ] )) await self.fulfillment(of: [firstExpectation]) await self.engine.setSchedules([scheduleA]) // Second sync: server fixed B, new timestamp let scheduleB = makeSchedule( source: .app, identifier: "failed_schedule_B", created: date ) let secondExpectation = expectation(description: "server fix sync") await self.engine.setOnUpsert { schedules in let ids = Set(schedules.map { $0.identifier }) XCTAssertTrue(ids.contains("failed_schedule_B")) secondExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [scheduleA, scheduleB], constraints: [], failedSchedules: [] ), timestamp: date + 100, remoteDataInfo: remoteDataInfo ) ] )) await self.fulfillment(of: [secondExpectation]) } func testFailedScheduleRemovedFromRemoteData() async throws { let date = Date() let scheduleA = makeSchedule(source: .app, created: date) let failedB = FailedScheduleRecord( identifier: "failed_schedule_B", createdDate: date, minSDKVersion: nil ) await self.subscriber.subscribe() let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) // First sync: A succeeds, B fails let firstExpectation = expectation(description: "first sync") await self.engine.setOnUpsert { _ in firstExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [scheduleA], constraints: [], failedSchedules: [failedB] ), timestamp: date, remoteDataInfo: remoteDataInfo ) ] )) await self.fulfillment(of: [firstExpectation]) await self.engine.setSchedules([scheduleA]) // Second sync: B removed entirely from remote data, new timestamp let secondExpectation = expectation(description: "second sync") await self.engine.setOnUpsert { schedules in let ids = schedules.map { $0.identifier } XCTAssertFalse(ids.contains("failed_schedule_B")) secondExpectation.fulfill() } self.remoteDataAccess.updatesSubject.send(InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [scheduleA], constraints: [], failedSchedules: [] ), timestamp: date + 100, remoteDataInfo: remoteDataInfo ) ] )) await self.fulfillment(of: [secondExpectation]) } func testSamePayloadWithFailuresSkipsProcessing() async throws { let date = Date() let scheduleA = makeSchedule(source: .app, created: date) let failedB = FailedScheduleRecord( identifier: "failed_schedule_B", createdDate: date, minSDKVersion: nil ) await self.subscriber.subscribe() let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-url")!, lastModifiedTime: nil, source: .app ) let payload = InAppRemoteData( payloads: [ .app: .init( data: .init( schedules: [scheduleA], constraints: [], failedSchedules: [failedB] ), timestamp: date, remoteDataInfo: remoteDataInfo ) ] ) let upsertExpectation = expectation(description: "upsert called once") await self.engine.setOnUpsert { _ in upsertExpectation.fulfill() } // Send the same payload twice — upsert should only fire once self.remoteDataAccess.updatesSubject.send(payload) self.remoteDataAccess.updatesSubject.send(payload) await self.fulfillment(of: [upsertExpectation]) } // MARK: - Helpers private func makeSchedules( source: RemoteDataSource, count: UInt = UInt.random(in: 1..<10), minSDKVersion: String? = nil, created: Date = Date() ) -> [AutomationSchedule] { return (1...count).map { _ in makeSchedule(source: source, minSDKVersion: minSDKVersion, created: created) } } private func makeSchedule( source: RemoteDataSource, identifier: String = UUID().uuidString, minSDKVersion: String? = nil, created: Date = Date() ) -> AutomationSchedule { let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-test-url/")!, lastModifiedTime: nil, source: source ) return AutomationSchedule( identifier: identifier, data: .actions(.string("actions")), triggers: [AutomationTrigger.activeSession(count: 1)], created: created, metadata: try! AirshipJSON.wrap([ InAppRemoteData.remoteInfoMetadataKey: remoteDataInfo ]), minSDKVersion: minSDKVersion ) } } ================================================ FILE: Airship/AirshipAutomation/Tests/RemoteData/AutomationSourceInfoStoreTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore @testable import AirshipAutomation final class AutomationSourceInfoStoreTest: XCTestCase { private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var infoStore: AutomationSourceInfoStore! override func setUp() async throws { self.infoStore = AutomationSourceInfoStore(dataStore: dataStore) } func testMigrateChannel() throws { let lastPayloadTimestamp = Date() - 1000.0 let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-url://")!, lastModifiedTime: UUID().uuidString, source: .app ) dataStore.setObject(lastPayloadTimestamp, forKey: "UAInAppRemoteDataClient.LastPayloadTimeStamp") dataStore.setObject("17.9.9", forKey: "UAInAppRemoteDataClient.LastSDKVersion") dataStore.setSafeCodable(remoteDataInfo, forKey: "UAInAppRemoteDataClient.LastRemoteDataInfo") dataStore.setSafeCodable(remoteDataInfo, forKey: "UAInAppRemoteDataClient.LastPayloadMetadata") let expected = AutomationSourceInfo( remoteDataInfo: nil, payloadTimestamp: lastPayloadTimestamp, airshipSDKVersion: "17.9.9" ) let actual = self.infoStore.getSourceInfo(source: .app, contactID: nil) XCTAssertEqual(expected, actual) } func testMigrateContact() throws { let lastPayloadTimestamp = Date() - 1000.0 let remoteDataInfo = RemoteDataInfo( url: URL(string: "some-url://")!, lastModifiedTime: UUID().uuidString, source: .contact ) dataStore.setObject(lastPayloadTimestamp, forKey: "UAInAppRemoteDataClient.LastPayloadTimeStamp.Contactfoo") dataStore.setObject("17.9.9", forKey: "UAInAppRemoteDataClient.LastSDKVersion.Contactfoo") dataStore.setSafeCodable(remoteDataInfo, forKey: "UAInAppRemoteDataClient.LastRemoteDataInfo.Contactfoo") dataStore.setSafeCodable(remoteDataInfo, forKey: "UAInAppRemoteDataClient.LastPayloadMetadata.Contactfoo") let expected = AutomationSourceInfo( remoteDataInfo: nil, payloadTimestamp: lastPayloadTimestamp, airshipSDKVersion: "17.9.9" ) let actual = self.infoStore.getSourceInfo(source: .contact, contactID: "foo") XCTAssertEqual(expected, actual) } func testAppStoreIgnoreContactID() throws { let sourceInfo = AutomationSourceInfo( remoteDataInfo: nil, payloadTimestamp: Date(), airshipSDKVersion: "17.9.9", failedSchedules: [] ) self.infoStore.setSourceInfo(sourceInfo, source: .app, contactID: "foo") XCTAssertEqual( sourceInfo, self.infoStore.getSourceInfo(source: .app, contactID: nil) ) XCTAssertEqual( sourceInfo, self.infoStore.getSourceInfo(source: .app, contactID: "foo") ) XCTAssertEqual( sourceInfo, self.infoStore.getSourceInfo(source: .app, contactID: UUID().uuidString) ) } func testContactStoreRespectsContactID() throws { let sourceInfo = AutomationSourceInfo( remoteDataInfo: nil, payloadTimestamp: Date(), airshipSDKVersion: "17.9.9", failedSchedules: [] ) self.infoStore.setSourceInfo(sourceInfo, source: .contact, contactID: "foo") XCTAssertNil( self.infoStore.getSourceInfo(source: .contact, contactID: nil) ) XCTAssertNil( self.infoStore.getSourceInfo(source: .contact, contactID: UUID().uuidString) ) XCTAssertEqual( sourceInfo, self.infoStore.getSourceInfo(source: .contact, contactID: "foo") ) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestActionRunner.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipAutomation @testable import AirshipCore final class TestActionRunner: AutomationActionRunnerProtocol, @unchecked Sendable { var actions: AirshipJSON? var situation: ActionSituation? var metadata: [String: Sendable]? func runActions(_ actions: AirshipCore.AirshipJSON, situation: ActionSituation, metadata: [String : Sendable]) async { self.actions = actions self.situation = situation self.metadata = metadata } func runActionsAsync(_ actions: AirshipCore.AirshipJSON, situation: ActionSituation, metadata: [String : Sendable]) { self.actions = actions self.situation = situation self.metadata = metadata } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestActiveTimer.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipAutomation import AirshipCore @MainActor final class TestActiveTimer: AirshipTimerProtocol { var time: TimeInterval = 0 var isStarted: Bool = false func start() { isStarted = true } func stop() { isStarted = false } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestAutomationEngine.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @testable import AirshipAutomation @testable import AirshipCore actor TestAutomationEngine: AutomationEngineProtocol { @MainActor var isPaused: Bool = false @MainActor var isExecutionPaused: Bool = false var isStarted: Bool = false private var onUpsert: (@Sendable ([AutomationSchedule]) async throws -> Void)? private var onStop: (@Sendable ([AutomationSchedule]) async throws -> Void)? private var onCancel: (@Sendable ([AutomationSchedule]) async throws -> Void)? private(set) var cancelledSchedules: [String] = [] @MainActor func setEnginePaused(_ paused: Bool) { self.isPaused = true } @MainActor func setExecutionPaused(_ paused: Bool) { self.isExecutionPaused = true } func start() { isStarted = true } func setOnStop(_ onStop: @escaping @Sendable ([AutomationSchedule]) async throws -> Void) { self.onStop = onStop } func stopSchedules(identifiers: [String]) async throws { try await self.onStop!(schedules) } func setOnUpsert(_ onUpsert: @escaping @Sendable ([AutomationSchedule]) async throws -> Void) { self.onUpsert = onUpsert } func upsertSchedules(_ schedules: [AutomationSchedule]) async throws { self.schedules.removeAll { schedule in schedules.contains { incoming in incoming.identifier == schedule.identifier } } self.schedules.append(contentsOf: schedules) try await self.onUpsert?(schedules) } func cancelSchedule(identifier: String) async throws { } func cancelSchedules(identifiers: [String]) async throws { self.cancelledSchedules.append(contentsOf: identifiers) let set = Set(identifiers) self.schedules.removeAll(where: { set.contains($0.identifier) }) } func cancelSchedules(group: String) async throws { self.schedules.removeAll(where: { $0.group == group }) } func cancelSchedulesWith(type: AutomationSchedule.ScheduleType) async throws { self.schedules.removeAll { schedule in switch schedule.data { case .actions: return true default: return false } } } private(set) var schedules: [AutomationSchedule] = [] func setSchedules(_ schedules: [AutomationSchedule]) { self.schedules = schedules } func getSchedule(identifier: String) async throws -> AutomationSchedule? { throw AirshipErrors.error("Not implemented") } func getSchedule(identifier: String) async throws -> AutomationSchedule { throw AirshipErrors.error("Not implemented") } func getSchedules(group: String) async throws -> [AutomationSchedule] { throw AirshipErrors.error("Not implemented") } func scheduleConditionsChanged() { } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestCachedAssets.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @testable import AirshipAutomation @testable import AirshipCore final class TestCachedAssets: AirshipCachedAssetsProtocol, @unchecked Sendable { var cached: [URL] = [] func cachedURL(remoteURL: URL) -> URL? { return isCached(remoteURL: remoteURL) ? remoteURL : nil } func isCached(remoteURL: URL) -> Bool { return cached.contains(remoteURL) } } final actor TestAssetManager: AssetCacheManagerProtocol { var cleared: [String] = [] var onCache: (@Sendable (String, [String]) async throws -> any AirshipCachedAssetsProtocol)? func setOnCache(_ onCache: @escaping @Sendable (String, [String]) async throws -> any AirshipCachedAssetsProtocol) { self.onCache = onCache } func cacheAssets(identifier: String, assets: [String]) async throws -> any AirshipCachedAssetsProtocol { try await self.onCache!(identifier, assets) } func clearCache(identifier: String) async { cleared.append(identifier) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestDisplayAdapter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @testable import AirshipAutomation @testable import AirshipCore @MainActor final class TestDisplayAdapter: DisplayAdapter, @unchecked Sendable { var isReady: Bool = true func waitForReady() async { } var onDisplay: ((AirshipDisplayTarget, any InAppMessageAnalyticsProtocol) async throws -> DisplayResult)? var displayed: Bool = false func display(displayTarget: AirshipDisplayTarget, analytics: any InAppMessageAnalyticsProtocol) async throws -> DisplayResult { self.displayed = true return try await self.onDisplay!(displayTarget, analytics) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestDisplayCoordinator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @testable import AirshipAutomation @testable import AirshipCore class TestDisplayCoordinator: DisplayCoordinator { var isReady: Bool = true func messageWillDisplay(_ message: InAppMessage) { } func messageFinishedDisplaying(_ message: InAppMessage) { } func waitForReady() async { } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestFrequencyLimitsManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipAutomation @testable import AirshipCore final actor TestFrequencyLimitManager: FrequencyLimitManagerProtocol { private var constraints: [FrequencyConstraint] = [] private var checkerBlock: (@Sendable ([String]) async throws -> FrequencyCheckerProtocol)? func setCheckerBlock(_ checkerBlock: @Sendable @escaping ([String]) throws -> FrequencyCheckerProtocol) { self.checkerBlock = checkerBlock } private var onConstraints: (@Sendable ([FrequencyConstraint]) async throws -> Void)? func setOnConstraints(_ onConstraints: @escaping @Sendable ([FrequencyConstraint]) async throws -> Void) { self.onConstraints = onConstraints } func setConstraints(_ constraints: [FrequencyConstraint]) async throws { self.constraints = constraints try await onConstraints?(constraints) } func getFrequencyChecker(constraintIDs: [String]?) async throws -> FrequencyCheckerProtocol { guard let constraintIDs = constraintIDs, !constraintIDs.isEmpty else { return FrequencyChecker(isOverLimitBlock: { false }, checkAndIncrementBlock: { true }) } return try await self.checkerBlock!(constraintIDs) } } final class TestFrequencyChecker: FrequencyCheckerProtocol, @unchecked Sendable { var isOverLimit: Bool = false var checkAndIncrementBlock: (() -> Bool)? var checkAndIncrementCalled: Bool = false func checkAndIncrement() -> Bool { checkAndIncrementCalled = true return checkAndIncrementBlock!() } @MainActor func setIsOverLimit(_ isOverLimit: Bool) { self.isOverLimit = isOverLimit } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestInAppMessageAnalytics.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipAutomation @testable import AirshipCore final class TestInAppMessageAnalytics: InAppMessageAnalyticsProtocol, @unchecked Sendable { var onMakeCustomEventContext: ((ThomasLayoutContext?) -> InAppCustomEventContext?)? func makeCustomEventContext(layoutContext: ThomasLayoutContext?) -> InAppCustomEventContext? { return onMakeCustomEventContext!(layoutContext) } var events: [(ThomasLayoutEvent, ThomasLayoutContext?)] = [] var impressionsRecored: UInt = 0 func recordEvent(_ event: ThomasLayoutEvent, layoutContext: ThomasLayoutContext?) { events.append((event, layoutContext)) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestInAppMessageAutomationExecutor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipAutomation @testable import AirshipCore final class TestInAppMessageAutomationExecutor: AutomationExecutorDelegate, @unchecked Sendable { typealias ExecutionData = PreparedInAppMessageData func isReady(data: AirshipAutomation.PreparedInAppMessageData, preparedScheduleInfo: AirshipAutomation.PreparedScheduleInfo) -> AirshipAutomation.ScheduleReadyResult { return .ready } func execute(data: AirshipAutomation.PreparedInAppMessageData, preparedScheduleInfo: AirshipAutomation.PreparedScheduleInfo) async throws -> AirshipAutomation.ScheduleExecuteResult { return .finished } func interrupted(schedule: AirshipAutomation.AutomationSchedule, preparedScheduleInfo: AirshipAutomation.PreparedScheduleInfo) async -> AirshipAutomation.InterruptedBehavior { return .finish } } ================================================ FILE: Airship/AirshipAutomation/Tests/Test Utils/TestRemoteDataAccess.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @testable import AirshipAutomation @testable import AirshipCore final class TestRemoteDataAccess: AutomationRemoteDataAccessProtocol, @unchecked Sendable { func source(forSchedule schedule: AutomationSchedule) -> AirshipCore.RemoteDataSource? { return .app } var isCurrentBlock: ((AutomationSchedule) async -> Bool)? var bestEffortRefreshBlock: ((AutomationSchedule) async -> Bool)? var requiresUpdateBlock: ((AutomationSchedule) async -> Bool)? var waitFullRefreshBlock: ((AutomationSchedule) async -> Void)? var contactIDBlock: ((AutomationSchedule) -> String?)? var notifiedOutdatedSchedules: [AutomationSchedule] = [] let updatesSubject = PassthroughSubject() var publisher: AnyPublisher { return updatesSubject.eraseToAnyPublisher() } func isCurrent(schedule: AutomationSchedule) async -> Bool { return await isCurrentBlock!(schedule) } func requiresUpdate(schedule: AutomationSchedule) async -> Bool { return await requiresUpdateBlock!(schedule) } func waitFullRefresh(schedule: AutomationSchedule) async { await waitFullRefreshBlock!(schedule) } func bestEffortRefresh(schedule: AutomationSchedule) async -> Bool { await bestEffortRefreshBlock!(schedule) } func notifyOutdated(schedule: AutomationSchedule) async { notifiedOutdatedSchedules.append(schedule) } func contactID(forSchedule schedule: AutomationSchedule) -> String? { return contactIDBlock!(schedule) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Utils/ActiveTimerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipAutomation @testable import AirshipCore final class ActiveTimerTest: XCTestCase { var subject: ActiveTimer! private let date = UATestDate(offset: 0, dateOverride: Date()) private let notificationCenter = NotificationCenter() private let stateTracker = TestAppStateTracker() @MainActor private func createSubject(state: AirshipCore.ApplicationState = .active) { stateTracker.currentState = state subject = ActiveTimer( appStateTracker: stateTracker, notificationCenter: AirshipNotificationCenter(notificationCenter: notificationCenter), date: date ) } @MainActor func testManualStartStopWorks() { createSubject() subject.start() date.offset = 2 XCTAssertEqual(2, subject.time) date.offset = 3 subject.stop() XCTAssertEqual(3, subject.time) } @MainActor func testMultipleSessions() { createSubject() subject.start() date.offset = 1 XCTAssertEqual(1, subject.time) subject.stop() date.offset += 1 XCTAssertEqual(1, subject.time) subject.start() date.offset += 2 subject.stop() XCTAssertEqual(3, subject.time) date.offset += 1 XCTAssertEqual(3, subject.time) } @MainActor func testStartDoesntWorkIfAppInBackground() { createSubject(state: .background) subject.start() date.offset = 2 XCTAssertEqual(0, subject.time) } @MainActor func testDoubleStartDoesntRestCounter() { createSubject() subject.start() date.offset = 2 XCTAssertEqual(2, subject.time) date.offset = 3 subject.start() date.offset = 2 subject.stop() XCTAssertEqual(2, subject.time) } @MainActor func testDoubleStopDoesntDoubleCounter() { createSubject() subject.start() date.offset = 3 subject.stop() XCTAssertEqual(3, subject.time) date.offset = 5 subject.stop() XCTAssertEqual(3, subject.time) } @MainActor func testHandlingAppState() { createSubject(state: .background) subject.start() date.offset = 3 XCTAssertEqual(0, subject.time) notificationCenter.post(name: AppStateTracker.didBecomeActiveNotification, object: nil) date.offset += 3 XCTAssertEqual(3, subject.time) notificationCenter.post(name: AppStateTracker.willResignActiveNotification, object: nil) date.offset = 5 XCTAssertEqual(3, subject.time) } @MainActor func testActiveNotificationDoesNothingOnDisabledTimer() { createSubject(state: .background) XCTAssertEqual(0, subject.time) notificationCenter.post(name: AppStateTracker.didBecomeActiveNotification, object: nil) date.offset += 3 XCTAssertEqual(0, subject.time) } @MainActor func testTimerStopsOnEnteringBackground() { createSubject() subject.start() date.offset = 2 XCTAssertEqual(2, subject.time) notificationCenter.post(name: AppStateTracker.willResignActiveNotification, object: nil) date.offset = 5 XCTAssertEqual(2, subject.time) subject.stop() XCTAssertEqual(2, subject.time) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Utils/AirshipAsyncSemaphoreTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipAutomation @testable import AirshipCore struct AirshipAsyncSemaphoreTest { @Test func testPermitAllowsExecution() async throws { let semaphore = AirshipAsyncSemaphore(value: 1) let result = try await semaphore.withPermit { return "success" } #expect(result == "success") } @Test func testMutualExclusionWithOnePermit() async throws { let semaphore = AirshipAsyncSemaphore(value: 1) let concurrent: AirshipActorValue = AirshipActorValue(0) let maxConcurrent: AirshipActorValue = AirshipActorValue(0) let completedCount: AirshipActorValue = AirshipActorValue(0) async let first: () = semaphore.withPermit { let current = await concurrent.getAndUpdate { $0 += 1 } await maxConcurrent.update { $0 = max($0, current) } try await Task.sleep(nanoseconds: 50_000_000) await concurrent.update { $0 -= 1 } await completedCount.update { $0 += 1 } } async let second: () = semaphore.withPermit { let current = await concurrent.getAndUpdate { $0 += 1 } await maxConcurrent.update { $0 = max($0, current) } try await Task.sleep(nanoseconds: 50_000_000) await concurrent.update { $0 -= 1 } await completedCount.update { $0 += 1 } } async let third: () = semaphore.withPermit { let current = await concurrent.getAndUpdate { $0 += 1 } await maxConcurrent.update { $0 = max($0, current) } try await Task.sleep(nanoseconds: 50_000_000) await concurrent.update { $0 -= 1 } await completedCount.update { $0 += 1 } } _ = try await (first, second, third) await #expect(maxConcurrent.value <= 1) await #expect(completedCount.value == 3) } @Test func testConcurrencyLimitWithTwoPermits() async throws { let semaphore = AirshipAsyncSemaphore(value: 2) let concurrent: AirshipActorValue = AirshipActorValue(0) let maxConcurrent: AirshipActorValue = AirshipActorValue(0) let completedCount: AirshipActorValue = AirshipActorValue(0) async let first: () = semaphore.withPermit { let current = await concurrent.getAndUpdate { $0 += 1 } await maxConcurrent.update { $0 = max($0, current) } try await Task.sleep(nanoseconds: 50_000_000) await concurrent.update { $0 -= 1 } await completedCount.update { $0 += 1 } } async let second: () = semaphore.withPermit { let current = await concurrent.getAndUpdate { $0 += 1 } await maxConcurrent.update { $0 = max($0, current) } try await Task.sleep(nanoseconds: 50_000_000) await concurrent.update { $0 -= 1 } await completedCount.update { $0 += 1 } } async let third: () = semaphore.withPermit { let current = await concurrent.getAndUpdate { $0 += 1 } await maxConcurrent.update { $0 = max($0, current) } try await Task.sleep(nanoseconds: 50_000_000) await concurrent.update { $0 -= 1 } await completedCount.update { $0 += 1 } } _ = try await (first, second, third) await #expect(maxConcurrent.value <= 2) await #expect(completedCount.value == 3) } } ================================================ FILE: Airship/AirshipAutomation/Tests/Utils/RetryingQueueTests.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore @testable import AirshipAutomation final class RetryingQueueTests: XCTestCase { private let taskSleeper: TestTaskSleeper = TestTaskSleeper() func testState() async throws { let queue = RetryingQueue( id: "test", taskSleeper: taskSleeper ) let result = await queue.run(name: "testState") { state in let runCount: Int = await state.value(key: "runCount") ?? 1 await state.setValue(runCount + 1, key: "runCount") if (runCount == 6) { return .success(result: runCount, ignoreReturnOrder: true) } return .retry } XCTAssertEqual(6, result) } func testExecutionOrderPriorities() async throws { let queue = RetryingQueue( id: "test", maxConcurrentOperations: 1 ) let numbers = await withTaskGroup(of: Int.self) { group in group.addTask { return await queue.run(name: "3", priority: 3) { _ in try await Task.sleep(nanoseconds: 1_000_000) return .success(result: 3, ignoreReturnOrder: false) } } group.addTask { return await queue.run(name: "2", priority: 2) { _ in try await Task.sleep(nanoseconds: 1_000_000) return .success(result: 2, ignoreReturnOrder: false) } } group.addTask { return await queue.run(name: "1", priority: 1) { _ in try await Task.sleep(nanoseconds: 1_000_000) return .success(result: 1, ignoreReturnOrder: false) } } var result = [Int]() for await item in group { result.append(item) } return result } XCTAssertEqual([1, 2, 3], numbers) } func testRetryAfter0() async throws { let queue = RetryingQueue( id: "test", initialBackOff: 10, maxBackOff: 60, taskSleeper: taskSleeper ) let result = await queue.run(name: "testRetryAfter0") { state in let runCount: Int = await state.value(key: "runCount") ?? 1 await state.setValue(runCount + 1, key: "runCount") if (runCount == 1) { return .retryAfter(0) } if (runCount == 3) { return .success(result: 0, ignoreReturnOrder: true) } return .retry } XCTAssertEqual(0, result) XCTAssertEqual([0, 10], self.taskSleeper.sleeps) } func testBackOff() async throws { let queue = RetryingQueue( id: "test", initialBackOff: 10, maxBackOff: 60, taskSleeper: taskSleeper ) let result = await queue.run(name: "testBackOff") { state in let runCount: Int = await state.value(key: "runCount") ?? 1 await state.setValue(runCount + 1, key: "runCount") if (runCount == 6) { return .success(result: 0, ignoreReturnOrder: true) } return .retry } XCTAssertEqual(0, result) XCTAssertEqual([10, 20, 40, 60, 60], self.taskSleeper.sleeps) } func testRetryAfterCanExceedMaxBackOff() async throws { let queue = RetryingQueue( id: "test", initialBackOff: 10, maxBackOff: 60, taskSleeper: taskSleeper ) let result = await queue.run(name: "testRetryAfterCanExceedMaxBackOff") { state in let runCount: Int = await state.value(key: "runCount") ?? 1 await state.setValue(runCount + 1, key: "runCount") if (runCount == 2) { return .retryAfter(10000) } if (runCount == 4) { return .success(result: 0, ignoreReturnOrder: true) } return .retry } XCTAssertEqual(0, result) XCTAssertEqual([10, 10000, 60], self.taskSleeper.sleeps) } func testThrowsRetries() async throws { let queue = RetryingQueue( id: "test", initialBackOff: 10, maxBackOff: 60, taskSleeper: taskSleeper ) let result = await queue.run(name: "testRetryAfterCanExceedMaxBackOff") { state in let isFirstRun: Bool = await state.value(key: "isFirstRun") ?? true await state.setValue(false, key: "isFirstRun") if (isFirstRun) { throw AirshipErrors.error("failed") } return .success(result: 0) } XCTAssertEqual(0, result) XCTAssertEqual([10], self.taskSleeper.sleeps) } func testDeadLock() async throws { let queue = RetryingQueue( id: "test", maxConcurrentOperations: 1, maxPendingResults: 1 ) let coordinator = DeadlockTestCoordinator() let expectationA = XCTestExpectation(description: "Task A completed") let expectationB = XCTestExpectation(description: "Task B completed") Task { let result = await queue.run(name: "Task A", priority: 10) { _ in print("\(Date()): Task A: Started work.") await coordinator.signalTaskBShouldBeAdded() await coordinator.waitForTaskAFinishWork() return .success(result: "A") } XCTAssertEqual(result, "A") expectationA.fulfill() } await coordinator.waitForTaskBToBeAdded() Task { let result = await queue.run(name: "Task B", priority: 0) { _ in return .success(result: "B") } XCTAssertEqual(result, "B") expectationB.fulfill() } await coordinator.signalTaskAFinishWork() await fulfillment(of: [expectationA, expectationB], timeout: 2.0) } func testRetryDoesNotBlock() async throws { let queue = RetryingQueue( id: "test", maxConcurrentOperations: 3, initialBackOff: 10 ) let taskNumber = ActorValue(1) let startedTasks = ActorValue(0) let results = ActorValue<[Int]>([]) let completed = expectation(description: "Completed") for _ in 1...2 { Task { @MainActor in let myTaskNumber = await taskNumber.getAndUpdate { task in task + 1 } let result = await queue.run(name: "Task \(myTaskNumber)") { state in let isFirstRun = await state.value(key: "isFirstRun") ?? true await state.setValue(false, key: "isFirstRun") if (isFirstRun) { await startedTasks.update { task in task + 1 } } while (await startedTasks.get() != 2) { await Task.yield() } if (myTaskNumber == 1 && isFirstRun) { return .retryAfter(0.2) } return .success(result: myTaskNumber) } await results.update { current in var current = current current.append(result) defer { if (current.count == 2) { completed.fulfill() } } return current } } } await fulfillment(of: [completed]) let resultsValue = await results.get() XCTAssertEqual(resultsValue, [2,1]) } } actor ActorValue { private var value: T init(_ value: T) { self.value = value } func set(_ value: T) { self.value = value } func get() -> T { return value } func getAndUpdate(block: @Sendable (T) -> T) -> T { let value = value self.value = block(self.value) return value } func update(block: @Sendable (T) -> T) { self.value = block(self.value) } } final class TestTaskSleeper : AirshipTaskSleeper, @unchecked Sendable { var sleeps : [TimeInterval] = [] func sleep(timeInterval: TimeInterval) async throws { sleeps.append(timeInterval) try await self.onSleep?(sleeps) await Task.yield() } var onSleep: (([TimeInterval]) async throws -> Void)? } private actor DeadlockTestCoordinator { private var taskBShouldBeAddedContinuation: CheckedContinuation? private var taskAFinishWorkContinuation: CheckedContinuation? func waitForTaskBToBeAdded() async { await withCheckedContinuation { continuation in self.taskBShouldBeAddedContinuation = continuation } } func signalTaskBShouldBeAdded() { taskBShouldBeAddedContinuation?.resume() } func waitForTaskAFinishWork() async { await withCheckedContinuation { continuation in self.taskAFinishWorkContinuation = continuation } } func signalTaskAFinishWork() { taskAFinishWorkContinuation?.resume() } } ================================================ FILE: Airship/AirshipBasement/Info.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) ================================================ FILE: Airship/AirshipBasement/Source/AirshipLogHandler.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Protocol used by Airship to log all log messages within the SDK. /// A custom log handlers should be set on `AirshipConfig.logHandler`. public protocol AirshipLogHandler: Sendable { /// Called to log a message. /// - Parameters: /// - logLevel: The Airship log level. /// - message: The log message. /// - fileID: The file ID. /// - line: The line number. /// - function: The function. func log( logLevel: AirshipLogLevel, message: String, fileID: String, line: UInt, function: String ) } ================================================ FILE: Airship/AirshipBasement/Source/AirshipLogPrivacyLevel.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import os /// Represents the possible log privacy level. public enum AirshipLogPrivacyLevel: String, Sendable, Decodable { /** * Private log privacy level. Set by default. */ case `private` = "private" /** * Public log privacy level. Logs publicly when set via the AirshipConfig. */ case `public` = "public" public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() do { let stringValue = try container.decode(String.self) switch(stringValue) { case "private": self = .private case "public": self = .public default: self = .private } } catch { guard let intValue = try? container.decode(Int.self) else { throw error } switch(intValue) { case 0: self = .private case 1: self = .public default: throw error } } } } ================================================ FILE: Airship/AirshipBasement/Source/AirshipLogger.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import os /// /// Airship logger. /// /// - Note: For internal use only. :nodoc: public final class AirshipLogger: Sendable { // Configuration for the logger private static let configuration: Configuration = Configuration() static var logLevel: AirshipLogLevel { return configuration.storage.logLevel } static var logHandler: any AirshipLogHandler { return configuration.storage.handler } /// Configures the logger. Called once during takeOff before we use the logger, so it should be /// thread safe by convention. If we run into issues with this, we may need to introduce locking or /// create a single instance that we inject everywhere. /// - Parameters: /// - logLevel: The log level /// - handler: The log handler @MainActor @_spi(AirshipInternal) public static func configure( logLevel: AirshipLogLevel, handler: (any AirshipLogHandler) ) { configuration.configure(logLevel: logLevel, handler: handler) } fileprivate final class Configuration: @unchecked Sendable { struct Storage: Sendable { var logLevel: AirshipLogLevel var handler: any AirshipLogHandler } var storage: Storage = .init( logLevel: .error, handler: DefaultLogHandler(privacyLevel: .private) ) @MainActor func configure( logLevel: AirshipLogLevel, handler: (any AirshipLogHandler) ) { let storage = Storage(logLevel: logLevel, handler: handler) // Replace both logLevel and handler at the same time self.storage = storage } } public static func trace( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: AirshipLogLevel.verbose, message: message(), fileID: fileID, line: line, function: function ) } public static func debug( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: AirshipLogLevel.debug, message: message(), fileID: fileID, line: line, function: function ) } public static func info( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: AirshipLogLevel.info, message: message(), fileID: fileID, line: line, function: function ) } public static func importantInfo( _ message: String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: AirshipLogLevel.info, message: message, fileID: fileID, line: line, function: function, skipLogLevelCheck: true ) } public static func warn( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: AirshipLogLevel.warn, message: message(), fileID: fileID, line: line, function: function ) } public static func error( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: AirshipLogLevel.error, message: message(), fileID: fileID, line: line, function: function ) } public static func impError( _ message: @autoclosure () -> String, skipLogLevelCheck: Bool = true, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: AirshipLogLevel.error, message: "🚨Airship Implementation Error🚨: \(message())", fileID: fileID, line: line, function: function, skipLogLevelCheck: skipLogLevelCheck ) } static func log( logLevel: AirshipLogLevel, message: @autoclosure () -> String, fileID: String, line: UInt, function: String, skipLogLevelCheck: Bool = false ) { guard self.logLevel != .none, self.logLevel != .undefined else { return } if skipLogLevelCheck || self.logLevel.intValue >= logLevel.intValue { logHandler.log( logLevel: logLevel, message: message(), fileID: fileID, line: line, function: function ) } } } fileprivate extension AirshipLogLevel { var intValue: Int { switch(self) { case .undefined: -1 case .none: 0 case .error: 1 case .warn: 2 case .info: 3 case .debug: 4 case .verbose: 5 } } } ================================================ FILE: Airship/AirshipBasement/Source/DefaultLogHandler.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import os /// Default log handler. Logs to either os.Logger or just prints depending on OS version. @_spi(AirshipInternal) public final class DefaultLogHandler: AirshipLogHandler { private let privacyLevel: AirshipLogPrivacyLevel public init(privacyLevel: AirshipLogPrivacyLevel) { self.privacyLevel = privacyLevel } private static let logger: Logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "", category: "Airship" ) public func log( logLevel: AirshipLogLevel, message: String, fileID: String, line: UInt, function: String ) { let logMessage = "[\(logLevel.icon)] [\(logLevel.initial)] \(fileID) \(function) [Line \(line)] \(message)" switch self.privacyLevel { case .private: DefaultLogHandler.logger.log( level: logLevel.logType, "\(logMessage, privacy: .private)" ) case .public: DefaultLogHandler.logger.notice( "\(logMessage, privacy: .public)" ) } } } extension AirshipLogLevel { fileprivate var initial: String { switch self { case .verbose: return "V" case .debug: return "D" case .info: return "I" case .warn: return "W" case .error: return "E" default: return "U" } } var icon: String { switch(self) { case .error: return "❌" case .warn: return "⚠️" case .info: return "🔹" case .debug: return "🛠️" case .verbose: return "📖" default: return "" } } fileprivate var logType: OSLogType { switch self { case .verbose: return OSLogType.debug case .debug: return OSLogType.debug case .info: return OSLogType.info case .warn: return OSLogType.default case .error: return OSLogType.error default: return OSLogType.default } } } ================================================ FILE: Airship/AirshipBasement/Source/LogLevel.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Represents the possible log levels. public enum AirshipLogLevel: String, Sendable, Decodable { /** * Undefined log level. */ case undefined /** * No log messages. */ case none /** * Log error messages. * * Used for critical errors, parse exceptions and other situations that cannot be gracefully handled. */ case error /** * Log warning messages. * * Used for API deprecations, invalid setup and other potentially problematic situations. */ case warn /** * Log informative messages. * * Used for reporting general SDK status. */ case info /** * Log debugging messages. * * Used for reporting general SDK status with more detailed information. */ case debug /** * Log detailed verbose messages. * * Used for reporting highly detailed SDK status that can be useful when debugging and troubleshooting. */ case verbose public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() do { let stringValue = try container.decode(String.self) switch(stringValue.lowercased()) { case "undefined": self = .undefined case "none": self = .none case "error": self = .error case "warn": self = .warn case "info": self = .info case "debug": self = .debug case "verbose": self = .verbose default: self = .undefined } } catch { guard let intValue = try? container.decode(Int.self) else { throw error } switch(intValue) { case -1: self = .undefined case 0: self = .none case 1: self = .error case 2: self = .warn case 3: self = .info case 4: self = .debug case 5: self = .verbose default: throw error } } } } ================================================ FILE: Airship/AirshipConfig.xcconfig ================================================ //* Copyright Airship and Contributors */ CURRENT_PROJECT_VERSION = 20.6.2 ================================================ FILE: Airship/AirshipCore/Info.plist ================================================ CFBundleDevelopmentRegion en CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 CFBundleName $(PRODUCT_NAME) CFBundlePackageType FMWK CFBundleShortVersionString 1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) ================================================ FILE: Airship/AirshipCore/Resources/PrivacyInfo.xcprivacy ================================================ NSPrivacyCollectedDataTypes NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeProductInteraction NSPrivacyCollectedDataTypeLinked NSPrivacyCollectedDataTypeTracking NSPrivacyCollectedDataTypePurposes NSPrivacyCollectedDataTypePurposeAnalytics NSPrivacyCollectedDataTypePurposeDeveloperAdvertising NSPrivacyCollectedDataTypePurposeProductPersonalization NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeOtherDataTypes NSPrivacyCollectedDataTypeLinked NSPrivacyCollectedDataTypeTracking NSPrivacyCollectedDataTypePurposes NSPrivacyCollectedDataTypePurposeAnalytics NSPrivacyCollectedDataTypePurposeProductPersonalization NSPrivacyCollectedDataType NSPrivacyCollectedDataTypeUserID NSPrivacyCollectedDataTypeLinked NSPrivacyCollectedDataTypeTracking NSPrivacyCollectedDataTypePurposes NSPrivacyCollectedDataTypePurposeDeveloperAdvertising NSPrivacyCollectedDataTypePurposeAppFunctionality NSPrivacyTracking NSPrivacyAccessedAPITypes NSPrivacyAccessedAPIType NSPrivacyAccessedAPICategoryUserDefaults NSPrivacyAccessedAPITypeReasons CA92.1 NSPrivacyAccessedAPIType NSPrivacyAccessedAPICategoryFileTimestamp NSPrivacyAccessedAPITypeReasons C617.1 NSPrivacyTrackingDomains ================================================ FILE: Airship/AirshipCore/Resources/UAEvents.xcdatamodeld/UAEvents.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipCore/Resources/UAMeteredUsage.xcdatamodeld/UAMeteredUsage.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipCore/Resources/UANativeBridge ================================================ if (typeof UAirship === 'undefined') { UAirship = (function() { var urbanAirship = (typeof _UAirship === 'object') ? Object.create(_UAirship) : {} var actionCallbacks = {} , callbackID = 0 function invoke(url) { var f = document.createElement('iframe') f.style.display = 'none' f.src = url document.body.appendChild(f) f.parentNode.removeChild(f) } urbanAirship.close = function() { invoke('uairship://close') } urbanAirship.dismiss = function(resolution) { var encodedResolution = encodeURIComponent(JSON.stringify(resolution)) invoke('uairship://dismiss/' + encodedResolution) } urbanAirship.setNamedUser = function(namedUser) { var encodedNamedUser = encodeURIComponent(namedUser) invoke('uairship://named_user?id=' + encodedNamedUser) } urbanAirship.editTags = function() { return TagEditor() } function TagEditor() { var actions = [] var editor = {} editor.addTag = function(tag) { if (tag) { var encodedUrl = encodeURIComponent('uairship://run-basic-actions?add_tags_action=' + encodeURIComponent(tag)) actions.push(encodedUrl) } return editor } editor.removeTag = function(tag) { if (tag) { var encodedUrl = encodeURIComponent('uairship://run-basic-actions?remove_tags_action=' + encodeURIComponent(tag)) actions.push(encodedUrl) } return editor } editor.apply = function() { var url = 'uairship://multi?' var i = 0 actions.forEach(function(action) { if (i != 0) { url += '&' + action } else { url += action } i++ }) invoke(url) actions = [] return editor } return editor } urbanAirship.runAction = function(actionName, argument, callback) { var callbackKey = 'ua-cb-' + (++callbackID) actionCallbacks[callbackKey] = function(err, data) { if (!callback) { return; } if(err) { callback(err) } else { callback(null, data) } } var encodedArgument = encodeURIComponent(JSON.stringify(argument)) invoke('uairship://run-action-cb/' + actionName + '/' + encodedArgument + '/' + callbackKey) } urbanAirship.finishAction = function(err, data, callbackKey) { if(actionCallbacks[callbackKey]) { actionCallbacks[callbackKey](err, data) delete actionCallbacks[callbackKey] } } return urbanAirship })() // Fire the ready event var uaLibraryReadyEvent = document.createEvent('Event') uaLibraryReadyEvent.initEvent('ualibraryready', true, true) document.dispatchEvent(uaLibraryReadyEvent) UAirship.isReady = true } ================================================ FILE: Airship/AirshipCore/Resources/UANotificationCategories.plist ================================================ ua_accept_decline_background authenticationRequired foreground identifier accept title Accept title_resource ua_notification_button_accept authenticationRequired foreground identifier decline title Decline title_resource ua_notification_button_decline ua_accept_decline_foreground foreground identifier accept title Accept title_resource ua_notification_button_accept authenticationRequired foreground identifier decline title Decline title_resource ua_notification_button_decline ua_buy_now foreground identifier buy_now title Buy Now title_resource ua_notification_button_buy_now ua_buy_now_share foreground identifier buy_now title Buy Now title_resource ua_notification_button_buy_now foreground identifier share title Share title_resource ua_notification_button_share ua_download foreground identifier download title Download title_resource ua_notification_button_download ua_download_share foreground identifier download title Download title_resource ua_notification_button_download foreground identifier share title Share title_resource ua_notification_button_share ua_follow authenticationRequired foreground identifier follow title Follow title_resource ua_notification_button_follow ua_follow_share authenticationRequired foreground identifier follow title Follow title_resource ua_notification_button_follow foreground identifier share title Share title_resource ua_notification_button_share ua_icons_happy_sad authenticationRequired foreground identifier happy title 😀 authenticationRequired foreground identifier sad title 😞 ua_icons_up_down authenticationRequired foreground identifier up title 👍 authenticationRequired foreground identifier down title 👎 ua_like authenticationRequired foreground identifier like title Like title_resource ua_notification_button_like ua_like_dislike authenticationRequired foreground identifier like title Like title_resource ua_notification_button_like authenticationRequired foreground identifier dislike title Dislike title_resource ua_notification_button_dislike ua_like_share authenticationRequired foreground identifier like title Like title_resource ua_notification_button_like foreground identifier share title Share title_resource ua_notification_button_share ua_more_like_less_like authenticationRequired foreground identifier more_like title More Like This title_resource ua_notification_button_more_like authenticationRequired foreground identifier less_like title Less Like This title_resource ua_notification_button_less_like ua_opt_in authenticationRequired foreground identifier opt_in title Opt-in title_resource ua_notification_button_opt_in ua_opt_in_share authenticationRequired foreground identifier opt_in title Opt-in title_resource ua_notification_button_opt_in foreground identifier share title Share title_resource ua_notification_button_share ua_opt_out authenticationRequired destructive foreground identifier opt_out title Opt-out title_resource ua_notification_button_opt_out ua_opt_out_share authenticationRequired destructive foreground identifier opt_out title Opt-out title_resource ua_notification_button_opt_out foreground identifier share title Share title_resource ua_notification_button_share ua_remind_me_later authenticationRequired foreground identifier remind title Remind Me Later title_resource ua_notification_button_remind ua_remind_share authenticationRequired foreground identifier remind title Remind Me Later title_resource ua_notification_button_remind foreground identifier share title Share title_resource ua_notification_button_share ua_share foreground identifier share title Share title_resource ua_notification_button_share ua_shop_now foreground identifier shop_now title Shop Now title_resource ua_notification_button_shop_now ua_shop_now_share foreground identifier shop_now title Shop Now title_resource ua_notification_button_shop_now foreground identifier share title Share title_resource ua_notification_button_share ua_unfollow authenticationRequired destructive foreground identifier unfollow title Unfollow title_resource ua_notification_button_unfollow ua_unfollow_share authenticationRequired destructive foreground identifier unfollow title Unfollow title_resource ua_notification_button_unfollow foreground identifier share title Share title_resource ua_notification_button_share ua_yes_no_background authenticationRequired foreground identifier yes title Yes title_resource ua_notification_button_yes authenticationRequired foreground identifier no title No title_resource ua_notification_button_no ua_yes_no_foreground foreground identifier yes title Yes title_resource ua_notification_button_yes authenticationRequired foreground identifier no title No title_resource ua_notification_button_no ua_add_calendar_remind foreground identifier add_calendar title Add to Calendar title_resource ua_notification_button_add_to_calendar authenticationRequired foreground identifier remind title Remind Me Later title_resource ua_notification_button_remind ua_add foreground identifier add title Add title_resource ua_notification_button_add ua_save authenticationRequired foreground identifier save title Save title_resource ua_notification_button_save ua_follow_save authenticationRequired foreground identifier follow title Follow title_resource ua_notification_button_follow authenticationRequired foreground identifier save title Save title_resource ua_notification_button_save ua_opt_in_remind authenticationRequired foreground identifier opt_in title Opt-in title_resource ua_notification_button_opt_in authenticationRequired foreground identifier remind title Remind Me Later title_resource ua_notification_button_remind ua_more_info foreground identifier more_info title Tell Me More title_resource ua_notification_button_tell_me_more ua_rate foreground identifier rate title Rate Now title_resource ua_notification_button_rate_now ua_rate_remind foreground identifier rate title Rate Now title_resource ua_notification_button_rate_now authenticationRequired foreground identifier remind title Remind Me Later title_resource ua_notification_button_remind ua_search foreground identifier search title Search title_resource ua_notification_button_search ua_book foreground identifier book title Book Now title_resource ua_notification_button_book_now ================================================ FILE: Airship/AirshipCore/Resources/UARemoteData.xcdatamodeld/.xccurrentversion ================================================ _XCCurrentVersionName UARemoteData 4.xcdatamodel ================================================ FILE: Airship/AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 2.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 3.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 4.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipCore/Resources/UARemoteDataMappingV1toV4.xcmappingmodel/xcmapping.xml ================================================ 134481920 FF576309-6C27-47EB-974A-9FA726A81DC1 107 NSPersistenceFrameworkVersion 1344 NSStoreModelVersionChecksumKey bMpud663vz0bXQE24C6Rh4MvJ5jVnzsD2sI3njZkKbc= NSStoreModelVersionHashes XDDevAttributeMapping 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= XDDevEntityMapping qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= XDDevMappingModel EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= XDDevPropertyMapping XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= XDDevRelationshipMapping akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= NSStoreModelVersionHashesDigest +Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A== NSStoreModelVersionHashesVersion 3 NSStoreModelVersionIdentifiers remoteDataInfo type timestamp UARemoteDataMappingV1toV4 UARemoteDataStorePayload Undefined 1 UARemoteDataStorePayload 1 data AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData.xcdatamodel YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxCYAAsADAAZADUANgA3AD8AQABbAFwAXQBjAGQAcACGAIcAiACJAIoAiwCMAI0AjgCPAKgAqwCyALgAxwDWANkA6AD3APoAWgEKARkBHQEhATABNgE3AT8BTgFPAVgBYgFjAWQBZQF6AXsBgwGEAYUBkQGlAaYBpwGoAakBqgGrAawBrQG8AcsB2gHeAe0B/AH9AgwCGwIqAjYCSAJJAkoCSwJMAk0CTgJPAl4CbQJ8AosCjAKbAqoCuQLBAtYC1wLfAusC/wMOAx0DLAMwAz8DTgNdA2wDewOHA5kDqAO3A8YD1QPWA+UD9AP1BAQEGQQaBCIELgRCBFEEYARvBHMEggSRBKAErwS+BMoE3ATrBPoFCQUYBRkFKAU3BUYFRwVKBVMFVwVbBV8FZwVqBW4Fb1UkbnVsbNYADQAOAA8AEAARABIAEwAUABUAFgAXABhfEA9feGRfcm9vdFBhY2thZ2VWJGNsYXNzXV94ZF9tb2RlbE5hbWVcX3hkX2NvbW1lbnRzXxAVX2NvbmZpZ3VyYXRpb25zQnlOYW1lXxAXX21vZGVsVmVyc2lvbklkZW50aWZpZXKAAoCXgACAlICVgJbeABoAGwAcAB0AHgAfACAADgAhACIAIwAkACUAJgAnACgAKQAJACcAFQAtAC4ALwAwADEAJwAnABVfEBxYREJ1Y2tldEZvckNsYXNzZXN3YXNFbmNvZGVkXxAaWERCdWNrZXRGb3JQYWNrYWdlc3N0b3JhZ2VfEBxYREJ1Y2tldEZvckludGVyZmFjZXNzdG9yYWdlXxAPX3hkX293bmluZ01vZGVsXxAdWERCdWNrZXRGb3JQYWNrYWdlc3dhc0VuY29kZWRWX293bmVyXxAbWERCdWNrZXRGb3JEYXRhVHlwZXNzdG9yYWdlW192aXNpYmlsaXR5XxAZWERCdWNrZXRGb3JDbGFzc2Vzc3RvcmFnZVVfbmFtZV8QH1hEQnVja2V0Rm9ySW50ZXJmYWNlc3dhc0VuY29kZWRfEB5YREJ1Y2tldEZvckRhdGFUeXBlc3dhc0VuY29kZWRfEBBfdW5pcXVlRWxlbWVudElEgASAkoCQgAGABIAAgJGAkxAAgAWAA4AEgASAAFBTWUVT0wA4ADkADgA6ADwAPldOUy5rZXlzWk5TLm9iamVjdHOhADuABqEAPYAHgCVfEBhVQVJlbW90ZURhdGFTdG9yZVBheWxvYWTfEBAAQQBCAEMARAAfAEUARgAhAEcASAAOACMASQBKACYASwBMAE0AJwAnABMAUQBSAC8AJwBMAFUAOwBMAFgAWQBaXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zZHVwbGljYXRlc18QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNzdG9yYWdlW19pc0Fic3RyYWN0gAmALYAEgASAAoAKgI2ABIAJgI+ABoAJgI6ACAgSPs2SdFdvcmRlcmVk0wA4ADkADgBeAGAAPqEAX4ALoQBhgAyAJV5YRF9QU3RlcmVvdHlwZdkAHwAjAGUADgAmAGYAIQBLAGcAPQBfAEwAawAVACcALwBaAG9fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAB4ALgAmALIAAgAQIgA3TADgAOQAOAHEAewA+qQByAHMAdAB1AHYAdwB4AHkAeoAOgA+AEIARgBKAE4AUgBWAFqkAfAB9AH4AfwCAAIEAggCDAISAF4AbgByAHoAfgCGAI4AmgCqAJV8QE1hEUE1Db21wb3VuZEluZGV4ZXNfEBBYRF9QU0tfZWxlbWVudElEXxAZWERQTVVuaXF1ZW5lc3NDb25zdHJhaW50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FRIVNRV0FXYVeBV6FXwVfRV+FX8VgBWCFYQVhRWGFYgViRXIFcoVzBXOFdAV0RXSFdMV1BXWFdgV2RXaFdwV3RYcFh4WIBYiFiQWJRYmFicWKBYqFiwWLRYuFjAWMRZwFnIWdBZ2FngWeRZ6FnsWfBZ+FoAWgRaCFoQWhRaIFscWyRbLFs0WzxbQFtEW0hbTFtUW1xbYFtkW2xbcFxsXHRcfFyEXIxckFyUXJhcnFykXKxcsFy0XLxcwF28XcRdzF3UXdxd4F3kXehd7F30XfxeAF4EXgxeEF40XmxeoF7YXwxfWF+0X/xhKGG0YjRitGK8YsRizGLUYtxi4GLkYuxi8GL4YvxjBGMMYxBjFGMcYyBjNGNoY3xjhGOMY6BjqGOwY7hkTGTcZXhmCGYQZhhmIGYoZjBmOGY8ZkRmeGa8ZsRmzGbUZtxm5GbsZvRm/GdAZ0hnUGdYZ2BnaGdwZ3hngGeIaIRojGiUaJxopGioaKxosGi0aLxoxGjIaMxo1GjYadRp3Gnkaexp9Gn4afxqAGoEagxqFGoYahxqJGooayRrLGs0azxrRGtIa0xrUGtUa1xrZGtoa2xrdGt4a6xrsGu0a7xsuGzAbMhs0GzYbNxs4GzkbOhs8Gz4bPxtAG0IbQxuCG4QbhhuIG4obixuMG40bjhuQG5IbkxuUG5YblxvWG9gb2hvcG94b3xvgG+Eb4hvkG+Yb5xvoG+ob6xwqHCwcLhwwHDIcMxw0HDUcNhw4HDocOxw8HD4cPxx+HIAcghyEHIYchxyIHIkcihyMHI4cjxyQHJIckxy4HNwdAx0nHSkdKx0tHS8dMR0zHTQdNh1DHVIdVB1WHVgdWh1cHV4dYB1vHXEdcx11HXcdeR17HX0dfx2+HcAdwh3EHcYdxx3IHckdyh3MHc4dzx3QHdId0x4SHhQeFh4YHhoeGx4cHh0eHh4gHiIeIx4kHiYeJx5mHmgeah5sHm4ebx5wHnEech50HnYedx54Hnoeex66Hrwevh7AHsIewx7EHsUexh7IHsoeyx7MHs4ezx7SHxEfEx8VHxcfGR8aHxsfHB8dHx8fIR8iHyMfJR8mH2UfZx9pH2sfbR9uH28fcB9xH3MfdR92H3cfeR96H5Mf0h/UH9Yf2B/aH9sf3B/dH94f4B/iH+Mf5B/mH+cgMiBVIHUglSCXIJkgmyCdIJ8goCChIKMgpCCmIKcgqSCrIKwgrSCvILAgtSDCIMcgySDLINAg0iDUINYg+yEfIUYhaiFsIW4hcCFyIXQhdiF3IXkhhiGXIZkhmyGdIZ8hoSGjIaUhpyG4IbohvCG+IcAhwiHEIcYhyCHKIgkiCyINIg8iESISIhMiFCIVIhciGSIaIhsiHSIeIl0iXyJhImMiZSJmImciaCJpImsibSJuIm8icSJyIrEisyK1IrciuSK6IrsivCK9Ir8iwSLCIsMixSLGItMi1CLVItcjFiMYIxojHCMeIx8jICMhIyIjJCMmIycjKCMqIysjaiNsI24jcCNyI3MjdCN1I3YjeCN6I3sjfCN+I38jviPAI8IjxCPGI8cjyCPJI8ojzCPOI88j0CPSI9MkEiQUJBYkGCQaJBskHCQdJB4kICQiJCMkJCQmJCckZiRoJGokbCRuJG8kcCRxJHIkdCR2JHckeCR6JHskoCTEJOslDyURJRMlFSUXJRklGyUcJR4lKyU6JTwlPiVAJUIlRCVGJUglVyVZJVslXSVfJWElYyVlJWclpiWoJaolrCWuJa8lsCWxJbIltCW2JbcluCW6Jbsl+iX8Jf4mACYCJgMmBCYFJgYmCCYKJgsmDCYOJg8mTiZQJlImVCZWJlcmWCZZJlomXCZeJl8mYCZiJmMmoiakJqYmqCaqJqsmrCatJq4msCayJrMmtCa2Jrcmuib5Jvsm/Sb/JwEnAicDJwQnBScHJwknCicLJw0nDidNJ08nUSdTJ1UnVidXJ1gnWSdbJ10nXidfJ2EnYiehJ6MnpSenJ6knqierJ6wnrSevJ7EnsiezJ7UntifBJ8onyyfNJ9Yn4SfwJ/soCSgeKDIoSShbKGgoaShqKGwoeSh6KHsofSiKKIsojCiOKJcopiizKMIo1CjoKP8pESkaKRspHSkqKSspLCkuKS8pOClCKUkAAAAAAAACAgAAAAAAAAVzAAAAAAAAAAAAAAAAAAApUQ== AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 4.xcdatamodel YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxCxAAsADAAZADUANgA3AD8AQABbAFwAXQBjAGQAcACGAIcAiACJAIoAiwCMAI0AjgCPAKgAqwCyALgAxwDWANkA6AD3APoAWgEKARkBHQEhATABNgE3AT8BTgFPAVgBZAFlAWYBZwFoAX0BfgGGAYcBiAGUAagBqQGqAasBrAGtAa4BrwGwAb8BzgHdAeEB8AH/AgACDwIeAi0COQJLAkwCTQJOAk8CUAJRAlICYQJwAn8CjgKPAp4CrQKuAr0CxQLaAtsC4wLvAwMDEgMhAzADNANDA1IDYQNwA38DiwOdA6wDuwPKA9kD2gPpA/gEBwQcBB0EJQQxBEUEVARjBHIEdgSFBJQEowSyBMEEzQTfBO4E/QUMBRsFHAUrBToFSQVeBV8FZwVzBYcFlgWlBbQFuAXHBdYF5QX0BgMGDwYhBjAGPwZOBl0GbAZ7BnwGiwaMBo8GmAacBqAGpAasBq8Gswa0VSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgLCAAICtgK6Ar94AGgAbABwAHQAeAB8AIAAOACEAIgAjACQAJQAmACcAKAApAAkAJwAVAC0ALgAvADAAMQAnACcAFV8QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABICrgKmAAYAEgACAqoCsEACABYADgASABIAAUFNZRVPTADgAOQAOADoAPAA+V05TLmtleXNaTlMub2JqZWN0c6EAO4AGoQA9gAeAJV8QGFVBUmVtb3RlRGF0YVN0b3JlUGF5bG9hZN8QEABBAEIAQwBEAB8ARQBGACEARwBIAA4AIwBJAEoAJgBLAEwATQAnACcAEwBRAFIALwAnAEwAVQA7AEwAWABZAFpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2VbX2lzQWJzdHJhY3SACYAtgASABIACgAqApoAEgAmAqIAGgAmAp4AICBLXSp5XV29yZGVyZWTTADgAOQAOAF4AYAA+oQBfgAuhAGGADIAlXlhEX1BTdGVyZW90eXBl2QAfACMAZQAOACYAZgAhAEsAZwA9AF8ATABrABUAJwAvAFoAb18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYAHgAuACYAsgACABAiADdMAOAA5AA4AcQB7AD6pAHIAcwB0AHUAdgB3AHgAeQB6gA6AD4AQgBGAEoATgBSAFYAWqQB8AH0AfgB/AIAAgQCCAIMAhIAXgBuAHIAegB+AIYAjgCaAKoAlXxATWERQTUNvbXBvdW5kSW5k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DgawBrEAPqCggCVQ0gCsAK0GtQa2WVhEUE1Nb2RlbKMGtQa3ALFXWERNb2RlbAAIABkAIgAsADEAOgA/AFEAVgBbAF0BwgHIAeEB8wH6AggCFQItAkcCSQJLAk0CTwJRAlMCjAKrAsgC5wL5AxkDIAM+A0oDZgNsA44DrwPCA8QDxgPIA8oDzAPOA9AD0gPUA9YD2APaA9wD3gPfA+MD8AP4BAMEBgQIBAsEDQQPBCoEbQSRBLUE2AT/BR8FRgVtBY0FsQXVBeEF4wXlBecF6QXrBe0F7wXxBfMF9QX3BfkF+wX9Bf4GAwYLBhgGGwYdBiAGIgYkBjMGWAZ8BqMGxwbJBssGzQbPBtEG0wbUBtYG4wb2BvgG+gb8Bv4HAAcCBwQHBgcIBxsHHQcfByEHIwclBycHKQcrBy0HLwdFB1gHdAeRB60HwQfTB+kIAghBCEcIUAhdCGkIcwh9CIgIkwigCKgIqgisCK4IsAixCLIIswi0CLYIuAi5CLoIvAi9CMYIxwjJCNII3QjmCPUI/AkECQ0JFgkpCTIJRQlcCW4JrQmvCbEJswm1CbYJtwm4CbkJuwm9Cb4JvwnBCcIKAQoDCgUKBwoJCgoKCwoMCg0KDwoRChIKEwoVChYKHwogCiIKYQpjCmUKZwppCmoKawpsCm0KbwpxCnIKcwp1CnYKtQq3CrkKuwq9Cr4KvwrACsEKwwrFCsYKxwrJCsoK0wrUCtYLFQsXCxkLGwsdCx4LHwsgCyELIwslCyYLJwspCyoLKwtqC2wLbgtwC3ILcwt0C3ULdgt4C3oLewt8C34LfwuMC40LjguQC5kLrwu2C8MMAgwEDAYMCAwKDAsMDAwNDA4MEAwSDBMMFAwWDBcMMAwyDDQMNgw3DDkMUAxZDGcMdAyCDJcMqwzCDNQNEw0VDRcNGQ0bDRwNHQ0eDR8NIQ0jDSQNJQ0nDSgNQw1MDWENcA2FDZMNqA28DdMN5Q3yDfsN/Q3/DgEOAw4MDg4OEA4SDhQOFg4bDiUOKg45DoQOpw7HDucO6Q7rDu0O7w7xDvIO8w71DvYO+A75DvsO/Q7+Dv8PAQ8CDwcPFA8ZDxsPHQ8iDyQPJg8oDz0PUg93D5sPwg/mD+gP6g/sD+4P8A/yD/MP9RACEBMQFRAXEBkQGxAdEB8QIRAjEDQQNhA4EDoQPBA+EEAQQhBEEEYQZBCCEJUQqRC+ENsQ7xEFEUQRRhFIEUoRTBFNEU4RTxFQEVIRVBFVEVYRWBFZEZgRmhGcEZ4RoBGhEaIRoxGkEaYRqBGpEaoRrBGtEewR7hHwEfIR9BH1EfYR9xH4EfoR/BH9Ef4SABIBEg4SDxIQEhISURJTElUSVxJZEloSWxJcEl0SXxJhEmISYxJlEmYSpRKnEqkSqxKtEq4SrxKwErESsxK1ErYStxK5EroSuxL6EvwS/hMAEwITAxMEEwUTBhMIEwoTCxMMEw4TDxNOE1ATUhNUE1YTVxNYE1kTWhNcE14TXxNgE2ITYxOiE6QTphOoE6oTqxOsE60TrhOwE7ITsxO0E7YTtxPcFAAUJxRLFE0UTxRRFFMUVRRXFFgUWhRnFHYUeBR6FHwUfhSAFIIUhBSTFJUUlxSZFJsUnRSfFKEUoxTDFO4VCBUhFTsVWxV+Fb0VvxXBFcMVxRXGFccVyBXJFcsVzRXOFc8V0RXSFhEWExYVFhcWGRYaFhsWHBYdFh8WIRYiFiMWJRYmFmUWZxZpFmsWbRZuFm8WcBZxFnMWdRZ2FncWeRZ6FrkWuxa9Fr8WwRbCFsMWxBbFFscWyRbKFssWzRbOFtEXEBcSFxQXFhcYFxkXGhcbFxwXHhcgFyEXIhckFyUXZBdmF2gXahdsF20XbhdvF3AXchd0F3UXdhd4F3kXoBffF+EX4xflF+cX6BfpF+oX6xftF+8X8BfxF/MX9Bf9GAsYGBgmGDMYRhhdGG8YuhjdGP0ZHRkfGSEZIxklGScZKBkpGSsZLBkuGS8ZMRkzGTQZNRk3GTgZPRlKGU8ZURlTGVgZWhlcGV4ZgxmnGc4Z8hn0GfYZ+Bn6GfwZ/hn/GgEaDhofGiEaIxolGicaKRorGi0aLxpAGkIaRBpGGkgaShpMGk4aUBpSGpEakxqVGpcamRqaGpsanBqdGp8aoRqiGqMapRqmGuUa5xrpGusa7RruGu8a8BrxGvMa9Rr2Gvca+Rr6GzkbOxs9Gz8bQRtCG0MbRBtFG0cbSRtKG0sbTRtOG1sbXBtdG18bnhugG6IbpBumG6cbqBupG6obrBuuG68bsBuyG7Mb8hv0G/Yb+Bv6G/sb/Bv9G/4cABwCHAMcBBwGHAccRhxIHEocTBxOHE8cUBxRHFIcVBxWHFccWBxaHFscmhycHJ4coByiHKMcpBylHKYcqByqHKscrByuHK8c7hzwHPIc9Bz2HPcc+Bz5HPoc/Bz+HP8dAB0CHQMdKB1MHXMdlx2ZHZsdnR2fHaEdox2kHaYdsx3CHcQdxh3IHcodzB3OHdAd3x3hHeMd5R3nHekd6x3tHe8eLh4wHjIeNB42HjceOB45HjoePB4+Hj8eQB5CHkMegh6EHoYeiB6KHosejB6NHo4ekB6SHpMelB6WHpce1h7YHtoe3B7eHt8e4B7hHuIe5B7mHuce6B7qHusfKh8sHy4fMB8yHzMfNB81HzYfOB86HzsfPB8+Hz8fQh+BH4MfhR+HH4kfih+LH4wfjR+PH5Efkh+TH5Uflh/VH9cf2R/bH90f3h/fH+Af4R/jH+Uf5h/nH+kf6iApICsgLSAvIDEgMiAzIDQgNSA3IDkgOiA7ID0gPiCJIKwgzCDsIO4g8CDyIPQg9iD3IPgg+iD7IP0g/iEAIQIhAyEEIQYhByEMIRkhHiEgISIhJyEpISshLSFSIXYhnSHBIcMhxSHHIckhyyHNIc4h0CHdIe4h8CHyIfQh9iH4Ifoh/CH+Ig8iESITIhUiFyIZIhsiHSIfIiEiYCJiImQiZiJoImkiaiJrImwibiJwInEiciJ0InUitCK2IrgiuiK8Ir0iviK/IsAiwiLEIsUixiLIIskjCCMKIwwjDiMQIxEjEiMTIxQjFiMYIxkjGiMcIx0jKiMrIywjLiNtI28jcSNzI3UjdiN3I3gjeSN7I30jfiN/I4EjgiPBI8MjxSPHI8kjyiPLI8wjzSPPI9Ej0iPTI9Uj1iQVJBckGSQbJB0kHiQfJCAkISQjJCUkJiQnJCkkKiRpJGskbSRvJHEkciRzJHQkdSR3JHkkeiR7JH0kfiS9JL8kwSTDJMUkxiTHJMgkySTLJM0kziTPJNEk0iT3JRslQiVmJWglaiVsJW4lcCVyJXMldSWCJZElkyWVJZclmSWbJZ0lnyWuJbAlsiW0JbYluCW6JbwlviX9Jf8mASYDJgUmBiYHJggmCSYLJg0mDiYPJhEmEiZRJlMmVSZXJlkmWiZbJlwmXSZfJmEmYiZjJmUmZialJqcmqSarJq0mriavJrAmsSazJrUmtia3Jrkmuib5Jvsm/Sb/JwEnAicDJwQnBScHJwknCicLJw0nDicRJ1AnUidUJ1YnWCdZJ1onWydcJ14nYCdhJ2InZCdlJ6QnpieoJ6onrCetJ64nryewJ7IntCe1J7YnuCe5J/gn+if8J/4oACgBKAIoAygEKAYoCCgJKAooDCgNKFgoeyibKLsovSi/KMEowyjFKMYoxyjJKMoozCjNKM8o0SjSKNMo1SjWKN8o7CjxKPMo9Sj6KPwo/ikAKSUpSSlwKZQplimYKZopnCmeKaApoSmjKbApwSnDKcUpxynJKcspzSnPKdEp4inkKeYp6CnqKewp7inwKfIp9CozKjUqNyo5KjsqPCo9Kj4qPypBKkMqRCpFKkcqSCqHKokqiyqNKo8qkCqRKpIqkyqVKpcqmCqZKpsqnCrbKt0q3yrhKuMq5CrlKuYq5yrpKusq7CrtKu8q8Cr9Kv4q/ysBK0ArQitEK0YrSCtJK0orSytMK04rUCtRK1IrVCtVK5QrliuYK5ornCudK54rnyugK6IrpCulK6YrqCupK+gr6ivsK+4r8CvxK/Ir8yv0K/Yr+Cv5K/or/Cv9LDwsPixALEIsRCxFLEYsRyxILEosTCxNLE4sUCxRLJAskiyULJYsmCyZLJosmyycLJ4soCyhLKIspCylLMos7i0VLTktOy09LT8tQS1DLUUtRi1ILVUtZC1mLWgtai1sLW4tcC1yLYEtgy2FLYctiS2LLY0tjy2RLdAt0i3ULdYt2C3ZLdot2y3cLd4t4C3hLeIt5C3lLiQuJi4oLiouLC4tLi4uLy4wLjIuNC41LjYuOC45Lnguei58Ln4ugC6BLoIugy6ELoYuiC6JLooujC6NLswuzi7QLtIu1C7VLtYu1y7YLtou3C7dLt4u4C7hLyAvIi8kLyYvKC8pLyovKy8sLy4vMC8xLzIvNC81L3Qvdi94L3ovfC99L34vfy+AL4IvhC+FL4YviC+JL7Av7y/xL/Mv9S/3L/gv+S/6L/sv/S//MAAwATADMAQwDzAYMBkwGzAkMC8wPjBJMFcwbDCAMJcwqTC2MLcwuDC6MMcwyDDJMMsw2DDZMNow3DDlMPQxATEQMSIxNjFNMV8xaDFpMWsxeDF5MXoxfDF9MYYxkDGXAAAAAAAAAgIAAAAAAAAGuAAAAAAAAAAAAAAAAAAAMZ8= ================================================ FILE: Airship/AirshipCore/Resources/UARemoteDataMappingV2toV4.xcmappingmodel/xcmapping.xml ================================================ 134481920 9D2078F8-1057-4DE0-B169-596FECABDEDC 107 NSPersistenceFrameworkVersion 1344 NSStoreModelVersionChecksumKey bMpud663vz0bXQE24C6Rh4MvJ5jVnzsD2sI3njZkKbc= NSStoreModelVersionHashes XDDevAttributeMapping 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= XDDevEntityMapping qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= XDDevMappingModel EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= XDDevPropertyMapping XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= XDDevRelationshipMapping akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= NSStoreModelVersionHashesDigest +Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A== NSStoreModelVersionHashesVersion 3 NSStoreModelVersionIdentifiers remoteDataInfo timestamp data type UARemoteDataMappingV2toV4 UARemoteDataStorePayload Undefined 1 UARemoteDataStorePayload 1 AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 2.xcdatamodel YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxCxAAsADAAZADUANgA3AD8AQABbAFwAXQBjAGQAcACGAIcAiACJAIoAiwCMAI0AjgCPAKgAqwCyALgAxwDWANkA6AD3APoAWgEKARkBHQEhATABNgE3AT8BTgFPAVgBZAFlAWYBZwFoAX0BfgGGAYcBiAGUAagBqQGqAasBrAGtAa4BrwGwAb8BzgHdAeEB8AH/AgACDwIeAi0COQJLAkwCTQJOAk8CUAJRAlICYQJwAn8CjgKPAp4CrQKuAr0CxQLaAtsC4wLvAwMDEgMhAzADNANDA1IDYQNwA38DiwOdA6wDuwPKA9kD6AP3A/gEBwQcBB0EJQQxBEUEVARjBHIEdgSFBJQEowSyBMEEzQTfBO4E/QUMBRsFHAUrBToFSQVeBV8FZwVzBYcFlgWlBbQFuAXHBdYF5QX0BgMGDwYhBjAGPwZOBl0GXgZtBnwGiwaMBo8GmAacBqAGpAasBq8Gswa0VSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgLCAAICtgK6Ar94AGgAbABwAHQAeAB8AIAAOACEAIgAjACQAJQAmACcAKAApAAkAJwAVAC0ALgAvADAAMQAnACcAFV8QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABICrgKmAAYAEgACAqoCsEACABYADgASABIAAUFNZRVPTADgAOQAOADoAPAA+V05TLmtleXNaTlMub2JqZWN0c6EAO4AGoQA9gAeAJV8QGFVBUmVtb3RlRGF0YVN0b3JlUGF5bG9hZN8QEABBAEIAQwBEAB8ARQBGACEARwBIAA4AIwBJAEoAJgBLAEwATQAnACcAEwBRAFIALwAnAEwAVQA7AEwAWABZAFpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2VbX2lzQWJzdHJhY3SACYAtgASABIACgAqApoAEgAmAqIAGgAmAp4AICBLHv9r2V29yZGVyZWTTADgAOQAOAF4AYAA+oQBfgAuhAGGADIAlXlhEX1BTdGVyZW90eXBl2QAfACMAZQAOACYAZgAhAEsAZwA9AF8ATABrABUAJwAvAFoAb18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYAHgAuACYAsgACABAiADdMAOAA5AA4AcQB7AD6pAHIAcwB0AHUAdgB3AHgAeQB6gA6AD4AQgBGAEoATgBSAFYAWqQB8AH0AfgB/AIAAgQCCAIMAhIAXgBuAHIAegB+AIYAjgCaAKoAlXxATWERQTUNvbXBvdW5kSW5k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TW9kZWyjBrUGtwCxV1hETW9kZWwACAAZACIALAAxADoAPwBRAFYAWwBdAcIByAHhAfMB+gIIAhUCLQJHAkkCSwJNAk8CUQJTAowCqwLIAucC+QMZAyADPgNKA2YDbAOOA68DwgPEA8YDyAPKA8wDzgPQA9ID1APWA9gD2gPcA94D3wPjA/AD+AQDBAYECAQLBA0EDwQqBG0EkQS1BNgE/wUfBUYFbQWNBbEF1QXhBeMF5QXnBekF6wXtBe8F8QXzBfUF9wX5BfsF/QX+BgMGCwYYBhsGHQYgBiIGJAYzBlgGfAajBscGyQbLBs0GzwbRBtMG1AbWBuMG9gb4BvoG/Ab+BwAHAgcEBwYHCAcbBx0HHwchByMHJQcnBykHKwctBy8HRQdYB3QHkQetB8EH0wfpCAIIQQhHCFAIXQhpCHMIfQiICJMIoAioCKoIrAiuCLAIsQiyCLMItAi2CLgIuQi6CLwIvQjGCMcIyQjSCN0I5gj1CPwJBAkNCRYJKQkyCUUJXAluCa0JrwmxCbMJtQm2CbcJuAm5CbsJvQm+Cb8JwQnCCgEKAwoFCgcKCQoKCgsKDAoNCg8KEQoSChMKFQoWCh8KIAoiCmEKYwplCmcKaQpqCmsKbAptCm8KcQpyCnMKdQp2CrUKtwq5CrsKvQq+Cr8KwArBCsMKxQrGCscKyQrKCtMK1ArWCxULFwsZCxsLHQseCx8LIAshCyMLJQsmCycLKQsqCysLagtsC24LcAtyC3MLdAt1C3YLeAt6C3sLfAt+C38LjAuNC44LkAuZC68LtgvDDAIMBAwGDAgMCgwLDAwMDQwODBAMEgwTDBQMFgwXDDAMMgw0DDYMNww5DFAMWQxnDHQMggyXDKsMwgzUDRMNFQ0XDRkNGw0cDR0NHg0fDSENIw0kDSUNJw0oDUMNTA1hDXANhQ2TDagNvA3TDeUN8g37Df0N/w4BDgMODA4ODhAOEg4UDhYOHw4kDi4OMw5+DqEOwQ7hDuMO5Q7nDukO6w7sDu0O7w7wDvIO8w71DvcO+A75DvsO/A8BDw4PEw8VDxcPHA8eDyAPIg83D0wPcQ+VD7wP4A/iD+QP5g/oD+oP7A/tD+8P/BANEA8QERATEBUQFxAZEBsQHRAuEDAQMhA0EDYQOBA6EDwQPhBAEF4QfBCPEKMQuBDVEOkQ/xE+EUARQhFEEUYRRxFIEUkRShFMEU4RTxFQEVIRUxGSEZQRlhGYEZoRmxGcEZ0RnhGgEaIRoxGkEaYRpxHmEegR6hHsEe4R7xHwEfER8hH0EfYR9xH4EfoR+xIIEgkSChIMEksSTRJPElESUxJUElUSVhJXElkSWxJcEl0SXxJgEp8SoRKjEqUSpxKoEqkSqhKrEq0SrxKwErESsxK0ErUS9BL2EvgS+hL8Ev0S/hL/EwATAhMEEwUTBhMIEwkTSBNKE0wTThNQE1ETUhNTE1QTVhNYE1kTWhNcE10TnBOeE6ATohOkE6UTphOnE6gTqhOsE60TrhOwE7ET1hP6FCEURRRHFEkUSxRNFE8UURRSFFQUYRRwFHIUdBR2FHgUehR8FH4UjRSPFJEUkxSVFJcUmRSbFJ0UvRToFQIVGxU1FVUVeBW3FbkVuxW9Fb8VwBXBFcIVwxXFFccVyBXJFcsVzBYLFg0WDxYRFhMWFBYVFhYWFxYZFhsWHBYdFh8WIBZfFmEWYxZlFmcWaBZpFmoWaxZtFm8WcBZxFnMWdBazFrUWtxa5FrsWvBa9Fr4WvxbBFsMWxBbFFscWyBbLFwoXDBcOFxAXEhcTFxQXFRcWFxgXGhcbFxwXHhcfF14XYBdiF2QXZhdnF2gXaRdqF2wXbhdvF3AXchdzF5QX0xfVF9cX2RfbF9wX3RfeF98X4RfjF+QX5RfnF+gX8Rf/GAwYGhgnGDoYURhjGK4Y0RjxGREZExkVGRcZGRkbGRwZHRkfGSAZIhkjGSUZJxkoGSkZKxksGTEZPhlDGUUZRxlMGU4ZUBlSGXcZmxnCGeYZ6BnqGewZ7hnwGfIZ8xn1GgIaExoVGhcaGRobGh0aHxohGiMaNBo2GjgaOho8Gj4aQBpCGkQaRhqFGocaiRqLGo0ajhqPGpAakRqTGpUalhqXGpkamhrZGtsa3RrfGuEa4hrjGuQa5RrnGuka6hrrGu0a7hstGy8bMRszGzUbNhs3GzgbORs7Gz0bPhs/G0EbQhtPG1AbURtTG5IblBuWG5gbmhubG5wbnRueG6AbohujG6QbphunG+Yb6BvqG+wb7hvvG/Ab8RvyG/Qb9hv3G/gb+hv7HDocPBw+HEAcQhxDHEQcRRxGHEgcShxLHEwcThxPHI4ckBySHJQclhyXHJgcmRyaHJwcnhyfHKAcohyjHOIc5BzmHOgc6hzrHOwc7RzuHPAc8hzzHPQc9hz3HRwdQB1nHYsdjR2PHZEdkx2VHZcdmB2aHacdth24HbodvB2+HcAdwh3EHdMd1R3XHdkd2x3dHd8d4R3jHiIeJB4mHigeKh4rHiweLR4uHjAeMh4zHjQeNh43HnYeeB56Hnwefh5/HoAegR6CHoQehh6HHogeih6LHsoezB7OHtAe0h7THtQe1R7WHtge2h7bHtwe3h7fHx4fIB8iHyQfJh8nHygfKR8qHywfLh8vHzAfMh8zH3IfdB92H3gfeh97H3wffR9+H4Afgh+DH4Qfhh+HH8YfyB/KH8wfzh/PH9Af0R/SH9Qf1h/XH9gf2h/bH/QgMyA1IDcgOSA7IDwgPSA+ID8gQSBDIEQgRSBHIEggkyC2INYg9iD4IPog/CD+IQAhASECIQQhBSEHIQghCiEMIQ0hDiEQIREhGiEnISwhLiEwITUhNyE5ITshYCGEIashzyHRIdMh1SHXIdkh2yHcId4h6yH8If4iACICIgQiBiIIIgoiDCIdIh8iISIjIiUiJyIpIisiLSIvIm4icCJyInQidiJ3IngieSJ6InwifiJ/IoAigiKDIsIixCLGIsgiyiLLIswizSLOItAi0iLTItQi1iLXIxYjGCMaIxwjHiMfIyAjISMiIyQjJiMnIygjKiMrIzgjOSM6IzwjeyN9I38jgSODI4QjhSOGI4cjiSOLI4wjjSOPI5AjzyPRI9Mj1SPXI9gj2SPaI9sj3SPfI+Aj4SPjI+QkIyQlJCckKSQrJCwkLSQuJC8kMSQzJDQkNSQ3JDgkdyR5JHskfSR/JIAkgSSCJIMkhSSHJIgkiSSLJIwkyyTNJM8k0STTJNQk1STWJNck2STbJNwk3STfJOAlBSUpJVAldCV2JXgleiV8JX4lgCWBJYMlkCWfJaEloyWlJaclqSWrJa0lvCW+JcAlwiXEJcYlyCXKJcwmCyYNJg8mESYTJhQmFSYWJhcmGSYbJhwmHSYfJiAmXyZhJmMmZSZnJmgmaSZqJmsmbSZvJnAmcSZzJnQmsya1JrcmuSa7JrwmvSa+Jr8mwSbDJsQmxSbHJsgnBycJJwsnDScPJxAnEScSJxMnFScXJxgnGScbJxwnHydeJ2AnYidkJ2YnZydoJ2knaidsJ24nbydwJ3IncyeyJ7Qntie4J7onuye8J70nvifAJ8InwyfEJ8YnxygGKAgoCigMKA4oDygQKBEoEigUKBYoFygYKBooGyhmKIkoqSjJKMsozSjPKNEo0yjUKNUo1yjYKNoo2yjdKN8o4CjhKOMo5CjpKPYo+yj9KP8pBCkGKQgpCikvKVMpeimeKaApoimkKaYpqCmqKasprSm6KcspzSnPKdEp0ynVKdcp2SnbKewp7inwKfIp9Cn2Kfgp+in8Kf4qPSo/KkEqQypFKkYqRypIKkkqSypNKk4qTypRKlIqkSqTKpUqlyqZKpoqmyqcKp0qnyqhKqIqoyqlKqYq5SrnKukq6yrtKu4q7yrwKvEq8yr1KvYq9yr5KvorBysIKwkrCytKK0wrTitQK1IrUytUK1UrVitYK1orWytcK14rXyueK6AroiukK6YrpyuoK6krqiusK64rryuwK7IrsyvyK/Qr9iv4K/or+yv8K/0r/iwALAIsAywELAYsByxGLEgsSixMLE4sTyxQLFEsUixULFYsVyxYLFosWyyaLJwsniygLKIsoyykLKUspiyoLKosqyysLK4sryzULPgtHy1DLUUtRy1JLUstTS1PLVAtUi1fLW4tcC1yLXQtdi14LXotfC2LLY0tjy2RLZMtlS2XLZktmy3aLdwt3i3gLeIt4y3kLeUt5i3oLeot6y3sLe4t7y4uLjAuMi40LjYuNy44LjkuOi48Lj4uPy5ALkIuQy6CLoQuhi6ILoouiy6MLo0uji6QLpIuky6ULpYuly7WLtgu2i7cLt4u3y7gLuEu4i7kLuYu5y7oLuou6y7uLy0vLy8xLzMvNS82LzcvOC85LzsvPS8+Lz8vQS9CL4Evgy+FL4cviS+KL4svjC+NL48vkS+SL5MvlS+WL9Uv1y/ZL9sv3S/eL98v4C/hL+Mv5S/mL+cv6S/qL/Uv/i//MAEwCjAVMCQwLzA9MFIwZjB9MI8wnDCdMJ4woDCtMK4wrzCxML4wvzDAMMIwyzDaMOcw9jEIMRwxMzFFMU4xTzFRMV4xXzFgMWIxYzFsMXYxfQAAAAAAAAICAAAAAAAABrgAAAAAAAAAAAAAAAAAADGF AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 4.xcdatamodel YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxCxAAsADAAZADUANgA3AD8AQABbAFwAXQBjAGQAcACGAIcAiACJAIoAiwCMAI0AjgCPAKgAqwCyALgAxwDWANkA6AD3APoAWgEKARkBHQEhATABNgE3AT8BTgFPAVgBZAFlAWYBZwFoAX0BfgGGAYcBiAGUAagBqQGqAasBrAGtAa4BrwGwAb8BzgHdAeEB8AH/AgACDwIeAi0COQJLAkwCTQJOAk8CUAJRAlICYQJwAn8CjgKPAp4CrQKuAr0CxQLaAtsC4wLvAwMDEgMhAzADNANDA1IDYQNwA38DiwOdA6wDuwPKA9kD2gPpA/gEBwQcBB0EJQQxBEUEVARjBHIEdgSFBJQEowSyBMEEzQTfBO4E/QUMBRsFHAUrBToFSQVeBV8FZwVzBYcFlgWlBbQFuAXHBdYF5QX0BgMGDwYhBjAGPwZOBl0GbAZ7BnwGiwaMBo8GmAacBqAGpAasBq8Gswa0VSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgLCAAICtgK6Ar94AGgAbABwAHQAeAB8AIAAOACEAIgAjACQAJQAmACcAKAApAAkAJwAVAC0ALgAvADAAMQAnACcAFV8QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABICrgKmAAYAEgACAqoCsEACABYADgASABIAAUFNZRVPTADgAOQAOADoAPAA+V05TLmtleXNaTlMub2JqZWN0c6EAO4AGoQA9gAeAJV8QGFVBUmVtb3RlRGF0YVN0b3JlUGF5bG9hZN8QEABBAEIAQwBEAB8ARQBGACEARwBIAA4AIwBJAEoAJgBLAEwATQAnACcAEwBRAFIALwAnAEwAVQA7AEwAWABZAFpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2VbX2lzQWJzdHJhY3SACYAtgASABIACgAqApoAEgAmAqIAGgAmAp4AICBKZBEbMV29yZGVyZWTTADgAOQAOAF4AYAA+oQBfgAuhAGGADIAlXlhEX1BTdGVyZW90eXBl2QAfACMAZQAOACYAZgAhAEsAZwA9AF8ATABrABUAJwAvAFoAb18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYAHgAuACYAsgACABAiADdMAOAA5AA4AcQB7AD6pAHIAcwB0AHUAdgB3AHgAeQB6gA6AD4AQgBGAEoATgBSAFYAWqQB8AH0AfgB/AIAAgQCCAIMAhIAXgBuAHIAegB+AIYAjgCaAKoAlXxATWERQTUNvbXBvdW5kSW5k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DgawBrEAPqCggCVQ0gCsAK0GtQa2WVhEUE1Nb2RlbKMGtQa3ALFXWERNb2RlbAAIABkAIgAsADEAOgA/AFEAVgBbAF0BwgHIAeEB8wH6AggCFQItAkcCSQJLAk0CTwJRAlMCjAKrAsgC5wL5AxkDIAM+A0oDZgNsA44DrwPCA8QDxgPIA8oDzAPOA9AD0gPUA9YD2APaA9wD3gPfA+MD8AP4BAMEBgQIBAsEDQQPBCoEbQSRBLUE2AT/BR8FRgVtBY0FsQXVBeEF4wXlBecF6QXrBe0F7wXxBfMF9QX3BfkF+wX9Bf4GAwYLBhgGGwYdBiAGIgYkBjMGWAZ8BqMGxwbJBssGzQbPBtEG0wbUBtYG4wb2BvgG+gb8Bv4HAAcCBwQHBgcIBxsHHQcfByEHIwclBycHKQcrBy0HLwdFB1gHdAeRB60HwQfTB+kIAghBCEcIUAhdCGkIcwh9CIgIkwigCKgIqgisCK4IsAixCLIIswi0CLYIuAi5CLoIvAi9CMYIxwjJCNII3QjmCPUI/AkECQ0JFgkpCTIJRQlcCW4JrQmvCbEJswm1CbYJtwm4CbkJuwm9Cb4JvwnBCcIKAQoDCgUKBwoJCgoKCwoMCg0KDwoRChIKEwoVChYKHwogCiIKYQpjCmUKZwppCmoKawpsCm0KbwpxCnIKcwp1CnYKtQq3CrkKuwq9Cr4KvwrACsEKwwrFCsYKxwrJCsoK0wrUCtYLFQsXCxkLGwsdCx4LHwsgCyELIwslCyYLJwspCyoLKwtqC2wLbgtwC3ILcwt0C3ULdgt4C3oLewt8C34LfwuMC40LjguQC5kLrwu2C8MMAgwEDAYMCAwKDAsMDAwNDA4MEAwSDBMMFAwWDBcMMAwyDDQMNgw3DDkMUAxZDGcMdAyCDJcMqwzCDNQNEw0VDRcNGQ0bDRwNHQ0eDR8NIQ0jDSQNJQ0nDSgNQw1MDWENcA2FDZMNqA28DdMN5Q3yDfsN/Q3/DgEOAw4MDg4OEA4SDhQOFg4bDiUOKg45DoQOpw7HDucO6Q7rDu0O7w7xDvIO8w71DvYO+A75DvsO/Q7+Dv8PAQ8CDwcPFA8ZDxsPHQ8iDyQPJg8oDz0PUg93D5sPwg/mD+gP6g/sD+4P8A/yD/MP9RACEBMQFRAXEBkQGxAdEB8QIRAjEDQQNhA4EDoQPBA+EEAQQhBEEEYQZBCCEJUQqRC+ENsQ7xEFEUQRRhFIEUoRTBFNEU4RTxFQEVIRVBFVEVYRWBFZEZgRmhGcEZ4RoBGhEaIRoxGkEaYRqBGpEaoRrBGtEewR7hHwEfIR9BH1EfYR9xH4EfoR/BH9Ef4SABIBEg4SDxIQEhISURJTElUSVxJZEloSWxJcEl0SXxJhEmISYxJlEmYSpRKnEqkSqxKtEq4SrxKwErESsxK1ErYStxK5EroSuxL6EvwS/hMAEwITAxMEEwUTBhMIEwoTCxMMEw4TDxNOE1ATUhNUE1YTVxNYE1kTWhNcE14TXxNgE2ITYxOiE6QTphOoE6oTqxOsE60TrhOwE7ITsxO0E7YTtxPcFAAUJxRLFE0UTxRRFFMUVRRXFFgUWhRnFHYUeBR6FHwUfhSAFIIUhBSTFJUUlxSZFJsUnRSfFKEUoxTDFO4VCBUhFTsVWxV+Fb0VvxXBFcMVxRXGFccVyBXJFcsVzRXOFc8V0RXSFhEWExYVFhcWGRYaFhsWHBYdFh8WIRYiFiMWJRYmFmUWZxZpFmsWbRZuFm8WcBZxFnMWdRZ2FncWeRZ6FrkWuxa9Fr8WwRbCFsMWxBbFFscWyRbKFssWzRbOFtEXEBcSFxQXFhcYFxkXGhcbFxwXHhcgFyEXIhckFyUXZBdmF2gXahdsF20XbhdvF3AXchd0F3UXdhd4F3kXoBffF+EX4xflF+cX6BfpF+oX6xftF+8X8BfxF/MX9Bf9GAsYGBgmGDMYRhhdGG8YuhjdGP0ZHRkfGSEZIxklGScZKBkpGSsZLBkuGS8ZMRkzGTQZNRk3GTgZQRlOGVMZVRlXGVwZXhlgGWIZhxmrGdIZ9hn4GfoZ/Bn+GgAaAhoDGgUaEhojGiUaJxopGisaLRovGjEaMxpEGkYaSBpKGkwaThpQGlIaVBpWGpUalxqZGpsanRqeGp8aoBqhGqMapRqmGqcaqRqqGuka6xrtGu8a8RryGvMa9Br1Gvca+Rr6Gvsa/Rr+Gz0bPxtBG0MbRRtGG0cbSBtJG0sbTRtOG08bURtSG18bYBthG2MbohukG6YbqBuqG6sbrButG64bsBuyG7MbtBu2G7cb9hv4G/ob/Bv+G/8cABwBHAIcBBwGHAccCBwKHAscShxMHE4cUBxSHFMcVBxVHFYcWBxaHFscXBxeHF8cnhygHKIcpBymHKccqBypHKocrByuHK8csByyHLMc8hz0HPYc+Bz6HPsc/Bz9HP4dAB0CHQMdBB0GHQcdLB1QHXcdmx2dHZ8doR2jHaUdpx2oHaodtx3GHcgdyh3MHc4d0B3SHdQd4x3lHecd6R3rHe0d7x3xHfMeMh40HjYeOB46HjsePB49Hj4eQB5CHkMeRB5GHkcehh6IHooejB6OHo8ekB6RHpIelB6WHpcemB6aHpse2h7cHt4e4B7iHuMe5B7lHuYe6B7qHuse7B7uHu8fLh8wHzIfNB82HzcfOB85HzofPB8+Hz8fQB9CH0MfRh+FH4cfiR+LH40fjh+PH5AfkR+TH5Uflh+XH5kfmh/ZH9sf3R/fH+Ef4h/jH+Qf5R/nH+kf6h/rH+0f7iAtIC8gMSAzIDUgNiA3IDggOSA7ID0gPiA/IEEgQiCNILAg0CDwIPIg9CD2IPgg+iD7IPwg/iD/IQEhAiEEIQYhByEIIQohCyEQIR0hIiEkISYhKyEtIS8hMSFWIXohoSHFIcchySHLIc0hzyHRIdIh1CHhIfIh9CH2Ifgh+iH8If4iACICIhMiFSIXIhkiGyIdIh8iISIjIiUiZCJmImgiaiJsIm0ibiJvInAiciJ0InUidiJ4InkiuCK6IrwiviLAIsEiwiLDIsQixiLIIskiyiLMIs0jDCMOIxAjEiMUIxUjFiMXIxgjGiMcIx0jHiMgIyEjLiMvIzAjMiNxI3MjdSN3I3kjeiN7I3wjfSN/I4EjgiODI4UjhiPFI8cjySPLI80jziPPI9Aj0SPTI9Uj1iPXI9kj2iQZJBskHSQfJCEkIiQjJCQkJSQnJCkkKiQrJC0kLiRtJG8kcSRzJHUkdiR3JHgkeSR7JH0kfiR/JIEkgiTBJMMkxSTHJMkkyiTLJMwkzSTPJNEk0iTTJNUk1iT7JR8lRiVqJWwlbiVwJXIldCV2JXcleSWGJZUllyWZJZslnSWfJaEloyWyJbQltiW4JbolvCW+JcAlwiYBJgMmBSYHJgkmCiYLJgwmDSYPJhEmEiYTJhUmFiZVJlcmWSZbJl0mXiZfJmAmYSZjJmUmZiZnJmkmaiapJqsmrSavJrEmsiazJrQmtSa3Jrkmuia7Jr0mvib9Jv8nAScDJwUnBicHJwgnCScLJw0nDicPJxEnEicVJ1QnVidYJ1onXCddJ14nXydgJ2InZCdlJ2YnaCdpJ6gnqiesJ64nsCexJ7Insye0J7YnuCe5J7onvCe9J/wn/igAKAIoBCgFKAYoBygIKAooDCgNKA4oECgRKFwofyifKL8owSjDKMUoxyjJKMooyyjNKM4o0CjRKNMo1SjWKNco2SjaKN8o7CjxKPMo9Sj6KPwo/ikAKSUpSSlwKZQplimYKZopnCmeKaApoSmjKbApwSnDKcUpxynJKcspzSnPKdEp4inkKeYp6CnqKewp7inwKfIp9CozKjUqNyo5KjsqPCo9Kj4qPypBKkMqRCpFKkcqSCqHKokqiyqNKo8qkCqRKpIqkyqVKpcqmCqZKpsqnCrbKt0q3yrhKuMq5CrlKuYq5yrpKusq7CrtKu8q8Cr9Kv4q/ysBK0ArQitEK0YrSCtJK0orSytMK04rUCtRK1IrVCtVK5QrliuYK5ornCudK54rnyugK6IrpCulK6YrqCupK+gr6ivsK+4r8CvxK/Ir8yv0K/Yr+Cv5K/or/Cv9LDwsPixALEIsRCxFLEYsRyxILEosTCxNLE4sUCxRLJAskiyULJYsmCyZLJosmyycLJ4soCyhLKIspCylLMos7i0VLTktOy09LT8tQS1DLUUtRi1ILVUtZC1mLWgtai1sLW4tcC1yLYEtgy2FLYctiS2LLY0tjy2RLdAt0i3ULdYt2C3ZLdot2y3cLd4t4C3hLeIt5C3lLiQuJi4oLiouLC4tLi4uLy4wLjIuNC41LjYuOC45Lnguei58Ln4ugC6BLoIugy6ELoYuiC6JLooujC6NLswuzi7QLtIu1C7VLtYu1y7YLtou3C7dLt4u4C7hLyAvIi8kLyYvKC8pLyovKy8sLy4vMC8xLzIvNC81L3Qvdi94L3ovfC99L34vfy+AL4IvhC+FL4YviC+JL7Av7y/xL/Mv9S/3L/gv+S/6L/sv/S//MAAwATADMAQwDzAYMBkwGzAkMC8wPjBJMFcwbDCAMJcwqTC2MLcwuDC6MMcwyDDJMMsw2DDZMNow3DDlMPQxATEQMSIxNjFNMV8xaDFpMWsxeDF5MXoxfDF9MYYxkDGXAAAAAAAAAgIAAAAAAAAGuAAAAAAAAAAAAAAAAAAAMZ8= ================================================ FILE: Airship/AirshipCore/Resources/UARemoteDataMappingV3toV4.xcmappingmodel/xcmapping.xml ================================================ 134481920 C445BE09-B38D-431E-87B7-5EDA490CBA9E 107 NSPersistenceFrameworkVersion 1344 NSStoreModelVersionChecksumKey bMpud663vz0bXQE24C6Rh4MvJ5jVnzsD2sI3njZkKbc= NSStoreModelVersionHashes XDDevAttributeMapping 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= XDDevEntityMapping qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= XDDevMappingModel EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= XDDevPropertyMapping XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= XDDevRelationshipMapping akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= NSStoreModelVersionHashesDigest +Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A== NSStoreModelVersionHashesVersion 3 NSStoreModelVersionIdentifiers UARemoteDataMappingV3toV4 UARemoteDataStorePayload Undefined 1 UARemoteDataStorePayload 1 type timestamp AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 3.xcdatamodel YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxCyAAsADAAZADUANgA3AD8AQABbAFwAXQBjAGQAcACGAIcAiACJAIoAiwCMAI0AjgCPAKgAqwCyALgAxwDWANkA6AD3APoAWgEKARkBHQEhATABNgE3AT8BTgFPAVgBZAFlAWYBZwFoAX0BfgGGAYcBiAGUAagBqQGqAasBrAGtAa4BrwGwAb8BzgHdAeEB8AH/AgACDwIeAi0COQJLAkwCTQJOAk8CUAJRAlICYQJwAn8CjgKPAp4CrQKuAr0CxQLaAtsC4wLvAwMDEgMhAzADNANDA1IDYQNwA38DiwOdA6wDuwPKA9kD2gPpA/gEBwQcBB0EJQQxBEUEVARjBHIEdgSFBJQEowSyBMEEzQTfBO4E/QUMBRsFHAUrBToFSQVeBV8FZwVzBYcFlgWlBbQFuAXHBdYF5QX0BgMGDwYhBjAGPwZOBl0GXgZtBnwGfQaMBo0GkAaZBp0GoQalBq0GsAa0BrVVJG51bGzWAA0ADgAPABAAEQASABMAFAAVABYAFwAYXxAPX3hkX3Jvb3RQYWNrYWdlViRjbGFzc11feGRfbW9kZWxOYW1lXF94ZF9jb21tZW50c18QFV9jb25maWd1cmF0aW9uc0J5TmFtZV8QF19tb2RlbFZlcnNpb25JZGVudGlmaWVygAKAsYAAgK6Ar4Cw3gAaABsAHAAdAB4AHwAgAA4AIQAiACMAJAAlACYAJwAoACkACQAnABUALQAuAC8AMAAxACcAJwAVXxAcWERCdWNrZXRGb3JDbGFzc2Vzd2FzRW5jb2RlZF8QGlhEQnVja2V0Rm9yUGFja2FnZXNzdG9yYWdlXxAcWERCdWNrZXRGb3JJbnRlcmZhY2Vzc3RvcmFnZV8QD194ZF9vd25pbmdNb2RlbF8QHVhEQnVja2V0Rm9yUGFja2FnZXN3YXNFbmNvZGVkVl9vd25lcl8QG1hEQnVja2V0Rm9yRGF0YVR5cGVzc3RvcmFnZVtfdmlzaWJpbGl0eV8QGVhEQnVja2V0Rm9yQ2xhc3Nlc3N0b3JhZ2VVX25hbWVfEB9YREJ1Y2tldEZvckludGVyZmFjZXN3YXNFbmNvZGVkXxAeWERCdWNrZXRGb3JEYXRhVHlwZXN3YXNFbmNvZGVkXxAQX3VuaXF1ZUVsZW1lbnRJRIAEgKyAqoABgASAAICrgK0QAIAFgAOABIAEgABQU1lFU9MAOAA5AA4AOgA8AD5XTlMua2V5c1pOUy5vYmplY3RzoQA7gAahAD2AB4AlXxAYVUFSZW1vdGVEYXRhU3RvcmVQYXlsb2Fk3xAQAEEAQgBDAEQAHwBFAEYAIQBHAEgADgAjAEkASgAmAEsATABNACcAJwATAFEAUgAvACcATABVADsATABYAFkAWl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAJgC2ABIAEgAKACoCngASACYCpgAaACYCogAgIElEqgV5Xb3JkZXJlZNMAOAA5AA4AXgBgAD6hAF+AC6EAYYAMgCVeWERfUFN0ZXJlb3R5cGXZAB8AIwBlAA4AJgBmACEASwBnAD0AXwBMAGsAFQAnAC8AWgBvXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgAeAC4AJgCyAAIAECIAN0wA4ADkADgBxAHsAPqkAcgBzAHQAdQB2AHcAeAB5AHqADoAPgBCAEYASgBOAFIAVgBapAHwAfQB+AH8AgACBAIIAgwCEgBeAG4AcgB6AH4AhgCOAJoAqgCVfEBNYRFBNQ29tcG91bmRJ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AK0Gtga3WVhEUE1Nb2RlbKMGtga4ALFXWERNb2RlbAAIABkAIgAsADEAOgA/AFEAVgBbAF0BxAHKAeMB9QH8AgoCFwIvAkkCSwJNAk8CUQJTAlUCjgKtAsoC6QL7AxsDIgNAA0wDaANuA5ADsQPEA8YDyAPKA8wDzgPQA9ID1APWA9gD2gPcA94D4APhA+UD8gP6BAUECAQKBA0EDwQRBCwEbwSTBLcE2gUBBSEFSAVvBY8FswXXBeMF5QXnBekF6wXtBe8F8QXzBfUF9wX5BfsF/QX/BgAGBQYNBhoGHQYfBiIGJAYmBjUGWgZ+BqUGyQbLBs0GzwbRBtMG1QbWBtgG5Qb4BvoG/Ab+BwAHAgcEBwYHCAcKBx0HHwchByMHJQcnBykHKwctBy8HMQdHB1oHdgeTB68HwwfVB+sIBAhDCEkIUghfCGsIdQh/CIoIlQiiCKoIrAiuCLAIsgizCLQItQi2CLgIugi7CLwIvgi/CMgIyQjLCNQI3wjoCPcI/gkGCQ8JGAkrCTQJRwleCXAJrwmxCbMJtQm3CbgJuQm6CbsJvQm/CcAJwQnDCcQKAwoFCgcKCQoLCgwKDQoOCg8KEQoTChQKFQoXChgKIQoiCiQKYwplCmcKaQprCmwKbQpuCm8KcQpzCnQKdQp3CngKtwq5CrsKvQq/CsAKwQrCCsMKxQrHCsgKyQrLCswK1QrWCtgLFwsZCxsLHQsfCyALIQsiCyMLJQsnCygLKQsrCywLLQtsC24LcAtyC3QLdQt2C3cLeAt6C3wLfQt+C4ALgQuOC48LkAuSC5sLsQu4C8UMBAwGDAgMCgwMDA0MDgwPDBAMEgwUDBUMFgwYDBkMMgw0DDYMOAw5DDsMUgxbDGkMdgyEDJkMrQzEDNYNFQ0XDRkNGw0dDR4NHw0gDSENIw0lDSYNJw0pDSoNRQ1ODWMNcg2HDZUNqg2+DdUN5w30Df0N/w4BDgMOBQ4ODhAOEg4UDhYOGA4dDicOLA47DoYOqQ7JDukO6w7tDu8O8Q7zDvQO9Q73DvgO+g77Dv0O/w8ADwEPAw8EDwkPFg8bDx0PHw8kDyYPKA8qDz8PVA95D50PxA/oD+oP7A/uD/AP8g/0D/UP9xAEEBUQFxAZEBsQHRAfECEQIxAlEDYQOBA6EDwQPhBAEEIQRBBGEEgQZhCEEJcQqxDAEN0Q8REHEUYRSBFKEUwRThFPEVARURFSEVQRVhFXEVgRWhFbEZoRnBGeEaARohGjEaQRpRGmEagRqhGrEawRrhGvEe4R8BHyEfQR9hH3EfgR+RH6EfwR/hH/EgASAhIDEhASERISEhQSUxJVElcSWRJbElwSXRJeEl8SYRJjEmQSZRJnEmgSpxKpEqsSrRKvErASsRKyErMStRK3ErgSuRK7ErwSvRL8Ev4TABMCEwQTBRMGEwcTCBMKEwwTDRMOExATERNQE1ITVBNWE1gTWRNaE1sTXBNeE2ATYRNiE2QTZROkE6YTqBOqE6wTrROuE68TsBOyE7QTtRO2E7gTuRPeFAIUKRRNFE8UURRTFFUUVxRZFFoUXBRpFHgUehR8FH4UgBSCFIQUhhSVFJcUmRSbFJ0UnxShFKMUpRTFFPAVChUjFT0VXRWAFb8VwRXDFcUVxxXIFckVyhXLFc0VzxXQFdEV0xXUFhMWFRYXFhkWGxYcFh0WHhYfFiEWIxYkFiUWJxYoFmcWaRZrFm0WbxZwFnEWchZzFnUWdxZ4FnkWexZ8FrsWvRa/FsEWwxbEFsUWxhbHFskWyxbMFs0WzxbQFtMXEhcUFxYXGBcaFxsXHBcdFx4XIBciFyMXJBcmFycXZhdoF2oXbBduF28XcBdxF3IXdBd2F3cXeBd6F3sXlBfTF9UX1xfZF9sX3BfdF94X3xfhF+MX5BflF+cX6BfxF/8YDBgaGCcYOhhRGGMYrhjRGPEZERkTGRUZFxkZGRsZHBkdGR8ZIBkiGSMZJRknGSgZKRkrGSwZMRk+GUMZRRlHGUwZThlQGVIZdxmbGcIZ5hnoGeoZ7BnuGfAZ8hnzGfUaAhoTGhUaFxoZGhsaHRofGiEaIxo0GjYaOBo6GjwaPhpAGkIaRBpGGoUahxqJGosajRqOGo8akBqRGpMalRqWGpcamRqaGtka2xrdGt8a4RriGuMa5BrlGuca6RrqGusa7RruGy0bLxsxGzMbNRs2GzcbOBs5GzsbPRs+Gz8bQRtCG08bUBtRG1MbkhuUG5YbmBuaG5sbnBudG54boBuiG6MbpBumG6cb5hvoG+ob7BvuG+8b8BvxG/Ib9Bv2G/cb+Bv6G/scOhw8HD4cQBxCHEMcRBxFHEYcSBxKHEscTBxOHE8cjhyQHJIclByWHJccmByZHJocnByeHJ8coByiHKMc4hzkHOYc6BzqHOsc7BztHO4c8BzyHPMc9Bz2HPcdHB1AHWcdix2NHY8dkR2THZUdlx2YHZodpx22Hbgduh28Hb4dwB3CHcQd0x3VHdcd2R3bHd0d3x3hHeMeIh4kHiYeKB4qHiseLB4tHi4eMB4yHjMeNB42Hjcedh54HnoefB5+Hn8egB6BHoIehB6GHoceiB6KHoseyh7MHs4e0B7SHtMe1B7VHtYe2B7aHtse3B7eHt8fHh8gHyIfJB8mHycfKB8pHyofLB8uHy8fMB8yHzMfNh91H3cfeR97H30ffh9/H4AfgR+DH4Ufhh+HH4kfih/JH8sfzR/PH9Ef0h/TH9Qf1R/XH9kf2h/bH90f3iAdIB8gISAjICUgJiAnICggKSArIC0gLiAvIDEgMiB9IKAgwCDgIOIg5CDmIOgg6iDrIOwg7iDvIPEg8iD0IPYg9yD4IPog+yEAIQ0hEiEUIRYhGyEdIR8hISFGIWohkSG1IbchuSG7Ib0hvyHBIcIhxCHRIeIh5CHmIegh6iHsIe4h8CHyIgMiBSIHIgkiCyINIg8iESITIhUiVCJWIlgiWiJcIl0iXiJfImAiYiJkImUiZiJoImkiqCKqIqwiriKwIrEisiKzIrQitiK4IrkiuiK8Ir0i/CL+IwAjAiMEIwUjBiMHIwgjCiMMIw0jDiMQIxEjHiMfIyAjIiNhI2MjZSNnI2kjaiNrI2wjbSNvI3EjciNzI3UjdiO1I7cjuSO7I70jviO/I8AjwSPDI8UjxiPHI8kjyiQJJAskDSQPJBEkEiQTJBQkFSQXJBkkGiQbJB0kHiRdJF8kYSRjJGUkZiRnJGgkaSRrJG0kbiRvJHEkciSxJLMktSS3JLkkuiS7JLwkvSS/JMEkwiTDJMUkxiTrJQ8lNiVaJVwlXiVgJWIlZCVmJWclaSV2JYUlhyWJJYsljSWPJZElkyWiJaQlpiWoJaolrCWuJbAlsiXxJfMl9SX3Jfkl+iX7Jfwl/SX/JgEmAiYDJgUmBiZFJkcmSSZLJk0mTiZPJlAmUSZTJlUmViZXJlkmWiaZJpsmnSafJqEmoiajJqQmpSanJqkmqiarJq0mribtJu8m8SbzJvUm9ib3Jvgm+Sb7Jv0m/ib/JwEnAicFJ0QnRidIJ0onTCdNJ04nTydQJ1InVCdVJ1YnWCdZJ5gnmiecJ54noCehJ6InoyekJ6YnqCepJ6onrCetJ+wn7ifwJ/In9Cf1J/Yn9yf4J/on/Cf9J/4oACgBKEwobyiPKK8osSizKLUotyi5KLoouyi9KL4owCjBKMMoxSjGKMcoySjKKNMo4CjlKOco6SjuKPAo8ij0KRkpPSlkKYgpiimMKY4pkCmSKZQplSmXKaQptSm3Kbkpuym9Kb8pwSnDKcUp1inYKdop3CneKeAp4inkKeYp6ConKikqKyotKi8qMCoxKjIqMyo1KjcqOCo5KjsqPCp7Kn0qfyqBKoMqhCqFKoYqhyqJKosqjCqNKo8qkCrPKtEq0yrVKtcq2CrZKtoq2yrdKt8q4CrhKuMq5CrxKvIq8yr1KzQrNis4KzorPCs9Kz4rPytAK0IrRCtFK0YrSCtJK4griiuMK44rkCuRK5IrkyuUK5YrmCuZK5ornCudK9wr3ivgK+Ir5CvlK+Yr5yvoK+or7CvtK+4r8CvxLDAsMiw0LDYsOCw5LDosOyw8LD4sQCxBLEIsRCxFLIQshiyILIosjCyNLI4sjyyQLJIslCyVLJYsmCyZLL4s4i0JLS0tLy0xLTMtNS03LTktOi08LUktWC1aLVwtXi1gLWItZC1mLXUtdy15LXstfS1/LYEtgy2FLcQtxi3ILcotzC3NLc4tzy3QLdIt1C3VLdYt2C3ZLhguGi4cLh4uIC4hLiIuIy4kLiYuKC4pLiouLC4tLmwubi5wLnIudC51LnYudy54LnoufC59Ln4ugC6BLsAuwi7ELsYuyC7JLsouyy7MLs4u0C7RLtIu1C7VLtgvFy8ZLxsvHS8fLyAvIS8iLyMvJS8nLygvKS8rLywvay9tL28vcS9zL3QvdS92L3cveS97L3wvfS9/L4AvoS/gL+Iv5C/mL+gv6S/qL+sv7C/uL/Av8S/yL/Qv9TAAMAkwCjAMMBUwIDAvMDowSDBdMHEwiDCaMKcwqDCpMKswuDC5MLowvDDJMMowyzDNMNYw5TDyMQExEzEnMT4xUDFZMVoxXDFpMWoxazFtMW4xdzGBMYgAAAAAAAACAgAAAAAAAAa5AAAAAAAAAAAAAAAAAAAxkA== AirshipCore/Resources/UARemoteData.xcdatamodeld/UARemoteData 4.xcdatamodel YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxCxAAsADAAZADUANgA3AD8AQABbAFwAXQBjAGQAcACGAIcAiACJAIoAiwCMAI0AjgCPAKgAqwCyALgAxwDWANkA6AD3APoAWgEKARkBHQEhATABNgE3AT8BTgFPAVgBZAFlAWYBZwFoAX0BfgGGAYcBiAGUAagBqQGqAasBrAGtAa4BrwGwAb8BzgHdAeEB8AH/AgACDwIeAi0COQJLAkwCTQJOAk8CUAJRAlICYQJwAn8CjgKPAp4CrQKuAr0CxQLaAtsC4wLvAwMDEgMhAzADNANDA1IDYQNwA38DiwOdA6wDuwPKA9kD2gPpA/gEBwQcBB0EJQQxBEUEVARjBHIEdgSFBJQEowSyBMEEzQTfBO4E/QUMBRsFHAUrBToFSQVeBV8FZwVzBYcFlgWlBbQFuAXHBdYF5QX0BgMGDwYhBjAGPwZOBl0GbAZ7BnwGiwaMBo8GmAacBqAGpAasBq8Gswa0VSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgLCAAICtgK6Ar94AGgAbABwAHQAeAB8AIAAOACEAIgAjACQAJQAmACcAKAApAAkAJwAVAC0ALgAvADAAMQAnACcAFV8QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABICrgKmAAYAEgACAqoCsEACABYADgASABIAAUFNZRVPTADgAOQAOADoAPAA+V05TLmtleXNaTlMub2JqZWN0c6EAO4AGoQA9gAeAJV8QGFVBUmVtb3RlRGF0YVN0b3JlUGF5bG9hZN8QEABBAEIAQwBEAB8ARQBGACEARwBIAA4AIwBJAEoAJgBLAEwATQAnACcAEwBRAFIALwAnAEwAVQA7AEwAWABZAFpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2VbX2lzQWJzdHJhY3SACYAtgASABIACgAqApoAEgAmAqIAGgAmAp4AICBLFzDydV29yZGVyZWTTADgAOQAOAF4AYAA+oQBfgAuhAGGADIAlXlhEX1BTdGVyZW90eXBl2QAfACMAZQAOACYAZgAhAEsAZwA9AF8ATABrABUAJwAvAFoAb18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYAHgAuACYAsgACABAiADdMAOAA5AA4AcQB7AD6pAHIAcwB0AHUAdgB3AHgAeQB6gA6AD4AQgBGAEoATgBSAFYAWqQB8AH0AfgB/AIAAgQCCAIMAhIAXgBuAHIAegB+AIYAjgCaAKoAlXxATWERQTUNvbXBvdW5kSW5k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sQA+oKCAJVDSAKwArQa1BrZZWERQTU1vZGVsowa1BrcAsVdYRE1vZGVsAAgAGQAiACwAMQA6AD8AUQBWAFsAXQHCAcgB4QHzAfoCCAIVAi0CRwJJAksCTQJPAlECUwKMAqsCyALnAvkDGQMgAz4DSgNmA2wDjgOvA8IDxAPGA8gDygPMA84D0APSA9QD1gPYA9oD3APeA98D4wPwA/gEAwQGBAgECwQNBA8EKgRtBJEEtQTYBP8FHwVGBW0FjQWxBdUF4QXjBeUF5wXpBesF7QXvBfEF8wX1BfcF+QX7Bf0F/gYDBgsGGAYbBh0GIAYiBiQGMwZYBnwGowbHBskGywbNBs8G0QbTBtQG1gbjBvYG+Ab6BvwG/gcABwIHBAcGBwgHGwcdBx8HIQcjByUHJwcpBysHLQcvB0UHWAd0B5EHrQfBB9MH6QgCCEEIRwhQCF0IaQhzCH0IiAiTCKAIqAiqCKwIrgiwCLEIsgizCLQItgi4CLkIugi8CL0IxgjHCMkI0gjdCOYI9Qj8CQQJDQkWCSkJMglFCVwJbgmtCa8JsQmzCbUJtgm3CbgJuQm7Cb0Jvgm/CcEJwgoBCgMKBQoHCgkKCgoLCgwKDQoPChEKEgoTChUKFgofCiAKIgphCmMKZQpnCmkKagprCmwKbQpvCnEKcgpzCnUKdgq1CrcKuQq7Cr0Kvgq/CsAKwQrDCsUKxgrHCskKygrTCtQK1gsVCxcLGQsbCx0LHgsfCyALIQsjCyULJgsnCykLKgsrC2oLbAtuC3ALcgtzC3QLdQt2C3gLegt7C3wLfgt/C4wLjQuOC5ALmQuvC7YLwwwCDAQMBgwIDAoMCwwMDA0MDgwQDBIMEwwUDBYMFwwwDDIMNAw2DDcMOQxQDFkMZwx0DIIMlwyrDMIM1A0TDRUNFw0ZDRsNHA0dDR4NHw0hDSMNJA0lDScNKA1DDUwNYQ1wDYUNkw2oDbwN0w3lDfIN+w39Df8OAQ4DDgwODg4QDhIOFA4WDhsOJQ4qDjkOhA6nDscO5w7pDusO7Q7vDvEO8g7zDvUO9g74DvkO+w79Dv4O/w8BDwIPBw8UDxkPGw8dDyIPJA8mDygPPQ9SD3cPmw/CD+YP6A/qD+wP7g/wD/IP8w/1EAIQExAVEBcQGRAbEB0QHxAhECMQNBA2EDgQOhA8ED4QQBBCEEQQRhBkEIIQlRCpEL4Q2xDvEQURRBFGEUgRShFMEU0RThFPEVARUhFUEVURVhFYEVkRmBGaEZwRnhGgEaERohGjEaQRphGoEakRqhGsEa0R7BHuEfAR8hH0EfUR9hH3EfgR+hH8Ef0R/hIAEgESDhIPEhASEhJRElMSVRJXElkSWhJbElwSXRJfEmESYhJjEmUSZhKlEqcSqRKrEq0SrhKvErASsRKzErUSthK3ErkSuhK7EvoS/BL+EwATAhMDEwQTBRMGEwgTChMLEwwTDhMPE04TUBNSE1QTVhNXE1gTWRNaE1wTXhNfE2ATYhNjE6ITpBOmE6gTqhOrE6wTrROuE7ATshOzE7QTthO3E9wUABQnFEsUTRRPFFEUUxRVFFcUWBRaFGcUdhR4FHoUfBR+FIAUghSEFJMUlRSXFJkUmxSdFJ8UoRSjFMMU7hUIFSEVOxVbFX4VvRW/FcEVwxXFFcYVxxXIFckVyxXNFc4VzxXRFdIWERYTFhUWFxYZFhoWGxYcFh0WHxYhFiIWIxYlFiYWZRZnFmkWaxZtFm4WbxZwFnEWcxZ1FnYWdxZ5FnoWuRa7Fr0WvxbBFsIWwxbEFsUWxxbJFsoWyxbNFs4W0RcQFxIXFBcWFxgXGRcaFxsXHBceFyAXIRciFyQXJRdkF2YXaBdqF2wXbRduF28XcBdyF3QXdRd2F3gXeRegF98X4RfjF+UX5xfoF+kX6hfrF+0X7xfwF/EX8xf0F/0YCxgYGCYYMxhGGF0Ybxi6GN0Y/RkdGR8ZIRkjGSUZJxkoGSkZKxksGS4ZLxkxGTMZNBk1GTcZOBk9GUoZTxlRGVMZWBlaGVwZXhmDGacZzhnyGfQZ9hn4GfoZ/Bn+Gf8aARoOGh8aIRojGiUaJxopGisaLRovGkAaQhpEGkYaSBpKGkwaThpQGlIakRqTGpUalxqZGpoamxqcGp0anxqhGqIaoxqlGqYa5RrnGuka6xrtGu4a7xrwGvEa8xr1GvYa9xr5GvobORs7Gz0bPxtBG0IbQxtEG0UbRxtJG0obSxtNG04bWxtcG10bXxueG6AbohukG6YbpxuoG6kbqhusG64brxuwG7IbsxvyG/Qb9hv4G/ob+xv8G/0b/hwAHAIcAxwEHAYcBxxGHEgcShxMHE4cTxxQHFEcUhxUHFYcVxxYHFocWxyaHJwcnhygHKIcoxykHKUcphyoHKocqxysHK4crxzuHPAc8hz0HPYc9xz4HPkc+hz8HP4c/x0AHQIdAx0oHUwdcx2XHZkdmx2dHZ8doR2jHaQdph2zHcIdxB3GHcgdyh3MHc4d0B3fHeEd4x3lHecd6R3rHe0d7x4uHjAeMh40HjYeNx44HjkeOh48Hj4ePx5AHkIeQx6CHoQehh6IHooeix6MHo0ejh6QHpIekx6UHpYelx7WHtge2h7cHt4e3x7gHuEe4h7kHuYe5x7oHuoe6x8qHywfLh8wHzIfMx80HzUfNh84HzofOx88Hz4fPx9CH4Efgx+FH4cfiR+KH4sfjB+NH48fkR+SH5MflR+WH9Uf1x/ZH9sf3R/eH98f4B/hH+Mf5R/mH+cf6R/qICkgKyAtIC8gMSAyIDMgNCA1IDcgOSA6IDsgPSA+IIkgrCDMIOwg7iDwIPIg9CD2IPcg+CD6IPsg/SD+IQAhAiEDIQQhBiEHIQwhGSEeISAhIiEnISkhKyEtIVIhdiGdIcEhwyHFIcchySHLIc0hziHQId0h7iHwIfIh9CH2Ifgh+iH8If4iDyIRIhMiFSIXIhkiGyIdIh8iISJgImIiZCJmImgiaSJqImsibCJuInAicSJyInQidSK0IrYiuCK6IrwivSK+Ir8iwCLCIsQixSLGIsgiySMIIwojDCMOIxAjESMSIxMjFCMWIxgjGSMaIxwjHSMqIysjLCMuI20jbyNxI3MjdSN2I3cjeCN5I3sjfSN+I38jgSOCI8EjwyPFI8cjySPKI8sjzCPNI88j0SPSI9Mj1SPWJBUkFyQZJBskHSQeJB8kICQhJCMkJSQmJCckKSQqJGkkayRtJG8kcSRyJHMkdCR1JHckeSR6JHskfSR+JL0kvyTBJMMkxSTGJMckyCTJJMskzSTOJM8k0STSJPclGyVCJWYlaCVqJWwlbiVwJXIlcyV1JYIlkSWTJZUllyWZJZslnSWfJa4lsCWyJbQltiW4JbolvCW+Jf0l/yYBJgMmBSYGJgcmCCYJJgsmDSYOJg8mESYSJlEmUyZVJlcmWSZaJlsmXCZdJl8mYSZiJmMmZSZmJqUmpyapJqsmrSauJq8msCaxJrMmtSa2JrcmuSa6Jvkm+yb9Jv8nAScCJwMnBCcFJwcnCScKJwsnDScOJxEnUCdSJ1QnVidYJ1knWidbJ1wnXidgJ2EnYidkJ2UnpCemJ6gnqiesJ60nrievJ7Ansie0J7Untie4J7kn+Cf6J/wn/igAKAEoAigDKAQoBigIKAkoCigMKA0oWCh7KJsouyi9KL8owSjDKMUoxijHKMkoyijMKM0ozyjRKNIo0yjVKNYo2yjoKO0o7yjxKPYo+Cj6KPwpISlFKWwpkCmSKZQplimYKZopnCmdKZ8prCm9Kb8pwSnDKcUpxynJKcspzSneKeAp4inkKeYp6CnqKewp7inwKi8qMSozKjUqNyo4KjkqOio7Kj0qPypAKkEqQypEKoMqhSqHKokqiyqMKo0qjiqPKpEqkyqUKpUqlyqYKtcq2SrbKt0q3yrgKuEq4irjKuUq5yroKukq6yrsKvkq+ir7Kv0rPCs+K0ArQitEK0UrRitHK0grSitMK00rTitQK1ErkCuSK5QrliuYK5krmiubK5wrniugK6EroiukK6Ur5CvmK+gr6ivsK+0r7ivvK/Ar8iv0K/Ur9iv4K/ksOCw6LDwsPixALEEsQixDLEQsRixILEksSixMLE0sjCyOLJAskiyULJUsliyXLJgsmiycLJ0sniygLKEsxizqLREtNS03LTktOy09LT8tQS1CLUQtUS1gLWItZC1mLWgtai1sLW4tfS1/LYEtgy2FLYctiS2LLY0tzC3OLdAt0i3ULdUt1i3XLdgt2i3cLd0t3i3gLeEuIC4iLiQuJi4oLikuKi4rLiwuLi4wLjEuMi40LjUudC52Lnguei58Ln0ufi5/LoAugi6ELoUuhi6ILokuyC7KLswuzi7QLtEu0i7TLtQu1i7YLtku2i7cLt0vHC8eLyAvIi8kLyUvJi8nLygvKi8sLy0vLi8wLzEvcC9yL3Qvdi94L3kvei97L3wvfi+AL4Evgi+EL4UvrC/rL+0v7y/xL/Mv9C/1L/Yv9y/5L/sv/C/9L/8wADALMBQwFTAXMCAwKzA6MEUwUzBoMHwwkzClMLIwszC0MLYwwzDEMMUwxzDUMNUw1jDYMOEw8DD9MQwxHjEyMUkxWzFkMWUxZzF0MXUxdjF4MXkxgjGMMZMAAAAAAAACAgAAAAAAAAa4AAAAAAAAAAAAAAAAAAAxmw== remoteDataInfo data ================================================ FILE: Airship/AirshipCore/Resources/UAirshipCache.xcdatamodeld/UAAirshipCache.xcdatamodel/contents ================================================ ================================================ FILE: Airship/AirshipCore/Resources/af.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Kanselleer"; "ua_cancel_edit_messages_description" = "Kanselleer boodskapwysigings"; "ua_connection_error" = "Konneksiefout"; "ua_content_error" = "Inhoudfout"; "ua_delete_message" = "Skrap boodskap"; "ua_delete_message_description" = "Skrap geselekteerde boodskappe"; "ua_delete_messages" = "Skrap boodskap"; "ua_delete_messages_description" = "Skrap geselekteerde boodskappe"; "ua_edit_messages" = "Wysig"; "ua_edit_messages_description" = "Wysig boodskappe"; "ua_empty_message_list" = "Geen boodskappe nie"; "ua_mark_messages_read" = "Merk Lees"; "ua_mark_messages_read_description" = "Merk geselekteerde boodskappe as gelees"; "ua_mc_failed_to_load" = "Kan nie boodskap laai nie. Probeer asseblief weer later."; "ua_mc_no_longer_available" = "Die geselekteerde boodskap is nie meer beskikbaar nie."; "ua_message_cell_description" = "Wys volledige boodskap"; "ua_message_cell_editing_description" = "Wissel seleksie"; "ua_message_center_title" = "Boodskapsentrum"; /* Message , sent at <DATE> */ "ua_message_description" = "Boodskap %@, gestuur om %@"; "ua_message_not_selected" = "Geen boodskappe gekies nie"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Ongeleesde boodskap %@, gestuur om %@"; "ua_notification_button_accept" = "Aanvaar"; "ua_notification_button_add" = "Voeg by"; "ua_notification_button_add_to_calendar" = "Voeg by Kalender"; "ua_notification_button_book_now" = "Bespreek nou"; "ua_notification_button_buy_now" = "Koop nou"; "ua_notification_button_copy" = "Kopieer"; "ua_notification_button_decline" = "Weier"; "ua_notification_button_dislike" = "Hou nie van nie"; "ua_notification_button_download" = "Aflaai"; "ua_notification_button_follow" = "Volg"; "ua_notification_button_less_like" = "Minder soos hierdie"; "ua_notification_button_like" = "Hou van"; "ua_notification_button_more_like" = "Meer soos hierdie"; "ua_notification_button_no" = "Geen"; "ua_notification_button_opt_in" = "Teken in"; "ua_notification_button_opt_out" = "Onttrek"; "ua_notification_button_rate_now" = "Beoordeel nou"; "ua_notification_button_remind" = "Herinner my later"; "ua_notification_button_save" = "Stoor"; "ua_notification_button_search" = "Soek"; "ua_notification_button_send_info" = "Stuur inligting"; "ua_notification_button_share" = "Deel"; "ua_notification_button_shop_now" = "Koop dit nou"; "ua_notification_button_tell_me_more" = "Vertel my meer"; "ua_notification_button_unfollow" = "Ontvolg"; "ua_notification_button_yes" = "Ja"; "ua_ok" = "OK"; "ua_preference_center_title" = "Voorkeursentrum"; "ua_retry_button" = "Probeer weer"; "ua_select_all_messages" = "Kies Alles"; "ua_select_all_messages_description" = "Kies alle boodskappe"; "ua_select_none_messages" = "Kies Geen"; "ua_select_none_messages_description" = "Stel boodskapkeuse terug"; "ua_unread_message_description" = "Boodskap ongelees"; // Generic localizations "ua_dismiss" = "Laat gaan"; "ua_escape" = "Ontsnap"; "ua_next" = "Volgende"; "ua_previous" = "Vorige"; "ua_submit" = "Indien"; "ua_loading" = "Laai"; "ua_pager_progress" = "Bladsy %@ van %@"; "ua_x_of_y" = "%@ van %@"; "ua_play" = "Speel"; "ua_pause" = "Laat wag"; "ua_stop" = "Hou op"; "ua_form_processing_error" = "Fout met die verwerking van vorm. Probeer asseblief weer"; "ua_close" = "Sluit"; "ua_mute" = "Demp"; "ua_unmute" = "Ontdemp"; "ua_required_field" = "* Verplig"; "ua_invalid_form_message" = "Herstel asseblief die ongeldige velde om voort te gaan"; ================================================ FILE: Airship/AirshipCore/Resources/am.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "ሰርዝ"; "ua_cancel_edit_messages_description" = "የመልእክት አርትዖቶችን ሰርዝ"; "ua_connection_error" = "የግንኙነት ስህተት"; "ua_content_error" = "የይዘት ስህተት"; "ua_delete_message" = "መልእክት ሰርዝ"; "ua_delete_message_description" = "የተመረጡ መልዕክቶችን ሰርዝ"; "ua_delete_messages" = "መልእክት ሰርዝ"; "ua_delete_messages_description" = "የተመረጡ መልዕክቶችን ሰርዝ"; "ua_edit_messages" = "አርትዕ"; "ua_edit_messages_description" = "መልዕክቶችን ያርትዑ"; "ua_empty_message_list" = "ምንም መልዕክቶች የሉም"; "ua_mark_messages_read" = "መነበቡን አመልክት"; "ua_mark_messages_read_description" = "የተመረጡትን መልዕክቶች እንደተነበቡ ምልክት ያድርጉ"; "ua_mc_failed_to_load" = "መልእክት መጫን አልተቻለም። እባክዎ ቆየት ብለው ይሞክሩ."; "ua_mc_no_longer_available" = "የተመረጠው መልእክት ከአሁን በኋላ አይገኝም።"; "ua_message_cell_description" = "ሙሉ መልእክት ያሳያል"; "ua_message_cell_editing_description" = "ምርጫ ይቀያየራል።"; "ua_message_center_title" = "የመልእክት ማእከል"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "መልዕክት %@፣ %@ ላይ ተልኳል"; "ua_message_not_selected" = "ምንም መልዕክቶች አልተመረጡም።"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "ያልተነበበ መልዕክት %@፣ %@ ላይ ተልኳል"; "ua_notification_button_accept" = "ተቀበል"; "ua_notification_button_add" = "አክል"; "ua_notification_button_add_to_calendar" = "ወደ ቀን መቁጠሪያ ያክሉ"; "ua_notification_button_book_now" = "አሁን ያዝ"; "ua_notification_button_buy_now" = "አሁን ይግዙ"; "ua_notification_button_copy" = "ቅዳ"; "ua_notification_button_decline" = "አትቀበል"; "ua_notification_button_dislike" = "አለመውደድ"; "ua_notification_button_download" = "አውርድ"; "ua_notification_button_follow" = "ተከተል"; "ua_notification_button_less_like" = "እንደዚህ ያነሰ"; "ua_notification_button_like" = "መውደድ"; "ua_notification_button_more_like" = "ተጨማሪ እንደዚህ"; "ua_notification_button_no" = "አይ"; "ua_notification_button_opt_in" = "መርጦ መግባት"; "ua_notification_button_opt_out" = "መርጦ ውጣ"; "ua_notification_button_rate_now" = "አሁን ደረጃ ይስጡ"; "ua_notification_button_remind" = "በኋላ አስታውሰኝ"; "ua_notification_button_save" = "አስቀምጥ"; "ua_notification_button_search" = "ፈልግ"; "ua_notification_button_send_info" = "መረጃ ላክ"; "ua_notification_button_share" = "አጋራ"; "ua_notification_button_shop_now" = "አሁን ይሸምቱ"; "ua_notification_button_tell_me_more" = "ተጨማሪ ንገረኝ"; "ua_notification_button_unfollow" = "አትከተል"; "ua_notification_button_yes" = "አዎ"; "ua_ok" = "እሺ"; "ua_preference_center_title" = "ምርጫ ማዕከል"; "ua_retry_button" = "እንደገና ይሞክሩ"; "ua_select_all_messages" = "ሁሉንም ይምረጡ"; "ua_select_all_messages_description" = "ሁሉንም መልዕክቶች ምረጥ"; "ua_select_none_messages" = "ምንምን ይምረጡ"; "ua_select_none_messages_description" = "የመልእክት ምርጫን ዳግም አስጀምር"; "ua_unread_message_description" = "መልእክት አልተነበበም።"; // Generic localizations "ua_dismiss" = "አሳርፍ"; "ua_escape" = "መሻገሪያ"; "ua_next" = "ሚቃኝ"; "ua_previous" = "ቀዳሚ"; "ua_submit" = "አስገባ"; "ua_loading" = "በመጫን ላይ"; "ua_pager_progress" = "ገጽ %@ ከ %@"; "ua_x_of_y" = "%@ ከ %@"; "ua_play" = "አጫውት"; "ua_pause" = "ለአጭር ጊዜ ቆም"; "ua_stop" = "አቁም"; "ua_form_processing_error" = "ቅጹን በማስኬድ ላይ ስህተት። እባክዎ እንደገና ይሞክሩ"; "ua_close" = "ዝጋ"; "ua_mute" = "ድምፅ አጥፋ"; "ua_unmute" = "ድምፅ አብራ"; "ua_required_field" = "* አስፈላጊ"; "ua_invalid_form_message" = "ለመቀጠል እባክዎን ልክ ያልሆኑ መስኮችን ያስተካክሉ"; ================================================ FILE: Airship/AirshipCore/Resources/ar.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "إلغاء"; "ua_cancel_edit_messages_description" = "إلغاء تعديلات الرسائل"; "ua_connection_error" = "خطأ في الإتصال"; "ua_content_error" = "خطأ في المحتوى"; "ua_delete_message" = "حذف رسالة"; "ua_delete_message_description" = "حذف الرسائل المختارة"; "ua_delete_messages" = "حذف رسالة"; "ua_delete_messages_description" = "حذف الرسائل المختارة"; "ua_edit_messages" = "يحرر"; "ua_edit_messages_description" = "تحرير الرسائل"; "ua_empty_message_list" = "لا توجد رسائل"; "ua_mark_messages_read" = "اجعلها مقروءة"; "ua_mark_messages_read_description" = "وضع علامة \"مقروءة\" على الرسائل المحددة"; "ua_mc_failed_to_load" = "غير قادر على تحميل الرسالة. الرجاء معاودة المحاولة في وقت لاحق."; "ua_mc_no_longer_available" = "الرسالة المحددة لم تعد متوفرة."; "ua_message_cell_description" = "يعرض الرسالة كاملة"; "ua_message_cell_editing_description" = "يبدل التحديد"; "ua_message_center_title" = "مركز الرسائل"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "تم إرسال الرسالة %@ في %@"; "ua_message_not_selected" = "لم يتم تحديد رسائل"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "رسالة غير مقروءة %@، أُرسلت في %@"; "ua_notification_button_accept" = "قبول"; "ua_notification_button_add" = "إضافة"; "ua_notification_button_add_to_calendar" = "إضافة إلى التقويم"; "ua_notification_button_book_now" = "احجز الآن"; "ua_notification_button_buy_now" = "اشتري الآن"; "ua_notification_button_copy" = "نسخ"; "ua_notification_button_decline" = "رفض"; "ua_notification_button_dislike" = "إلغاء إعجاب"; "ua_notification_button_download" = "تحميل"; "ua_notification_button_follow" = "تابِع"; "ua_notification_button_less_like" = "القليل من هذا"; "ua_notification_button_like" = "إعجاب"; "ua_notification_button_more_like" = "المزيد من هذا"; "ua_notification_button_no" = "لا"; "ua_notification_button_opt_in" = "اشترك"; "ua_notification_button_opt_out" = "انسحب"; "ua_notification_button_rate_now" = "قيم الآن"; "ua_notification_button_remind" = "ذكرني لاحقا"; "ua_notification_button_save" = "حفظ"; "ua_notification_button_search" = "بحث"; "ua_notification_button_send_info" = "إرسال معلومات"; "ua_notification_button_share" = "مشاركة"; "ua_notification_button_shop_now" = "تسوق الآن"; "ua_notification_button_tell_me_more" = "أخبرني أكثر"; "ua_notification_button_unfollow" = "إلغاء متابعة"; "ua_notification_button_yes" = "نعم"; "ua_ok" = "موافق"; "ua_preference_center_title" = "مركز التفضيلات"; "ua_retry_button" = "إعادة المحاولة"; "ua_select_all_messages" = "اختر الكل"; "ua_select_all_messages_description" = "يختار كل الرسائل"; "ua_select_none_messages" = "اختر لا شيء"; "ua_select_none_messages_description" = "إعادة تعيين اختيار الرسالة"; "ua_unread_message_description" = "الرسالة غير مقروءة"; // Generic localizations "ua_dismiss" = "تجاهل"; "ua_escape" = "خروج"; "ua_next" = "التالي"; "ua_previous" = "السابق"; "ua_submit" = "إرسال"; "ua_loading" = "تحميل"; "ua_pager_progress" = "الصفحة %@ من %@"; "ua_x_of_y" = "%@ من %@"; "ua_play" = "تشغيل"; "ua_pause" = "إيقاف مؤقت"; "ua_stop" = "إيقاف"; "ua_form_processing_error" = "خطأ في معالجة النموذج. يرجى المحاولة مرة أخرى"; "ua_close" = "إغلاق"; "ua_mute" = "كتم الصوت"; "ua_unmute" = "إلغاء كتم الصوت"; "ua_required_field" = "* مطلوب"; "ua_invalid_form_message" = "يرجى تصحيح الحقول غير الصالحة للمتابعة"; ================================================ FILE: Airship/AirshipCore/Resources/bg.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Отмяна"; "ua_cancel_edit_messages_description" = "Отмени редакциите на съобщението"; "ua_connection_error" = "Грешка при свързване"; "ua_content_error" = "Грешка в съдържанието"; "ua_delete_message" = "Изтрий съобщението"; "ua_delete_message_description" = "Изтрий избраните съобщения"; "ua_delete_messages" = "Изтрий съобщението"; "ua_delete_messages_description" = "Изтрий избраните съобщения"; "ua_edit_messages" = "Редактиране"; "ua_edit_messages_description" = "Редактиране на съобщения"; "ua_empty_message_list" = "Няма съобщения"; "ua_mark_messages_read" = "Маркирай като прочетено"; "ua_mark_messages_read_description" = "Маркирай избраните съобщения като прочетени"; "ua_mc_failed_to_load" = "Съобщението не може да се зареди. Моля, опитайте отново по-късно."; "ua_mc_no_longer_available" = "Избраното съобщение вече не е налично."; "ua_message_cell_description" = "Показва пълно съобщение"; "ua_message_cell_editing_description" = "Превключване на избора"; "ua_message_center_title" = "Център за съобщения"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Съобщение %@, изпратено на %@"; "ua_message_not_selected" = "Няма избрани съобщения"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Непрочетено съобщение %@, изпратено на %@"; "ua_notification_button_accept" = "Приемам"; "ua_notification_button_add" = "Добавяне"; "ua_notification_button_add_to_calendar" = "Добавяне към календара"; "ua_notification_button_book_now" = "Резервирай сега"; "ua_notification_button_buy_now" = "Купи сега"; "ua_notification_button_copy" = "Копиране"; "ua_notification_button_decline" = "Отказвам се"; "ua_notification_button_dislike" = "Не харесвам"; "ua_notification_button_download" = "Изтегляне"; "ua_notification_button_follow" = "Последвам"; "ua_notification_button_less_like" = "По-малко като това"; "ua_notification_button_like" = "Харесвам"; "ua_notification_button_more_like" = "Още като това"; "ua_notification_button_no" = "Не"; "ua_notification_button_opt_in" = "Включвам се"; "ua_notification_button_opt_out" = "Изключвам се"; "ua_notification_button_rate_now" = "Оцени сега"; "ua_notification_button_remind" = "Напомни ми по-късно"; "ua_notification_button_save" = "Запазване"; "ua_notification_button_search" = "Търси"; "ua_notification_button_send_info" = "Изпрати информация"; "ua_notification_button_share" = "Споделяне"; "ua_notification_button_shop_now" = "Пазарувай сега"; "ua_notification_button_tell_me_more" = "Кажи ми повече"; "ua_notification_button_unfollow" = "Спирам да следвам"; "ua_notification_button_yes" = "Да"; "ua_ok" = "OK"; "ua_preference_center_title" = "Център за предпочитания"; "ua_retry_button" = "Опитайте отново"; "ua_select_all_messages" = "Маркирай всичко"; "ua_select_all_messages_description" = "Избери всички съобщения"; "ua_select_none_messages" = "Изберете Няма"; "ua_select_none_messages_description" = "Нулиране на избора на съобщение"; "ua_unread_message_description" = "Съобщението е непрочетено"; // Generic localizations "ua_dismiss" = "Отхвърлям"; "ua_escape" = "Излизам"; "ua_next" = "Следващ"; "ua_previous" = "Предишен"; "ua_submit" = "Изпращам"; "ua_loading" = "Зареждане"; "ua_pager_progress" = "Страница %@ от %@"; "ua_x_of_y" = "%@ от %@"; "ua_play" = "Пуснете"; "ua_pause" = "Пауза"; "ua_stop" = "Стоп"; "ua_form_processing_error" = "Грешка при обработка на формуляра. Моля, опитайте отново"; "ua_close" = "Затвори"; "ua_mute" = "Заглуши"; "ua_unmute" = "Включи звука"; "ua_required_field" = "* Задължително"; "ua_invalid_form_message" = "Моля, коригирайте невалидните полета, за да продължите"; ================================================ FILE: Airship/AirshipCore/Resources/ca.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Cancel·lar"; "ua_cancel_edit_messages_description" = "Cancel·lar les edicions del missatge"; "ua_connection_error" = "Error de connexió"; "ua_content_error" = "Error de contingut"; "ua_delete_message" = "Missatge esborrat"; "ua_delete_message_description" = "Suprimir els missatges seleccionats"; "ua_delete_messages" = "Missatge esborrat"; "ua_delete_messages_description" = "Suprimir els missatges seleccionats"; "ua_edit_messages" = "Editar"; "ua_edit_messages_description" = "Editar missatges"; "ua_empty_message_list" = "No hi ha missatges"; "ua_mark_messages_read" = "Marcar com a Llegit"; "ua_mark_messages_read_description" = "Marcar els missatges seleccionats com a llegits"; "ua_mc_failed_to_load" = "No es pot carregar el missatge. Si us plau, intenteu-ho més tard."; "ua_mc_no_longer_available" = "El missatge seleccionat ja no està disponible."; "ua_message_cell_description" = "Mostra el missatge complet"; "ua_message_cell_editing_description" = "Selecció de toggles"; "ua_message_center_title" = "Centre de missatges"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Missatge %@, enviat a les %@"; "ua_message_not_selected" = "No s\'ha seleccionat cap missatge"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Missatge no llegit %@, enviat a les %@"; "ua_notification_button_accept" = "Acceptar"; "ua_notification_button_add" = "Afegir"; "ua_notification_button_add_to_calendar" = "Afegir al calendari"; "ua_notification_button_book_now" = "Reservar ara"; "ua_notification_button_buy_now" = "Comprar ara"; "ua_notification_button_copy" = "Copiar"; "ua_notification_button_decline" = "Refusar"; "ua_notification_button_dislike" = "No m\'agrada"; "ua_notification_button_download" = "Descarregar"; "ua_notification_button_follow" = "Seguir"; "ua_notification_button_less_like" = "Menys com aquest"; "ua_notification_button_like" = "M\'agrada"; "ua_notification_button_more_like" = "Més com aquest"; "ua_notification_button_no" = "No"; "ua_notification_button_opt_in" = "Activar"; "ua_notification_button_opt_out" = "Desactivar"; "ua_notification_button_rate_now" = "Valorar ara"; "ua_notification_button_remind" = "Recordeu-m\'ho més tard"; "ua_notification_button_save" = "Desar"; "ua_notification_button_search" = "Cercar"; "ua_notification_button_send_info" = "Enviar informació"; "ua_notification_button_share" = "Compartir"; "ua_notification_button_shop_now" = "Anar a la botiga ara"; "ua_notification_button_tell_me_more" = "Expliqueu-me més"; "ua_notification_button_unfollow" = "Deixar de seguir"; "ua_notification_button_yes" = "Sí"; "ua_ok" = "D\'acord"; "ua_preference_center_title" = "Centre de preferències"; "ua_retry_button" = "Torneu-ho a provar"; "ua_select_all_messages" = "Seleccioneu Tot"; "ua_select_all_messages_description" = "Selecciona tots els missatges"; "ua_select_none_messages" = "Seleccioneu Cap"; "ua_select_none_messages_description" = "Restablir la selecció de missatges"; "ua_unread_message_description" = "Missatge no llegit"; // Generic localizations "ua_dismiss" = "Descartar"; "ua_escape" = "Escapar"; "ua_next" = "Següent"; "ua_previous" = "Anterior"; "ua_submit" = "Enviar"; "ua_loading" = "Carregant"; "ua_pager_progress" = "Pàgina %@ de %@"; "ua_x_of_y" = "%@ de %@"; "ua_play" = "Reproduir"; "ua_pause" = "Pausar"; "ua_stop" = "Aturar"; "ua_form_processing_error" = "Error en processar el formulari. Si us plau, torneu-ho a provar"; "ua_close" = "Tancar"; "ua_mute" = "Silenciar"; "ua_unmute" = "Activar so"; "ua_required_field" = "* Obligatori"; "ua_invalid_form_message" = "Si us plau, corregiu els camps no vàlids per continuar"; ================================================ FILE: Airship/AirshipCore/Resources/cs.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Zrušit"; "ua_cancel_edit_messages_description" = "Zrušit úpravy zpráv"; "ua_connection_error" = "Chyba připojení"; "ua_content_error" = "Chyba obsahu"; "ua_delete_message" = "Smazat zprávu"; "ua_delete_message_description" = "Smazat vybrané zprávy"; "ua_delete_messages" = "Smazat zprávu"; "ua_delete_messages_description" = "Smazat vybrané zprávy"; "ua_edit_messages" = "Upravit"; "ua_edit_messages_description" = "Upravit zprávy"; "ua_empty_message_list" = "Žádné zprávy"; "ua_mark_messages_read" = "Označit jako přečtené"; "ua_mark_messages_read_description" = "Označit vybrané zprávy jako přečtené"; "ua_mc_failed_to_load" = "Zprávu nelze načíst. Zkuste to prosím později znovu."; "ua_mc_no_longer_available" = "Vybraná zpráva již není dostupná."; "ua_message_cell_description" = "Zobrazí celou zprávu"; "ua_message_cell_editing_description" = "Přepíná výběr"; "ua_message_center_title" = "Centrum zpráv"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Zpráva %@, odeslána v %@"; "ua_message_not_selected" = "Nejsou vybrány žádné zprávy"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Nepřečtená zpráva %@, odeslána v %@"; "ua_notification_button_accept" = "Přijmout"; "ua_notification_button_add" = "Přidat"; "ua_notification_button_add_to_calendar" = "Přidat do kalendáře"; "ua_notification_button_book_now" = "Zarezervovat hned"; "ua_notification_button_buy_now" = "Nakupovat teď"; "ua_notification_button_copy" = "Kopírovat"; "ua_notification_button_decline" = "Odmítnout"; "ua_notification_button_dislike" = "Nelíbí se mi"; "ua_notification_button_download" = "Stáhnout"; "ua_notification_button_follow" = "Sledovat"; "ua_notification_button_less_like" = "Méně se mi líbí toto"; "ua_notification_button_like" = "Líbí se mi"; "ua_notification_button_more_like" = "Víc se mi líbí toto"; "ua_notification_button_no" = "Ne"; "ua_notification_button_opt_in" = "Přidat se"; "ua_notification_button_opt_out" = "Odhlásit se"; "ua_notification_button_rate_now" = "Hodnotit teď"; "ua_notification_button_remind" = "Připomenout později"; "ua_notification_button_save" = "Uložit"; "ua_notification_button_search" = "Vyhledávání"; "ua_notification_button_send_info" = "Odeslat informace"; "ua_notification_button_share" = "Sdílet"; "ua_notification_button_shop_now" = "Koupit teď"; "ua_notification_button_tell_me_more" = "Chci vědět víc"; "ua_notification_button_unfollow" = "Nesledovat"; "ua_notification_button_yes" = "Ano"; "ua_ok" = "OK"; "ua_preference_center_title" = "Centrum preferencí"; "ua_retry_button" = "Zkusit znovu"; "ua_select_all_messages" = "Vybrat Vše"; "ua_select_all_messages_description" = "Vybere všechny zprávy"; "ua_select_none_messages" = "Nevybírat nic"; "ua_select_none_messages_description" = "Obnovit výběr zpráv"; "ua_unread_message_description" = "Zpráva nepřečtena"; // Generic localizations "ua_dismiss" = "Zrušit"; "ua_escape" = "Odejít"; "ua_next" = "Další"; "ua_previous" = "Předchozí"; "ua_submit" = "Odeslat"; "ua_loading" = "Načítání"; "ua_pager_progress" = "Strana %@ z %@"; "ua_x_of_y" = "%@ z %@"; "ua_play" = "Přehrát"; "ua_pause" = "Pozastavit"; "ua_stop" = "Zastavit"; "ua_form_processing_error" = "Chyba při zpracování formuláře. Zkuste to prosím znovu"; "ua_close" = "Zavřít"; "ua_mute" = "Ztlumit"; "ua_unmute" = "Zrušit ztlumení"; "ua_required_field" = "* Povinné"; "ua_invalid_form_message" = "Opravte prosím neplatná pole a pokračujte"; ================================================ FILE: Airship/AirshipCore/Resources/da.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Annullér"; "ua_cancel_edit_messages_description" = "Annuller redigeringer af beskeder"; "ua_connection_error" = "Forbindelsesfejl"; "ua_content_error" = "Indholdsfejl"; "ua_delete_message" = "Slet besked"; "ua_delete_message_description" = "Slet valgte beskeder"; "ua_delete_messages" = "Slet besked"; "ua_delete_messages_description" = "Slet valgte beskeder"; "ua_edit_messages" = "Rediger"; "ua_edit_messages_description" = "Rediger beskeder"; "ua_empty_message_list" = "Ingen meddelelser"; "ua_mark_messages_read" = "Markeret som læst"; "ua_mark_messages_read_description" = "Marker valgte beskeder læst"; "ua_mc_failed_to_load" = "Kan ikke indlæse meddelelse. Prøv igen senere."; "ua_mc_no_longer_available" = "Den valgte besked er ikke længere tilgængelig."; "ua_message_cell_description" = "Viser hele meddelelsen"; "ua_message_cell_editing_description" = "Skifte valg"; "ua_message_center_title" = "Meddelelsescenter"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Besked %@, sendt kl. %@"; "ua_message_not_selected" = "Der er ikke valgt nogen beskeder"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Ulæst besked %@, sendt kl. %@"; "ua_notification_button_accept" = "Acceptér"; "ua_notification_button_add" = "Tilføj"; "ua_notification_button_add_to_calendar" = "Føj til kalender"; "ua_notification_button_book_now" = "Bestil nu"; "ua_notification_button_buy_now" = "Køb nu"; "ua_notification_button_copy" = "Kopiér"; "ua_notification_button_decline" = "Afvis"; "ua_notification_button_dislike" = "Synes ikke om"; "ua_notification_button_download" = "Hent"; "ua_notification_button_follow" = "Følg"; "ua_notification_button_less_like" = "Mindre som denne"; "ua_notification_button_like" = "Synes om"; "ua_notification_button_more_like" = "Mere som denne"; "ua_notification_button_no" = "Nej"; "ua_notification_button_opt_in" = "Tilmeld"; "ua_notification_button_opt_out" = "Frameld"; "ua_notification_button_rate_now" = "Bedøm nu"; "ua_notification_button_remind" = "Mind mig om det senere"; "ua_notification_button_save" = "Gem"; "ua_notification_button_search" = "Søg"; "ua_notification_button_send_info" = "Send info"; "ua_notification_button_share" = "Del"; "ua_notification_button_shop_now" = "Handl nu"; "ua_notification_button_tell_me_more" = "Fortæl mig mere"; "ua_notification_button_unfollow" = "Følg ikke længere"; "ua_notification_button_yes" = "Ja"; "ua_ok" = "OK"; "ua_preference_center_title" = "Præferencecenter"; "ua_retry_button" = "Prøv igen"; "ua_select_all_messages" = "Vælg alle"; "ua_select_all_messages_description" = "Vælger alle beskeder"; "ua_select_none_messages" = "Vælg ingen"; "ua_select_none_messages_description" = "Nulstil beskedvalg"; "ua_unread_message_description" = "Besked er ulæst"; // Generic localizations "ua_dismiss" = "Afvis"; "ua_escape" = "Undslip"; "ua_next" = "Næste"; "ua_previous" = "Forrige"; "ua_submit" = "Indsend"; "ua_loading" = "Indlæser"; "ua_pager_progress" = "Side %@ af %@"; "ua_x_of_y" = "%@ af %@"; "ua_play" = "Afspil"; "ua_pause" = "Pause"; "ua_stop" = "Stop"; "ua_form_processing_error" = "Fejl ved behandling af formular. Prøv venligst igen"; "ua_close" = "Luk"; "ua_mute" = "Slå lyd fra"; "ua_unmute" = "Slå lyd til"; "ua_required_field" = "* Påkrævet"; "ua_invalid_form_message" = "Ret venligst de ugyldige felter for at fortsætte"; ================================================ FILE: Airship/AirshipCore/Resources/de.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Abbrechen"; "ua_cancel_edit_messages_description" = "Nachrichtenbearbeitung abbrechen"; "ua_connection_error" = "Verbindungsfehler"; "ua_content_error" = "Inhaltsfehler"; "ua_delete_message" = "Nachricht löschen"; "ua_delete_message_description" = "Ausgewählte Nachrichten löschen"; "ua_delete_messages" = "Nachricht löschen"; "ua_delete_messages_description" = "Ausgewählte Nachrichten löschen"; "ua_edit_messages" = "Bearbeiten"; "ua_edit_messages_description" = "Nachrichten bearbeiten"; "ua_empty_message_list" = "Keine Nachrichten"; "ua_mark_messages_read" = "Als gelesen markieren"; "ua_mark_messages_read_description" = "Ausgewählte Nachrichten als gelesen markieren"; "ua_mc_failed_to_load" = "Nachricht konnte nicht geladen werden. Bitte versuchen Sie es später noch einmal."; "ua_mc_no_longer_available" = "Die ausgewählte Nachricht ist nicht mehr verfügbar."; "ua_message_cell_description" = "Zeigt die vollständige Nachricht an"; "ua_message_cell_editing_description" = "Schaltet die Auswahl um"; "ua_message_center_title" = "Nachrichtenzentrum"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Nachricht %@, gesendet um %@"; "ua_message_not_selected" = "Keine Nachrichten ausgewählt"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Ungelesene Nachricht %@, gesendet um %@"; "ua_notification_button_accept" = "Akzeptieren"; "ua_notification_button_add" = "Hinzufügen"; "ua_notification_button_add_to_calendar" = "Zum Kalender hinzufügen"; "ua_notification_button_book_now" = "Jetzt buchen"; "ua_notification_button_buy_now" = "Jetzt kaufen"; "ua_notification_button_copy" = "Kopieren"; "ua_notification_button_decline" = "Ablehnen"; "ua_notification_button_dislike" = "Nicht liken"; "ua_notification_button_download" = "Herunterladen"; "ua_notification_button_follow" = "Folgen"; "ua_notification_button_less_like" = "Weniger wie dieses"; "ua_notification_button_like" = "Liken"; "ua_notification_button_more_like" = "Mehr wie dieses"; "ua_notification_button_no" = "Nein"; "ua_notification_button_opt_in" = "Anmelden"; "ua_notification_button_opt_out" = "Abmelden"; "ua_notification_button_rate_now" = "Jetzt bewerten"; "ua_notification_button_remind" = "Später erinnern"; "ua_notification_button_save" = "Speichern"; "ua_notification_button_search" = "Suche"; "ua_notification_button_send_info" = "Infos senden"; "ua_notification_button_share" = "Teilen"; "ua_notification_button_shop_now" = "Jetzt kaufen"; "ua_notification_button_tell_me_more" = "Mehr erfahren"; "ua_notification_button_unfollow" = "Nicht mehr folgen"; "ua_notification_button_yes" = "Ja"; "ua_ok" = "OK"; "ua_preference_center_title" = "Präferenzzentrum"; "ua_retry_button" = "Wiederholen"; "ua_select_all_messages" = "Alles auswählen"; "ua_select_all_messages_description" = "Wählt alle Nachrichten aus"; "ua_select_none_messages" = "Nichts auswählen"; "ua_select_none_messages_description" = "Nachrichtenauswahl zurücksetzen"; "ua_unread_message_description" = "Nachricht ungelesen"; // Generic localizations "ua_dismiss" = "Verwerfen"; "ua_escape" = "Verlassen"; "ua_next" = "Weiter"; "ua_previous" = "Zurück"; "ua_submit" = "Senden"; "ua_loading" = "Laden"; "ua_pager_progress" = "Seite %@ von %@"; "ua_x_of_y" = "%@ von %@"; "ua_play" = "Abspielen"; "ua_pause" = "Pausieren"; "ua_stop" = "Stoppen"; "ua_form_processing_error" = "Fehler bei der Verarbeitung des Formulars. Bitte versuchen Sie es erneut"; "ua_close" = "Schließen"; "ua_mute" = "Stumm schalten"; "ua_unmute" = "Stummschaltung aufheben"; "ua_required_field" = "* Erforderlich"; "ua_invalid_form_message" = "Bitte beheben Sie die ungültigen Felder, um fortzufahren"; ================================================ FILE: Airship/AirshipCore/Resources/el.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Ματαίωση"; "ua_cancel_edit_messages_description" = "Ακύρωση επεξεργασιών μηνυμάτων"; "ua_connection_error" = "Σφάλμα σύνδεσης"; "ua_content_error" = "Σφάλμα περιεχομένου"; "ua_delete_message" = "Διαγραφή μηνύματος"; "ua_delete_message_description" = "Διαγραφή επιλεγμένων μηνυμάτων"; "ua_delete_messages" = "Διαγραφή μηνύματος"; "ua_delete_messages_description" = "Διαγραφή επιλεγμένων μηνυμάτων"; "ua_edit_messages" = "Επεξεργασία"; "ua_edit_messages_description" = "Επεξεργασία μηνυμάτων"; "ua_empty_message_list" = "Χωρίς μηνύματα"; "ua_mark_messages_read" = "Σημείωση ως διαβασμένο"; "ua_mark_messages_read_description" = "Επισημάνετε επιλεγμένα μηνύματα ως αναγνωσμένα"; "ua_mc_failed_to_load" = "Δεν είναι δυνατή η φόρτωση του μηνύματος. Παρακαλώ δοκιμάστε ξανά αργότερα."; "ua_mc_no_longer_available" = "Το επιλεγμένο μήνυμα δεν είναι πλέον διαθέσιμο."; "ua_message_cell_description" = "Εμφανίζει πλήρες μήνυμα"; "ua_message_cell_editing_description" = "Εναλλάσσει την επιλογή"; "ua_message_center_title" = "Κέντρο μηνυμάτων"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Μήνυμα %@, στάλθηκε στις %@"; "ua_message_not_selected" = "Δεν επιλέχθηκαν μηνύματα"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Μη αναγνωσμένο μήνυμα %@, στάλθηκε στις %@"; "ua_notification_button_accept" = "Αποδέχομαι"; "ua_notification_button_add" = "Προσθήκη"; "ua_notification_button_add_to_calendar" = "Προσθήκη στο Ημερολόγιο"; "ua_notification_button_book_now" = "Κάνετε κράτηση τώρα"; "ua_notification_button_buy_now" = "Αγόρασε τώρα"; "ua_notification_button_copy" = "Αντίγραφο"; "ua_notification_button_decline" = "Πτώση"; "ua_notification_button_dislike" = "Δεν αρέσει"; "ua_notification_button_download" = "Κατεβάστε"; "ua_notification_button_follow" = "Ακολουθήστε"; "ua_notification_button_less_like" = "Λιγότερο σαν αυτό"; "ua_notification_button_like" = "Αρέσει"; "ua_notification_button_more_like" = "Περισσότερα σαν αυτό"; "ua_notification_button_no" = "Όχι"; "ua_notification_button_opt_in" = "Συμμετοχή"; "ua_notification_button_opt_out" = "Εξαίρεση"; "ua_notification_button_rate_now" = "Βαθμολογήστε τώρα"; "ua_notification_button_remind" = "Θύμισέ μου αργότερα"; "ua_notification_button_save" = "Αποθηκεύσετε"; "ua_notification_button_search" = "Αναζήτηση"; "ua_notification_button_send_info" = "Αποστολή πληροφοριών"; "ua_notification_button_share" = "Κοινοποίηση"; "ua_notification_button_shop_now" = "Ψώνισε τώρα"; "ua_notification_button_tell_me_more" = "Πες μου κι άλλα"; "ua_notification_button_unfollow" = "Κατάργηση παρακολούθησης"; "ua_notification_button_yes" = "Ναι"; "ua_ok" = "Εντάξει"; "ua_preference_center_title" = "Κέντρο προτιμήσεων"; "ua_retry_button" = "Ξαναδοκιμάσετε"; "ua_select_all_messages" = "Επιλογή όλων"; "ua_select_all_messages_description" = "Επιλέγει όλα τα μηνύματα"; "ua_select_none_messages" = "Επιλογή κανενός"; "ua_select_none_messages_description" = "Επαναφορά της επιλογής μηνύματος"; "ua_unread_message_description" = "Μη αναγνωσμένο μήνυμα"; // Generic localizations "ua_dismiss" = "Απόρριψη"; "ua_escape" = "Διαφυγή"; "ua_next" = "Επόμενο"; "ua_previous" = "Προηγούμενο"; "ua_submit" = "Υποβολή"; "ua_loading" = "Φόρτωση"; "ua_pager_progress" = "Σελίδα %@ από %@"; "ua_x_of_y" = "%@ από %@"; "ua_play" = "Αναπαραγωγή"; "ua_pause" = "Παύση"; "ua_stop" = "Διακοπή"; "ua_form_processing_error" = "Σφάλμα επεξεργασίας φόρμας. Παρακαλώ δοκιμάστε ξανά"; "ua_close" = "Κλείσιμο"; "ua_mute" = "Σίγαση"; "ua_unmute" = "Κατάργηση σίγασης"; "ua_required_field" = "* Υποχρεωτικό"; "ua_invalid_form_message" = "Παρακαλώ διορθώστε τα μη έγκυρα πεδία για να συνεχίσετε"; ================================================ FILE: Airship/AirshipCore/Resources/en.lproj/UrbanAirship.strings ================================================ "ua_connection_error" = "Connection Error"; "ua_content_error" = "Content Error"; "ua_delete_message" = "Delete"; "ua_delete_message_description" = "Delete message"; "ua_delete_messages" = "Delete"; "ua_delete_messages_description" = "Delete selected messages"; "ua_done" = "Done"; "ua_done_description" = "Dismiss message center"; "ua_message_cell_editing_description" = "Toggles selection"; "ua_message_cell_description" = "Displays full message"; "ua_empty_message_list" = "No messages"; "ua_mark_messages_read" = "Mark Read"; "ua_mark_messages_read_description" = "Mark selected messages read"; "ua_cancel_edit_messages" = "Cancel"; "ua_cancel_edit_messages_description" = "Cancel message edits"; "ua_select_all_messages" = "Select All"; "ua_select_all_messages_description" = "Selects all messages"; "ua_select_none_messages" = "Select None"; "ua_select_none_messages_description" = "Reset message selection"; "ua_edit_messages" = "Edit"; "ua_edit_messages_description" = "Edit messages"; "ua_unread_message_description" = "Message unread"; // Message <TITLE>, sent at <DATE> "ua_message_description" = "Message %@, sent at %@"; // Unread message <TITLE>, sent at <DATE> "ua_message_unread_description" = "Unread message %@, sent at %@"; "ua_mc_failed_to_load" = "Unable to load message. Please try again later."; "ua_mc_no_longer_available" = "The selected message is no longer available."; "ua_message_center_title" = "Message Center"; "ua_message_not_selected" = "No messages selected"; "ua_notification_button_accept" = "Accept"; "ua_notification_button_add" = "Add"; "ua_notification_button_add_to_calendar" = "Add to Calendar"; "ua_notification_button_book_now" = "Book Now"; "ua_notification_button_buy_now" = "Buy Now"; "ua_notification_button_copy" = "Copy"; "ua_notification_button_decline" = "Decline"; "ua_notification_button_dislike" = "Dislike"; "ua_notification_button_download" = "Download"; "ua_notification_button_follow" = "Follow"; "ua_notification_button_less_like" = "Less Like This"; "ua_notification_button_like" = "Like"; "ua_notification_button_more_like" = "More Like This"; "ua_notification_button_no" = "No"; "ua_notification_button_opt_in" = "Opt-in"; "ua_notification_button_opt_out" = "Opt-out"; "ua_notification_button_rate_now" = "Rate Now"; "ua_notification_button_remind" = "Remind Me Later"; "ua_notification_button_save" = "Save"; "ua_notification_button_search" = "Search"; "ua_notification_button_send_info" = "Send Info"; "ua_notification_button_share" = "Share"; "ua_notification_button_shop_now" = "Shop Now"; "ua_notification_button_tell_me_more" = "Tell Me More"; "ua_notification_button_unfollow" = "Unfollow"; "ua_notification_button_yes" = "Yes"; "ua_ok" = "OK"; "ua_retry_button" = "Retry"; "ua_chat_title" = "Chat"; "ua_chat_send_button" = "Send"; "ua_preference_center_title" = "Preference Center"; "ua_preference_center_empty" = "Unable to load preferences. Please try again later."; // Generic localizations "ua_dismiss" = "Dismiss"; "ua_escape" = "Escape"; "ua_next" = "Next"; "ua_previous" = "Previous"; "ua_submit" = "Submit"; "ua_loading" = "Loading"; "ua_pager_progress" = "Page %@ of %@"; "ua_x_of_y" = "%@ of %@"; "ua_play" = "Play"; "ua_pause" = "Pause"; "ua_stop" = "Stop"; "ua_form_processing_error" = "Error processing form. Please try again"; "ua_close" = "Close"; "ua_mute" = "Mute"; "ua_unmute" = "Unmute"; "ua_required_field" = "* Required"; "ua_invalid_form_message" = "Please fix the invalid fields to continue"; ================================================ FILE: Airship/AirshipCore/Resources/es-419.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Cancelar"; "ua_cancel_edit_messages_description" = "Cancelar ediciones de mensajes"; "ua_connection_error" = "Error de conexión"; "ua_content_error" = "Error de contenido"; "ua_delete_message" = "Borrar mensaje"; "ua_delete_message_description" = "Eliminar mensajes seleccionados"; "ua_delete_messages" = "Borrar mensaje"; "ua_delete_messages_description" = "Eliminar mensajes seleccionados"; "ua_edit_messages" = "Editar"; "ua_edit_messages_description" = "Editar mensajes"; "ua_empty_message_list" = "No hay mensajes"; "ua_mark_messages_read" = "Marcar como leído"; "ua_mark_messages_read_description" = "Marcar mensajes seleccionados como leídos"; "ua_mc_failed_to_load" = "No se puede cargar el mensaje. Por favor inténtalo de nuevo más tarde."; "ua_mc_no_longer_available" = "El mensaje seleccionado ya no está disponible."; "ua_message_cell_description" = "Muestra el mensaje completo"; "ua_message_cell_editing_description" = "Alterna la selección"; "ua_message_center_title" = "Centro de mensajes"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Mensaje %@, enviado a las %@"; "ua_message_not_selected" = "No hay mensajes seleccionados"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Mensaje no leído %@, enviado a las %@"; "ua_notification_button_accept" = "Aceptar"; "ua_notification_button_add" = "Anadir"; "ua_notification_button_add_to_calendar" = "Añadir al calendario"; "ua_notification_button_book_now" = "Reservar ahora"; "ua_notification_button_buy_now" = "Comprar ahora"; "ua_notification_button_copy" = "Copiar"; "ua_notification_button_decline" = "Rechazar"; "ua_notification_button_dislike" = "No me gusta"; "ua_notification_button_download" = "Descargar"; "ua_notification_button_follow" = "Seguir"; "ua_notification_button_less_like" = "Menos como esto"; "ua_notification_button_like" = "Me gusta"; "ua_notification_button_more_like" = "Más como esto"; "ua_notification_button_no" = "No"; "ua_notification_button_opt_in" = "Participar"; "ua_notification_button_opt_out" = "No participar"; "ua_notification_button_rate_now" = "Calificar ahora"; "ua_notification_button_remind" = "Recuérdame más tarde"; "ua_notification_button_save" = "Guardar"; "ua_notification_button_search" = "Buscar"; "ua_notification_button_send_info" = "Enviar información"; "ua_notification_button_share" = "Compartir"; "ua_notification_button_shop_now" = "Ir a comprar ahora"; "ua_notification_button_tell_me_more" = "Díme más"; "ua_notification_button_unfollow" = "Dejar de seguir"; "ua_notification_button_yes" = "Sí"; "ua_ok" = "Aceptar"; "ua_preference_center_title" = "Centro de preferencias"; "ua_retry_button" = "Reintentar"; "ua_select_all_messages" = "Seleccionar todo"; "ua_select_all_messages_description" = "Selecciona todos los mensajes"; "ua_select_none_messages" = "Deseleccionar todo"; "ua_select_none_messages_description" = "Restablecer selección de mensaje"; "ua_unread_message_description" = "Mensaje no leído"; // Generic localizations "ua_dismiss" = "Descartar"; "ua_escape" = "Escape"; "ua_next" = "Siguiente"; "ua_previous" = "Anterior"; "ua_submit" = "Enviar"; "ua_loading" = "Cargando"; "ua_pager_progress" = "Página %@ de %@"; "ua_x_of_y" = "%@ de %@"; "ua_play" = "Reproducir"; "ua_pause" = "Pausar"; "ua_stop" = "Detener"; "ua_form_processing_error" = "Error al procesar el formulario. Por favor intente nuevamente"; "ua_close" = "Cerrar"; "ua_mute" = "Silenciar"; "ua_unmute" = "Activar sonido"; "ua_required_field" = "* Obligatorio"; "ua_invalid_form_message" = "Por favor, corrija los campos no válidos para continuar"; ================================================ FILE: Airship/AirshipCore/Resources/es.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Cancelar"; "ua_cancel_edit_messages_description" = "Cancelar ediciones de mensajes"; "ua_connection_error" = "Error de conexión"; "ua_content_error" = "Error de contenido"; "ua_delete_message" = "Borrar mensaje"; "ua_delete_message_description" = "Eliminar mensajes seleccionados"; "ua_delete_messages" = "Borrar mensaje"; "ua_delete_messages_description" = "Eliminar mensajes seleccionados"; "ua_edit_messages" = "Editar"; "ua_edit_messages_description" = "Editar mensajes"; "ua_empty_message_list" = "No hay mensajes"; "ua_mark_messages_read" = "Marcar como leído"; "ua_mark_messages_read_description" = "Marcar mensajes seleccionados como leídos"; "ua_mc_failed_to_load" = "No se pudo cargar el mensaje. Por favor intenta más tarde."; "ua_mc_no_longer_available" = "El mensaje seleccionado ya no está disponible."; "ua_message_cell_description" = "Muestra el mensaje completo"; "ua_message_cell_editing_description" = "Alterna la selección"; "ua_message_center_title" = "Centro de mensajes"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Mensaje %@, enviado a las %@"; "ua_message_not_selected" = "No hay mensajes seleccionados"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Mensaje no leído %@, enviado a las %@"; "ua_notification_button_accept" = "Aceptar"; "ua_notification_button_add" = "Añadir"; "ua_notification_button_add_to_calendar" = "Añadir al calendario"; "ua_notification_button_book_now" = "Reservar ahora"; "ua_notification_button_buy_now" = "Comprar ahora"; "ua_notification_button_copy" = "Copiar"; "ua_notification_button_decline" = "Rechazar"; "ua_notification_button_dislike" = "No me gusta"; "ua_notification_button_download" = "Descargar"; "ua_notification_button_follow" = "Seguir"; "ua_notification_button_less_like" = "Menos como éste"; "ua_notification_button_like" = "Me gusta"; "ua_notification_button_more_like" = "Más como éste"; "ua_notification_button_no" = "No"; "ua_notification_button_opt_in" = "Acepto"; "ua_notification_button_opt_out" = "No acepto"; "ua_notification_button_rate_now" = "Calificar ahora"; "ua_notification_button_remind" = "Recordármelo más tarde"; "ua_notification_button_save" = "Guardar"; "ua_notification_button_search" = "Buscar"; "ua_notification_button_send_info" = "Enviar información"; "ua_notification_button_share" = "Compartir"; "ua_notification_button_shop_now" = "Obtener ahora"; "ua_notification_button_tell_me_more" = "Díme más"; "ua_notification_button_unfollow" = "Dejar de seguir"; "ua_notification_button_yes" = "Sí"; "ua_ok" = "Aceptar"; "ua_preference_center_title" = "Centro de preferencias"; "ua_retry_button" = "Intentar de nuevo"; "ua_select_all_messages" = "Seleccionar todo"; "ua_select_all_messages_description" = "Selecciona todos los mensajes"; "ua_select_none_messages" = "Deseleccionar todo"; "ua_select_none_messages_description" = "Restablecer selección de mensaje"; "ua_unread_message_description" = "Mensaje no leído"; // Generic localizations "ua_dismiss" = "Descartar"; "ua_escape" = "Escapar"; "ua_next" = "Siguiente"; "ua_previous" = "Anterior"; "ua_submit" = "Enviar"; "ua_loading" = "Cargando"; "ua_pager_progress" = "Página %@ de %@"; "ua_x_of_y" = "%@ de %@"; "ua_play" = "Reproducir"; "ua_pause" = "Pausar"; "ua_stop" = "Detener"; "ua_form_processing_error" = "Error al procesar el formulario. Por favor, inténtelo de nuevo"; "ua_close" = "Cerrar"; "ua_mute" = "Silenciar"; "ua_unmute" = "Activar sonido"; "ua_required_field" = "* Obligatorio"; "ua_invalid_form_message" = "Por favor, corrija los campos no válidos para continuar"; ================================================ FILE: Airship/AirshipCore/Resources/et.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Tühista"; "ua_cancel_edit_messages_description" = "Tühista sõnumi muudatused"; "ua_connection_error" = "Ühenduse viga"; "ua_content_error" = "Sisu viga"; "ua_delete_message" = "Kustuta sõnum"; "ua_delete_message_description" = "Kustutage valitud sõnumid"; "ua_delete_messages" = "Kustuta sõnum"; "ua_delete_messages_description" = "Kustutage valitud sõnumid"; "ua_edit_messages" = "Muuda"; "ua_edit_messages_description" = "Redigeeri sõnumeid"; "ua_empty_message_list" = "Sõnumeid pole"; "ua_mark_messages_read" = "Märgi loetuks"; "ua_mark_messages_read_description" = "Märgi valitud sõnumid loetuks"; "ua_mc_failed_to_load" = "Sõnumit ei saa laadida. Palun proovi hiljem uuesti."; "ua_mc_no_longer_available" = "Valitud sõnum pole enam saadaval."; "ua_message_cell_description" = "Kuvab kogu sõnumi"; "ua_message_cell_editing_description" = "Lülitab valiku sisse"; "ua_message_center_title" = "Sõnumikeskus"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Sõnum %@, saadetud aadressil %@"; "ua_message_not_selected" = "Ühtegi sõnumit pole valitud"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Lugemata sõnum %@, saadetud kell %@"; "ua_notification_button_accept" = "Nõustu"; "ua_notification_button_add" = "Lisama"; "ua_notification_button_add_to_calendar" = "Lisa kalendrisse"; "ua_notification_button_book_now" = "Broneeri kohe"; "ua_notification_button_buy_now" = "Osta kohe"; "ua_notification_button_copy" = "Kopeeri"; "ua_notification_button_decline" = "Keeldumine"; "ua_notification_button_dislike" = "Ei meeldi"; "ua_notification_button_download" = "Lae alla"; "ua_notification_button_follow" = "Follow"; "ua_notification_button_less_like" = "Vähem Sellist"; "ua_notification_button_like" = "meeldib"; "ua_notification_button_more_like" = "Rohkem sellist"; "ua_notification_button_no" = "Ei"; "ua_notification_button_opt_in" = "Lubatud"; "ua_notification_button_opt_out" = "Loobumine"; "ua_notification_button_rate_now" = "Hinda kohe"; "ua_notification_button_remind" = "Tuleta mulle hiljem meelde"; "ua_notification_button_save" = "Salvesta"; "ua_notification_button_search" = "Otsing"; "ua_notification_button_send_info" = "Saada Info"; "ua_notification_button_share" = "Jaga"; "ua_notification_button_shop_now" = "Osta nüüd"; "ua_notification_button_tell_me_more" = "Räägi mulle rohkem"; "ua_notification_button_unfollow" = "Jälgimise lõpetamine"; "ua_notification_button_yes" = "Jah"; "ua_ok" = "Okei"; "ua_preference_center_title" = "Eelistuskeskus"; "ua_retry_button" = "Uuesti proovima"; "ua_select_all_messages" = "Vali kõik"; "ua_select_all_messages_description" = "Valib kõik sõnumid"; "ua_select_none_messages" = "Valige Puudub"; "ua_select_none_messages_description" = "Lähtestage sõnumi valik"; "ua_unread_message_description" = "Sõnum lugemata"; // Generic localizations "ua_dismiss" = "Lahkuda"; "ua_escape" = "Põgenemine"; "ua_next" = "Järgmine"; "ua_previous" = "Eelmine"; "ua_submit" = "Esitada"; "ua_loading" = "Laadimine"; "ua_pager_progress" = "Lehekülg %@ / %@"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "Mängima"; "ua_pause" = "Paus"; "ua_stop" = "Peatama"; "ua_form_processing_error" = "Viga vormi töötlemisel. Palun proovige uuesti"; "ua_close" = "Sulge"; "ua_mute" = "Vaigista"; "ua_unmute" = "Tühista vaigistus"; "ua_required_field" = "* Kohustuslik"; "ua_invalid_form_message" = "Palun parandage jätkamiseks kehtetud väljad"; ================================================ FILE: Airship/AirshipCore/Resources/fa.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "لغو"; "ua_cancel_edit_messages_description" = "لغو ویرایش پیام"; "ua_connection_error" = "خطای اتصال"; "ua_content_error" = "خطای محتوا"; "ua_delete_message" = "حذف پیام"; "ua_delete_message_description" = "پیام های انتخابی را حذف کنید"; "ua_delete_messages" = "حذف پیام"; "ua_delete_messages_description" = "پیام های انتخابی را حذف کنید"; "ua_edit_messages" = "ویرایش"; "ua_edit_messages_description" = "ویرایش پیام ها"; "ua_empty_message_list" = "هیچ پیامی وجود ندارد"; "ua_mark_messages_read" = "علامت گذاری به عنوان خوانده شده"; "ua_mark_messages_read_description" = "علامت گذاری پیام های انتخاب شده به عنوان خوانده شده"; "ua_mc_failed_to_load" = "بارگیری پیام ممکن نیست. لطفاً بعداً دوباره امتحان کنید."; "ua_mc_no_longer_available" = "پیام انتخاب شده دیگر در دسترس نیست."; "ua_message_cell_description" = "پیام کامل را نمایش می دهد"; "ua_message_cell_editing_description" = "انتخاب را تغییر می دهد"; "ua_message_center_title" = "مرکز پیام"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "پیام %@، ارسال شده در %@"; "ua_message_not_selected" = "هیچ پیامی انتخاب نشد"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "پیام خوانده نشده %@، ارسال شده در %@"; "ua_notification_button_accept" = "تایید"; "ua_notification_button_add" = "اضافه"; "ua_notification_button_add_to_calendar" = "به تقویم اضافه کنید"; "ua_notification_button_book_now" = "اکنون رزرو کنید"; "ua_notification_button_buy_now" = "هم اکنون خریداری کنید"; "ua_notification_button_copy" = "کپی"; "ua_notification_button_decline" = "رد کردن"; "ua_notification_button_dislike" = "دوست نداشتن"; "ua_notification_button_download" = "دانلود"; "ua_notification_button_follow" = "دنبال کردن"; "ua_notification_button_less_like" = "کمتر شبیه این"; "ua_notification_button_like" = "پسندیدن"; "ua_notification_button_more_like" = "بیشتر شبیه این"; "ua_notification_button_no" = "خیر"; "ua_notification_button_opt_in" = "شرکت کنید"; "ua_notification_button_opt_out" = "انصراف دهید"; "ua_notification_button_rate_now" = "اکنون بسنج"; "ua_notification_button_remind" = "بعدا به من یادآوری کن"; "ua_notification_button_save" = "ذخیره"; "ua_notification_button_search" = "جستجو"; "ua_notification_button_send_info" = "ارسال اطلاعات"; "ua_notification_button_share" = "اشتراک گذاری"; "ua_notification_button_shop_now" = "اکنون خرید کنید"; "ua_notification_button_tell_me_more" = "بیشتر بگویید"; "ua_notification_button_unfollow" = "لغو دنبال کردن"; "ua_notification_button_yes" = "بله"; "ua_ok" = "بسیارخوب"; "ua_preference_center_title" = "مرکز ترجیحات"; "ua_retry_button" = "دوباره امتحان کنید"; "ua_select_all_messages" = "انتخاب همه"; "ua_select_all_messages_description" = "انتخاب همه پیام ها"; "ua_select_none_messages" = "هیچ کدام را انتخاب نکنید"; "ua_select_none_messages_description" = "بازنشانی انتخاب پیام"; "ua_unread_message_description" = "پیام خوانده نشده"; // Generic localizations "ua_dismiss" = "رد کردن"; "ua_escape" = "فرار"; "ua_next" = "بعدی"; "ua_previous" = "قبلی"; "ua_submit" = "ارسال"; "ua_loading" = "در حال بارگذاری"; "ua_pager_progress" = "صفحه %@ از %@"; "ua_x_of_y" = "%@ از %@"; "ua_play" = "پخش"; "ua_pause" = "مکث"; "ua_stop" = "توقف"; "ua_form_processing_error" = "خطا در پردازش فرم. لطفا دوباره تلاش کنید"; "ua_close" = "بستن"; "ua_mute" = "بی‌صدا کردن"; "ua_unmute" = "صدادار کردن"; "ua_required_field" = "* الزامی"; "ua_invalid_form_message" = "لطفاً فیلدهای نامعتبر را برای ادامه اصلاح کنید"; ================================================ FILE: Airship/AirshipCore/Resources/fi.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Peruuta"; "ua_cancel_edit_messages_description" = "Peruuta viestien muokkaukset"; "ua_connection_error" = "Yhteysvirhe"; "ua_content_error" = "Sisältövirhe"; "ua_delete_message" = "Poista viesti"; "ua_delete_message_description" = "Poista valitut viestit"; "ua_delete_messages" = "Poista viesti"; "ua_delete_messages_description" = "Poista valitut viestit"; "ua_edit_messages" = "Muokata"; "ua_edit_messages_description" = "Muokkaa viestejä"; "ua_empty_message_list" = "Ei viestejä"; "ua_mark_messages_read" = "Merkitse luetuksi"; "ua_mark_messages_read_description" = "Merkitse valitut viestit luetuiksi"; "ua_mc_failed_to_load" = "Viestin lataaminen ei onnistu. Yritä uudelleen myöhemmin."; "ua_mc_no_longer_available" = "Valittu viesti ei ole enää saatavilla."; "ua_message_cell_description" = "Näyttää koko viestin"; "ua_message_cell_editing_description" = "Vaihtaa valinnan"; "ua_message_center_title" = "Viestikeskus"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Viesti %@, lähetetty numeroon %@"; "ua_message_not_selected" = "Ei valittuja viestejä"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Lukematon viesti %@, lähetetty klo %@"; "ua_notification_button_accept" = "Hyväksy"; "ua_notification_button_add" = "Lisää"; "ua_notification_button_add_to_calendar" = "Lisää kalenteriin"; "ua_notification_button_book_now" = "Varaa nyt"; "ua_notification_button_buy_now" = "Osta nyt"; "ua_notification_button_copy" = "Kopioi"; "ua_notification_button_decline" = "Hylkää"; "ua_notification_button_dislike" = "Älä tykkää"; "ua_notification_button_download" = "Lataa"; "ua_notification_button_follow" = "Seuraa"; "ua_notification_button_less_like" = "Vähemmän tämän kaltaisia"; "ua_notification_button_like" = "Tykkää"; "ua_notification_button_more_like" = "Lisää tämän kaltaisia"; "ua_notification_button_no" = "Ei"; "ua_notification_button_opt_in" = "Opt-in"; "ua_notification_button_opt_out" = "Opt-out"; "ua_notification_button_rate_now" = "Arvostele nyt"; "ua_notification_button_remind" = "Muistuta minua myöhemmin"; "ua_notification_button_save" = "Tallenna"; "ua_notification_button_search" = "Hae"; "ua_notification_button_send_info" = "Lähetä tietoa"; "ua_notification_button_share" = "Jaa"; "ua_notification_button_shop_now" = "Tee nyt ostoksia"; "ua_notification_button_tell_me_more" = "Kerro minulle lisää"; "ua_notification_button_unfollow" = "Älä seuraa"; "ua_notification_button_yes" = "Kyllä"; "ua_ok" = "OK"; "ua_preference_center_title" = "Asetuskeskus"; "ua_retry_button" = "Yritä uudelleen"; "ua_select_all_messages" = "Valitse kaikki"; "ua_select_all_messages_description" = "Valitsee kaikki viestit"; "ua_select_none_messages" = "Älä valitse mitään"; "ua_select_none_messages_description" = "Nollaa viestivalinta"; "ua_unread_message_description" = "Viesti lukematon"; // Generic localizations "ua_dismiss" = "Hylkää"; "ua_escape" = "Poistu"; "ua_next" = "Seuraava"; "ua_previous" = "Edellinen"; "ua_submit" = "Lähetä"; "ua_loading" = "Lataus"; "ua_pager_progress" = "Sivu %@ / %@"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "Toista"; "ua_pause" = "Tauko"; "ua_stop" = "Pysäytä"; "ua_form_processing_error" = "Virhe lomakkeen käsittelyssä. Yritä uudelleen"; "ua_close" = "Sulje"; "ua_mute" = "Mykistä"; "ua_unmute" = "Poista mykistys"; "ua_required_field" = "* Pakollinen"; "ua_invalid_form_message" = "Korjaa virheelliset kentät jatkaaksesi"; ================================================ FILE: Airship/AirshipCore/Resources/fr-CA.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Annuler"; "ua_cancel_edit_messages_description" = "Annuler les modifications de message"; "ua_connection_error" = "Erreur de connexion"; "ua_content_error" = "Erreur de contenu"; "ua_delete_message" = "Supprimer le message"; "ua_delete_message_description" = "Supprimer les messages sélectionnés"; "ua_delete_messages" = "Supprimer le message"; "ua_delete_messages_description" = "Supprimer les messages sélectionnés"; "ua_edit_messages" = "Éditer"; "ua_edit_messages_description" = "Modifier les messages"; "ua_empty_message_list" = "Pas de messages"; "ua_mark_messages_read" = "Marquer comme lu"; "ua_mark_messages_read_description" = "Marquer les messages sélectionnés comme lus"; "ua_mc_failed_to_load" = "Impossible de charger le message. Veuillez réessayer plus tard."; "ua_mc_no_longer_available" = "Le message sélectionné n\'est plus disponible."; "ua_message_cell_description" = "Affiche le message complet"; "ua_message_cell_editing_description" = "Bascule la sélection"; "ua_message_center_title" = "Centre de messagerie"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Message %@, envoyé à %@"; "ua_message_not_selected" = "Aucun message sélectionné"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Message non lu %@, envoyé à %@"; "ua_notification_button_accept" = "J\'accepte"; "ua_notification_button_add" = "Ajouter"; "ua_notification_button_add_to_calendar" = "Ajouter au calendrier"; "ua_notification_button_book_now" = "Reserve maintenant"; "ua_notification_button_buy_now" = "Acheter maintenant"; "ua_notification_button_copy" = "Copie"; "ua_notification_button_decline" = "Déclin"; "ua_notification_button_dislike" = "Ne pas aimer"; "ua_notification_button_download" = "Télécharger"; "ua_notification_button_follow" = "Suivre"; "ua_notification_button_less_like" = "Moins comme ça"; "ua_notification_button_like" = "Comme"; "ua_notification_button_more_like" = "Plus comme ça"; "ua_notification_button_no" = "Non"; "ua_notification_button_opt_in" = "Adhésion"; "ua_notification_button_opt_out" = "Se désengager"; "ua_notification_button_rate_now" = "Évaluer maintenant"; "ua_notification_button_remind" = "Rappelez-moi plus tard"; "ua_notification_button_save" = "sauvegarder"; "ua_notification_button_search" = "Rechercher"; "ua_notification_button_send_info" = "Envoyer des informations"; "ua_notification_button_share" = "Partager"; "ua_notification_button_shop_now" = "Achetez maintenant"; "ua_notification_button_tell_me_more" = "Dis m\'en plus"; "ua_notification_button_unfollow" = "Ne plus suivre"; "ua_notification_button_yes" = "Oui"; "ua_ok" = "d\'accord"; "ua_preference_center_title" = "Centre de préférences"; "ua_retry_button" = "Réessayez"; "ua_select_all_messages" = "Tout sélectionner"; "ua_select_all_messages_description" = "Sélectionne tous les messages"; "ua_select_none_messages" = "Ne rien sélectionner"; "ua_select_none_messages_description" = "Réinitialiser la sélection des messages"; "ua_unread_message_description" = "Message non lu"; // Generic localizations "ua_dismiss" = "Rejeter"; "ua_escape" = "Échapper"; "ua_next" = "Suivant"; "ua_previous" = "Précédent"; "ua_submit" = "Soumettre"; "ua_loading" = "Chargement"; "ua_pager_progress" = "Page %@ sur %@"; "ua_x_of_y" = "%@ sur %@"; "ua_play" = "Jouer"; "ua_pause" = "Pause"; "ua_stop" = "Arrêter"; "ua_form_processing_error" = "Erreur lors du traitement du formulaire. Veuillez réessayer"; "ua_close" = "Fermer"; "ua_mute" = "Couper le son"; "ua_unmute" = "Activer le son"; "ua_required_field" = "* Obligatoire"; "ua_invalid_form_message" = "Veuillez corriger les champs non valides pour continuer"; ================================================ FILE: Airship/AirshipCore/Resources/fr.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Annuler"; "ua_cancel_edit_messages_description" = "Annuler les modifications de message"; "ua_connection_error" = "Erreur de connexion"; "ua_content_error" = "Erreur de contenu"; "ua_delete_message" = "Supprimer le message"; "ua_delete_message_description" = "Supprimer le message sélectionné"; "ua_delete_messages" = "Supprimer les messages"; "ua_delete_messages_description" = "Supprimer les messages sélectionnés"; "ua_edit_messages" = "Éditer"; "ua_edit_messages_description" = "Modifier les messages"; "ua_empty_message_list" = "Aucun message"; "ua_mark_messages_read" = "Marquer comme lu"; "ua_mark_messages_read_description" = "Marquer les messages sélectionnés comme lus"; "ua_mc_failed_to_load" = "Impossible de charger le message. Veuillez réessayer plus tard."; "ua_mc_no_longer_available" = "Le message sélectionné n\'est plus disponible."; "ua_message_cell_description" = "Affiche le message complet"; "ua_message_cell_editing_description" = "Bascule la sélection"; "ua_message_center_title" = "Centre de messagerie"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Message %@, envoyé à %@"; "ua_message_not_selected" = "Aucun message sélectionné"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Message non lu %@, envoyé à %@"; "ua_notification_button_accept" = "Accepter"; "ua_notification_button_add" = "Ajouter"; "ua_notification_button_add_to_calendar" = "Ajouter au calendrier"; "ua_notification_button_book_now" = "Réserver maintenant"; "ua_notification_button_buy_now" = "Acheter maintenant"; "ua_notification_button_copy" = "Copier"; "ua_notification_button_decline" = "Refuser"; "ua_notification_button_dislike" = "Je n\'aime pas"; "ua_notification_button_download" = "Télécharger"; "ua_notification_button_follow" = "Suivre"; "ua_notification_button_less_like" = "Moins de contenus comme celui-là"; "ua_notification_button_like" = "J\'aime"; "ua_notification_button_more_like" = "Plus de contenus comme celui-là"; "ua_notification_button_no" = "Non"; "ua_notification_button_opt_in" = "S\'abonner"; "ua_notification_button_opt_out" = "Se désabonner"; "ua_notification_button_rate_now" = "Évaluer maintenant"; "ua_notification_button_remind" = "Me le rappeler plus tard"; "ua_notification_button_save" = "Enregistrer"; "ua_notification_button_search" = "Rechercher"; "ua_notification_button_send_info" = "Envoyer des infos"; "ua_notification_button_share" = "Partager"; "ua_notification_button_shop_now" = "Acheter maintenant"; "ua_notification_button_tell_me_more" = "En savoir plus"; "ua_notification_button_unfollow" = "Ne plus suivre"; "ua_notification_button_yes" = "Oui"; "ua_ok" = "OK"; "ua_preference_center_title" = "Préférences"; "ua_retry_button" = "Réessayer"; "ua_select_all_messages" = "Tout sélectionner"; "ua_select_all_messages_description" = "Sélectionne tous les messages"; "ua_select_none_messages" = "Ne rien sélectionner"; "ua_select_none_messages_description" = "Réinitialiser la sélection des messages"; "ua_unread_message_description" = "Message non lu"; // Generic localizations "ua_dismiss" = "Rejeter"; "ua_escape" = "Échapper"; "ua_next" = "Suivant"; "ua_previous" = "Précédent"; "ua_submit" = "Soumettre"; "ua_loading" = "Chargement"; "ua_pager_progress" = "Page %@ sur %@"; "ua_x_of_y" = "%@ sur %@"; "ua_play" = "Jouer"; "ua_pause" = "Pause"; "ua_stop" = "Arrêter"; "ua_form_processing_error" = "Erreur lors du traitement du formulaire. Veuillez réessayer"; "ua_close" = "Fermer"; "ua_mute" = "Couper le son"; "ua_unmute" = "Activer le son"; "ua_required_field" = "* Obligatoire"; "ua_invalid_form_message" = "Veuillez corriger les champs non valides pour continuer"; ================================================ FILE: Airship/AirshipCore/Resources/hi.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "रद्द करें"; "ua_cancel_edit_messages_description" = "संदेश संपादन रद्द करें"; "ua_connection_error" = "कनेक्शन त्रुटि"; "ua_content_error" = "सामग्री त्रुटि"; "ua_delete_message" = "संदेश को हटाएं"; "ua_delete_message_description" = "चयनित संदेशों को हटाएं"; "ua_delete_messages" = "संदेश को हटाएं"; "ua_delete_messages_description" = "चयनित संदेशों को हटाएं"; "ua_edit_messages" = "संपादित करें"; "ua_edit_messages_description" = "संदेश संपादित करें"; "ua_empty_message_list" = "कोई संदेश नहीं"; "ua_mark_messages_read" = "पढ़ा गया चिह्नित करें"; "ua_mark_messages_read_description" = "चयनित संदेशों को पढ़े के रूप में चिह्नित करें"; "ua_mc_failed_to_load" = "संदेश लोड करने में असमर्थ। कृपया दोबारा कोशिश करें।"; "ua_mc_no_longer_available" = "चयनित संदेश अब उपलब्ध नहीं है."; "ua_message_cell_description" = "पूरा संदेश प्रदर्शित करता है"; "ua_message_cell_editing_description" = "चयन टॉगल करता है"; "ua_message_center_title" = "संदेश केंद्र"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "संदेश %@, %@ पर भेजा गया"; "ua_message_not_selected" = "कोई संदेश नहीं चुना गया"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "अपठित संदेश %@, %@ पर भेजा गया"; "ua_notification_button_accept" = "स्वीकार करें"; "ua_notification_button_add" = "जोड़ें"; "ua_notification_button_add_to_calendar" = "कैलेंडर में जोड़ें"; "ua_notification_button_book_now" = "अभी बुक करें"; "ua_notification_button_buy_now" = "अभी खरीदें"; "ua_notification_button_copy" = "कॉपी करें"; "ua_notification_button_decline" = "अस्वीकार करें"; "ua_notification_button_dislike" = "नापसंद करें"; "ua_notification_button_download" = "डाउनलोड करें"; "ua_notification_button_follow" = "फॉलो करें"; "ua_notification_button_less_like" = "इसकी तरह कम"; "ua_notification_button_like" = "पसंद करें"; "ua_notification_button_more_like" = "ज्यादातर इस की तरह"; "ua_notification_button_no" = "नहीं"; "ua_notification_button_opt_in" = "ऑप्ट-इन"; "ua_notification_button_opt_out" = "ऑप्ट-आउट"; "ua_notification_button_rate_now" = "अभी रेट करें"; "ua_notification_button_remind" = "मुझे बाद में याद दिलाएं"; "ua_notification_button_save" = "सहेजें"; "ua_notification_button_search" = "खोजें"; "ua_notification_button_send_info" = "जानकारी भेजें"; "ua_notification_button_share" = "साझा करें"; "ua_notification_button_shop_now" = "अभी खरीददारी करें"; "ua_notification_button_tell_me_more" = "मुझे और बताएं"; "ua_notification_button_unfollow" = "अनफॉलो करें"; "ua_notification_button_yes" = "हां"; "ua_ok" = "ठीक है"; "ua_preference_center_title" = "वरीयता केंद्र"; "ua_retry_button" = "पुन: प्रयास करें"; "ua_select_all_messages" = "सभी का चयन करें"; "ua_select_all_messages_description" = "सभी संदेशों का चयन करता है"; "ua_select_none_messages" = "किसी का चयन न करें"; "ua_select_none_messages_description" = "संदेश चयन रीसेट करें"; "ua_unread_message_description" = "संदेश अपठित करें"; // Generic localizations "ua_dismiss" = "खारिज करें"; "ua_escape" = "बचें"; "ua_next" = "अगला"; "ua_previous" = "पिछला"; "ua_submit" = "जमा करें"; "ua_loading" = "लोड हो रहा है"; "ua_pager_progress" = "पृष्ठ %@ का %@"; "ua_x_of_y" = "%@ में से %@"; "ua_play" = "चलाएं"; "ua_pause" = "विराम"; "ua_stop" = "रोकें"; "ua_form_processing_error" = "फॉर्म प्रोसेसिंग में त्रुटि। कृपया पुनः प्रयास करें"; "ua_close" = "बंद करें"; "ua_mute" = "म्यूट करें"; "ua_unmute" = "अनम्यूट करें"; "ua_required_field" = "* आवश्यक"; "ua_invalid_form_message" = "कृपया जारी रखने के लिए अमान्य फ़ील्ड को ठीक करें"; ================================================ FILE: Airship/AirshipCore/Resources/hr.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Otkaži"; "ua_cancel_edit_messages_description" = "Otkažite uređivanje poruka"; "ua_connection_error" = "Greška u povezivanju"; "ua_content_error" = "Pogreška sadržaja"; "ua_delete_message" = "Izbriši poruku"; "ua_delete_message_description" = "Izbrišite odabrane poruke"; "ua_delete_messages" = "Izbriši poruku"; "ua_delete_messages_description" = "Izbrišite odabrane poruke"; "ua_edit_messages" = "Uredi"; "ua_edit_messages_description" = "Uredite poruke"; "ua_empty_message_list" = "Nema poruka"; "ua_mark_messages_read" = "Označi kao pročitano"; "ua_mark_messages_read_description" = "Označite odabrane poruke kao pročitane"; "ua_mc_failed_to_load" = "Nije moguće učitati poruku. Molimo pokušajte ponovno kasnije."; "ua_mc_no_longer_available" = "Odabrana poruka više nije dostupna."; "ua_message_cell_description" = "Prikazuje cijelu poruku"; "ua_message_cell_editing_description" = "Prebacuje odabir"; "ua_message_center_title" = "Centar za poruke"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Poruka %@, poslana na %@"; "ua_message_not_selected" = "Nije odabrana nijedna poruka"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Nepročitana poruka %@, poslana u %@"; "ua_notification_button_accept" = "Prihvati"; "ua_notification_button_add" = "Dodaj"; "ua_notification_button_add_to_calendar" = "Dodaj u kalendar"; "ua_notification_button_book_now" = "Rezervirajte sada"; "ua_notification_button_buy_now" = "Kupi odmah"; "ua_notification_button_copy" = "Kopiraj"; "ua_notification_button_decline" = "Odbij"; "ua_notification_button_dislike" = "Ne sviđa mi se"; "ua_notification_button_download" = "Preuzimanje datoteka"; "ua_notification_button_follow" = "Slijedi"; "ua_notification_button_less_like" = "Manje ovakvih"; "ua_notification_button_like" = "Kao"; "ua_notification_button_more_like" = "Više ovakvih"; "ua_notification_button_no" = "Ne"; "ua_notification_button_opt_in" = "Uključite se"; "ua_notification_button_opt_out" = "Isključivanje"; "ua_notification_button_rate_now" = "Ocijeni sada"; "ua_notification_button_remind" = "Podsjeti me kasnije"; "ua_notification_button_save" = "Sačuvaj"; "ua_notification_button_search" = "Traži"; "ua_notification_button_send_info" = "Pošaljite informacije"; "ua_notification_button_share" = "Udio"; "ua_notification_button_shop_now" = "Kupite sada"; "ua_notification_button_tell_me_more" = "Reci mi više"; "ua_notification_button_unfollow" = "Prestani da slijediš"; "ua_notification_button_yes" = "Da"; "ua_ok" = "U redu"; "ua_preference_center_title" = "Centar za preferencije"; "ua_retry_button" = "Pokušajte ponovno"; "ua_select_all_messages" = "Odaberi sve"; "ua_select_all_messages_description" = "Odabire sve poruke"; "ua_select_none_messages" = "Ne odaberi ništa"; "ua_select_none_messages_description" = "Poništi odabir poruke"; "ua_unread_message_description" = "Poruka nepročitana"; // Generic localizations "ua_dismiss" = "Odbaciti"; "ua_escape" = "Izlaz"; "ua_next" = "Sljedeće"; "ua_previous" = "Prethodno"; "ua_submit" = "Podnijeti"; "ua_loading" = "Učitavanje"; "ua_pager_progress" = "Stranica %@ od %@"; "ua_x_of_y" = "%@ od %@"; "ua_play" = "Reproduciraj"; "ua_pause" = "Pauziraj"; "ua_stop" = "Zaustavi"; "ua_form_processing_error" = "Pogreška u obradi obrasca. Molimo pokušajte ponovno"; "ua_close" = "Zatvori"; "ua_mute" = "Isključi zvuk"; "ua_unmute" = "Uključi zvuk"; "ua_required_field" = "* Obavezno"; "ua_invalid_form_message" = "Molimo ispravite nevažeća polja za nastavak"; ================================================ FILE: Airship/AirshipCore/Resources/hu.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Mégse"; "ua_cancel_edit_messages_description" = "Üzenetszerkesztések visszavonása"; "ua_connection_error" = "Kapcsolati hiba"; "ua_content_error" = "Tartalmi hiba"; "ua_delete_message" = "Üzenet törlése"; "ua_delete_message_description" = "A kiválasztott üzenetek törlése"; "ua_delete_messages" = "Üzenet törlése"; "ua_delete_messages_description" = "A kiválasztott üzenetek törlése"; "ua_edit_messages" = "Szerkesztés"; "ua_edit_messages_description" = "Üzenetek szerkesztése"; "ua_empty_message_list" = "Nincs üzenete"; "ua_mark_messages_read" = "Jelöld olvasottnak"; "ua_mark_messages_read_description" = "A kiválasztott üzenetek megjelölése olvasottként"; "ua_mc_failed_to_load" = "Nem tudtam betölteni az üzenetet. Próbálja újra később."; "ua_mc_no_longer_available" = "A kiválasztott üzenet már nem érhető el."; "ua_message_cell_description" = "A teljes üzenetet jeleníti meg"; "ua_message_cell_editing_description" = "Váltsa a kijelölést"; "ua_message_center_title" = "Üzenetközpont"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Üzenet: %@, elküldve: %@"; "ua_message_not_selected" = "Nincsenek kiválasztott üzenetek"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Olvasatlan üzenet: %@, elküldve: %@"; "ua_notification_button_accept" = "Fogadd el"; "ua_notification_button_add" = "Hozzáadás"; "ua_notification_button_add_to_calendar" = "Hozzáadás naptárhoz"; "ua_notification_button_book_now" = "Foglalja le most"; "ua_notification_button_buy_now" = "Megvétel most"; "ua_notification_button_copy" = "Másolás"; "ua_notification_button_decline" = "Utasítsd el"; "ua_notification_button_dislike" = "Nem tetszik"; "ua_notification_button_download" = "Töltsd le"; "ua_notification_button_follow" = "Követem"; "ua_notification_button_less_like" = "Kevesebb ilyen"; "ua_notification_button_like" = "Tetszik"; "ua_notification_button_more_like" = "Még több ilyen"; "ua_notification_button_no" = "Nem"; "ua_notification_button_opt_in" = "Kérem"; "ua_notification_button_opt_out" = "Nem kérem"; "ua_notification_button_rate_now" = "Most értékelem"; "ua_notification_button_remind" = "Emlékeztess később"; "ua_notification_button_save" = "Mentés"; "ua_notification_button_search" = "Keress"; "ua_notification_button_send_info" = "Küldjön infót"; "ua_notification_button_share" = "Oszd meg"; "ua_notification_button_shop_now" = "Vásárlás most"; "ua_notification_button_tell_me_more" = "Mondjon róla többet"; "ua_notification_button_unfollow" = "Nem követem"; "ua_notification_button_yes" = "Igen"; "ua_ok" = "OK"; "ua_preference_center_title" = "Preferencia központ"; "ua_retry_button" = "Próbáld újra"; "ua_select_all_messages" = "Válaszd ki mindet"; "ua_select_all_messages_description" = "Kijelöli az összes üzenetet"; "ua_select_none_messages" = "Ne válassz ki egyet sem"; "ua_select_none_messages_description" = "Üzenetválasztás visszaállítása"; "ua_unread_message_description" = "Az üzenet olvasatlan"; // Generic localizations "ua_dismiss" = "Elutasítás"; "ua_escape" = "Kilépés"; "ua_next" = "Következő"; "ua_previous" = "Előző"; "ua_submit" = "Beküldés"; "ua_loading" = "Betöltés"; "ua_pager_progress" = "%@. oldal a %@-ból"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "Lejátszás"; "ua_pause" = "Szünet"; "ua_stop" = "Leállítás"; "ua_form_processing_error" = "Hiba az űrlap feldolgozása során. Kérjük, próbálja újra"; "ua_close" = "Bezárás"; "ua_mute" = "Némítás"; "ua_unmute" = "Némítás feloldása"; "ua_required_field" = "* Kötelező"; "ua_invalid_form_message" = "Kérjük, javítsa ki az érvénytelen mezőket a folytatáshoz"; ================================================ FILE: Airship/AirshipCore/Resources/id.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Batal"; "ua_cancel_edit_messages_description" = "Batalkan pengeditan pesan"; "ua_connection_error" = "Galat Koneksi"; "ua_content_error" = "Kesalahan Konten"; "ua_delete_message" = "Hapus pesan"; "ua_delete_message_description" = "Hapus pesan yang dipilih"; "ua_delete_messages" = "Hapus pesan"; "ua_delete_messages_description" = "Hapus pesan yang dipilih"; "ua_edit_messages" = "Edit"; "ua_edit_messages_description" = "Edit pesan"; "ua_empty_message_list" = "Tidak ada pesan"; "ua_mark_messages_read" = "Tandai sebagai Sudah Dibaca"; "ua_mark_messages_read_description" = "Tandai pesan yang dipilih telah dibaca"; "ua_mc_failed_to_load" = "Tidak dapat memuat pesan. Silakan coba lagi nanti."; "ua_mc_no_longer_available" = "Pesan yang dipilih tidak lagi tersedia."; "ua_message_cell_description" = "Menampilkan pesan lengkap"; "ua_message_cell_editing_description" = "Mengalihkan pilihan"; "ua_message_center_title" = "Pusat Pesan"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Pesan %@, dikirim pada %@"; "ua_message_not_selected" = "Tidak ada pesan yang dipilih"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Pesan yang belum dibaca %@, dikirim pada %@"; "ua_notification_button_accept" = "Setujui"; "ua_notification_button_add" = "Tambahkan"; "ua_notification_button_add_to_calendar" = "Tambahkan ke Kalender"; "ua_notification_button_book_now" = "Pesan Sekarang"; "ua_notification_button_buy_now" = "Beli Sekarang"; "ua_notification_button_copy" = "Salin"; "ua_notification_button_decline" = "Tolak"; "ua_notification_button_dislike" = "Tidak Suka"; "ua_notification_button_download" = "Unduh"; "ua_notification_button_follow" = "Ikuti"; "ua_notification_button_less_like" = "Kurangi yang Seperti Ini"; "ua_notification_button_like" = "Suka"; "ua_notification_button_more_like" = "Lebih Banyak yang Seperti Ini"; "ua_notification_button_no" = "Tidak"; "ua_notification_button_opt_in" = "Turut serta"; "ua_notification_button_opt_out" = "Batal turut serta"; "ua_notification_button_rate_now" = "Beri Nilai Sekarang"; "ua_notification_button_remind" = "Ingatkan Lagi Nanti"; "ua_notification_button_save" = "Simpan"; "ua_notification_button_search" = "Cari"; "ua_notification_button_send_info" = "Kirim Info"; "ua_notification_button_share" = "Bagikan"; "ua_notification_button_shop_now" = "Belanja Sekarang"; "ua_notification_button_tell_me_more" = "Beri Info Lebih Lengkap"; "ua_notification_button_unfollow" = "Berhenti ikuti"; "ua_notification_button_yes" = "Ya"; "ua_ok" = "OKE"; "ua_preference_center_title" = "Pusat Preferensi"; "ua_retry_button" = "Coba lagi"; "ua_select_all_messages" = "Pilih Semua"; "ua_select_all_messages_description" = "Pilih semua pesan"; "ua_select_none_messages" = "Batal Pilih Semua"; "ua_select_none_messages_description" = "Atur ulang pemilihan pesan"; "ua_unread_message_description" = "Pesan belum dibaca"; // Generic localizations "ua_dismiss" = "Tutup"; "ua_escape" = "Keluar"; "ua_next" = "Berikutnya"; "ua_previous" = "Sebelumnya"; "ua_submit" = "Kirim"; "ua_loading" = "Memuat"; "ua_pager_progress" = "Halaman %@ dari %@"; "ua_x_of_y" = "%@ dari %@"; "ua_play" = "Putar"; "ua_pause" = "Jeda"; "ua_stop" = "Berhenti"; "ua_form_processing_error" = "Kesalahan memproses formulir. Silakan coba lagi"; "ua_close" = "Tutup"; "ua_mute" = "Bisukan"; "ua_unmute" = "Bunyikan"; "ua_required_field" = "* Wajib diisi"; "ua_invalid_form_message" = "Silakan perbaiki kolom yang tidak valid untuk melanjutkan"; ================================================ FILE: Airship/AirshipCore/Resources/it.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Annulla"; "ua_cancel_edit_messages_description" = "Annulla le modifiche ai messaggi"; "ua_connection_error" = "Errore di connessione"; "ua_content_error" = "Errore di contenuto"; "ua_delete_message" = "Cancella il messaggio"; "ua_delete_message_description" = "Elimina i messaggi selezionati"; "ua_delete_messages" = "Cancella il messaggio"; "ua_delete_messages_description" = "Elimina i messaggi selezionati"; "ua_edit_messages" = "Modificare"; "ua_edit_messages_description" = "Modifica messaggi"; "ua_empty_message_list" = "Nessun messaggio"; "ua_mark_messages_read" = "Segna come letto"; "ua_mark_messages_read_description" = "Segna i messaggi selezionati come letti"; "ua_mc_failed_to_load" = "Impossibile caricare i messaggi. Riprovare più tardi"; "ua_mc_no_longer_available" = "Il messaggio selezionato non è più disponibile."; "ua_message_cell_description" = "Visualizza il messaggio completo"; "ua_message_cell_editing_description" = "Commuta la selezione"; "ua_message_center_title" = "Centro messaggi"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Messaggio %@, inviato alle %@"; "ua_message_not_selected" = "Nessun messaggio selezionato"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Messaggio non letto %@, inviato alle %@"; "ua_notification_button_accept" = "Accetta"; "ua_notification_button_add" = "Aggiungi"; "ua_notification_button_add_to_calendar" = "Aggiungi al calendario"; "ua_notification_button_book_now" = "Prenota adesso"; "ua_notification_button_buy_now" = "Acquista ora"; "ua_notification_button_copy" = "Copia"; "ua_notification_button_decline" = "Rifiuta"; "ua_notification_button_dislike" = "Non mi piace"; "ua_notification_button_download" = "Scarica"; "ua_notification_button_follow" = "Segui"; "ua_notification_button_less_like" = "Di meno come questo"; "ua_notification_button_like" = "Mi piace"; "ua_notification_button_more_like" = "Di più come questo"; "ua_notification_button_no" = "No"; "ua_notification_button_opt_in" = "Opt-in"; "ua_notification_button_opt_out" = "Opt-out"; "ua_notification_button_rate_now" = "Valuta adesso"; "ua_notification_button_remind" = "Ricordamelo più tardi"; "ua_notification_button_save" = "Salva"; "ua_notification_button_search" = "Cerca"; "ua_notification_button_send_info" = "Invia informazioni"; "ua_notification_button_share" = "Condividi"; "ua_notification_button_shop_now" = "Fai shopping ora"; "ua_notification_button_tell_me_more" = "Più informazioni"; "ua_notification_button_unfollow" = "Smetti di seguire"; "ua_notification_button_yes" = "Sì"; "ua_ok" = "OK"; "ua_preference_center_title" = "Centro di preferenze"; "ua_retry_button" = "Riprovare"; "ua_select_all_messages" = "Seleziona tutti"; "ua_select_all_messages_description" = "Seleziona tutti i messaggi"; "ua_select_none_messages" = "Non selezionare"; "ua_select_none_messages_description" = "Reimposta la selezione del messaggio"; "ua_unread_message_description" = "Messaggio non letto"; // Generic localizations "ua_dismiss" = "Chiudere"; "ua_escape" = "Sfuggire"; "ua_next" = "Successivo"; "ua_previous" = "Precedente"; "ua_submit" = "Inviare"; "ua_loading" = "Caricamento"; "ua_pager_progress" = "Pagina %@ di %@"; "ua_x_of_y" = "%@ di %@"; "ua_play" = "Riprodurre"; "ua_pause" = "Pausa"; "ua_stop" = "Fermare"; "ua_form_processing_error" = "Errore durante l'elaborazione del modulo. Per favore riprova"; "ua_close" = "Chiudi"; "ua_mute" = "Silenzia"; "ua_unmute" = "Riattiva audio"; "ua_required_field" = "* Obbligatorio"; "ua_invalid_form_message" = "Si prega di correggere i campi non validi per continuare"; ================================================ FILE: Airship/AirshipCore/Resources/iw.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "ביטול"; "ua_cancel_edit_messages_description" = "בטל את עריכות ההודעה"; "ua_connection_error" = "שגיאת התחברות"; "ua_content_error" = "שגיאת תוכן"; "ua_delete_message" = "למחוק הודעה"; "ua_delete_message_description" = "מחק את ההודעות שנבחרו"; "ua_delete_messages" = "למחוק הודעה"; "ua_delete_messages_description" = "מחק את ההודעות שנבחרו"; "ua_edit_messages" = "לַעֲרוֹך"; "ua_edit_messages_description" = "ערוך הודעות"; "ua_empty_message_list" = "אין הודעות"; "ua_mark_messages_read" = "סמן כנקרא"; "ua_mark_messages_read_description" = "סמן את ההודעות שנבחרו כנקראו"; "ua_mc_failed_to_load" = "לא ניתן לטעון הודעה. נסה שוב מאוחר יותר."; "ua_mc_no_longer_available" = "ההודעה שנבחרה אינה זמינה יותר."; "ua_message_cell_description" = "מציג את ההודעה המלאה"; "ua_message_cell_editing_description" = "מחליף בחירה"; "ua_message_center_title" = "מרכז ההודעות"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "הודעה %@, נשלחה ב-%@"; "ua_message_not_selected" = "לא נבחרו הודעות"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "הודעה שלא נקראה %@, נשלחה ב-%@"; "ua_notification_button_accept" = "אשר"; "ua_notification_button_add" = "הוסף"; "ua_notification_button_add_to_calendar" = "הוסף ליומן"; "ua_notification_button_book_now" = "הזמן עכשיו"; "ua_notification_button_buy_now" = "קנה עכשיו"; "ua_notification_button_copy" = "העתק"; "ua_notification_button_decline" = "דחה"; "ua_notification_button_dislike" = "לא אהבתי"; "ua_notification_button_download" = "הורדה"; "ua_notification_button_follow" = "עקוב"; "ua_notification_button_less_like" = "פחות כמו זה"; "ua_notification_button_like" = "אהבתי"; "ua_notification_button_more_like" = "יותר כמו זה"; "ua_notification_button_no" = "לא"; "ua_notification_button_opt_in" = "הסכמת הרשמה"; "ua_notification_button_opt_out" = "ביטול הסכמת הרשמה"; "ua_notification_button_rate_now" = "דרג עכשיו"; "ua_notification_button_remind" = "הזכר לי מאוחר יותר"; "ua_notification_button_save" = "שמור"; "ua_notification_button_search" = "חיפוש"; "ua_notification_button_send_info" = "שלח פרטים"; "ua_notification_button_share" = "שתף"; "ua_notification_button_shop_now" = "קנה עכשיו"; "ua_notification_button_tell_me_more" = "ספר לי עוד"; "ua_notification_button_unfollow" = "הסר עקיבה"; "ua_notification_button_yes" = "כן"; "ua_ok" = "אישור"; "ua_preference_center_title" = "מרכז העדפות"; "ua_retry_button" = "נסה שוב"; "ua_select_all_messages" = "בחר הכול"; "ua_select_all_messages_description" = "בוחר את כל ההודעות"; "ua_select_none_messages" = "ללא בחירה"; "ua_select_none_messages_description" = "אפס את בחירת ההודעה"; "ua_unread_message_description" = "הודעה שלא נקראה"; // Generic localizations "ua_dismiss" = "להתעלם"; "ua_escape" = "לצאת"; "ua_next" = "הבא"; "ua_previous" = "הקודם"; "ua_submit" = "להגיש"; "ua_loading" = "טוען"; "ua_pager_progress" = "עמוד %@ מתוך %@"; "ua_x_of_y" = "%@ מתוך %@"; "ua_play" = "לנגן"; "ua_pause" = "להפסיק"; "ua_stop" = "לעצור"; "ua_form_processing_error" = "שגיאה בעיבוד הטופס. אנא נסה שוב"; "ua_close" = "סגור"; "ua_mute" = "השתק"; "ua_unmute" = "בטל השתקה"; "ua_required_field" = "* נדרש"; "ua_invalid_form_message" = "אנא תקן את השדות הלא תקינים כדי להמשיך"; ================================================ FILE: Airship/AirshipCore/Resources/ja.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "キャンセルする"; "ua_cancel_edit_messages_description" = "メッセージ編集をキャンセル"; "ua_connection_error" = "接続エラー"; "ua_content_error" = "コンテンツエラー"; "ua_delete_message" = "メッセージを削除"; "ua_delete_message_description" = "選択したメッセージを削除"; "ua_delete_messages" = "メッセージを削除"; "ua_delete_messages_description" = "選択したメッセージを削除"; "ua_edit_messages" = "編集"; "ua_edit_messages_description" = "メッセージを編集"; "ua_empty_message_list" = "メッセージはありません"; "ua_mark_messages_read" = "既読にする"; "ua_mark_messages_read_description" = "選択したメッセージに既読のマークを付ける"; "ua_mc_failed_to_load" = "メッセージを読み込むことができません。後ほどもう一度お試しください。"; "ua_mc_no_longer_available" = "選択したメッセージは使用できなくなります。"; "ua_message_cell_description" = "完全なメッセージを表示"; "ua_message_cell_editing_description" = "選択を切り替えます"; "ua_message_center_title" = "メッセージ センター"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "メッセージ%@、%@で送信"; "ua_message_not_selected" = "メッセージが選択されていません"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "未読メッセージ%@、%@で送信"; "ua_notification_button_accept" = "同意"; "ua_notification_button_add" = "追加する"; "ua_notification_button_add_to_calendar" = "カレンダーに追加"; "ua_notification_button_book_now" = "今すぐ予約する"; "ua_notification_button_buy_now" = "今すぐ購入する"; "ua_notification_button_copy" = "コピーする"; "ua_notification_button_decline" = "拒否"; "ua_notification_button_dislike" = "いいね!を取り消す"; "ua_notification_button_download" = "ダウンロード"; "ua_notification_button_follow" = "フォローする"; "ua_notification_button_less_like" = "もっと違う項目"; "ua_notification_button_like" = "いいね!"; "ua_notification_button_more_like" = "さらに似ている項目"; "ua_notification_button_no" = "いいえ"; "ua_notification_button_opt_in" = "オプトイン"; "ua_notification_button_opt_out" = "オプトアウト"; "ua_notification_button_rate_now" = "今すぐ評価する"; "ua_notification_button_remind" = "後で通知"; "ua_notification_button_save" = "保存する"; "ua_notification_button_search" = "検索する"; "ua_notification_button_send_info" = "情報を送信する"; "ua_notification_button_share" = "共有"; "ua_notification_button_shop_now" = "今すぐお店へ移動する"; "ua_notification_button_tell_me_more" = "詳しく教えてください"; "ua_notification_button_unfollow" = "フォローしない"; "ua_notification_button_yes" = "はい"; "ua_ok" = "OK"; "ua_preference_center_title" = "プリファレンスセンター"; "ua_retry_button" = "もう一度やり直す"; "ua_select_all_messages" = "すべて選択する"; "ua_select_all_messages_description" = "すべてのメッセージを選択"; "ua_select_none_messages" = "何も選択しない"; "ua_select_none_messages_description" = "メッセージ選択をリセット"; "ua_unread_message_description" = "未読メッセージ"; // Generic localizations "ua_dismiss" = "解散する"; "ua_escape" = "脱出"; "ua_next" = "次へ"; "ua_previous" = "前"; "ua_submit" = "は提出します"; "ua_loading" = "ロード中"; "ua_pager_progress" = "ページ%@/%@"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "再生"; "ua_pause" = "一時停止"; "ua_stop" = "止める"; "ua_form_processing_error" = "フォームの処理中にエラーが発生しました。もう一度お試しください"; "ua_close" = "閉じる"; "ua_mute" = "ミュート"; "ua_unmute" = "ミュート解除"; "ua_required_field" = "* 必須"; "ua_invalid_form_message" = "続行するには、無効なフィールドを修正してください"; ================================================ FILE: Airship/AirshipCore/Resources/ko.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "취소"; "ua_cancel_edit_messages_description" = "메시지 수정 취소"; "ua_connection_error" = "연결 오류"; "ua_content_error" = "콘텐츠 오류"; "ua_delete_message" = "메시지 삭제"; "ua_delete_message_description" = "선택한 메시지 삭제"; "ua_delete_messages" = "메시지 삭제"; "ua_delete_messages_description" = "선택한 메시지 삭제"; "ua_edit_messages" = "편집하다"; "ua_edit_messages_description" = "메시지 수정"; "ua_empty_message_list" = "메시지가 없습니다"; "ua_mark_messages_read" = "읽음 표시"; "ua_mark_messages_read_description" = "선택한 메시지를 읽은 상태로 표시"; "ua_mc_failed_to_load" = "메시지를 로드할 수 없습니다. 나중에 다시 시도해주십시오."; "ua_mc_no_longer_available" = "선택한 메시지는 더 이상 사용할 수 없습니다."; "ua_message_cell_description" = "전체 메시지 표시"; "ua_message_cell_editing_description" = "선택 토글"; "ua_message_center_title" = "메시지 센터"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "메시지 %@, %@ 에 전송됨"; "ua_message_not_selected" = "선택된 메시지 없음"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "읽지 않은 메시지 %@, %@ 에 전송됨"; "ua_notification_button_accept" = "수락"; "ua_notification_button_add" = "추가"; "ua_notification_button_add_to_calendar" = "캘린더에 추가"; "ua_notification_button_book_now" = "지금 예약"; "ua_notification_button_buy_now" = "지금 구매"; "ua_notification_button_copy" = "복사"; "ua_notification_button_decline" = "거절"; "ua_notification_button_dislike" = "싫어요"; "ua_notification_button_download" = "다운로드"; "ua_notification_button_follow" = "팔로우"; "ua_notification_button_less_like" = "덜 좋아함"; "ua_notification_button_like" = "좋아요"; "ua_notification_button_more_like" = "더 좋아함"; "ua_notification_button_no" = "아니요"; "ua_notification_button_opt_in" = "구독"; "ua_notification_button_opt_out" = "구독중지"; "ua_notification_button_rate_now" = "지금 평가"; "ua_notification_button_remind" = "나중에 알림"; "ua_notification_button_save" = "저장"; "ua_notification_button_search" = "검색"; "ua_notification_button_send_info" = "정보 보내기"; "ua_notification_button_share" = "공유"; "ua_notification_button_shop_now" = "지금 쇼핑"; "ua_notification_button_tell_me_more" = "더 자세히"; "ua_notification_button_unfollow" = "언팔로우"; "ua_notification_button_yes" = "예"; "ua_ok" = "확인"; "ua_preference_center_title" = "환경 설정 센터"; "ua_retry_button" = "다시 시도"; "ua_select_all_messages" = "모두 선택"; "ua_select_all_messages_description" = "모든 메시지 선택"; "ua_select_none_messages" = "없음 선택"; "ua_select_none_messages_description" = "메시지 선택 재설정"; "ua_unread_message_description" = "읽지 않은 메시지"; // Generic localizations "ua_dismiss" = "해제"; "ua_escape" = "탈출"; "ua_next" = "다음"; "ua_previous" = "이전"; "ua_submit" = "제출"; "ua_loading" = "불러오는 중"; "ua_pager_progress" = "페이지 %@/%@"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "재생"; "ua_pause" = "일시정지"; "ua_stop" = "정지"; "ua_form_processing_error" = "양식 처리 중 오류가 발생했습니다. 다시 시도해 주세요"; "ua_close" = "닫기"; "ua_mute" = "음소거"; "ua_unmute" = "음소거 해제"; "ua_required_field" = "* 필수"; "ua_invalid_form_message" = "계속하려면 잘못된 필드를 수정하세요"; ================================================ FILE: Airship/AirshipCore/Resources/lt.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Atšaukti"; "ua_cancel_edit_messages_description" = "Atšaukti žinučių redagavimą"; "ua_connection_error" = "Ryšio klaida"; "ua_content_error" = "Turinio klaida"; "ua_delete_message" = "Ištrinti žinutę"; "ua_delete_message_description" = "Ištrinti pasirinktas žinutes"; "ua_delete_messages" = "Ištrinti žinutę"; "ua_delete_messages_description" = "Ištrinti pasirinktas žinutes"; "ua_edit_messages" = "Redaguoti"; "ua_edit_messages_description" = "Redaguoti žinutes"; "ua_empty_message_list" = "Jokių žinučių"; "ua_mark_messages_read" = "Pažymėti kaip skaitytą"; "ua_mark_messages_read_description" = "Pažymėti pasirinktas žinutes kaip perskaitytas"; "ua_mc_failed_to_load" = "Nepavyko įkelti žinutės. Pabandykite dar kartą vėliau."; "ua_mc_no_longer_available" = "Pasirinkta žinutė nebepasiekiama."; "ua_message_cell_description" = "Rodo visą žinutę"; "ua_message_cell_editing_description" = "Perjungia pasirinkimą"; "ua_message_center_title" = "Žinučių centras"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Žinutė %@, išsiųsta %@"; "ua_message_not_selected" = "Jokių žinučių nepasirinkta"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Neskaityta žinutė %@, išsiųsta %@"; "ua_notification_button_accept" = "Priimti"; "ua_notification_button_add" = "Pridėti"; "ua_notification_button_add_to_calendar" = "Pridėti prie kalendoriaus"; "ua_notification_button_book_now" = "Užsakyti dabar"; "ua_notification_button_buy_now" = "Pirkti dabar"; "ua_notification_button_copy" = "Kopijuoti"; "ua_notification_button_decline" = "Atmesti"; "ua_notification_button_dislike" = "Nepatinka"; "ua_notification_button_download" = "Atsisiųsti"; "ua_notification_button_follow" = "Sekti"; "ua_notification_button_less_like" = "Mažiau tokio"; "ua_notification_button_like" = "Panašus"; "ua_notification_button_more_like" = "Daugiau tokio"; "ua_notification_button_no" = "Nr."; "ua_notification_button_opt_in" = "Sutikti"; "ua_notification_button_opt_out" = "Atsisakyti"; "ua_notification_button_rate_now" = "Įvertinti dabar"; "ua_notification_button_remind" = "Priminti man vėliau"; "ua_notification_button_save" = "Išsaugoti"; "ua_notification_button_search" = "Paieška"; "ua_notification_button_send_info" = "Siųsti informaciją"; "ua_notification_button_share" = "Dalintis"; "ua_notification_button_shop_now" = "Apsipirkti dabar"; "ua_notification_button_tell_me_more" = "Noriu sužinoti daugiau"; "ua_notification_button_unfollow" = "Nebesekti"; "ua_notification_button_yes" = "Taip"; "ua_ok" = "Gerai"; "ua_preference_center_title" = "Pirmenybių centras"; "ua_retry_button" = "Bandyti dar kartą"; "ua_select_all_messages" = "Pasirinkti viską"; "ua_select_all_messages_description" = "Parenka visas žinutes"; "ua_select_none_messages" = "Pasirinkti jokio"; "ua_select_none_messages_description" = "Iš naujo nustatyti žinutės pasirinkimą"; "ua_unread_message_description" = "Žinutė neskaityta"; // Generic localizations "ua_dismiss" = "Atsisakyti"; "ua_escape" = "Išeiti"; "ua_next" = "Kitas"; "ua_previous" = "Ankstesnis"; "ua_submit" = "Pateikti"; "ua_loading" = "Įkeliama"; "ua_pager_progress" = "Puslapis %@ iš %@"; "ua_x_of_y" = "%@ iš %@"; "ua_play" = "Groti"; "ua_pause" = "Pristabdyti"; "ua_stop" = "Sustabdyti"; "ua_form_processing_error" = "Klaida apdorojant formą. Bandykite dar kartą"; "ua_close" = "Uždaryti"; "ua_mute" = "Nutildyti"; "ua_unmute" = "Įjungti garsą"; "ua_required_field" = "* Privaloma"; "ua_invalid_form_message" = "Prašome ištaisyti netinkamus laukus, kad galėtumėte tęsti"; ================================================ FILE: Airship/AirshipCore/Resources/lv.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Atcelt"; "ua_cancel_edit_messages_description" = "Atcelt ziņojumu labojumus"; "ua_connection_error" = "Savienojuma kļūda"; "ua_content_error" = "Satura kļūda"; "ua_delete_message" = "Dzēst ziņojumu"; "ua_delete_message_description" = "Dzēst atlasītos ziņojumus"; "ua_delete_messages" = "Dzēst ziņojumu"; "ua_delete_messages_description" = "Dzēst atlasītos ziņojumus"; "ua_edit_messages" = "Rediģēt"; "ua_edit_messages_description" = "Rediģēt ziņojumus"; "ua_empty_message_list" = "Nav ziņojumu"; "ua_mark_messages_read" = "Atzīmēt kā izlasītu"; "ua_mark_messages_read_description" = "Atzīmēt atlasītās ziņojumus kā izlasītus"; "ua_mc_failed_to_load" = "Nevar ielādēt ziņojumu. Lūdzu, pamēģiniet vēlreiz vēlāk."; "ua_mc_no_longer_available" = "Atlasītais ziņojums vairs nav pieejama."; "ua_message_cell_description" = "Parāda pilnu ziņojumu"; "ua_message_cell_editing_description" = "Pārslēdz atlasi"; "ua_message_center_title" = "Ziņojumu centrs"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Ziņojums %@, nosūtīts uz %@"; "ua_message_not_selected" = "Nav atlasīts neviens ziņojums"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Nelasīts ziņojums %@, nosūtīts plkst. %@"; "ua_notification_button_accept" = "Pieņemt"; "ua_notification_button_add" = "Pievienot"; "ua_notification_button_add_to_calendar" = "Pievienot kalendāram"; "ua_notification_button_book_now" = "Rezervēt tagad"; "ua_notification_button_buy_now" = "Pirkt tagad"; "ua_notification_button_copy" = "Kopēt"; "ua_notification_button_decline" = "Noraidīt"; "ua_notification_button_dislike" = "Nepatīk"; "ua_notification_button_download" = "Lejupielādēt"; "ua_notification_button_follow" = "Sekot"; "ua_notification_button_less_like" = "Mazāk kā šis"; "ua_notification_button_like" = "Patīk"; "ua_notification_button_more_like" = "Vairāk kā šis"; "ua_notification_button_no" = "Nē"; "ua_notification_button_opt_in" = "Pieteikties"; "ua_notification_button_opt_out" = "Atteikties"; "ua_notification_button_rate_now" = "Novērtēt tagad"; "ua_notification_button_remind" = "Atgādināt man vēlāk"; "ua_notification_button_save" = "Saglabāt"; "ua_notification_button_search" = "Meklēt"; "ua_notification_button_send_info" = "Nosūtīt Info"; "ua_notification_button_share" = "Dalīties"; "ua_notification_button_shop_now" = "Iepirkties tūlīt"; "ua_notification_button_tell_me_more" = "Pastāstiet man vairāk"; "ua_notification_button_unfollow" = "Pārtraukt sekošanu"; "ua_notification_button_yes" = "Jā"; "ua_ok" = "Labi"; "ua_preference_center_title" = "Preferenču centrs"; "ua_retry_button" = "Mēģiniet vēlreiz"; "ua_select_all_messages" = "Atlasīt Visi"; "ua_select_all_messages_description" = "Atlasa visus ziņojumus"; "ua_select_none_messages" = "Atlasīt Neviens"; "ua_select_none_messages_description" = "Atiestatīt ziņojumu atlasi"; "ua_unread_message_description" = "Ziņojums nelasīts"; // Generic localizations "ua_dismiss" = "Noraidīt"; "ua_escape" = "Izbēgt"; "ua_next" = "Nākamais"; "ua_previous" = "Iepriekšējais"; "ua_submit" = "Iesniegt"; "ua_loading" = "Ielādē"; "ua_pager_progress" = "Lapa %@ no %@"; "ua_x_of_y" = "%@ no %@"; "ua_play" = "Atskaņot"; "ua_pause" = "Pauze"; "ua_stop" = "Apturēt"; "ua_form_processing_error" = "Kļūda, apstrādājot veidlapu. Lūdzu, mēģiniet vēlreiz"; "ua_close" = "Aizvērt"; "ua_mute" = "Izslēgt skaņu"; "ua_unmute" = "Ieslēgt skaņu"; "ua_required_field" = "* Obligāts"; "ua_invalid_form_message" = "Lūdzu, labojiet nederīgos laukus, lai turpinātu"; ================================================ FILE: Airship/AirshipCore/Resources/ms.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Batal"; "ua_cancel_edit_messages_description" = "Batalkan suntingan mesej"; "ua_connection_error" = "Ralat Sambungan"; "ua_content_error" = "Ralat Kandungan"; "ua_delete_message" = "Padamkan mesej"; "ua_delete_message_description" = "Padamkan mesej yang dipilih"; "ua_delete_messages" = "Padamkan mesej"; "ua_delete_messages_description" = "Padamkan mesej yang dipilih"; "ua_edit_messages" = "Suntingan"; "ua_edit_messages_description" = "Sunting mesej"; "ua_empty_message_list" = "Tiada Mesej"; "ua_mark_messages_read" = "Tanda Dibaca"; "ua_mark_messages_read_description" = "Tanda mesej yang dipilih telah dibaca"; "ua_mc_failed_to_load" = "Tidak dapat memuatkan mesej. Sila cuba sebentar lagi."; "ua_mc_no_longer_available" = "Mesej yang dipilih tidak lagi tersedia."; "ua_message_cell_description" = "Memaparkan mesej penuh"; "ua_message_cell_editing_description" = "Togol pemilihan"; "ua_message_center_title" = "Pusat Mesej"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Mesej %@, dihantar pada %@"; "ua_message_not_selected" = "Tiada mesej dipilih"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Mesej belum dibaca %@, dihantar pada %@"; "ua_notification_button_accept" = "Terima"; "ua_notification_button_add" = "Tambah"; "ua_notification_button_add_to_calendar" = "Tambah Ke Kalendar"; "ua_notification_button_book_now" = "Tempah Sekarang"; "ua_notification_button_buy_now" = "Beli Sekarang"; "ua_notification_button_copy" = "Salin"; "ua_notification_button_decline" = "Tolak"; "ua_notification_button_dislike" = "Tidak suka"; "ua_notification_button_download" = "Muat turun"; "ua_notification_button_follow" = "Ikut"; "ua_notification_button_less_like" = "Kurang Seperti Ini"; "ua_notification_button_like" = "Suka"; "ua_notification_button_more_like" = "Lebih Seperti Ini"; "ua_notification_button_no" = "Tidak"; "ua_notification_button_opt_in" = "Ikut serta"; "ua_notification_button_opt_out" = "Tarik diri"; "ua_notification_button_rate_now" = "Nilaikan Sekarang"; "ua_notification_button_remind" = "Ingatkan Saya Kemudian"; "ua_notification_button_save" = "Simpan"; "ua_notification_button_search" = "Carian"; "ua_notification_button_send_info" = "Menghantar Info"; "ua_notification_button_share" = "Kongsi"; "ua_notification_button_shop_now" = "Berbelanjalah Sekarang"; "ua_notification_button_tell_me_more" = "Beritahu Saya Lagi"; "ua_notification_button_unfollow" = "Nyahikut"; "ua_notification_button_yes" = "Ya"; "ua_ok" = "OK"; "ua_preference_center_title" = "Pusat Keutamaan"; "ua_retry_button" = "Cuba semula"; "ua_select_all_messages" = "Pilih Semua"; "ua_select_all_messages_description" = "Memilih semua mesej"; "ua_select_none_messages" = "Pilih Tiada"; "ua_select_none_messages_description" = "Tetapkan semula pemilihan mesej"; "ua_unread_message_description" = "Mesej belum dibaca"; // Generic localizations "ua_dismiss" = "Singkirkan"; "ua_escape" = "Melarikan diri"; "ua_next" = "Seterusnya"; "ua_previous" = "Terdahulu"; "ua_submit" = "Serahkan"; "ua_loading" = "Memuat"; "ua_pager_progress" = "Halaman %@ daripada %@"; "ua_x_of_y" = "%@ daripada %@"; "ua_play" = "Mainkan"; "ua_pause" = "Jeda"; "ua_stop" = "Berhenti"; "ua_form_processing_error" = "Ralat memproses borang. Sila cuba lagi"; "ua_close" = "Tutup"; "ua_mute" = "Bisukan"; "ua_unmute" = "Nyahbisu"; "ua_required_field" = "* Wajib"; "ua_invalid_form_message" = "Sila betulkan medan yang tidak sah untuk meneruskan"; ================================================ FILE: Airship/AirshipCore/Resources/nl.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Annuleer"; "ua_cancel_edit_messages_description" = "Berichtbewerkingen annuleren"; "ua_connection_error" = "Verbindingsfout"; "ua_content_error" = "Inhoudsfout"; "ua_delete_message" = "Bericht verwijderen"; "ua_delete_message_description" = "Geselecteerde berichten verwijderen"; "ua_delete_messages" = "Bericht verwijderen"; "ua_delete_messages_description" = "Geselecteerde berichten verwijderen"; "ua_edit_messages" = "Bewerking"; "ua_edit_messages_description" = "Berichten bewerken"; "ua_empty_message_list" = "Geen berichten"; "ua_mark_messages_read" = "Markeer als Gelezen"; "ua_mark_messages_read_description" = "Markeer geselecteerde berichten als gelezen"; "ua_mc_failed_to_load" = "Kan bericht niet laden. Probeer het later opnieuw."; "ua_mc_no_longer_available" = "Het geselecteerde bericht is niet meer beschikbaar."; "ua_message_cell_description" = "Geeft volledig bericht weer"; "ua_message_cell_editing_description" = "Schakelt selectie in"; "ua_message_center_title" = "Berichtencentrum"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Bericht %@, verzonden om %@"; "ua_message_not_selected" = "Geen berichten geselecteerd"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Ongelezen bericht %@, verzonden om %@"; "ua_notification_button_accept" = "Accepteer"; "ua_notification_button_add" = "Voeg toe"; "ua_notification_button_add_to_calendar" = "Aan Kalender toevoegen"; "ua_notification_button_book_now" = "Boek Nu"; "ua_notification_button_buy_now" = "Koop nu"; "ua_notification_button_copy" = "Kopieer"; "ua_notification_button_decline" = "Wijs af"; "ua_notification_button_dislike" = "Vind ik niet leuk"; "ua_notification_button_download" = "Download"; "ua_notification_button_follow" = "Volg"; "ua_notification_button_less_like" = "Minder \"Vind Dit Leuk\""; "ua_notification_button_like" = "Vind ik leuk"; "ua_notification_button_more_like" = "Meer \"Vind Dit Leuk\""; "ua_notification_button_no" = "Nee"; "ua_notification_button_opt_in" = "Aanmelden"; "ua_notification_button_opt_out" = "Afmelden"; "ua_notification_button_rate_now" = "Beoordeel Nu"; "ua_notification_button_remind" = "Herinner mij later"; "ua_notification_button_save" = "Sla op"; "ua_notification_button_search" = "Zoek"; "ua_notification_button_send_info" = "Stuur Info"; "ua_notification_button_share" = "Deel"; "ua_notification_button_shop_now" = "Winkel nu"; "ua_notification_button_tell_me_more" = "Vertel Mij Meer"; "ua_notification_button_unfollow" = "Ontvolg"; "ua_notification_button_yes" = "Ja"; "ua_ok" = "OK"; "ua_preference_center_title" = "Voorkeurscentrum"; "ua_retry_button" = "Probeer opnieuw"; "ua_select_all_messages" = "Selecteer Alle"; "ua_select_all_messages_description" = "Selecteert alle berichten"; "ua_select_none_messages" = "Selecteer Geen"; "ua_select_none_messages_description" = "Berichtselectie resetten"; "ua_unread_message_description" = "Bericht ongelezen"; // Generic localizations "ua_dismiss" = "Afwijzen"; "ua_escape" = "Ontsnappen"; "ua_next" = "Volgende"; "ua_previous" = "Vorige"; "ua_submit" = "Indienen"; "ua_loading" = "Laden"; "ua_pager_progress" = "Pagina %@ van %@"; "ua_x_of_y" = "%@ van %@"; "ua_play" = "Spelen"; "ua_pause" = "Pauze"; "ua_stop" = "Stoppen"; "ua_form_processing_error" = "Fout bij het verwerken van formulier. Probeer het opnieuw"; "ua_close" = "Sluiten"; "ua_mute" = "Dempen"; "ua_unmute" = "Dempen opheffen"; "ua_required_field" = "* Verplicht"; "ua_invalid_form_message" = "Corrigeer de ongeldige velden om door te gaan"; ================================================ FILE: Airship/AirshipCore/Resources/no.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Avbryt"; "ua_cancel_edit_messages_description" = "Avbryt meldingsredigeringer"; "ua_connection_error" = "Tilkoblingsfeil"; "ua_content_error" = "Innholdsfeil"; "ua_delete_message" = "Slett melding"; "ua_delete_message_description" = "Slett valgte meldinger"; "ua_delete_messages" = "Slett melding"; "ua_delete_messages_description" = "Slett valgte meldinger"; "ua_edit_messages" = "Rediger"; "ua_edit_messages_description" = "Rediger meldinger"; "ua_empty_message_list" = "Ingen meldinger"; "ua_mark_messages_read" = "Merk som lest"; "ua_mark_messages_read_description" = "Merk valgte meldinger som lest"; "ua_mc_failed_to_load" = "Kan ikke laste inn meldingen. Prøv igjen senere."; "ua_mc_no_longer_available" = "Den valgte meldingen er ikke lenger tilgjengelig."; "ua_message_cell_description" = "Viser hele meldingen"; "ua_message_cell_editing_description" = "Bytter valg"; "ua_message_center_title" = "Meldingssenter"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Melding %@, sendt kl. %@"; "ua_message_not_selected" = "Ingen meldinger er valgt"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Ulest melding %@, sendt kl. %@"; "ua_notification_button_accept" = "Aksepter"; "ua_notification_button_add" = "Legg til"; "ua_notification_button_add_to_calendar" = "Legg til kalender"; "ua_notification_button_book_now" = "Bestill nå"; "ua_notification_button_buy_now" = "Kjøp nå"; "ua_notification_button_copy" = "Kopier"; "ua_notification_button_decline" = "Avslå"; "ua_notification_button_dislike" = "Mislike"; "ua_notification_button_download" = "Last ned"; "ua_notification_button_follow" = "Følg"; "ua_notification_button_less_like" = "Mindre som dette"; "ua_notification_button_like" = "Like"; "ua_notification_button_more_like" = "Mer som dette"; "ua_notification_button_no" = "Nei"; "ua_notification_button_opt_in" = "Meld deg på"; "ua_notification_button_opt_out" = "Meld deg av"; "ua_notification_button_rate_now" = "Gi karakter nå"; "ua_notification_button_remind" = "Minn meg på det senere"; "ua_notification_button_save" = "Lagre"; "ua_notification_button_search" = "Søk"; "ua_notification_button_send_info" = "Send info"; "ua_notification_button_share" = "Del"; "ua_notification_button_shop_now" = "Handle nå"; "ua_notification_button_tell_me_more" = "Fortell meg mer"; "ua_notification_button_unfollow" = "Slutt å følge"; "ua_notification_button_yes" = "Ja"; "ua_ok" = "OK"; "ua_preference_center_title" = "Preferansesenter"; "ua_retry_button" = "Prøv igjen"; "ua_select_all_messages" = "Velg alle"; "ua_select_all_messages_description" = "Velger alle meldinger"; "ua_select_none_messages" = "Velg ingen"; "ua_select_none_messages_description" = "Tilbakestill meldingsvalg"; "ua_unread_message_description" = "Melding ulest"; // Generic localizations "ua_dismiss" = "Avvis"; "ua_escape" = "Avbryt"; "ua_next" = "Neste"; "ua_previous" = "Forrige"; "ua_submit" = "Send inn"; "ua_loading" = "Laster"; "ua_pager_progress" = "Side %@ av %@"; "ua_x_of_y" = "%@ av %@"; "ua_play" = "Spill av"; "ua_pause" = "Pause"; "ua_stop" = "Stopp"; "ua_form_processing_error" = "Feil ved behandling av skjema. Vennligst prøv igjen"; "ua_close" = "Lukk"; "ua_mute" = "Demp"; "ua_unmute" = "Opphev demping"; "ua_required_field" = "* Påkrevd"; "ua_invalid_form_message" = "Vennligst rett opp de ugyldige feltene for å fortsette"; ================================================ FILE: Airship/AirshipCore/Resources/pl.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Anuluj"; "ua_cancel_edit_messages_description" = "Anuluj edycję wiadomości"; "ua_connection_error" = "Błąd połączenia"; "ua_content_error" = "Błąd treści"; "ua_delete_message" = "Usuń wiadomość"; "ua_delete_message_description" = "Usuń wybrane wiadomości"; "ua_delete_messages" = "Usuń wiadomość"; "ua_delete_messages_description" = "Usuń wybrane wiadomości"; "ua_edit_messages" = "Edytuj"; "ua_edit_messages_description" = "Edytuj wiadomości"; "ua_empty_message_list" = "Brak wiadomości"; "ua_mark_messages_read" = "Zaznacz jako przeczytane"; "ua_mark_messages_read_description" = "Zaznacz wybrane wiadomości jako przeczytane"; "ua_mc_failed_to_load" = "Nie można załadować wiadomości. Spróbuj ponownie później."; "ua_mc_no_longer_available" = "Wybrana wiadomość nie jest już dostępna."; "ua_message_cell_description" = "Wyświetla pełną wiadomość"; "ua_message_cell_editing_description" = "Przełącza wybór"; "ua_message_center_title" = "Centrum wiadomości"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Wiadomość %@, wysłana o %@"; "ua_message_not_selected" = "Nie wybrano wiadomości"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Nieprzeczytana wiadomość %@, wysłana o %@"; "ua_notification_button_accept" = "Akceptuj"; "ua_notification_button_add" = "Dodaj"; "ua_notification_button_add_to_calendar" = "Dodaj do kalendarza"; "ua_notification_button_book_now" = "Rezerwuj teraz"; "ua_notification_button_buy_now" = "Kup teraz"; "ua_notification_button_copy" = "Kopiuj"; "ua_notification_button_decline" = "Odrzuć"; "ua_notification_button_dislike" = "Cofnij polubienie"; "ua_notification_button_download" = "Pobierz"; "ua_notification_button_follow" = "Obserwuj"; "ua_notification_button_less_like" = "Mniej podobnych treści"; "ua_notification_button_like" = "Polub"; "ua_notification_button_more_like" = "Więcej podobnych treści"; "ua_notification_button_no" = "Nie"; "ua_notification_button_opt_in" = "Subskrybuj"; "ua_notification_button_opt_out" = "Zrezygnuj"; "ua_notification_button_rate_now" = "Oceń teraz"; "ua_notification_button_remind" = "Przypomnij mi później"; "ua_notification_button_save" = "Zapisz"; "ua_notification_button_search" = "Szukaj"; "ua_notification_button_send_info" = "Wyślij informacje"; "ua_notification_button_share" = "Udostępnij"; "ua_notification_button_shop_now" = "Kupuj teraz"; "ua_notification_button_tell_me_more" = "Więcej informacji"; "ua_notification_button_unfollow" = "Przestań obserwować"; "ua_notification_button_yes" = "Tak"; "ua_ok" = "OK"; "ua_preference_center_title" = "Centrum preferencji"; "ua_retry_button" = "Ponów próbę"; "ua_select_all_messages" = "Zaznacz wszystko"; "ua_select_all_messages_description" = "Zaznacza wszystkie wiadomości"; "ua_select_none_messages" = "Nie zaznaczaj nic"; "ua_select_none_messages_description" = "Zresetuj wybór wiadomości"; "ua_unread_message_description" = "Wiadomość nieprzeczytana"; // Generic localizations "ua_dismiss" = "Odrzuć"; "ua_escape" = "Uciec"; "ua_next" = "Następny"; "ua_previous" = "Poprzedni"; "ua_submit" = "Złożyć"; "ua_loading" = "Ładowanie"; "ua_pager_progress" = "Strona %@ z %@"; "ua_x_of_y" = "%@ z %@"; "ua_play" = "Odtwarzać"; "ua_pause" = "Pauza"; "ua_stop" = "Zatrzymać"; "ua_form_processing_error" = "Błąd przetwarzania formularza. Proszę spróbować ponownie"; "ua_close" = "Zamknij"; "ua_mute" = "Wycisz"; "ua_unmute" = "Wyłącz wyciszenie"; "ua_required_field" = "* Wymagane"; "ua_invalid_form_message" = "Proszę poprawić nieprawidłowe pola, aby kontynuować"; ================================================ FILE: Airship/AirshipCore/Resources/pt-PT.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Cancelar"; "ua_cancel_edit_messages_description" = "Cancelar edições de mensagens"; "ua_connection_error" = "Erro de Ligação"; "ua_content_error" = "Erro de conteúdo"; "ua_delete_message" = "Apagar mensagem"; "ua_delete_message_description" = "Apagar mensagens selecionadas"; "ua_delete_messages" = "Apagar mensagem"; "ua_delete_messages_description" = "Apagar mensagens selecionadas"; "ua_edit_messages" = "Editar"; "ua_edit_messages_description" = "Editar mensagens"; "ua_empty_message_list" = "Sem Mensagens"; "ua_mark_messages_read" = "Marcar como Lido"; "ua_mark_messages_read_description" = "Marcar mensagens selecionadas como lidas"; "ua_mc_failed_to_load" = "Não é possível carregar mensagem. Por favor, tente novamente mais tarde."; "ua_mc_no_longer_available" = "A mensagem selecionada já não está disponível."; "ua_message_cell_description" = "Exibe mensagem completa"; "ua_message_cell_editing_description" = "Alterna a seleção"; "ua_message_center_title" = "Centro de Mensagens"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Mensagem %@, enviada em %@"; "ua_message_not_selected" = "Nenhuma mensagem selecionada"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Mensagem não lida %@, enviada em %@"; "ua_notification_button_accept" = "Aceitar"; "ua_notification_button_add" = "Adicionar"; "ua_notification_button_add_to_calendar" = "Adicionar ao Calendário"; "ua_notification_button_book_now" = "Marcar Agora"; "ua_notification_button_buy_now" = "Compre Agora"; "ua_notification_button_copy" = "Cópia"; "ua_notification_button_decline" = "Declinar"; "ua_notification_button_dislike" = "Não Gostar"; "ua_notification_button_download" = "Descarregar"; "ua_notification_button_follow" = "Seguir"; "ua_notification_button_less_like" = "Menos Como Isto"; "ua_notification_button_like" = "Gostar"; "ua_notification_button_more_like" = "Mais Como Isto"; "ua_notification_button_no" = "Não"; "ua_notification_button_opt_in" = "Opt-in"; "ua_notification_button_opt_out" = "Opt-out"; "ua_notification_button_rate_now" = "Classificar Agora"; "ua_notification_button_remind" = "Lembrar-me depois"; "ua_notification_button_save" = "Gravar"; "ua_notification_button_search" = "Pesquisa"; "ua_notification_button_send_info" = "Enviar Informações"; "ua_notification_button_share" = "Partilhar"; "ua_notification_button_shop_now" = "Comprar Agora"; "ua_notification_button_tell_me_more" = "Diga-me Mais"; "ua_notification_button_unfollow" = "Deixar de Seguir"; "ua_notification_button_yes" = "Sim"; "ua_ok" = "OK"; "ua_preference_center_title" = "Centro de Preferências"; "ua_retry_button" = "Tentar novamente"; "ua_select_all_messages" = "Selecionar Tudo"; "ua_select_all_messages_description" = "Seleciona todas as mensagens"; "ua_select_none_messages" = "Não Selecionar"; "ua_select_none_messages_description" = "Redefinir seleção de mensagem"; "ua_unread_message_description" = "Mensagem não lida"; // Generic localizations "ua_dismiss" = "Dispensar"; "ua_escape" = "Escapar"; "ua_next" = "Próximo"; "ua_previous" = "Anterior"; "ua_submit" = "Submeter"; "ua_loading" = "Carregando"; "ua_pager_progress" = "Página %@ de %@"; "ua_x_of_y" = "%@ de %@"; "ua_play" = "Reproduzir"; "ua_pause" = "Pausar"; "ua_stop" = "Parar"; "ua_form_processing_error" = "Erro ao processar o formulário. Por favor, tente novamente"; "ua_close" = "Fechar"; "ua_mute" = "Silenciar"; "ua_unmute" = "Ativar som"; "ua_required_field" = "* Obrigatório"; "ua_invalid_form_message" = "Por favor, corrija os campos inválidos para continuar"; ================================================ FILE: Airship/AirshipCore/Resources/pt.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Cancelar"; "ua_cancel_edit_messages_description" = "Cancelar edições de mensagens"; "ua_connection_error" = "Erro de conexão"; "ua_content_error" = "Erro de conteúdo"; "ua_delete_message" = "Apagar mensagem"; "ua_delete_message_description" = "Excluir mensagens selecionadas"; "ua_delete_messages" = "Apagar mensagem"; "ua_delete_messages_description" = "Excluir mensagens selecionadas"; "ua_edit_messages" = "Editar"; "ua_edit_messages_description" = "Editar mensagens"; "ua_empty_message_list" = "Nenhuma mensagem"; "ua_mark_messages_read" = "Marcar como lido"; "ua_mark_messages_read_description" = "Marcar mensagens selecionadas como lidas"; "ua_mc_failed_to_load" = "Não foi possível carregar a mensagem. Tente novamente mais tarde"; "ua_mc_no_longer_available" = "A mensagem selecionada não está mais disponível."; "ua_message_cell_description" = "Exibe mensagem completa"; "ua_message_cell_editing_description" = "Alterna a seleção"; "ua_message_center_title" = "Central de mensagens"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Mensagem %@, enviada em %@"; "ua_message_not_selected" = "Nenhuma mensagem selecionada"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Mensagem não lida %@, enviada em %@"; "ua_notification_button_accept" = "Aceitar"; "ua_notification_button_add" = "Adicionar"; "ua_notification_button_add_to_calendar" = "Adicionar ao calendário"; "ua_notification_button_book_now" = "Reservar agora"; "ua_notification_button_buy_now" = "Comprar agora"; "ua_notification_button_copy" = "Copiar"; "ua_notification_button_decline" = "Recusar"; "ua_notification_button_dislike" = "Descurtir"; "ua_notification_button_download" = "Baixar"; "ua_notification_button_follow" = "Seguir"; "ua_notification_button_less_like" = "Menos como este"; "ua_notification_button_like" = "Curtir"; "ua_notification_button_more_like" = "Mais como este"; "ua_notification_button_no" = "Não"; "ua_notification_button_opt_in" = "Aceitar (Opt-in)"; "ua_notification_button_opt_out" = "Não aceitar (Opt-out)"; "ua_notification_button_rate_now" = "Avaliar agora"; "ua_notification_button_remind" = "Lembrar mais tarde"; "ua_notification_button_save" = "Salvar"; "ua_notification_button_search" = "Pesquisar"; "ua_notification_button_send_info" = "Enviar informações"; "ua_notification_button_share" = "Compartilhar"; "ua_notification_button_shop_now" = "Ir à loja"; "ua_notification_button_tell_me_more" = "Conte-me mais"; "ua_notification_button_unfollow" = "Deixar de seguir"; "ua_notification_button_yes" = "Sim"; "ua_ok" = "OK"; "ua_preference_center_title" = "Centro de preferências"; "ua_retry_button" = "Tentar novamente"; "ua_select_all_messages" = "Selecionar tudo"; "ua_select_all_messages_description" = "Seleciona todas as mensagens"; "ua_select_none_messages" = "Não selecionar"; "ua_select_none_messages_description" = "Redefinir seleção de mensagem"; "ua_unread_message_description" = "Mensagem não lida"; // Generic localizations "ua_dismiss" = "Dispensar"; "ua_escape" = "Escapar"; "ua_next" = "Próximo"; "ua_previous" = "Anterior"; "ua_submit" = "Enviar"; "ua_loading" = "Carregando"; "ua_pager_progress" = "Página %@ de %@"; "ua_x_of_y" = "%@ de %@"; "ua_play" = "Reproduzir"; "ua_pause" = "Pausar"; "ua_stop" = "Parar"; "ua_form_processing_error" = "Erro ao processar formulário. Por favor, tente novamente"; "ua_close" = "Fechar"; "ua_mute" = "Silenciar"; "ua_unmute" = "Ativar som"; "ua_required_field" = "* Obrigatório"; "ua_invalid_form_message" = "Por favor, corrija os campos inválidos para continuar"; ================================================ FILE: Airship/AirshipCore/Resources/ro.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Anulează"; "ua_cancel_edit_messages_description" = "Anulați editările mesajelor"; "ua_connection_error" = "Eroare de Conexiune"; "ua_content_error" = "Eroare de conținut"; "ua_delete_message" = "Stergeți mesajul"; "ua_delete_message_description" = "Ștergeți mesajele selectate"; "ua_delete_messages" = "Stergeți mesajul"; "ua_delete_messages_description" = "Ștergeți mesajele selectate"; "ua_edit_messages" = "Editați | ×"; "ua_edit_messages_description" = "Editați mesajele"; "ua_empty_message_list" = "Niciun mesaj"; "ua_mark_messages_read" = "Marchează ca Citit"; "ua_mark_messages_read_description" = "Marcați mesajele selectate ca citite"; "ua_mc_failed_to_load" = "Imposibil de încărcat mesaje. Te rugăm să încerci mai târziu."; "ua_mc_no_longer_available" = "Mesajul selectat nu mai este disponibil."; "ua_message_cell_description" = "Afișează mesajul complet"; "ua_message_cell_editing_description" = "Comută selecția"; "ua_message_center_title" = "Centrul de Mesaje"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Mesaj %@, trimis la %@"; "ua_message_not_selected" = "Niciun mesaj selectat"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Mesaj necitit %@, trimis la %@"; "ua_notification_button_accept" = "Acceptă"; "ua_notification_button_add" = "Adaugă"; "ua_notification_button_add_to_calendar" = "Adaugă în Calendar"; "ua_notification_button_book_now" = "Rezervă Acum"; "ua_notification_button_buy_now" = "Cumpără Acum"; "ua_notification_button_copy" = "Copiază"; "ua_notification_button_decline" = "Refiză"; "ua_notification_button_dislike" = "Nu îți place"; "ua_notification_button_download" = "Descarcă"; "ua_notification_button_follow" = "Urmărește"; "ua_notification_button_less_like" = "Mai Puține Asemănătoare"; "ua_notification_button_like" = "Îți place"; "ua_notification_button_more_like" = "Mai Multe Asemănătoare"; "ua_notification_button_no" = "Nu"; "ua_notification_button_opt_in" = "Abonare"; "ua_notification_button_opt_out" = "Dezabonare"; "ua_notification_button_rate_now" = "Apreciază Acum"; "ua_notification_button_remind" = "Amintește-mi mai târziu"; "ua_notification_button_save" = "Salvează"; "ua_notification_button_search" = "Caută"; "ua_notification_button_send_info" = "Trimite Informații"; "ua_notification_button_share" = "Distribuie"; "ua_notification_button_shop_now" = "Cumpără acum"; "ua_notification_button_tell_me_more" = "Mai multe informații"; "ua_notification_button_unfollow" = "Nu mai urmări"; "ua_notification_button_yes" = "Da"; "ua_ok" = "OK"; "ua_preference_center_title" = "Centrul de preferințe"; "ua_retry_button" = "Încearcă din nou"; "ua_select_all_messages" = "Selectează Toate"; "ua_select_all_messages_description" = "Selectați toate mesajele"; "ua_select_none_messages" = "Selectează Niciunul"; "ua_select_none_messages_description" = "Resetați selecția mesajului"; "ua_unread_message_description" = "Mesaj necitit"; // Generic localizations "ua_dismiss" = "Renunță"; "ua_escape" = "Ieși"; "ua_next" = "Următorul"; "ua_previous" = "Anterior"; "ua_submit" = "Trimite"; "ua_loading" = "Se încarcă"; "ua_pager_progress" = "Pagina %@ din %@"; "ua_x_of_y" = "%@ din %@"; "ua_play" = "Redă"; "ua_pause" = "Pauză"; "ua_stop" = "Oprire"; "ua_form_processing_error" = "Eroare la procesarea formularului. Vă rugăm să încercați din nou"; "ua_close" = "Închide"; "ua_mute" = "Oprește sunetul"; "ua_unmute" = "Pornește sunetul"; "ua_required_field" = "* Obligatoriu"; "ua_invalid_form_message" = "Vă rugăm să corectați câmpurile nevalide pentru a continua"; ================================================ FILE: Airship/AirshipCore/Resources/ru.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Отмена"; "ua_cancel_edit_messages_description" = "Отменить редактирование сообщения"; "ua_connection_error" = "Ошибка подключения"; "ua_content_error" = "Ошибка содержимого"; "ua_delete_message" = "Удаленное сообщение"; "ua_delete_message_description" = "Удалить выбранные сообщения"; "ua_delete_messages" = "Удаленное сообщение"; "ua_delete_messages_description" = "Удалить выбранные сообщения"; "ua_edit_messages" = "Редактировать"; "ua_edit_messages_description" = "Редактировать сообщения"; "ua_empty_message_list" = "Нет сообщений"; "ua_mark_messages_read" = "Отметить как прочитанное"; "ua_mark_messages_read_description" = "Отметить выбранные сообщения прочитанными"; "ua_mc_failed_to_load" = "Невозможно загрузить сообщение. Пожалуйста, повторите попытку позже."; "ua_mc_no_longer_available" = "Выбранное сообщение больше недоступно."; "ua_message_cell_description" = "Отображает полное сообщение"; "ua_message_cell_editing_description" = "Переключает выбор"; "ua_message_center_title" = "Центр сообщений"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Сообщение %@, отправлено %@"; "ua_message_not_selected" = "Сообщения не выбраны"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Непрочитанное сообщение %@, отправленное %@"; "ua_notification_button_accept" = "Принять"; "ua_notification_button_add" = "Добавить"; "ua_notification_button_add_to_calendar" = "Добавить в календарь"; "ua_notification_button_book_now" = "Забронировать сейчас"; "ua_notification_button_buy_now" = "Купите сейчас"; "ua_notification_button_copy" = "Копировать"; "ua_notification_button_decline" = "Отклонить"; "ua_notification_button_dislike" = "Не нравится"; "ua_notification_button_download" = "Скачать"; "ua_notification_button_follow" = "Подписаться"; "ua_notification_button_less_like" = "Меньше подобного"; "ua_notification_button_like" = "Нравится"; "ua_notification_button_more_like" = "Больше подобного"; "ua_notification_button_no" = "Нет"; "ua_notification_button_opt_in" = "Подписаться на рассылку"; "ua_notification_button_opt_out" = "Отписаться от рассылки"; "ua_notification_button_rate_now" = "Оценить сейчас"; "ua_notification_button_remind" = "Напомнить мне позже"; "ua_notification_button_save" = "Сохранить"; "ua_notification_button_search" = "Поиск"; "ua_notification_button_send_info" = "Отправить информацию"; "ua_notification_button_share" = "Поделиться"; "ua_notification_button_shop_now" = "Купите сейчас"; "ua_notification_button_tell_me_more" = "Сказать больше"; "ua_notification_button_unfollow" = "Отписаться"; "ua_notification_button_yes" = "Да"; "ua_ok" = "ОК"; "ua_preference_center_title" = "Центр предпочтений"; "ua_retry_button" = "Повторить попытку"; "ua_select_all_messages" = "Выбрать все"; "ua_select_all_messages_description" = "Выбирает все сообщения"; "ua_select_none_messages" = "Не выбирать ничего"; "ua_select_none_messages_description" = "Сбросить выбор сообщения"; "ua_unread_message_description" = "Сообщение непрочитано"; // Generic localizations "ua_dismiss" = "Отклонить"; "ua_escape" = "Выход"; "ua_next" = "Следующий"; "ua_previous" = "Предыдущий"; "ua_submit" = "Отправить"; "ua_loading" = "Загрузка"; "ua_pager_progress" = "Страница %@ из %@"; "ua_x_of_y" = "%@ из %@"; "ua_play" = "Воспроизвести"; "ua_pause" = "Пауза"; "ua_stop" = "Стоп"; "ua_form_processing_error" = "Ошибка при обработке формы. Пожалуйста, попробуйте еще раз"; "ua_close" = "Закрыть"; "ua_mute" = "Отключить звук"; "ua_unmute" = "Включить звук"; "ua_required_field" = "* Обязательно"; "ua_invalid_form_message" = "Пожалуйста, исправьте неверные поля, чтобы продолжить"; ================================================ FILE: Airship/AirshipCore/Resources/sk.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Zrušiť"; "ua_cancel_edit_messages_description" = "Zrušiť úpravy správ"; "ua_connection_error" = "Chyba pripojenia"; "ua_content_error" = "Chyba obsahu"; "ua_delete_message" = "Odstrániť správu"; "ua_delete_message_description" = "Vymazať vybrané správy"; "ua_delete_messages" = "Odstrániť správu"; "ua_delete_messages_description" = "Vymazať vybrané správy"; "ua_edit_messages" = "Upraviť"; "ua_edit_messages_description" = "Upraviť správy"; "ua_empty_message_list" = "Žiadne správy"; "ua_mark_messages_read" = "Označiť ako prečítané"; "ua_mark_messages_read_description" = "Označiť vybrané správy ako prečítané"; "ua_mc_failed_to_load" = "Nie je možné načítať správu. Skúste to neskôr, prosím."; "ua_mc_no_longer_available" = "Vybratá správa už nie je k dispozícii."; "ua_message_cell_description" = "Zobrazí celú správu"; "ua_message_cell_editing_description" = "Prepína výber"; "ua_message_center_title" = "Centrum správ"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Správa %@ odoslaná o %@"; "ua_message_not_selected" = "Nie sú vybraté žiadne správy"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Neprečítaná správa %@, odoslaná o %@"; "ua_notification_button_accept" = "Prijať"; "ua_notification_button_add" = "Pridať"; "ua_notification_button_add_to_calendar" = "Pridať do kalendára"; "ua_notification_button_book_now" = "Rezervovať teraz"; "ua_notification_button_buy_now" = "Kúpiť teraz"; "ua_notification_button_copy" = "Kopírovať"; "ua_notification_button_decline" = "Odmietnuť"; "ua_notification_button_dislike" = "Nepáčiť sa"; "ua_notification_button_download" = "Stiahnuť"; "ua_notification_button_follow" = "Sledovať"; "ua_notification_button_less_like" = "Menej ako je uvedené"; "ua_notification_button_like" = "Páčiť sa"; "ua_notification_button_more_like" = "Viac ako je uvedené"; "ua_notification_button_no" = "Nie"; "ua_notification_button_opt_in" = "Aktivovať možnosti výberu"; "ua_notification_button_opt_out" = "Deaktivovať možnosti výberu"; "ua_notification_button_rate_now" = "Hodnotiť teraz"; "ua_notification_button_remind" = "Pripomenúť neskôr"; "ua_notification_button_save" = "Uložiť"; "ua_notification_button_search" = "Vyhľadať"; "ua_notification_button_send_info" = "Poslať informáciu"; "ua_notification_button_share" = "Zdieľať"; "ua_notification_button_shop_now" = "Nakupovať teraz"; "ua_notification_button_tell_me_more" = "Dajte viac informácií"; "ua_notification_button_unfollow" = "Zrušiť sledovanie"; "ua_notification_button_yes" = "Áno"; "ua_ok" = "OK"; "ua_preference_center_title" = "Centrum preferencií"; "ua_retry_button" = "Opakovať pokus"; "ua_select_all_messages" = "Vybrať všetko"; "ua_select_all_messages_description" = "Vyberie všetky správy"; "ua_select_none_messages" = "Nevybrať ani jednu"; "ua_select_none_messages_description" = "Obnoviť výber správ"; "ua_unread_message_description" = "Správa neprečítaná"; // Generic localizations "ua_dismiss" = "Zamietnuť"; "ua_escape" = "Uniknúť"; "ua_next" = "Ďalší"; "ua_previous" = "Predchádzajúci"; "ua_submit" = "Predložiť"; "ua_loading" = "Načítavanie"; "ua_pager_progress" = "Strana %@ z %@"; "ua_x_of_y" = "%@ z %@"; "ua_play" = "Hrať"; "ua_pause" = "Pauza"; "ua_stop" = "Zastaviť"; "ua_form_processing_error" = "Chyba pri spracovaní formulára. Skúste to znova"; "ua_close" = "Zavrieť"; "ua_mute" = "Stlmiť"; "ua_unmute" = "Zrušiť stlmenie"; "ua_required_field" = "* Povinné"; "ua_invalid_form_message" = "Opravte neplatné polia a pokračujte"; ================================================ FILE: Airship/AirshipCore/Resources/sl.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Prekliči"; "ua_cancel_edit_messages_description" = "Prekliči urejanje sporočil"; "ua_connection_error" = "Napaka v povezavi"; "ua_content_error" = "Napaka vsebine"; "ua_delete_message" = "Izbriši sporočilo"; "ua_delete_message_description" = "Izbriši izbrana sporočila"; "ua_delete_messages" = "Izbriši sporočilo"; "ua_delete_messages_description" = "Izbriši izbrana sporočila"; "ua_edit_messages" = "Uredi"; "ua_edit_messages_description" = "Uredite sporočila"; "ua_empty_message_list" = "Brez sporočil"; "ua_mark_messages_read" = "Označi kot prebrano"; "ua_mark_messages_read_description" = "Označi izbrana sporočila kot prebrana"; "ua_mc_failed_to_load" = "Sporočila ni mogoče naložiti. Prosim poskusite kasneje."; "ua_mc_no_longer_available" = "Izbrano sporočilo ni več na voljo."; "ua_message_cell_description" = "Prikaže celotno sporočilo"; "ua_message_cell_editing_description" = "Preklopi izbiro"; "ua_message_center_title" = "Center za sporočila"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Sporočilo %@, poslano ob %@"; "ua_message_not_selected" = "Izbrano ni nobeno sporočilo"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Neprebrano sporočilo %@, poslano ob %@"; "ua_notification_button_accept" = "Sprejmi"; "ua_notification_button_add" = "Dodaj"; "ua_notification_button_add_to_calendar" = "Dodaj v koledar"; "ua_notification_button_book_now" = "Rezervirajte zdaj"; "ua_notification_button_buy_now" = "Kupi zdaj"; "ua_notification_button_copy" = "Kopirati"; "ua_notification_button_decline" = "Zavrni"; "ua_notification_button_dislike" = "Ne maram"; "ua_notification_button_download" = "Prenesi"; "ua_notification_button_follow" = "Sledite"; "ua_notification_button_less_like" = "Manj takega"; "ua_notification_button_like" = "Všeč mi je"; "ua_notification_button_more_like" = "Več takega"; "ua_notification_button_no" = "št"; "ua_notification_button_opt_in" = "Privolitev"; "ua_notification_button_opt_out" = "Odjava"; "ua_notification_button_rate_now" = "Oceni zdaj"; "ua_notification_button_remind" = "Opomni me kasneje"; "ua_notification_button_save" = "Shrani"; "ua_notification_button_search" = "Iskanje"; "ua_notification_button_send_info" = "Pošlji informacije"; "ua_notification_button_share" = "Deliti"; "ua_notification_button_shop_now" = "Nakupujte zdaj"; "ua_notification_button_tell_me_more" = "Povej mi več"; "ua_notification_button_unfollow" = "Prekliči spremljanje"; "ua_notification_button_yes" = "da"; "ua_ok" = "v redu"; "ua_preference_center_title" = "Preference Center"; "ua_retry_button" = "Poskusi znova"; "ua_select_all_messages" = "Izberi vse"; "ua_select_all_messages_description" = "Izbere vsa sporočila"; "ua_select_none_messages" = "Izberite Brez"; "ua_select_none_messages_description" = "Ponastavi izbiro sporočila"; "ua_unread_message_description" = "Sporočilo neprebrano"; // Generic localizations "ua_dismiss" = "Opusti"; "ua_escape" = "Pobeg"; "ua_next" = "Naslednji"; "ua_previous" = "Prejšnji"; "ua_submit" = "Predloži"; "ua_loading" = "Nalaganje"; "ua_pager_progress" = "Stran %@ od %@"; "ua_x_of_y" = "%@ od %@"; "ua_play" = "Igraj"; "ua_pause" = "Premor"; "ua_stop" = "Ustavi"; "ua_form_processing_error" = "Napaka pri obdelavi obrazca. Poskusite znova"; "ua_close" = "Zapri"; "ua_mute" = "Utišaj"; "ua_unmute" = "Prekliči utišanje"; "ua_required_field" = "* Obvezno"; "ua_invalid_form_message" = "Prosimo, popravite neveljavna polja za nadaljevanje"; ================================================ FILE: Airship/AirshipCore/Resources/sr.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Откажи"; "ua_cancel_edit_messages_description" = "Откажите измене порука"; "ua_connection_error" = "Грешка везе"; "ua_content_error" = "Грешка у садржају"; "ua_delete_message" = "Избриши поруку"; "ua_delete_message_description" = "Избришите изабране поруке"; "ua_delete_messages" = "Избриши поруку"; "ua_delete_messages_description" = "Избришите изабране поруке"; "ua_edit_messages" = "Уреди"; "ua_edit_messages_description" = "Уредите поруке"; "ua_empty_message_list" = "Нема порука"; "ua_mark_messages_read" = "Означи као прочитано"; "ua_mark_messages_read_description" = "Означите изабране поруке као прочитане"; "ua_mc_failed_to_load" = "Није могуће учитати поруку. Покушајте поново касније."; "ua_mc_no_longer_available" = "Изабрана порука више није доступна."; "ua_message_cell_description" = "Приказује целу поруку"; "ua_message_cell_editing_description" = "Укључује избор"; "ua_message_center_title" = "Центар за поруке"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Порука %@, послата у %@"; "ua_message_not_selected" = "Није изабрана ниједна порука"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Непрочитана порука %@, послата у %@"; "ua_notification_button_accept" = "Прихвати"; "ua_notification_button_add" = "Додај"; "ua_notification_button_add_to_calendar" = "Додај у календар"; "ua_notification_button_book_now" = "Резервиши одмах"; "ua_notification_button_buy_now" = "Купите одмах"; "ua_notification_button_copy" = "Копирај"; "ua_notification_button_decline" = "Одбиј"; "ua_notification_button_dislike" = "Не свиђа ми се"; "ua_notification_button_download" = "Преузимање"; "ua_notification_button_follow" = "Прати"; "ua_notification_button_less_like" = "Мање попут овога"; "ua_notification_button_like" = "Као"; "ua_notification_button_more_like" = "Више попут овога"; "ua_notification_button_no" = "Не"; "ua_notification_button_opt_in" = "Опт-ин"; "ua_notification_button_opt_out" = "Одустани"; "ua_notification_button_rate_now" = "Оцените сад"; "ua_notification_button_remind" = "Подсети ме касније"; "ua_notification_button_save" = "Сачувај"; "ua_notification_button_search" = "Претрага"; "ua_notification_button_send_info" = "Пошаљите информације"; "ua_notification_button_share" = "Објави"; "ua_notification_button_shop_now" = "Купујте сада"; "ua_notification_button_tell_me_more" = "Реци ми више"; "ua_notification_button_unfollow" = "Престани да пратиш"; "ua_notification_button_yes" = "Да"; "ua_ok" = "У реду"; "ua_preference_center_title" = "Центар приоритета"; "ua_retry_button" = "Покушај поново"; "ua_select_all_messages" = "Изабери све"; "ua_select_all_messages_description" = "Изабери све поруке"; "ua_select_none_messages" = "Не изабери ниједан"; "ua_select_none_messages_description" = "Ресетујте избор поруке"; "ua_unread_message_description" = "Порука непрочитана"; // Generic localizations "ua_dismiss" = "Odbaci"; "ua_escape" = "Izađi"; "ua_next" = "Sledeće"; "ua_previous" = "Prethodno"; "ua_submit" = "Podnesi"; "ua_loading" = "Učitavanje"; "ua_pager_progress" = "Strana %@ od %@"; "ua_x_of_y" = "%@ од %@"; "ua_play" = "Pusti"; "ua_pause" = "Pauziraj"; "ua_stop" = "Zaustavi"; "ua_form_processing_error" = "Грешка при обради обрасца. Молимо покушајте поново"; "ua_close" = "Затвори"; "ua_mute" = "Искључи звук"; "ua_unmute" = "Укључи звук"; "ua_required_field" = "* Обавезно"; "ua_invalid_form_message" = "Молимо исправите неважећа поља да бисте наставили"; ================================================ FILE: Airship/AirshipCore/Resources/sv.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Avbryt"; "ua_cancel_edit_messages_description" = "Avbryt meddelanderedigeringar"; "ua_connection_error" = "Anslutningsfel"; "ua_content_error" = "Innehållsfel"; "ua_delete_message" = "Radera meddelande"; "ua_delete_message_description" = "Ta bort markerade meddelanden"; "ua_delete_messages" = "Radera meddelande"; "ua_delete_messages_description" = "Ta bort markerade meddelanden"; "ua_edit_messages" = "Redigera"; "ua_edit_messages_description" = "Redigera meddelanden"; "ua_empty_message_list" = "Inga meddelanden"; "ua_mark_messages_read" = "Markera som läst"; "ua_mark_messages_read_description" = "Markera valda meddelanden som lästa"; "ua_mc_failed_to_load" = "Det går inte att läsa in meddelande. Försök igen senare."; "ua_mc_no_longer_available" = "Det valda meddelandet är inte längre tillgängligt."; "ua_message_cell_description" = "Visar hela meddelandet"; "ua_message_cell_editing_description" = "Växlar val"; "ua_message_center_title" = "Meddelandecenter"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Meddelande %@, skickat kl. %@"; "ua_message_not_selected" = "Inga meddelanden har valts"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Oläst meddelande %@, skickat kl. %@"; "ua_notification_button_accept" = "Acceptera"; "ua_notification_button_add" = "Lägg till"; "ua_notification_button_add_to_calendar" = "Lägg till i kalender"; "ua_notification_button_book_now" = "Boka nu"; "ua_notification_button_buy_now" = "Köp nu"; "ua_notification_button_copy" = "Kopiera"; "ua_notification_button_decline" = "Neka"; "ua_notification_button_dislike" = "Ogilla"; "ua_notification_button_download" = "Ladda ner"; "ua_notification_button_follow" = "Följ"; "ua_notification_button_less_like" = "Mindre som detta"; "ua_notification_button_like" = "Gilla"; "ua_notification_button_more_like" = "Mer som detta"; "ua_notification_button_no" = "Nej"; "ua_notification_button_opt_in" = "Anmäl dig"; "ua_notification_button_opt_out" = "Avanmäl dig"; "ua_notification_button_rate_now" = "Betygsätt nu"; "ua_notification_button_remind" = "Påminn mig senare"; "ua_notification_button_save" = "Spara"; "ua_notification_button_search" = "Sök"; "ua_notification_button_send_info" = "Skicka info"; "ua_notification_button_share" = "Dela"; "ua_notification_button_shop_now" = "Handla nu"; "ua_notification_button_tell_me_more" = "Berätta mer"; "ua_notification_button_unfollow" = "Sluta följa"; "ua_notification_button_yes" = "Ja"; "ua_ok" = "OK"; "ua_preference_center_title" = "Preferenscenter"; "ua_retry_button" = "Försök igen"; "ua_select_all_messages" = "Välj alla"; "ua_select_all_messages_description" = "Väljer alla meddelanden"; "ua_select_none_messages" = "Välj inget"; "ua_select_none_messages_description" = "Återställ meddelandeval"; "ua_unread_message_description" = "Meddelande oläst"; // Generic localizations "ua_dismiss" = "Avfärda"; "ua_escape" = "Rymma"; "ua_next" = "Nästa"; "ua_previous" = "Föregående"; "ua_submit" = "Skicka"; "ua_loading" = "Laddar"; "ua_pager_progress" = "Sida %@ av %@"; "ua_x_of_y" = "%@ av %@"; "ua_play" = "Spela"; "ua_pause" = "Pausa"; "ua_stop" = "Stoppa"; "ua_form_processing_error" = "Fel vid bearbetning av formulär. Försök igen"; "ua_close" = "Stäng"; "ua_mute" = "Tysta"; "ua_unmute" = "Avtysta"; "ua_required_field" = "* Obligatoriskt"; "ua_invalid_form_message" = "Vänligen åtgärda de ogiltiga fälten för att fortsätta"; ================================================ FILE: Airship/AirshipCore/Resources/sw.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Ghairi"; "ua_cancel_edit_messages_description" = "Ghairi uhariri wa ujumbe"; "ua_connection_error" = "Hitilafu ya Muunganisho"; "ua_content_error" = "Hitilafu ya Maudhui"; "ua_delete_message" = "Futa ujumbe"; "ua_delete_message_description" = "Futa ujumbe uliochaguliwa"; "ua_delete_messages" = "Futa ujumbe"; "ua_delete_messages_description" = "Futa ujumbe uliochaguliwa"; "ua_edit_messages" = "Hariri"; "ua_edit_messages_description" = "Hariri ujumbe"; "ua_empty_message_list" = "Hakuna ujumbe"; "ua_mark_messages_read" = "Alama kuwa Imesomwa"; "ua_mark_messages_read_description" = "Tia alama kuwa ujumbe uliochaguliwa umesomwa"; "ua_mc_failed_to_load" = "Imeshindwa kupakia ujumbe. Tafadhali jaribu tena baadae."; "ua_mc_no_longer_available" = "Ujumbe uliochaguliwa haupatikani tena."; "ua_message_cell_description" = "Inaonyesha ujumbe kamili"; "ua_message_cell_editing_description" = "Hugeuza uteuzi"; "ua_message_center_title" = "Kituo cha Ujumbe"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Ujumbe kwa %@, ulitumwa kwa %@"; "ua_message_not_selected" = "Hakuna ujumbe uliochaguliwa"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Ujumbe ambao haujasomwa %@, ulitumwa kwa %@"; "ua_notification_button_accept" = "Kubali"; "ua_notification_button_add" = "Ongeza"; "ua_notification_button_add_to_calendar" = "Ongeza kwenye Kalenda"; "ua_notification_button_book_now" = "Weka Nafasi Sasa"; "ua_notification_button_buy_now" = "Nunua Sasa"; "ua_notification_button_copy" = "Nakili"; "ua_notification_button_decline" = "Kataa"; "ua_notification_button_dislike" = "Kutopenda"; "ua_notification_button_download" = "Pakua"; "ua_notification_button_follow" = "Fuata"; "ua_notification_button_less_like" = "Kidogo Kama Hii"; "ua_notification_button_like" = "Penda"; "ua_notification_button_more_like" = "Zaidi Kama Hii"; "ua_notification_button_no" = "Hapana"; "ua_notification_button_opt_in" = "Jijumuishe"; "ua_notification_button_opt_out" = "Chagua kutoka"; "ua_notification_button_rate_now" = "Kadiria Sasa"; "ua_notification_button_remind" = "Nikumbushe Baadaye"; "ua_notification_button_save" = "Hifadhi"; "ua_notification_button_search" = "Tafuta"; "ua_notification_button_send_info" = "Tuma Taarifa"; "ua_notification_button_share" = "Shiriki"; "ua_notification_button_shop_now" = "Nunua Sasa"; "ua_notification_button_tell_me_more" = "Niambie zaidi"; "ua_notification_button_unfollow" = "Acha kufuata"; "ua_notification_button_yes" = "Ndiyo"; "ua_ok" = "SAWA"; "ua_preference_center_title" = "Kituo cha Upendeleo"; "ua_retry_button" = "Jaribu tena"; "ua_select_all_messages" = "Chagua Zote"; "ua_select_all_messages_description" = "Huchagua ujumbe wote"; "ua_select_none_messages" = "Chagua Hakuna"; "ua_select_none_messages_description" = "Weka upya uteuzi wa ujumbe"; "ua_unread_message_description" = "Ujumbe haujasomwa"; // Generic localizations "ua_dismiss" = "Ondoa"; "ua_escape" = "Kutoroka"; "ua_next" = "Ifuatayo"; "ua_previous" = "Iliyopita"; "ua_submit" = "Wasilisha"; "ua_loading" = "Inapakia"; "ua_pager_progress" = "Ukurasa %@ ya %@"; "ua_x_of_y" = "%@ kati ya %@"; "ua_play" = "Cheza"; "ua_pause" = "Sitisha"; "ua_stop" = "Simamisha"; "ua_form_processing_error" = "Hitilafu ya kusindika fomu. Tafadhali jaribu tena"; "ua_close" = "Funga"; "ua_mute" = "Nyamazisha"; "ua_unmute" = "Ondoa unyamavishi"; "ua_required_field" = "* Inahitajika"; "ua_invalid_form_message" = "Tafadhali sahihisha sehemu zisizo sahihi ili kuendelea"; ================================================ FILE: Airship/AirshipCore/Resources/th.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "ยกเลิก"; "ua_cancel_edit_messages_description" = "ยกเลิกการแก้ไขข้อความ"; "ua_connection_error" = "การเชื่อมต่อผิดพลาด"; "ua_content_error" = "เนื้อหาผิดพลาด"; "ua_delete_message" = "ลบข้อความ"; "ua_delete_message_description" = "ลบข้อความที่เลือก"; "ua_delete_messages" = "ลบข้อความ"; "ua_delete_messages_description" = "ลบข้อความที่เลือก"; "ua_edit_messages" = "แก้ไข"; "ua_edit_messages_description" = "แก้ไขข้อความ"; "ua_empty_message_list" = "ไม่มีข้อความ"; "ua_mark_messages_read" = "ทำเครื่องหมายว่าอ่านแล้ว"; "ua_mark_messages_read_description" = "ทำเครื่องหมายข้อความที่เลือกว่าอ่านแล้ว"; "ua_mc_failed_to_load" = "ไม่สามารถโหลดข้อความ กรุณาลองใหม่อีกครั้งในภายหลัง"; "ua_mc_no_longer_available" = "ข้อความที่เลือกไม่สามารถใช้ได้อีกต่อไป"; "ua_message_cell_description" = "แสดงข้อความเต็ม"; "ua_message_cell_editing_description" = "สลับการเลือก"; "ua_message_center_title" = "ศูนย์ข้อความ"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "ข้อความ %@ ส่งเมื่อ %@"; "ua_message_not_selected" = "ไม่ได้เลือกข้อความ"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "ข้อความที่ยังไม่ได้อ่าน %@ ส่งเมื่อ %@"; "ua_notification_button_accept" = "ยอมรับ"; "ua_notification_button_add" = "เพิ่ม"; "ua_notification_button_add_to_calendar" = "เพิ่มไปยังปฏิทิน"; "ua_notification_button_book_now" = "จองตอนนี้"; "ua_notification_button_buy_now" = "ซื้อตอนนี้"; "ua_notification_button_copy" = "คัดลอก"; "ua_notification_button_decline" = "ปฏิเสธ"; "ua_notification_button_dislike" = "ไม่ถูกใจ"; "ua_notification_button_download" = "ดาวน์โหลด"; "ua_notification_button_follow" = "ติดตาม"; "ua_notification_button_less_like" = "ไม่เอาแบบนี้อีก"; "ua_notification_button_like" = "ถูกใจ"; "ua_notification_button_more_like" = "เอาแบบนี้อีก"; "ua_notification_button_no" = "ไม่"; "ua_notification_button_opt_in" = "เลือกเก็บ"; "ua_notification_button_opt_out" = "เลือกทิ้ง"; "ua_notification_button_rate_now" = "ให้คะแนนตอนนี้"; "ua_notification_button_remind" = "เตือนฉันภายหลัง"; "ua_notification_button_save" = "บันทึก"; "ua_notification_button_search" = "ค้นหา"; "ua_notification_button_send_info" = "ส่งข้อมูล"; "ua_notification_button_share" = "แชร์"; "ua_notification_button_shop_now" = "ช้อปตอนนี้"; "ua_notification_button_tell_me_more" = "บอกรายละเอียดฉันเพิ่มเติม"; "ua_notification_button_unfollow" = "เลิกติดตาม"; "ua_notification_button_yes" = "ใช่"; "ua_ok" = "ตกลง"; "ua_preference_center_title" = "ศูนย์การตั้งค่า"; "ua_retry_button" = "ลองใหม่"; "ua_select_all_messages" = "เลือกทั้งหมด"; "ua_select_all_messages_description" = "เลือกข้อความทั้งหมด"; "ua_select_none_messages" = "ไม่เลือกเลย"; "ua_select_none_messages_description" = "รีเซ็ตการเลือกข้อความ"; "ua_unread_message_description" = "ยังไม่ได้อ่านข้อความ"; // Generic localizations "ua_dismiss" = "ปฏิเสธ"; "ua_escape" = "หนีออกจาก"; "ua_next" = "ถัดไป"; "ua_previous" = "ก่อนหน้า"; "ua_submit" = "ส่ง"; "ua_loading" = "กำลังโหลด"; "ua_pager_progress" = "หน้า %@ จาก %@"; "ua_x_of_y" = "%@ จาก %@"; "ua_play" = "เล่น"; "ua_pause" = "หยุดชั่วคราว"; "ua_stop" = "หยุด"; "ua_form_processing_error" = "เกิดข้อผิดพลาดในการประมวลผลแบบฟอร์ม โปรดลองอีกครั้ง"; "ua_close" = "ปิด"; "ua_mute" = "ปิดเสียง"; "ua_unmute" = "เปิดเสียง"; "ua_required_field" = "* จำเป็น"; "ua_invalid_form_message" = "โปรดแก้ไขฟิลด์ที่ไม่ถูกต้องเพื่อดำเนินการต่อ"; ================================================ FILE: Airship/AirshipCore/Resources/tr.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "İptal"; "ua_cancel_edit_messages_description" = "Mesaj düzenlemelerini iptal et"; "ua_connection_error" = "Bağlantı Hatası"; "ua_content_error" = "İçerik Hatası"; "ua_delete_message" = "Mesajı sil"; "ua_delete_message_description" = "Seçili mesajları sil"; "ua_delete_messages" = "Mesajı sil"; "ua_delete_messages_description" = "Seçili mesajları sil"; "ua_edit_messages" = "Düzenlemek"; "ua_edit_messages_description" = "Mesajları düzenle"; "ua_empty_message_list" = "Mesaj Yok"; "ua_mark_messages_read" = "Okundu Olarak İşaretle"; "ua_mark_messages_read_description" = "Seçilen mesajları okundu olarak işaretle"; "ua_mc_failed_to_load" = "Mesaj yüklenemiyor. Lütfen daha sonra tekrar deneyin."; "ua_mc_no_longer_available" = "Seçilen mesaj artık mevcut değil."; "ua_message_cell_description" = "Tam mesajı görüntüler"; "ua_message_cell_editing_description" = "Seçimi değiştirir"; "ua_message_center_title" = "Mesaj Merkezi"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "%@ mesajı, %@ tarihinde gönderildi"; "ua_message_not_selected" = "Hiçbir mesaj seçilmedi"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "%@ okunmamış iletisi, %@ adresine gönderildi"; "ua_notification_button_accept" = "Kabul Et"; "ua_notification_button_add" = "Ekle"; "ua_notification_button_add_to_calendar" = "Takvime Ekle"; "ua_notification_button_book_now" = "Hemen Ayırt"; "ua_notification_button_buy_now" = "Şimdi Satın Al"; "ua_notification_button_copy" = "Kopyala"; "ua_notification_button_decline" = "Reddet"; "ua_notification_button_dislike" = "Beğenme"; "ua_notification_button_download" = "İndir"; "ua_notification_button_follow" = "Takip Et"; "ua_notification_button_less_like" = "Benzerlerinden Daha Az"; "ua_notification_button_like" = "Beğen"; "ua_notification_button_more_like" = "Benzerlerinden Daha Fazla"; "ua_notification_button_no" = "Hayır"; "ua_notification_button_opt_in" = "Ekle"; "ua_notification_button_opt_out" = "Çıkar"; "ua_notification_button_rate_now" = "Şimdi Oyla"; "ua_notification_button_remind" = "Daha Sonra Hatırlat"; "ua_notification_button_save" = "Kaydet"; "ua_notification_button_search" = "Ara"; "ua_notification_button_send_info" = "Bilgi Gönder"; "ua_notification_button_share" = "Paylaş"; "ua_notification_button_shop_now" = "Şimdi Alışveriş Yap"; "ua_notification_button_tell_me_more" = "Daha Fazla Bilgi"; "ua_notification_button_unfollow" = "Takibi Bırak"; "ua_notification_button_yes" = "Evet"; "ua_ok" = "Tamam"; "ua_preference_center_title" = "Tercih Merkezi"; "ua_retry_button" = "Tekrar dene"; "ua_select_all_messages" = "Tümünü Seç"; "ua_select_all_messages_description" = "Tüm mesajları seçer"; "ua_select_none_messages" = "Hiçbirini Seçme"; "ua_select_none_messages_description" = "Mesaj seçimini sıfırla"; "ua_unread_message_description" = "Mesaj okunmadı"; // Generic localizations "ua_dismiss" = "Kapat"; "ua_escape" = "Çıkış"; "ua_next" = "Sonraki"; "ua_previous" = "Önceki"; "ua_submit" = "Gönder"; "ua_loading" = "Yükleniyor"; "ua_pager_progress" = "%@ / %@ Sayfa"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "Oynat"; "ua_pause" = "Duraklat"; "ua_stop" = "Durdur"; "ua_form_processing_error" = "Form işlenirken hata oluştu. Lütfen tekrar deneyin"; "ua_close" = "Kapat"; "ua_mute" = "Sessiz"; "ua_unmute" = "Sesi Aç"; "ua_required_field" = "* Zorunlu"; "ua_invalid_form_message" = "Devam etmek için lütfen geçersiz alanları düzeltin"; ================================================ FILE: Airship/AirshipCore/Resources/uk.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Скасувати"; "ua_cancel_edit_messages_description" = "Скасувати редагування повідомлення"; "ua_connection_error" = "Помилка з\'єднання"; "ua_content_error" = "Помилка вмісту"; "ua_delete_message" = "Видалити повідомлення"; "ua_delete_message_description" = "Видалити вибрані повідомлення"; "ua_delete_messages" = "Видалити повідомлення"; "ua_delete_messages_description" = "Видалити вибрані повідомлення"; "ua_edit_messages" = "Редагувати"; "ua_edit_messages_description" = "Редагувати повідомлення"; "ua_empty_message_list" = "Немає повідомлень"; "ua_mark_messages_read" = "Позначити як прочитане"; "ua_mark_messages_read_description" = "Позначити вибрані повідомлення як прочитані"; "ua_mc_failed_to_load" = "Не вдається завантажити повідомлення. Будь-ласка, спробуйте пізніше."; "ua_mc_no_longer_available" = "Вибране повідомлення більше не доступне."; "ua_message_cell_description" = "Відображає повне повідомлення"; "ua_message_cell_editing_description" = "Перемикає вибір"; "ua_message_center_title" = "Центр повідомлень"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Повідомлення %@, надіслано о %@"; "ua_message_not_selected" = "Не вибрано жодного повідомлення"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Непрочитане повідомлення %@, надіслано о %@"; "ua_notification_button_accept" = "Прийняти"; "ua_notification_button_add" = "Додати"; "ua_notification_button_add_to_calendar" = "Додати до календаря"; "ua_notification_button_book_now" = "Резервувати зараз"; "ua_notification_button_buy_now" = "Придбайте зараз"; "ua_notification_button_copy" = "Копіювати"; "ua_notification_button_decline" = "Відхилити"; "ua_notification_button_dislike" = "Не люблю"; "ua_notification_button_download" = "Завантажити"; "ua_notification_button_follow" = "Слідкувати"; "ua_notification_button_less_like" = "Менше подібного"; "ua_notification_button_like" = "Люблю"; "ua_notification_button_more_like" = "Більше подібного"; "ua_notification_button_no" = "Ні"; "ua_notification_button_opt_in" = "Вибрати"; "ua_notification_button_opt_out" = "Відмовитися"; "ua_notification_button_rate_now" = "Оцінити зараз"; "ua_notification_button_remind" = "Нагадай мені пізніше"; "ua_notification_button_save" = "Зберегти"; "ua_notification_button_search" = "Пошук"; "ua_notification_button_send_info" = "Надіслати інформацію"; "ua_notification_button_share" = "Поділіться"; "ua_notification_button_shop_now" = "Здійснити покупку"; "ua_notification_button_tell_me_more" = "Розкажи мені більше"; "ua_notification_button_unfollow" = "Скасувати підписку"; "ua_notification_button_yes" = "Так"; "ua_ok" = "ОК"; "ua_preference_center_title" = "Центр параметрів"; "ua_retry_button" = "Повторіть спробу"; "ua_select_all_messages" = "Вибрати все"; "ua_select_all_messages_description" = "Вибрати всі повідомлення"; "ua_select_none_messages" = "Скасувати вибір"; "ua_select_none_messages_description" = "Скинути вибір повідомлення"; "ua_unread_message_description" = "Повідомлення непрочитане"; // Generic localizations "ua_dismiss" = "Відхилити"; "ua_escape" = "Вихід"; "ua_next" = "Далі"; "ua_previous" = "Попередній"; "ua_submit" = "Подати"; "ua_loading" = "Завантаження"; "ua_pager_progress" = "Сторінка %@ з %@"; "ua_x_of_y" = "%@ з %@"; "ua_play" = "Грати"; "ua_pause" = "Пауза"; "ua_stop" = "Стоп"; "ua_form_processing_error" = "Помилка обробки форми. Будь ласка, спробуйте ще раз"; "ua_close" = "Закрити"; "ua_mute" = "Вимкнути звук"; "ua_unmute" = "Увімкнути звук"; "ua_required_field" = "* Обов'язково"; "ua_invalid_form_message" = "Будь ласка, виправте недійсні поля, щоб продовжити"; ================================================ FILE: Airship/AirshipCore/Resources/vi.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Hủy bỏ"; "ua_cancel_edit_messages_description" = "Hủy các chỉnh sửa tin nhắn"; "ua_connection_error" = "Lỗi Kết nối"; "ua_content_error" = "Lỗi nội dung"; "ua_delete_message" = "Xóa tin nhắn"; "ua_delete_message_description" = "Xóa các tin nhắn đã chọn"; "ua_delete_messages" = "Xóa tin nhắn"; "ua_delete_messages_description" = "Xóa các tin nhắn đã chọn"; "ua_edit_messages" = "Chỉnh sửa"; "ua_edit_messages_description" = "Chỉnh sửa tin nhắn"; "ua_empty_message_list" = "Không có tin nhắn"; "ua_mark_messages_read" = "Đánh dấu đã Đọc"; "ua_mark_messages_read_description" = "Đánh dấu các tin nhắn đã chọn là đã đọc"; "ua_mc_failed_to_load" = "Không thể tải tin nhắn. Vui lòng thử lại sau."; "ua_mc_no_longer_available" = "Tin nhắn đã chọn không còn nữa."; "ua_message_cell_description" = "Hiển thị thông báo đầy đủ"; "ua_message_cell_editing_description" = "Chuyển đổi lựa chọn"; "ua_message_center_title" = "Trung tâm Tin nhắn"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Tin nhắn %@, được gửi lúc %@"; "ua_message_not_selected" = "Không có tin nhắn nào được chọn"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Tin nhắn chưa đọc %@, được gửi lúc %@"; "ua_notification_button_accept" = "Chấp nhận"; "ua_notification_button_add" = "Thêm"; "ua_notification_button_add_to_calendar" = "Thêm vào Lịch"; "ua_notification_button_book_now" = "Đặt Ngay"; "ua_notification_button_buy_now" = "Mua Ngay"; "ua_notification_button_copy" = "Sao chép"; "ua_notification_button_decline" = "Từ chối"; "ua_notification_button_dislike" = "Không thích"; "ua_notification_button_download" = "Tải về"; "ua_notification_button_follow" = "Theo dõi"; "ua_notification_button_less_like" = "Ít Tương tự hơn"; "ua_notification_button_like" = "Thích"; "ua_notification_button_more_like" = "Thêm Tương Tự"; "ua_notification_button_no" = "Không"; "ua_notification_button_opt_in" = "Tham gia"; "ua_notification_button_opt_out" = "Không tham gia"; "ua_notification_button_rate_now" = "Đánh giá Ngay"; "ua_notification_button_remind" = "Nhắc Tôi Sau"; "ua_notification_button_save" = "Lưu"; "ua_notification_button_search" = "Tìm kiếm"; "ua_notification_button_send_info" = "Gửi Thông tin"; "ua_notification_button_share" = "Chia sẻ"; "ua_notification_button_shop_now" = "Mua hàng Ngay"; "ua_notification_button_tell_me_more" = "Cho Tôi Biết Thêm"; "ua_notification_button_unfollow" = "Hủy theo dõi"; "ua_notification_button_yes" = "Có"; "ua_ok" = "OK"; "ua_preference_center_title" = "Trung tâm Ưu tiên"; "ua_retry_button" = "Thử lại"; "ua_select_all_messages" = "Chọn Tất cả"; "ua_select_all_messages_description" = "Chọn tất cả các tin nhắn"; "ua_select_none_messages" = "Chọn Không"; "ua_select_none_messages_description" = "Đặt lại lựa chọn tin nhắn"; "ua_unread_message_description" = "Tin nhắn chưa đọc"; // Generic localizations "ua_dismiss" = "Bỏ qua"; "ua_escape" = "Thoát"; "ua_next" = "Kế tiếp"; "ua_previous" = "Trước đó"; "ua_submit" = "Gửi"; "ua_loading" = "Đang tải"; "ua_pager_progress" = "Trang %@ của %@"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "Phát"; "ua_pause" = "Tạm dừng"; "ua_stop" = "Dừng"; "ua_form_processing_error" = "Lỗi xử lý biểu mẫu. Vui lòng thử lại"; "ua_close" = "Đóng"; "ua_mute" = "Tắt tiếng"; "ua_unmute" = "Bật tiếng"; "ua_required_field" = "* Bắt buộc"; "ua_invalid_form_message" = "Vui lòng sửa các trường không hợp lệ để tiếp tục"; ================================================ FILE: Airship/AirshipCore/Resources/zh-HK.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "取消"; "ua_cancel_edit_messages_description" = "取消消息編輯"; "ua_connection_error" = "連接錯誤"; "ua_content_error" = "內容錯誤"; "ua_delete_message" = "刪除消息"; "ua_delete_message_description" = "刪除選定的消息"; "ua_delete_messages" = "刪除消息"; "ua_delete_messages_description" = "刪除選定的消息"; "ua_edit_messages" = "編輯"; "ua_edit_messages_description" = "編輯消息"; "ua_empty_message_list" = "沒有消息"; "ua_mark_messages_read" = "標記閱讀"; "ua_mark_messages_read_description" = "將所選消息標記為已讀"; "ua_mc_failed_to_load" = "無法加載消息。請稍後再試。"; "ua_mc_no_longer_available" = "所選消息不再可用。"; "ua_message_cell_description" = "顯示完整消息"; "ua_message_cell_editing_description" = "切換選擇"; "ua_message_center_title" = "留言中心"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "消息 %@,發送於 %@"; "ua_message_not_selected" = "未選擇任何消息"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "未讀消息 %@,發送於 %@"; "ua_notification_button_accept" = "接受"; "ua_notification_button_add" = "添加"; "ua_notification_button_add_to_calendar" = "添加到日曆"; "ua_notification_button_book_now" = "現在預訂"; "ua_notification_button_buy_now" = "立即購買"; "ua_notification_button_copy" = "複製"; "ua_notification_button_decline" = "衰退"; "ua_notification_button_dislike" = "不喜歡"; "ua_notification_button_download" = "下載"; "ua_notification_button_follow" = "跟隨"; "ua_notification_button_less_like" = "少這樣"; "ua_notification_button_like" = "喜歡"; "ua_notification_button_more_like" = "更像這樣"; "ua_notification_button_no" = "不"; "ua_notification_button_opt_in" = "選擇參加"; "ua_notification_button_opt_out" = "選擇退出"; "ua_notification_button_rate_now" = "現在就評價吧"; "ua_notification_button_remind" = "稍後提醒我"; "ua_notification_button_save" = "保存"; "ua_notification_button_search" = "搜索"; "ua_notification_button_send_info" = "發送信息"; "ua_notification_button_share" = "分享"; "ua_notification_button_shop_now" = "現在去購物"; "ua_notification_button_tell_me_more" = "告訴我更多"; "ua_notification_button_unfollow" = "取消關注"; "ua_notification_button_yes" = "是的"; "ua_ok" = "好的"; "ua_preference_center_title" = "偏好中心"; "ua_retry_button" = "重試"; "ua_select_all_messages" = "全選"; "ua_select_all_messages_description" = "選擇所有消息"; "ua_select_none_messages" = "選擇無"; "ua_select_none_messages_description" = "重置消息選擇"; "ua_unread_message_description" = "留言未讀"; // Generic localizations "ua_dismiss" = "解除"; "ua_escape" = "逃生"; "ua_next" = "下一個"; "ua_previous" = "上一個"; "ua_submit" = "提交"; "ua_loading" = "正在載入"; "ua_pager_progress" = "第%@頁,共%@頁"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "播放"; "ua_pause" = "暫停"; "ua_stop" = "停止"; "ua_form_processing_error" = "處理表單時出錯。請重試"; "ua_close" = "關閉"; "ua_mute" = "靜音"; "ua_unmute" = "取消靜音"; "ua_required_field" = "* 必填"; "ua_invalid_form_message" = "請修正無效欄位以繼續"; ================================================ FILE: Airship/AirshipCore/Resources/zh-Hans.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "取消"; "ua_cancel_edit_messages_description" = "取消消息编辑"; "ua_connection_error" = "连接错误"; "ua_content_error" = "内容错误"; "ua_delete_message" = "删除消息"; "ua_delete_message_description" = "删除选定消息"; "ua_delete_messages" = "删除消息"; "ua_delete_messages_description" = "删除选定消息"; "ua_edit_messages" = "编辑"; "ua_edit_messages_description" = "编辑消息"; "ua_empty_message_list" = "无消息"; "ua_mark_messages_read" = "标记已读"; "ua_mark_messages_read_description" = "将所选消息标记为已读"; "ua_mc_failed_to_load" = "无法加载消息。请稍后再试。"; "ua_mc_no_longer_available" = "所选消息不再可用。"; "ua_message_cell_description" = "显示完整消息"; "ua_message_cell_editing_description" = "切换选择"; "ua_message_center_title" = "消息中心"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "消息 %@,发送于 %@"; "ua_message_not_selected" = "未选择任何消息"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "未读消息 %@,发送于 %@"; "ua_notification_button_accept" = "接受"; "ua_notification_button_add" = "添加"; "ua_notification_button_add_to_calendar" = "添加到日历"; "ua_notification_button_book_now" = "现在预订"; "ua_notification_button_buy_now" = "立刻购买"; "ua_notification_button_copy" = "复制"; "ua_notification_button_decline" = "拒绝"; "ua_notification_button_dislike" = "不喜欢"; "ua_notification_button_download" = "下载"; "ua_notification_button_follow" = "关注"; "ua_notification_button_less_like" = "更少类似"; "ua_notification_button_like" = "喜欢"; "ua_notification_button_more_like" = "更多类似"; "ua_notification_button_no" = "否"; "ua_notification_button_opt_in" = "参加"; "ua_notification_button_opt_out" = "不参加"; "ua_notification_button_rate_now" = "立即评价"; "ua_notification_button_remind" = "稍后提醒我"; "ua_notification_button_save" = "保存"; "ua_notification_button_search" = "搜索"; "ua_notification_button_send_info" = "发送信息"; "ua_notification_button_share" = "分享"; "ua_notification_button_shop_now" = "现在就选购吧!"; "ua_notification_button_tell_me_more" = "告诉我更多"; "ua_notification_button_unfollow" = "取消关注"; "ua_notification_button_yes" = "是"; "ua_ok" = "好"; "ua_preference_center_title" = "偏好中心"; "ua_retry_button" = "重试"; "ua_select_all_messages" = "全选"; "ua_select_all_messages_description" = "选择所有消息"; "ua_select_none_messages" = "全不选"; "ua_select_none_messages_description" = "重置消息选择"; "ua_unread_message_description" = "未读消息"; // Generic localizations "ua_dismiss" = "解雇"; "ua_escape" = "逃脱"; "ua_next" = "下一个"; "ua_previous" = "以前"; "ua_submit" = "提交"; "ua_loading" = "加载中"; "ua_pager_progress" = "第%@页,共%@页"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "播放"; "ua_pause" = "暂停"; "ua_stop" = "停止"; "ua_form_processing_error" = "处理表单时出错。请重试"; "ua_close" = "关闭"; "ua_mute" = "静音"; "ua_unmute" = "取消静音"; "ua_required_field" = "* 必填"; "ua_invalid_form_message" = "请修正无效字段以继续"; ================================================ FILE: Airship/AirshipCore/Resources/zh-Hant.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "取消"; "ua_cancel_edit_messages_description" = "取消消息編輯"; "ua_connection_error" = "連接錯誤"; "ua_content_error" = "內容錯誤"; "ua_delete_message" = "刪除消息"; "ua_delete_message_description" = "刪除選定的消息"; "ua_delete_messages" = "刪除消息"; "ua_delete_messages_description" = "刪除選定的消息"; "ua_edit_messages" = "編輯"; "ua_edit_messages_description" = "編輯消息"; "ua_empty_message_list" = "無訊息"; "ua_mark_messages_read" = "標記已讀"; "ua_mark_messages_read_description" = "將所選消息標記為已讀"; "ua_mc_failed_to_load" = "無法加載訊息。請稍後再試。"; "ua_mc_no_longer_available" = "所選消息不再可用。"; "ua_message_cell_description" = "顯示完整消息"; "ua_message_cell_editing_description" = "切換選擇"; "ua_message_center_title" = "訊息中心"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "消息 %@,發送於 %@"; "ua_message_not_selected" = "未選擇任何消息"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "未讀消息 %@,發送於 %@"; "ua_notification_button_accept" = "接受"; "ua_notification_button_add" = "添加"; "ua_notification_button_add_to_calendar" = "添加到日曆"; "ua_notification_button_book_now" = "現在預訂"; "ua_notification_button_buy_now" = "即刻下單"; "ua_notification_button_copy" = "複製"; "ua_notification_button_decline" = "拒絕"; "ua_notification_button_dislike" = "不喜歡"; "ua_notification_button_download" = "下載"; "ua_notification_button_follow" = "關注"; "ua_notification_button_less_like" = "更少類似"; "ua_notification_button_like" = "喜歡"; "ua_notification_button_more_like" = "更多類似"; "ua_notification_button_no" = "否"; "ua_notification_button_opt_in" = "參加"; "ua_notification_button_opt_out" = "不參加"; "ua_notification_button_rate_now" = "現在就評價吧"; "ua_notification_button_remind" = "稍後提醒我"; "ua_notification_button_save" = "保存"; "ua_notification_button_search" = "搜索"; "ua_notification_button_send_info" = "發送信息"; "ua_notification_button_share" = "分享"; "ua_notification_button_shop_now" = "即刻購買"; "ua_notification_button_tell_me_more" = "告訴我更多"; "ua_notification_button_unfollow" = "取消關注"; "ua_notification_button_yes" = "是"; "ua_ok" = "好"; "ua_preference_center_title" = "偏好中心"; "ua_retry_button" = "重試"; "ua_select_all_messages" = "全選"; "ua_select_all_messages_description" = "選擇所有消息"; "ua_select_none_messages" = "全不選"; "ua_select_none_messages_description" = "重置消息選擇"; "ua_unread_message_description" = "留言未讀"; // Generic localizations "ua_dismiss" = "解除"; "ua_escape" = "逃離"; "ua_next" = "下一個"; "ua_previous" = "上一個"; "ua_submit" = "提交"; "ua_loading" = "正在載入"; "ua_pager_progress" = "第%@頁,共%@頁"; "ua_x_of_y" = "%@ / %@"; "ua_play" = "播放"; "ua_pause" = "暫停"; "ua_stop" = "停止"; "ua_form_processing_error" = "處理表單時出錯。請重試"; "ua_close" = "關閉"; "ua_mute" = "靜音"; "ua_unmute" = "取消靜音"; "ua_required_field" = "* 必填"; "ua_invalid_form_message" = "請修正無效欄位以繼續"; ================================================ FILE: Airship/AirshipCore/Resources/zu.lproj/UrbanAirship.strings ================================================ "ua_cancel_edit_messages" = "Khansela"; "ua_cancel_edit_messages_description" = "Khansela ukuhlelwa komlayezo"; "ua_connection_error" = "Iphutha Lokuxhuma"; "ua_content_error" = "Iphutha Lokuqukethwe"; "ua_delete_message" = "Susa umlayezo"; "ua_delete_message_description" = "Susa imilayezo ekhethiwe"; "ua_delete_messages" = "Susa umlayezo"; "ua_delete_messages_description" = "Susa imilayezo ekhethiwe"; "ua_edit_messages" = "Hlela"; "ua_edit_messages_description" = "Hlela imilayezo"; "ua_empty_message_list" = "Ayikho imilayezo"; "ua_mark_messages_read" = "Maka Funda"; "ua_mark_messages_read_description" = "Maka imilayezo ekhethiwe njengefundiwe"; "ua_mc_failed_to_load" = "Ayikwazi ukulayisha umlayezo. Sicela uzame futhi emuva kwesikhathi."; "ua_mc_no_longer_available" = "Umlayezo okhethiwe awusatholakali."; "ua_message_cell_description" = "Ibonisa umlayezo ogcwele"; "ua_message_cell_editing_description" = "Iguqula ukukhetha"; "ua_message_center_title" = "Isikhungo Somlayezo"; /* Message <TITLE>, sent at <DATE> */ "ua_message_description" = "Umlayezo %@, uthunyelwe ngo-%@"; "ua_message_not_selected" = "Ayikho imilayezo ekhethiwe"; /* Unread message <TITLE>, sent at <DATE> */ "ua_message_unread_description" = "Umlayezo ongafundiwe %@, uthunyelwe ngo-%@"; "ua_notification_button_accept" = "Yamukela"; "ua_notification_button_add" = "Engeza"; "ua_notification_button_add_to_calendar" = "Engeza kukhalenda"; "ua_notification_button_book_now" = "Bhukha Manje"; "ua_notification_button_buy_now" = "Thenga Manje"; "ua_notification_button_copy" = "Kopisha"; "ua_notification_button_decline" = "Yenqaba"; "ua_notification_button_dislike" = "Ukungathandi"; "ua_notification_button_download" = "Landa"; "ua_notification_button_follow" = "Landela"; "ua_notification_button_less_like" = "Kancane Kanje"; "ua_notification_button_like" = "Thanda"; "ua_notification_button_more_like" = "Okuningi Okufana Nalokhu"; "ua_notification_button_no" = "Cha"; "ua_notification_button_opt_in" = "Khetha ukungena"; "ua_notification_button_opt_out" = "Phuma"; "ua_notification_button_rate_now" = "Linganisa Manje"; "ua_notification_button_remind" = "Ngikhumbuze ngokuhamba kwesikhathi"; "ua_notification_button_save" = "Londoloza"; "ua_notification_button_search" = "Sesha"; "ua_notification_button_send_info" = "Thumela Ulwazi"; "ua_notification_button_share" = "Yabelana"; "ua_notification_button_shop_now" = "Thenga Manje"; "ua_notification_button_tell_me_more" = "Ngitshele Okuningi"; "ua_notification_button_unfollow" = "Yekela ukulandela"; "ua_notification_button_yes" = "Yebo"; "ua_ok" = "KULUNGILE"; "ua_preference_center_title" = "Isikhungo Esithandwayo"; "ua_retry_button" = "Zama futhi"; "ua_select_all_messages" = "Khetha konke"; "ua_select_all_messages_description" = "Ikhetha yonke imilayezo"; "ua_select_none_messages" = "Khetha Lutho"; "ua_select_none_messages_description" = "Setha kabusha ukukhetha komlayezo"; "ua_unread_message_description" = "Umlayezo awufundiwe"; // Generic localizations "ua_dismiss" = "Khipha"; "ua_escape" = "Phuma"; "ua_next" = "Okulandelayo"; "ua_previous" = "Okwangaphambili"; "ua_submit" = "Faka"; "ua_loading" = "Iyalayisha"; "ua_pager_progress" = "Ikhasi %@ ye-%@"; "ua_x_of_y" = "%@ kw %@"; "ua_play" = "Dlala"; "ua_pause" = "Misa"; "ua_stop" = "Yeka"; "ua_form_processing_error" = "Iphutha ekucubunguleni ifomu. Sicela uzame futhi"; "ua_close" = "Vala"; "ua_mute" = "Thulisa"; "ua_unmute" = "Susa ukuthula"; "ua_required_field" = "* Kuyadingeka"; "ua_invalid_form_message" = "Sicela ulungise izinkambu ezingavumelekile ukuze uqhubeke"; ================================================ FILE: Airship/AirshipCore/Source/APNSEnvironment.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct APNSEnvironment { #if !targetEnvironment(macCatalyst) private static let defaultProfilePath: String? = Bundle.main.path( forResource: "embedded", ofType: "mobileprovision" ) #else private static let defaultProfilePath: String? = URL( fileURLWithPath: URL( fileURLWithPath: Bundle.main.resourcePath ?? "" ) .deletingLastPathComponent().path ) .appendingPathComponent("embedded.provisionprofile").path #endif public static func isProduction() throws -> Bool { return try isProduction(self.defaultProfilePath) } public static func isProduction( _ profilePath: String? ) throws -> Bool { guard let path = profilePath, let embeddedProfile: String = try? String( contentsOfFile: path, encoding: .isoLatin1 ) else { throw AirshipErrors.error("No mobile provisioning profile found \(profilePath ?? "null")") } let scanner = Scanner(string: embeddedProfile) _ = scanner.scanUpToString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>") guard let extractedPlist = scanner.scanUpToString("</plist>"), let plistData = extractedPlist.appending("</plist>") .data(using: .utf8), let plistDict = try? PropertyListSerialization.propertyList( from: plistData, options: [], format: nil ) as? [AnyHashable: Any] else { throw AirshipErrors.error("Unable to read provisioning profile \(path)") } guard let entitlements = plistDict["Entitlements"] as? [AnyHashable: Any] else { throw AirshipErrors.error("Unable to read provisioning profile \(path). No entitlements.") } guard let apsEnvironment = entitlements["aps-environment"] as? String else { throw AirshipErrors.error("aps-environment value is not set \(path), ensure that the app is properly provisioned for push.") } switch(apsEnvironment) { case "production": return true case "development": return false default: throw AirshipErrors.error("Unexpected aps-environment \(apsEnvironment)") } } } ================================================ FILE: Airship/AirshipCore/Source/APNSRegistrar.swift ================================================ // Copyright Airship and Contributors import Foundation @MainActor protocol APNSRegistrar: Sendable { var isRegisteredForRemoteNotifications: Bool { get } func registerForRemoteNotifications() var isRemoteNotificationBackgroundModeEnabled: Bool { get } var isBackgroundRefreshStatusAvailable: Bool { get } } #if os(watchOS) import WatchKit final class DefaultAPNSRegistrar: APNSRegistrar { var isRegisteredForRemoteNotifications: Bool { WKExtension.shared().isRegisteredForRemoteNotifications } func registerForRemoteNotifications() { WKExtension.shared().registerForRemoteNotifications() } var isRemoteNotificationBackgroundModeEnabled: Bool { return true } var isBackgroundRefreshStatusAvailable: Bool { return true } } #elseif os(macOS) import AppKit final class DefaultAPNSRegistrar: APNSRegistrar { var isRegisteredForRemoteNotifications: Bool { return NSApplication.shared.isRegisteredForRemoteNotifications } func registerForRemoteNotifications() { NSApplication.shared.registerForRemoteNotifications() } var isRemoteNotificationBackgroundModeEnabled: Bool { return true } var isBackgroundRefreshStatusAvailable: Bool { return true } } #elseif canImport(UIKit) import UIKit final class DefaultAPNSRegistrar: APNSRegistrar { var isRegisteredForRemoteNotifications: Bool { return UIApplication.shared.isRegisteredForRemoteNotifications } func registerForRemoteNotifications() { UIApplication.shared.registerForRemoteNotifications() } static var _isRemoteNotificationBackgroundModeEnabled: Bool { let backgroundModes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [Any] return backgroundModes? .contains(where: { ($0 as? String) == "remote-notification" }) == true } var isRemoteNotificationBackgroundModeEnabled: Bool { return Self._isRemoteNotificationBackgroundModeEnabled } var isBackgroundRefreshStatusAvailable: Bool { #if os(tvOS) return true #else // This covers iOS and iPadOS return UIApplication.shared.backgroundRefreshStatus == .available #endif } } #endif ================================================ FILE: Airship/AirshipCore/Source/APNSRegistrationResult.swift ================================================ // Copyright Airship and Contributors import Foundation /// The result of an APNs registration. public enum APNSRegistrationResult: Sendable { /// Registration was successful and a new device token was received. case success(deviceToken: String) /// Registration failed. case failure(error: any Error) } ================================================ FILE: Airship/AirshipCore/Source/AccountEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public extension CustomEvent { /// Account template enum AccountTemplate: Sendable { /// Account registered case registered /// User logged in case loggedIn /// User logged out case loggedOut fileprivate static let templateName: String = "account" fileprivate var eventName: String { return switch self { case .registered: "registered_account" case .loggedIn: "logged_in" case .loggedOut: "logged_out" } } } /// Additional acount template properties struct AccountProperties: Encodable, Sendable { /// User ID. public var userID: String? /// The event's category. public var category: String? /// The event's type. public var type: String? /// If the value is a lifetime value or not. public var isLTV: Bool public init( category: String? = nil, type: String? = nil, isLTV: Bool = false, userID: String? = nil ) { self.userID = userID self.category = category self.type = type self.isLTV = isLTV } enum CodingKeys: String, CodingKey { case userID = "user_id" case category case type case isLTV = "ltv" } } /// Constructs a custom event using the account template. /// - Parameters: /// - accountTemplate: The account template. /// - properties: Optional additional properties /// - encoder: Encoder used to encode the additional properties. Defaults to `CustomEvent.defaultEncoder`. init( accountTemplate: AccountTemplate, properties: AccountProperties = AccountProperties(), encoder: @autoclosure () -> JSONEncoder = CustomEvent.defaultEncoder() ) { self = .init(name: accountTemplate.eventName) self.templateType = AccountTemplate.templateName do { try self.setProperties(properties, encoder: encoder()) } catch { /// Should never happen so we are just catching the exception and logging AirshipLogger.error("Failed to generate event \(error)") } } } ================================================ FILE: Airship/AirshipCore/Source/ActionArguments.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Action situations public enum ActionSituation: Int, Sendable { /// Action invoked manually case manualInvocation /// Action invoked from the app being launched from a push notification case launchedFromPush /// Action invoked from a foreground push case foregroundPush /// Action invoked from a background push case backgroundPush /// Action invoked from a web view case webViewInvocation /// Action invoked from a foreground action button case foregroundInteractiveButton /// Action invoked from a background action button case backgroundInteractiveButton /// Action invoked from an automation case automation } /// Contains the arguments passed into an action during execution. public struct ActionArguments: Sendable { /// Metadata key for the user notification action identifier. Available when an action is triggered from a /// user notification action. The ID will be a String. public static let userNotificationActionIDMetadataKey: String = "com.urbanairship.user_notification_action_id" /// Metadata key for the push notification. Available when an action is triggered /// from a push notification or user notification action. The payload will be an `AirshipJSON`. public static let pushPayloadJSONMetadataKey: String = "com.urbanairship.payload" public static let isForegroundPresentationMetadataKey: String = "com.urbanairship.is_foreground_presentation" /// Metadata key for the inbox message's identifier. Available when an action is triggered from an /// inbox message. The ID will be a String. public static let inboxMessageIDMetadataKey: String = "com.urbanairship.messageID" /// Metadata key for the user notification action response info text. Available when an action is triggered /// from a user notification action with the behavior `UIUserNotificationActionBehaviorTextInput`. public static let responseInfoMetadataKey: String = "com.urbanairship.response_info" /// The action argument's value public var value: AirshipJSON /// The action argument's situation public var situation: ActionSituation /// The action argument's metadata public var metadata: [String: any Sendable] public init( string: String, situation: ActionSituation = .manualInvocation, metadata: [String : any Sendable] = [:] ) { self.value = .string(string) self.situation = situation self.metadata = metadata } public init( double: Double, situation: ActionSituation = .manualInvocation, metadata: [String : any Sendable] = [:] ) { self.value = AirshipJSON.number(double) self.situation = situation self.metadata = metadata } public init( bool: Bool, situation: ActionSituation = .manualInvocation, metadata: [String : any Sendable] = [:] ) { self.value = .bool(bool) self.situation = situation self.metadata = metadata } public init( value: AirshipJSON = AirshipJSON.null, situation: ActionSituation = .manualInvocation, metadata: [String : any Sendable] = [:] ) { self.value = value self.situation = situation self.metadata = metadata } } ================================================ FILE: Airship/AirshipCore/Source/ActionRegistry.swift ================================================ /* Copyright Airship and Contributors */ /// This protocol is responsible for runtime-persisting actions and associating /// them with names and predicates. @MainActor public protocol AirshipActionRegistry: Sendable { func registerEntry( names: [String], entry: @escaping () -> ActionEntry ) func registerEntry( names: [String], entry: ActionEntry ) @discardableResult func removeEntry(name: String) -> Bool @discardableResult func updateEntry(name: String, action: any AirshipAction) -> Bool @discardableResult func updateEntry(name: String, predicate: (@Sendable (ActionArguments) async -> Bool)?) -> Bool @discardableResult func updateEntry(name: String, situation: ActionSituation, action: any AirshipAction) -> Bool func entry(name: String) -> ActionEntry? func registerActions(actionsManifests: [any ActionsManifest]) } @MainActor public class DefaultAirshipActionRegistry: AirshipActionRegistry { private var entries: [String: EntryHolder] = [:] public func registerEntry( names: [String], entry: @escaping () -> ActionEntry ) { let entryHolder = EntryHolder(entryBlock: entry) names.forEach { name in entries[name] = entryHolder } } public func registerEntry( names: [String], entry: ActionEntry ) { let entryHolder = EntryHolder(entryBlock: { entry }) names.forEach { name in entries[name] = entryHolder } } @discardableResult public func removeEntry(name: String) -> Bool { guard let entryHolder = entries[name] else { return false } entries.compactMap { (key, value) in if (entryHolder === value) { return key } return nil }.forEach { name in entries[name] = nil } return true } @discardableResult public func updateEntry(name: String, action: any AirshipAction) -> Bool { guard let entryHolder = entries[name] else { return false } entryHolder.entry.action = action return true } @discardableResult public func updateEntry(name: String, predicate: (@Sendable (ActionArguments) async -> Bool)?) -> Bool { guard let entryHolder = entries[name] else { return false } entryHolder.entry.predicate = predicate return true } @discardableResult public func updateEntry(name: String, situation: ActionSituation, action: any AirshipAction) -> Bool { guard let entryHolder = entries[name] else { return false } entryHolder.entry.situationOverrides[situation] = action return true } public func entry(name: String) -> ActionEntry? { return self.entries[name]?.entry } public func registerActions(actionsManifests: [any ActionsManifest]) { actionsManifests.forEach { actionsManifest in actionsManifest.manifest.forEach { (names, entry) in registerEntry(names: names, entry: entry) } } } } /// Action registry entry public struct ActionEntry: Sendable { var situationOverrides: [ActionSituation: any AirshipAction] = [:] var action: any AirshipAction var predicate: (@Sendable (ActionArguments) async -> Bool)? public init( action: any AirshipAction, situationOverrides: [ActionSituation: any AirshipAction] = [:], predicate: (@Sendable (ActionArguments) async -> Bool)? = nil ) { self.action = action self.predicate = predicate } func action(situation: ActionSituation) -> any AirshipAction { return situationOverrides[situation] ?? action } } fileprivate class EntryHolder { private var _entry: ActionEntry? var entry: ActionEntry { get { if let entry = _entry { return entry } let resolved = entryBlock() _entry = resolved return resolved } set { _entry = newValue } } private let entryBlock: () -> ActionEntry init(entryBlock: @escaping () -> ActionEntry) { self.entryBlock = entryBlock } } /// Airship action manifest. /// - Note: for internal use only. :nodoc: public protocol ActionsManifest { var manifest: [[String]: () -> ActionEntry] { get } } struct DefaultActionsManifest: ActionsManifest { let manifest: [[String]: () -> ActionEntry] = { var entries: [[String]: () -> ActionEntry] = [ OpenExternalURLAction.defaultNames: { return ActionEntry( action: OpenExternalURLAction(), predicate: OpenExternalURLAction.defaultPredicate ) }, AddTagsAction.defaultNames: { return ActionEntry( action: AddTagsAction(), predicate: AddTagsAction.defaultPredicate ) }, RemoveTagsAction.defaultNames: { return ActionEntry( action: RemoveTagsAction(), predicate: RemoveTagsAction.defaultPredicate ) }, ModifyTagsAction.defaultNames: { return ActionEntry( action: ModifyTagsAction(), predicate: ModifyTagsAction.defaultPredicate ) }, DeepLinkAction.defaultNames: { return ActionEntry( action: DeepLinkAction(), predicate: DeepLinkAction.defaultPredicate ) }, AddCustomEventAction.defaultNames: { return ActionEntry( action: AddCustomEventAction(), predicate: AddCustomEventAction.defaultPredicate ) }, FetchDeviceInfoAction.defaultNames: { return ActionEntry( action: FetchDeviceInfoAction(), predicate: FetchDeviceInfoAction.defaultPredicate ) }, EnableFeatureAction.defaultNames: { return ActionEntry( action: EnableFeatureAction(), predicate: EnableFeatureAction.defaultPredicate ) }, ModifyAttributesAction.defaultNames: { return ActionEntry( action: ModifyAttributesAction(), predicate: ModifyAttributesAction.defaultPredicate ) }, SubscriptionListAction.defaultNames: { return ActionEntry( action: SubscriptionListAction(), predicate: SubscriptionListAction.defaultPredicate ) }, PromptPermissionAction.defaultNames: { return ActionEntry( action: PromptPermissionAction(), predicate: PromptPermissionAction.defaultPredicate ) } ] #if os(iOS) || os(visionOS) entries[RateAppAction.defaultNames] = { return ActionEntry( action: RateAppAction(), predicate: RateAppAction.defaultPredicate ) } entries[PasteboardAction.defaultNames] = { return ActionEntry( action: PasteboardAction() ) } #endif #if os(iOS) entries[ShareAction.defaultNames] = { return ActionEntry( action: ShareAction(), predicate: ShareAction.defaultPredicate ) } #endif return entries }() } ================================================ FILE: Airship/AirshipCore/Source/ActionResult.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Action result public enum ActionResult: Sendable { /// Action ran and produced a result case completed(AirshipJSON) /// Action ran with an error case error(any Error) /// Arguments rejected either by the action or predicate case argumentsRejected /// Action not found case actionNotFound } ================================================ FILE: Airship/AirshipCore/Source/ActionRunner.swift ================================================ /* Copyright Airship and Contributors */ /// A helper class for running actions by name or by reference. public final class ActionRunner { /// Runs an action /// - Parameters: /// - action: The action to run /// - arguments: The action's arguments /// - Returns An action result public class func run( action: any AirshipAction, arguments: ActionArguments ) async -> ActionResult { guard await action.accepts(arguments: arguments) else { AirshipLogger.debug( "Action \(action) rejected arguments \(arguments)." ) return .argumentsRejected } do { let result = try await action.perform(arguments: arguments) ?? .null AirshipLogger.debug( "Action \(action) finished with result \(result), argument: \(arguments)." ) return .completed(result) } catch { AirshipLogger.debug( "Action \(action) finished with error \(error), argument: \(arguments)." ) return .error(error) } } /// Runs an action /// - Parameters: /// - actionName: The name of the action /// - arguments: The action's arguments /// - Returns An action result public class func run( actionName: String, arguments: ActionArguments ) async -> ActionResult { guard let entry = await Airship.actionRegistry.entry(name: actionName) else { return .actionNotFound } guard await entry.predicate?(arguments) != false else { AirshipLogger.debug( "Action \(actionName) predicate rejected argument: \(arguments)." ) return .argumentsRejected } let action: any AirshipAction = entry.action(situation: arguments.situation) return await self.run( action: action, arguments: arguments ) } /// Runs an action /// - Parameters: /// - actionsPayload: A map of action name to action value. /// - situation: The action's situation /// - metadata: The action's metadata /// - Returns A map of action name to action result @discardableResult public class func run( actionsPayload: AirshipJSON, situation: ActionSituation, metadata: [String: any Sendable] ) async -> [String: ActionResult] { guard case .object(let payload) = actionsPayload else { AirshipLogger.error("Invalid actions payload: \(actionsPayload)") return [:] } var results: [String: ActionResult] = [:] for (key, value) in payload { results[key] = await run( actionName: key, arguments: ActionArguments( value: value, situation: situation, metadata: metadata ) ) } return results } public class func _run( actionsPayload: [String: Any], situation: ActionSituation ) async { guard let value = try? AirshipJSON.wrap(actionsPayload) else { AirshipLogger.error("Invalid actions payload: \(actionsPayload)") return } await run( actionsPayload: value, situation: situation, metadata: [:] ) } } ================================================ FILE: Airship/AirshipCore/Source/ActivityViewController.swift ================================================ /* Copyright Airship and Contributors */ #if os(iOS) import UIKit final class ActivityViewController: UIActivityViewController, UIPopoverPresentationControllerDelegate, UIPopoverControllerDelegate { @objc public var dismissalBlock: (() -> Void)? public override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) dismissalBlock?() } /** * Returns the desired source rect dimensions for the popover. * - Returns: popover dimensions. */ @objc public func sourceRect() -> CGRect { let windowBounds = try? AirshipUtils.mainWindow()?.bounds // Return a smaller rectangle by 25% on each axis, producing a 50% smaller rectangle inset. return windowBounds? .insetBy( dx: (windowBounds?.width ?? 0.0) / 4.0, dy: (windowBounds?.height ?? 0.0) / 4.0 ) ?? CGRect.zero } public func popoverPresentationController( _ popoverPresentationController: UIPopoverPresentationController, willRepositionPopoverTo rect: UnsafeMutablePointer<CGRect>, in view: AutoreleasingUnsafeMutablePointer<UIView> ) { rect.pointee = sourceRect() } } #endif ================================================ FILE: Airship/AirshipCore/Source/AddCustomEventAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// An action that adds a custom event. /// /// Expected argument values: A dictionary of keys for the custom event. When a /// custom event action is triggered from a Message Center Rich Push Message, /// the interaction type and ID will automatically be filled for the message if /// they are left blank. /// /// Valid situations: All. /// /// Result value: nil public final class AddCustomEventAction: AirshipAction { private static let eventNameKey: String = "name" private static let eventValue: String = "value" /// Default names - "add_custom_event_action", "^+e" public static let defaultNames: [String] = ["add_custom_event_action", "^+e"] /// Default predicate - rejects foreground pushes with visible display options and `ActionSituation.backgroundPush` public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in if (args.situation == .backgroundPush) { return false } return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } /// Metadata key for in-app context. /// - Note: For internal use only. :nodoc: public static let _inAppMetadata: String = "in_app_metadata" public func accepts(arguments: ActionArguments) async -> Bool { return true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { guard let dict = arguments.value.unWrap() as? [AnyHashable: Any], let eventName = getEventName(dict) else { throw AirshipErrors .error("Invalid custom event argument: \(arguments.value)") } let eventValue = getEventValue(dict) let interactionID = parseString( dict, key: CustomEvent.eventInteractionIDKey ) let interactionType = parseString( dict, key: CustomEvent.eventInteractionTypeKey ) let transactionID = parseString( dict, key: CustomEvent.eventTransactionIDKey ) let properties = dict[CustomEvent.eventPropertiesKey] as? [String: Any] var event = CustomEvent(name: eventName, value: eventValue ?? 1.0) if let inApp = arguments.metadata[Self._inAppMetadata] { do { event.inApp = try AirshipJSON.wrap(inApp) } catch { AirshipLogger.error("Failed to encode in-app info for custom event: \(inApp), error: \(error)") } } event.transactionID = transactionID if let properties { try event.setProperties(properties) } if interactionID != nil || interactionType != nil { event.interactionType = interactionType event.interactionID = interactionID } else if let messageID = arguments.metadata[ActionArguments.inboxMessageIDMetadataKey] as? String { event.setInteractionFromMessageCenterMessage(messageID) } if let json = arguments.metadata[ActionArguments.pushPayloadJSONMetadataKey] as? AirshipJSON, let unwrapped = json.unWrap() as? [String: AnyHashable] { event.conversionSendID = unwrapped["_"] as? String event.conversionPushMetadata = unwrapped["com.urbanairship.metadata"] as? String } guard event.isValid() else { throw AirshipErrors.error("Invalid custom event: \(arguments.value)") } event.track() return nil } func parseString(_ dict: [AnyHashable: Any], key: String) -> String? { guard let value = dict[key] else { return nil } guard value is String else { return "\(value)" } return value as? String } func parseDouble(_ dict: [AnyHashable: Any], key: String) -> Double? { guard let value = dict[key] else { return nil } guard let value = value as? Double else { if let string = parseString(dict, key: key) { return Double(string) } return nil } return value } private func getEventName(_ dict: [AnyHashable: Any]) -> String? { return parseString(dict, key: Self.eventNameKey) ?? parseString(dict, key: CustomEvent.eventNameKey) } private func getEventValue(_ dict: [AnyHashable: Any]) -> Double? { return parseDouble(dict, key: Self.eventValue) ?? parseDouble(dict, key: CustomEvent.eventValueKey) } } ================================================ FILE: Airship/AirshipCore/Source/AddTagsAction.swift ================================================ /* Copyright Airship and Contributors */ /// Adds tags. /// /// Expected argument values: `String` (single tag), `[String]` (single or multiple tags), or an object. /// An example tag group JSON payload: /// { /// "channel": { /// "channel_tag_group": ["channel_tag_1", "channel_tag_2"], /// "other_channel_tag_group": ["other_channel_tag_1"] /// }, /// "named_user": { /// "named_user_tag_group": ["named_user_tag_1", "named_user_tag_2"], /// "other_named_user_tag_group": ["other_named_user_tag_1"] /// }, /// "device": [ "tag", "another_tag"] /// } /// /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush` /// `ActionSituation.webViewInvocation`, `ActionSituation.foregroundInteractiveButton`, /// `ActionSituation.backgroundInteractiveButton`, `ActionSituation.manualInvocation`, and /// `ActionSituation.automation` public final class AddTagsAction: AirshipAction { /// Default names - "add_tags_action", "^+t" public static let defaultNames: [String] = ["add_tags_action", "^+t"] /// Default predicate - rejects foreground pushes with visible display options public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } private let channel: @Sendable () -> any AirshipChannel private let contact: @Sendable () -> any AirshipContact private let tagMutationsChannel: AirshipAsyncChannel<TagActionMutation> = AirshipAsyncChannel<TagActionMutation>() public var tagMutations: AsyncStream<TagActionMutation> { get async { return await tagMutationsChannel.makeStream() } } public convenience init() { self.init( channel: Airship.componentSupplier(), contact: Airship.componentSupplier() ) } init( channel: @escaping @Sendable () -> any AirshipChannel, contact: @escaping @Sendable () -> any AirshipContact ) { self.channel = channel self.contact = contact } public func accepts(arguments: ActionArguments) async -> Bool { guard arguments.situation != .backgroundPush else { return false } return true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let unwrapped = arguments.value.unWrap() if let tag = unwrapped as? String { channel().editTags { editor in editor.add(tag) } sendTagMutation(.channelTags([tag])) } else if let tags = arguments.value.unWrap() as? [String] { channel().editTags { editor in editor.add(tags) } sendTagMutation(.channelTags(tags)) } else if let args: TagsActionsArgs = try arguments.value.decode() { if let channelTagGroups = args.channel { channel().editTagGroups { editor in channelTagGroups.forEach { group, tags in editor.add(tags, group: group) } } sendTagMutation(.channelTagGroups(channelTagGroups)) } if let contactTagGroups = args.namedUser { contact().editTagGroups { editor in contactTagGroups.forEach { group, tags in editor.add(tags, group: group) } } sendTagMutation(.contactTagGroups(contactTagGroups)) } if let deviceTags = args.device { channel().editTags() { editor in editor.add(deviceTags) } sendTagMutation(.channelTags(deviceTags)) } } return nil } private func sendTagMutation(_ mutation: TagActionMutation) { Task { @MainActor in await tagMutationsChannel.send(mutation) } } } ================================================ FILE: Airship/AirshipCore/Source/Airship.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(UIKit) public import UIKit #endif #if canImport(AppKit) && os(macOS) import AppKit #endif #if canImport(WatchKit) import WatchKit #endif #if canImport(AirshipBasement) @_spi(AirshipInternal) import AirshipBasement #endif /// Main entry point for Airship. The application must call `takeOff` within `application(_:didFinishLaunchingWithOptions:)` /// before accessing any instances on Airship or Airship modules. public final class Airship: Sendable { /// Airship deep link scheme /// - Note: For internal use only. :nodoc: public static let deepLinkScheme: String = "uairship" private static let appSettingsDeepLinkHost: String = "app_settings" private static let appStoreDeepLinkHost: String = "app_store" private static let itunesIDKey: String = "itunesID" /// A flag that checks if the Airship instance is available. `true` if available, otherwise `false`. public static var isFlying: Bool { return Airship._shared != nil } private let _airshipInstanceHolder: AirshipAtomicValue<any AirshipInstance> var airshipInstance: any AirshipInstance { _airshipInstanceHolder.value } /// Airship config. public static var config: RuntimeConfig { return shared.airshipInstance.config } /// Action registry. public static var actionRegistry: any AirshipActionRegistry { return shared.airshipInstance.actionRegistry } /// The Airship permissions manager. public static var permissionsManager: any AirshipPermissionsManager { return shared.airshipInstance.permissionsManager } #if !os(tvOS) && !os(watchOS) /// A user configurable UAJavaScriptCommandDelegate /// - NOTE: this delegate is not retained. public static weak var javaScriptCommandDelegate: (any JavaScriptCommandDelegate)? { get { return shared.airshipInstance.javaScriptCommandDelegate } set { shared._airshipInstanceHolder.value.javaScriptCommandDelegate = newValue } } /// The channel capture utility. public static var channelCapture: any AirshipChannelCapture { return shared.airshipInstance.channelCapture } #endif /// A user configurable deep link delegate. /// - NOTE: this delegate is not retained. public static weak var deepLinkDelegate: (any DeepLinkDelegate)? { get { return shared.airshipInstance.deepLinkDelegate } set { shared._airshipInstanceHolder.value.deepLinkDelegate = newValue } } /// A user configurable deep link handler. /// Takes precedence over `deepLinkDelegate` when set. @MainActor public static var onDeepLink: (@Sendable @MainActor (URL) async -> Void)? { get { return shared.airshipInstance.onDeepLink } set { shared._airshipInstanceHolder.value.onDeepLink = newValue } } /// The URL allow list used for validating URLs for landing pages, /// wallet action, open external URL action, deep link /// action (if delegate is not set), and HTML in-app messages. public static var urlAllowList: any AirshipURLAllowList { return shared.airshipInstance.urlAllowList } /// The locale manager. public static var localeManager: any AirshipLocaleManager { return shared.airshipInstance.localeManager } /// The privacy manager public static var privacyManager: any AirshipPrivacyManager { return shared.airshipInstance.privacyManager } static var inputValidator: any AirshipInputValidation.Validator { return shared.airshipInstance.inputValidator } /// - NOTE: For internal use only. :nodoc: public var components: [any AirshipComponent] { return airshipInstance.components } static let _sharedHolder: AirshipAtomicValue<Airship?> = AirshipAtomicValue<Airship?>(nil) static var _shared: Airship? { get { _sharedHolder.value } set { _sharedHolder.value = newValue } } static var shared: Airship { if !Airship.isFlying { assertionFailure("TakeOff must be called before accessing Airship.") } return _shared! } /// Shared Push instance. public static var push: any AirshipPush { return requireComponent(ofType: (any AirshipPush).self) } /// Shared Contact instance. public static var contact: any AirshipContact { return requireComponent(ofType: (any AirshipContact).self) } /// Shared Analytics instance. public static var analytics: any AirshipAnalytics { return requireComponent(ofType: (any AirshipAnalytics).self) } /// Shared Channel instance. public static var channel: any AirshipChannel { return requireComponent(ofType: (any AirshipChannel).self) } @MainActor private static var onReadyCallbacks: [@MainActor @Sendable () -> Void] = [] init(instance: any AirshipInstance) { self._airshipInstanceHolder = AirshipAtomicValue(instance) } /// Initializes Airship. If any errors are found with the config or if Airship is already intiialized it will throw with /// the error. /// - Parameters: /// - config: The Airship config. If nil, config will be loading from a plist. @MainActor public class func takeOff( _ config: AirshipConfig? = nil ) throws { try commonTakeOff(config) } #if !os(macOS) && !os(watchOS) /// Initializes Airship. If any errors are found with the config or if Airship is already intiialized it will throw with /// the error. /// - Parameters: /// - config: The Airship config. If nil, config will be loading from a plist. /// - launchOptions: The launch options passed into `application:didFinishLaunchingWithOptions:`. @MainActor @available(*, deprecated, message: "Use Airship.takeOff(_:) instead") public class func takeOff( _ config: AirshipConfig? = nil, launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) throws { try Self.takeOff(config) } #endif /// On ready callback gets called immediately when ready otherwise gets called immediately after takeoff /// - Parameter callback: callback closure that's called when Airship is ready @MainActor public static func onReady(callback: @MainActor @Sendable @escaping () -> Void) { onReadyCallbacks.append(callback) if isFlying { executeOnReady() } } /// Helper method that executes any remaining onReady closures and resets the array @MainActor private static func executeOnReady() { let toExecute = onReadyCallbacks onReadyCallbacks.removeAll() toExecute.forEach { $0() } } @MainActor private class func configureLogger(_ config: AirshipConfig, inProduction: Bool) { let handler = if let logHandler = config.logHandler { logHandler } else { DefaultLogHandler( privacyLevel: inProduction ? config.productionLogPrivacyLevel : config.developmentLogPrivacyLevel ) } AirshipLogger.configure( logLevel: inProduction ? config.productionLogLevel : config.developmentLogLevel, handler: handler ) } @MainActor private class func commonTakeOff(_ config: AirshipConfig?, onReady: (() -> Void)? = nil) throws { guard !Airship.isFlying else { throw AirshipErrors.error("Airship already initalized. TakeOff can only be called once.") } // Get the config let resolvedConfig = try (config ?? AirshipConfig.default()) // Determine production flag and configure logger so we can log errors var inProduction: Bool = true do { inProduction = try resolvedConfig.resolveInProduction() configureLogger(resolvedConfig, inProduction: inProduction) } catch { configureLogger(resolvedConfig, inProduction: inProduction) AirshipLogger.impError("Unable to determine AirshipConfig.inProduction \(error), defaulting to true") } let credentials = try resolvedConfig.resolveCredentails(inProduction) // We have valid config, log issues resolvedConfig.logIssues() AirshipLogger.info( "Airship TakeOff! SDK Version \(AirshipVersion.version), App Key: \(credentials.appKey), inProduction: \(inProduction)" ) ChallengeResolver.shared.resolver = resolvedConfig.connectionChallengeResolver _shared = Airship( instance: DefaultAirshipInstance( airshipConfig: resolvedConfig, appCredentials: credentials ) ) let integrationDelegate = DefaultAppIntegrationDelegate( push: requireComponent(ofType: (any InternalAirshipPush).self), analytics: requireComponent(ofType: (any InternalAirshipAnalytics).self), pushableComponents: _shared?.components.compactMap { return $0 as? (any AirshipPushableComponent) } ?? [] ) if resolvedConfig.isAutomaticSetupEnabled { AirshipLogger.info("Automatic setup enabled.") AutoIntegration.shared.integrate(with: integrationDelegate) } else { AppIntegration.integrationDelegate = integrationDelegate } onReady?() self.shared.airshipInstance.airshipReady() executeOnReady() if resolvedConfig.isExtendedBroadcastsEnabled { var userInfo: [String: Any] = [:] userInfo[AirshipNotifications.AirshipReady.channelIDKey] = self.channel.identifier userInfo[AirshipNotifications.AirshipReady.appKey] = credentials.appKey userInfo[AirshipNotifications.AirshipReady.payloadVersionKey] = 1 NotificationCenter.default.post( name: AirshipNotifications.AirshipReady.name, object: userInfo ) } else { NotificationCenter.default.post( name: AirshipNotifications.AirshipReady.name, object: nil ) } } /// - NOTE: For internal use only. :nodoc: public class func component<E>(ofType componentType: E.Type) -> E? { return shared.airshipInstance.component(ofType: componentType) } /// - NOTE: For internal use only. :nodoc: public class func requireComponent<E>(ofType componentType: E.Type) -> E { let component = shared.airshipInstance.component( ofType: componentType ) if component == nil { assertionFailure("Missing required component: \(componentType)") } return component! } /// - NOTE: For internal use only. :nodoc: public class func componentSupplier<E>() -> @Sendable () -> E { return { return requireComponent(ofType: E.self) } } /// Processes a deep link. /// For `uairship://` scheme URLs, Airship will handle the deep link internally. /// For other URLs, Airship will forward the deep link to the deep link listener if set. /// - Parameters: /// - url: The deep link. /// - Returns `true` if the link was able to be processed, otherwise `false`. @MainActor public static func processDeepLink(_ url: URL) async -> Bool { return await Airship.shared.deepLink(url) } @MainActor private func deepLink( _ deepLink: URL ) async -> Bool { guard deepLink.scheme != Airship.deepLinkScheme else { guard await handleAirshipDeeplink(deepLink) else { let component = self.airshipInstance.components.first( where: { $0.deepLink(deepLink) } ) if component != nil { AirshipLogger.debug("Handling Airship deep link: \(deepLink)") return true } // Try handler first, then delegate if let onDeepLink = self.airshipInstance.onDeepLink { AirshipLogger.debug("Handling deep link via onDeepLink closure: \(deepLink)") await onDeepLink(deepLink) return true } else if let deepLinkDelegate = self.airshipInstance.deepLinkDelegate { AirshipLogger.debug("Handling deep link by receivedDeepLink: \(deepLink) on delegate: \(deepLinkDelegate)") await deepLinkDelegate.receivedDeepLink(deepLink) return true } AirshipLogger.debug("Unhandled deep link \(deepLink)") return true } return true } // Try handler first, then delegate if let deepLinkHandler = self.airshipInstance.onDeepLink { AirshipLogger.debug("Handling deep link via onDeepLink closure: \(deepLink)") await deepLinkHandler(deepLink) return true } else if let deepLinkDelegate = self.airshipInstance.deepLinkDelegate { AirshipLogger.debug("Handling deep link via receivedDeepLink: \(deepLink) on delegate: \(deepLinkDelegate)") await deepLinkDelegate.receivedDeepLink(deepLink) return true } AirshipLogger.debug("Unhandled deep link \(deepLink)") return false } /// Handle the Airship deep links for app_settings and app_store. /// - Note: For internal use only. :nodoc: /// `uairship://app_settings` and `uairship://app_store?itunesID=<ITUNES_ID>` deep links will be handled internally. If no itunesID provided, use the one in Airship Config. /// - Parameters: /// - deepLink: The deep link. /// - Returns: `true` if the deeplink is handled, `false` otherwise. @MainActor private func handleAirshipDeeplink(_ deeplink: URL) async -> Bool { switch deeplink.host { case Airship.appSettingsDeepLinkHost: AirshipLogger.debug("Handling Settings deep link: \(deeplink)") return await self.airshipInstance.urlOpener.openSettings() case Airship.appStoreDeepLinkHost: AirshipLogger.debug("Handling App Store deep link: \(deeplink)") let appStoreUrl = "itms-apps://itunes.apple.com/app/" guard let itunesID = getItunesID(deeplink) else { return true } if let url = URL(string: appStoreUrl + itunesID) { await self.airshipInstance.urlOpener.openURL(url) } return true default: return false } } /// Gets the iTunes ID. /// - Note: For internal use only. :nodoc: /// - Parameters: /// - deepLink: The deep link. /// - Returns: The iTunes ID or `nil` if it's not set. private func getItunesID(_ deeplink: URL) -> String? { let urlComponents = URLComponents( url: deeplink, resolvingAgainstBaseURL: false ) let queryMap = urlComponents?.queryItems? .reduce(into: [String: String?]()) { $0[$1.name] = $1.value } ?? [:] return queryMap[Airship.itunesIDKey] as? String ?? airshipInstance.config.airshipConfig.itunesID } // Taken from IAA so we can continue to use the existing value if set private static let newUserCutOffDateKey: String = "UAInAppRemoteDataClient.ScheduledNewUserCutoffTime" var installDate: Date { if let date = self.airshipInstance.preferenceDataStore.value(forKey: Airship.newUserCutOffDateKey) as? Date { return date } var date: Date! if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last, let attributes = try? FileManager.default.attributesOfItem(atPath: documentsURL.path), let installDate = attributes[.creationDate] as? Date { date = installDate } else { date = self.airshipInstance.component(ofType: (any AirshipChannel).self)?.identifier != nil ? Date.distantPast : Date() } self.airshipInstance.preferenceDataStore.setObject(date, forKey: Airship.newUserCutOffDateKey) return date } } /// NSNotificationCenter keys event names public final class AirshipNotifications { /// Notification when Airship is ready. public final class AirshipReady { /// Notification name public static let name: NSNotification.Name = NSNotification.Name( "com.urbanairship.airship_ready" ) /// Airship ready channel ID key. Only available if `extendedBroadcastEnabled` is true in config. public static let channelIDKey: String = "channel_id" /// Airship ready app key. Only available if `extendedBroadcastEnabled` is true in config. public static let appKey: String = "app_key" /// Airship ready payload version. Only available if `extendedBroadcastEnabled` is true in config. public static let payloadVersionKey: String = "payload_version" } } public extension Airship { /// Waits for Airship to be ready using async/await. /// /// This method provides a modern async/await interface for waiting until Airship /// has finished initializing. It's particularly useful when you need to ensure /// Airship is ready before performing operations that depend on it. /// /// ## Usage /// /// ```swift /// // Wait for Airship to be ready /// await Airship.waitForReady() /// /// // Now safe to use Airship components /// Airship.push.enableUserNotifications() /// ``` /// /// ## Behavior /// /// - If Airship is already initialized (`isFlying` is `true`), this method returns immediately /// - If Airship is not yet initialized, this method suspends until initialization completes /// - The method will not throw or fail - it simply waits for the ready state /// /// - Note: This method must be called from the main thread. /// - Important: This method assumes `Airship.takeOff` has been called. If `takeOff` /// is never called, this method will suspend indefinitely. @MainActor static func waitForReady() async { guard !Airship.isFlying else { return } await withCheckedContinuation { continuation in Airship.onReady { continuation.resume() } } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Airship action. Actions can be registered in the `ActionRegistry` and ran through the `ActionRunner`. public protocol AirshipAction: AnyObject, Sendable { /// Called before an action is performed to determine if the /// the action can accept the arguments. /// This method can be used both to verify that an argument's value is an appropriate type, /// as well as to limit the scope of execution of a desired range of values. Rejecting /// arguments will result in the action not being performed when it is run. /// - Parameters: /// - ActionArgument A UAActionArgument value representing the arguments passed to the action. /// - Returns: YES if the action can perform with the arguments, otherwise NO func accepts(arguments: ActionArguments) async -> Bool /// Performs the action. /// You should not ordinarily call this method directly. Instead, use the `ActionRunner`. /// - Parameters: /// - arguments Arguments value representing the arguments passed to the action. /// - Returns:An optional value. func perform(arguments: ActionArguments) async throws -> AirshipJSON? } ================================================ FILE: Airship/AirshipCore/Source/AirshipActorValue.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @preconcurrency import Combine /// NOTE: For internal use only. :nodoc: public actor AirshipActorValue<T: Sendable> { private let subject: PassthroughSubject<T, Never> = PassthroughSubject() public private(set) var value: T { didSet { subject.send(value) } } public var updates: AsyncStream<T> { AsyncStream { [weak subject] continuation in guard let subject = subject else { continuation.finish() return } let cancellable: AnyCancellable = subject.sink { value in continuation.yield(value) } continuation.onTermination = { _ in cancellable.cancel() } } } public init(_ value: T) { self.value = value } public func set(_ value: T) { self.value = value } public func getAndUpdate(block: @Sendable (inout T) -> Void) -> T { block(&self.value) return self.value } public func update(block: @Sendable (inout T) -> Void) { block(&self.value) } } /// NOTE: For internal use only. :nodoc: public final class AirshipMainActorValue<T: Sendable>: @unchecked Sendable { private let subject: PassthroughSubject<T, Never> = PassthroughSubject() @MainActor public private(set) var value: T { didSet { subject.send(value) } } @MainActor public var updates: AsyncStream<T> { AsyncStream { [weak subject] continuation in continuation.yield(value) guard let subject = subject else { continuation.finish() return } let cancellable: AnyCancellable? = subject.sink { value in continuation.yield(value) } continuation.onTermination = { _ in cancellable?.cancel() } } } public init(_ value: T) { self.value = value } @MainActor public func set(_ value: T) { self.value = value } @MainActor public func getAndUpdate(block: @Sendable (inout T) -> Void) -> T { block(&self.value) return value } @MainActor public func update(block: @Sendable (inout T) -> Void) { block(&self.value) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipAnalytics.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import Combine #if canImport(UserNotifications) public import UserNotifications #endif /// Analytics protocol public protocol AirshipAnalytics: AnyObject, Sendable { /// The conversion send ID. :nodoc: var conversionSendID: String? { get } /// The conversion push metadata. :nodoc: var conversionPushMetadata: String? { get } /// The current session ID. var sessionID: String { get } /// Adds a custom event. /// - Parameter event: The event. func recordCustomEvent(_ event: CustomEvent) /// Tracks a custom event. /// - Parameter event: The event. func recordRegionEvent(_ event: RegionEvent) /// Tracks install attribution data. /// - Parameters: /// - appPurchaseDate: The app purchase date. /// - iAdImpressionDate: The iAd impression date. func trackInstallAttribution(appPurchaseDate: Date?, iAdImpressionDate: Date?) /// Associates identifiers with the device. This call will add a special event /// that will be batched and sent up with our other analytics events. Previous /// associated identifiers will be replaced. /// /// /// - Parameter associatedIdentifiers: The associated identifiers. func associateDeviceIdentifiers( _ associatedIdentifiers: AssociatedIdentifiers ) /// The device's current associated identifiers. /// - Returns: The device's current associated identifiers. func currentAssociatedDeviceIdentifiers() -> AssociatedIdentifiers /// Initiates screen tracking for a specific app screen, must be called once per tracked screen. /// - Parameter screen: The screen's identifier. @MainActor func trackScreen(_ screen: String?) /// Registers an SDK extension with the analytics module. /// For internal use only. :nodoc: /// /// - Parameters: /// - ext: The SDK extension. /// - version: The version. func registerSDKExtension(_ ext: AirshipSDKExtension, version: String) /// A publisher of event data that is tracked through Airship. var eventPublisher: AnyPublisher<AirshipEventData, Never> { get } } /// Internal Analytics protocol /// For internal use only. :nodoc: public protocol InternalAirshipAnalytics: AirshipAnalytics { var eventFeed: AirshipAnalyticsFeed { get } @MainActor var screenUpdates: AsyncStream<String?> { get } @MainActor var currentScreen: String? { get } @MainActor var regionUpdates: AsyncStream<Set<String>> { get } @MainActor var currentRegions: Set<String> { get } func recordEvent(_ event: AirshipEvent) #if !os(tvOS) @MainActor func onNotificationResponse( response: UNNotificationResponse, action: UNNotificationAction? ) #endif /// Called to notify analytics the app was launched from a push notification. /// For internal use only. :nodoc: /// - Parameter notification: The push notification. @MainActor func launched(fromNotification notification: [AnyHashable: Any]) @MainActor func addHeaderProvider(_ headerProvider: @Sendable @escaping () async -> [String: String]) } ================================================ FILE: Airship/AirshipCore/Source/AirshipAnalyticsFeed.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// For internal use only. :nodoc: public final class AirshipAnalyticsFeed: Sendable { private let isEnabled: @Sendable () -> Bool public init(isEnabled: @Sendable @escaping () -> Bool) { self.isEnabled = isEnabled } public convenience init(privacyManager: any AirshipPrivacyManager, isAnalyticsEnabled: Bool) { self.init( isEnabled: { [weak privacyManager] in return privacyManager?.isEnabled(.analytics) == true && isAnalyticsEnabled } ) } public enum Event: Equatable, Sendable { case screen(screen: String?) case analytics(eventType: EventType, body: AirshipJSON, value: Double? = 1) } private let channel: AirshipAsyncChannel<Event> = AirshipAsyncChannel<Event>() public var updates: AsyncStream<Event> { get async { return await channel.makeStream() } } @discardableResult func notifyEvent(_ event: Event) async -> Bool { guard isEnabled() else { return false } await channel.send(event) return true } } ================================================ FILE: Airship/AirshipCore/Source/AirshipAppCredentials.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// App credentails public struct AirshipAppCredentials: Sendable { /// App key public let appKey: String /// App secret public let appSecret: String } ================================================ FILE: Airship/AirshipCore/Source/AirshipApptimizeIntegration.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /** * This class provides handy access for Airship method for the integration with Apptimize SDK */ @objc(UAirshipApptimizeIntegration) final class AirshipApptimizeIntegration: NSObject { @objc public static var airshipVersion: String { return AirshipVersion.get() } @objc public static var isFlying: Bool { return Airship.isFlying } @objc(getUserID:) public static func getUserID(completion: @Sendable @escaping (String?) -> Void) { guard Airship.isFlying else { return } Task { @Sendable in let id = await Airship.contact.namedUserID completion(id) } } @objc public static var channelID: String? { guard Airship.isFlying else { return nil } return Airship.channel.identifier } @objc public static var channelTags: [String]? { guard Airship.isFlying else { return nil } return Airship.channel.tags } @objc public static func addTags(_ tags: [String], group: String) { guard Airship.isFlying else { return } Airship.channel.editTagGroups { $0.add(tags, group: group) } } @objc public static func setTags(_ tags: [String], group: String) { guard Airship.isFlying else { return } Airship.channel.editTagGroups({ $0.set(tags, group: group) }) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipAsyncChannel.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import Combine /// Simple implementation of a `channel` that allows multiple AsyncStreams of the same data. /// - Note: for internal use only. :nodoc: public actor AirshipAsyncChannel<T: Sendable> { public enum BufferPolicy: Sendable { case unbounded case bufferingNewest(Int) case bufferingOldest(Int) fileprivate func toStreamPolicy() -> AsyncStream<T>.Continuation.BufferingPolicy { switch (self) { case .unbounded: return .unbounded case .bufferingOldest(let buffer): return .bufferingOldest(buffer) case .bufferingNewest(let buffer): return .bufferingNewest(buffer) } } } private var nextID: Int = 0 private var listeners: [Int: Listener] = [:] public func send(_ value: T) async { listeners.values.forEach { listener in listener.send(value: value) } } public init() {} public func makeStream(bufferPolicy: BufferPolicy = .unbounded) -> AsyncStream<T> { let id = self.nextID nextID += 1 return AsyncStream(bufferingPolicy: bufferPolicy.toStreamPolicy()) { continuation in let listener = Listener(continuation: continuation) listeners[id] = listener continuation.onTermination = { [weak self] _ in Task { [weak self] in await self?.removeListener(id: id) } } } } private func removeListener(id: Int) { self.listeners[id] = nil } final class Listener: Sendable { let continuation: AsyncStream<T>.Continuation init(continuation: AsyncStream<T>.Continuation) { self.continuation = continuation } func send(value: T) { self.continuation.yield(value) } } } public extension AirshipAsyncChannel { /// Makes a stream that is nonisolated from the actor by wrapping the /// actor stream in an async stream. /// - Parameters: /// - bufferPolicy: The buffer policy. /// - initialValue: Optional initial value closure. If provided and the value is nil, it will finish the stream. /// - transform: Transforms the channel values to the stream value. If nil, it a value is mapped to nil it will finish the stream. /// - Returns: An AsyncStream. nonisolated func makeNonIsolatedStream<R: Sendable>( bufferPolicy: BufferPolicy = .unbounded, initialValue: (@Sendable () async -> R?)? = nil, transform: @escaping @Sendable (T) async -> R? ) -> AsyncStream<R> { return AsyncStream<R> { [weak self] continuation in let task = Task { [weak self] in guard let stream = await self?.makeStream() else { return } if let initialValue { guard let value = await initialValue() else { continuation.finish() return } continuation.yield(value) } for await update in stream.map(transform) { if let update { continuation.yield(update) } else { continuation.finish() } } continuation.finish() } continuation.onTermination = { _ in task.cancel() } } } /// Makes a stream that is nonisolated from the actor by wrapping the /// actor stream in an async stream. /// - Parameters: /// - bufferPolicy: The buffer policy. /// - initialValue: Optional initial value closure. If provided and the value is nil, it will finish the stream. /// - transform: Transforms the channel values to the stream value. If nil, it a value is mapped to nil it will finish the stream. /// - Returns: An AsyncStream. nonisolated func makeNonIsolatedStream( bufferPolicy: BufferPolicy = .unbounded, initialValue: (@Sendable () async -> T)? = nil ) -> AsyncStream<T> { return makeNonIsolatedStream( bufferPolicy: bufferPolicy, initialValue: initialValue, transform: { $0 } ) } /// Makes a stream that is nonisolated from the actor by wrapping the /// actor stream in an async stream. Values will only be emitted if they are different than the previous value. /// - Parameters: /// - bufferPolicy: The buffer policy. /// - initialValue: Optional initial value closure. If provided and the value is nil, it will finish the stream. /// - Returns: An AsyncStream. nonisolated func makeNonIsolatedDedupingStream<R: Sendable&Equatable>( bufferPolicy: BufferPolicy = .unbounded, initialValue: (@Sendable () async -> R?)? = nil, transform: @escaping @Sendable (T) async -> R? ) -> AsyncStream<R> { return AsyncStream<R> { [weak self] continuation in let task = Task { [weak self] in guard let stream = await self?.makeStream() else { return } var last: R? = nil if let initialValue { guard let value = await initialValue() else { continuation.finish() return } continuation.yield(value) last = value } for await update in stream.map(transform) { guard let update else { continuation.finish() return } if update != last { continuation.yield(update) last = update } } } continuation.onTermination = { _ in task.cancel() } } } /// Makes a stream that is nonisolated from the actor by wrapping the /// actor stream in an async stream. Values will only be emitted if they are different than the previous value. /// - Parameters: /// - bufferPolicy: The buffer policy. /// - initialValue: Optional initial value closure. If provided and the value is nil, it will finish the stream. /// - Returns: An AsyncStream. nonisolated func makeNonIsolatedDedupingStream( bufferPolicy: BufferPolicy = .unbounded, initialValue: (@Sendable () async -> T?)? = nil ) -> AsyncStream<T> where T: Equatable { return makeNonIsolatedDedupingStream( bufferPolicy: bufferPolicy, initialValue: initialValue, transform: { $0 } ) } } public extension AsyncStream where Element : Sendable { /// Creates a combine publisher from an AsyncStream. /// - Note: for internal use only. :nodoc: @MainActor var airshipPublisher: AnyPublisher<Element?, Never>{ let subject = CurrentValueSubject<Element?, Never>(nil) Task { @MainActor [weak subject] in for await update in self { guard let subject else { return } subject.send(update) } } return subject.eraseToAnyPublisher() } } ================================================ FILE: Airship/AirshipCore/Source/AirshipAsyncImage.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI /// - Note: for internal use only. :nodoc: public struct AirshipAsyncImage<Placeholder: View, ImageView: View>: View { private let url: String private let imageLoader: AirshipImageLoader private let image: (Image, CGSize) -> ImageView private let placeholder: () -> Placeholder public init( url: String, imageLoader: AirshipImageLoader = AirshipImageLoader(), image: @escaping (Image, CGSize) -> ImageView, placeholder: @escaping () -> Placeholder ) { self.url = url self.imageLoader = imageLoader self.image = image self.placeholder = placeholder } @State private var loadedURL: String? @State private var loadedImage: AirshipImageData? @State private var currentImage: AirshipNativeImage? @State private var imageIndex: Int = 0 @State private var animationTask: Task<Void, Never>? public var body: some View { content .task(id: url) { guard loadedURL != url else { startAnimation() return } self.loadedImage = nil self.currentImage = nil do { let image = try await imageLoader.load(url: url) self.loadedURL = url self.loadedImage = image startAnimation() } catch is CancellationError { } catch { AirshipLogger.error("Unable to load image \(url): \(error)") } } } private var content: some View { Group { if let image = currentImage { self.image(Image(airshipNativeImage: image), image.size) .animation(nil, value: self.imageIndex) .onDisappear { animationTask?.cancel() } } else { self.placeholder() } } } private func startAnimation() { self.animationTask?.cancel() self.animationTask = Task { @MainActor in await animateImage() } } @MainActor private func animateImage() async { guard let loadedImage = self.loadedImage else { return } guard loadedImage.isAnimated else { self.currentImage = await loadedImage.loadFrames().first?.image return } let frameActor = loadedImage.getActor() imageIndex = 0 var frame = await frameActor.loadFrame(at: imageIndex) self.currentImage = frame?.image while !Task.isCancelled { let duration = frame?.duration ?? AirshipImageData.minFrameDuration async let delay: () = Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) let nextIndex = (imageIndex + 1) % loadedImage.imageFramesCount do { let (_, nextFrame) = try await (delay, frameActor.loadFrame(at: nextIndex)) frame = nextFrame } catch {} // most likely it's a task cancelled exception when animation is stopped imageIndex = nextIndex if !Task.isCancelled { self.currentImage = frame?.image } } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipAuthorizedNotificationSettings.swift ================================================ // Copyright Airship and Contributors import Foundation import UserNotifications // Authorized notification settings. public struct AirshipAuthorizedNotificationSettings: OptionSet, Sendable { public let rawValue: UInt public init(rawValue: UInt) { self.rawValue = rawValue } // Badge public static let badge: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 0) // Sound public static let sound: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 1) // Alert public static let alert: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 2) // Carplad public static let carPlay: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 3) // Lockscreen public static let lockScreen: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 4) // Notification Center public static let notificationCenter: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 5) // Critical alert public static let criticalAlert: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 6) // Announcement public static let announcement: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 7) // Scheduled delivery public static let scheduledDelivery: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 8) // Time sensitive public static let timeSensitive: AirshipAuthorizedNotificationSettings = AirshipAuthorizedNotificationSettings(rawValue: 1 << 9) } extension AirshipAuthorizedNotificationSettings { static func from(settings: UNNotificationSettings) -> AirshipAuthorizedNotificationSettings { var authorizedSettings: AirshipAuthorizedNotificationSettings = [] #if !os(watchOS) if settings.badgeSetting == .enabled { authorizedSettings.insert(.badge) } #endif #if !os(tvOS) if settings.soundSetting == .enabled { authorizedSettings.insert(.sound) } if settings.alertSetting == .enabled { authorizedSettings.insert(.alert) } #if !os(watchOS) && !os(macOS) if settings.carPlaySetting == .enabled { authorizedSettings.insert(.carPlay) } if settings.lockScreenSetting == .enabled { authorizedSettings.insert(.lockScreen) } #endif if settings.notificationCenterSetting == .enabled { authorizedSettings.insert(.notificationCenter) } if settings.criticalAlertSetting == .enabled { authorizedSettings.insert(.criticalAlert) } #if !os(visionOS) && !os(macOS) /// Announcement authorization is always included in visionOS if settings.announcementSetting == .enabled { authorizedSettings.insert(.announcement) } #endif #endif #if !os(tvOS) && !targetEnvironment(macCatalyst) if settings.timeSensitiveSetting == .enabled { authorizedSettings.insert(.timeSensitive) } if settings.scheduledDeliverySetting == .enabled { authorizedSettings.insert(.scheduledDelivery) } #endif return authorizedSettings } } ================================================ FILE: Airship/AirshipCore/Source/AirshipBase64.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: For internal use only. :nodoc: public final class AirshipBase64 { public class func data(from base64String: String) -> Data? { var normalizedString = base64String.components(separatedBy: .newlines) .joined(separator: "") .replacingOccurrences( of: "=", with: "" ) // Must be a multiple of 4 characters post padding // For more information: https://tools.ietf.org/html/rfc4648#section-8 switch normalizedString.count % 4 { case 2: normalizedString += "==" case 3: normalizedString += "=" default: break } return Data( base64Encoded: normalizedString, options: .ignoreUnknownCharacters ) } public class func string(from data: Data) -> String? { let base64 = data.base64EncodedData(options: .lineLength64Characters) return String(data: base64, encoding: .ascii) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipButton.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Button view. struct AirshipButton<Label> : View where Label : View { @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var pagerState: PagerState @EnvironmentObject private var videoState: VideoState @EnvironmentObject private var thomasState: ThomasState @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @Environment(\.layoutState) private var layoutState @Environment(\.isButtonActionsEnabled) private var isButtonActionsEnabled private let identifier: String private let reportingMetadata: AirshipJSON? private let description: String? private let clickBehaviors: [ThomasButtonClickBehavior]? private let eventHandlers: [ThomasEventHandler]? private let actions: ThomasActionsPayload? private let tapEffect: ThomasButtonTapEffect? private let label: () -> Label @State private var isProcessing: Bool = false init( identifier: String, reportingMetadata: AirshipJSON? = nil, description: String?, clickBehaviors: [ThomasButtonClickBehavior]? = nil, eventHandlers: [ThomasEventHandler]? = nil, actions: ThomasActionsPayload? = nil, tapEffect: ThomasButtonTapEffect? = nil, label: @escaping () -> Label ) { self.identifier = identifier self.reportingMetadata = reportingMetadata self.description = description self.clickBehaviors = clickBehaviors self.eventHandlers = eventHandlers self.actions = actions self.tapEffect = tapEffect self.label = label } var body: some View { Button( action: { if (isButtonActionsEnabled) { Task { @MainActor in isProcessing = true await doButtonActions() isProcessing = false } } }, label: self.label ) .optionalAccessibilityLabel(self.description) .buttonTapEffect(tapEffect ?? .default) .disabled(isProcessing) } @MainActor private func doButtonActions() async { if clickBehaviors?.contains(.formSubmit) == true || clickBehaviors?.contains(.formValidate) == true { guard await formState.validate() else { return } } let taps = self.eventHandlers?.filter { $0.type == .tap } if let taps, !taps.isEmpty { /// Tap handlers taps.forEach { tap in handleStateActions(tap.stateActions) } // WORKAROUND: SwiftUI state updates are not immediately available to child views. // Yielding allows the state changes to propagate through the view hierarchy // before executing behaviors that may depend on the updated state. await Task.yield() } // Button reporting thomasEnvironment.buttonTapped( buttonIdentifier: self.identifier, reportingMetadata: self.reportingMetadata, layoutState: layoutState ) // Buttons await handleBehaviors(self.clickBehaviors ?? []) handleActions(self.actions) } private func handleBehaviors( _ behaviors: [ThomasButtonClickBehavior]? ) async { guard let behaviors else { return } for behavior in behaviors { switch(behavior) { case .dismiss: thomasEnvironment.dismiss( buttonIdentifier: self.identifier, buttonDescription: self.description ?? self.identifier, cancel: false, layoutState: layoutState ) case .cancel: thomasEnvironment.dismiss( buttonIdentifier: self.identifier, buttonDescription: self.description ?? self.identifier, cancel: true, layoutState: layoutState ) case .pagerNext: pagerState.process(request: .next) case .pagerPrevious: pagerState.process(request: .back) case .pagerNextOrDismiss: if pagerState.isLastPage { thomasEnvironment.dismiss( buttonIdentifier: self.identifier, buttonDescription: self.description ?? self.identifier, cancel: false, layoutState: layoutState ) } else { pagerState.process(request: .next) } case .pagerNextOrFirst: if pagerState.isLastPage { pagerState.process(request: .first) } else { pagerState.process(request: .next) } case .pagerPause: pagerState.pause() case .pagerResume: pagerState.resume() case .pagerPauseToggle: pagerState.togglePause() case .formValidate: // Already handled above break case .formSubmit: do { try await formState.submit(layoutState: layoutState) } catch { AirshipLogger.error("Failed to submit \(error)") } case .videoPlay: videoState.play() case .videoPause: videoState.pause() case .videoTogglePlay: videoState.togglePlay() case .videoMute: videoState.mute() case .videoUnmute: videoState.unmute() case .videoToggleMute: videoState.toggleMute() } } } private func handleActions(_ actionPayload: ThomasActionsPayload?) { if let actionPayload { thomasEnvironment.runActions(actionPayload, layoutState: layoutState) } } private func handleStateActions(_ stateActions: [ThomasStateAction]) { thomasState.processStateActions(stateActions) } } fileprivate struct AirshipButtonEmptyStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label } } fileprivate extension View { @ViewBuilder func buttonTapEffect(_ tapEffect: ThomasButtonTapEffect) -> some View { switch(tapEffect) { case .default: #if os(tvOS) self.buttonStyle(TVButtonStyle()) #else self.buttonStyle(.plain) #endif case .none: self.buttonStyle(AirshipButtonEmptyStyle()) } } @ViewBuilder func optionalAccessibilityLabel(_ label: String?) -> some View { if let label { self.accessibilityLabel(label) } else { self } } } #if os(tvOS) struct TVButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { return ButtonView(configuration: configuration) } struct ButtonView: View { @Environment(\.isFocused) var isFocused @Environment(\.isEnabled) var isEnabled let configuration: ButtonStyle.Configuration var body: some View { configuration.label .hoverEffect(.highlight, isEnabled: isFocused) .colorMultiply(isEnabled ? Color.white : ThomasConstants.disabledColor) } } } #endif ================================================ FILE: Airship/AirshipCore/Source/AirshipCache.swift ================================================ public import Foundation import CoreData public protocol AirshipCache: Actor { func deleteCachedValue(key: String) async func getCachedValue<T: Codable & Sendable>(key: String) async -> T? func setCachedValue<T: Codable & Sendable>(_ value: T?, key: String, ttl: TimeInterval) async } actor CoreDataAirshipCache: AirshipCache { private let coreData: UACoreData? private let appVersion: String private let sdkVersion: String private let date: any AirshipDateProtocol private let cleanUpTask: Task<Void, Never> static func makeCoreData(appKey: String, inMemory: Bool = false) -> UACoreData? { let modelURL = AirshipCoreResources.bundle.url( forResource: "UAirshipCache", withExtension: "momd" ) if let modelURL = modelURL { return UACoreData( name: "UAirshipCache", modelURL: modelURL, inMemory: inMemory, stores: ["AirshipCache-\(appKey).sqlite"] ) } AirshipLogger.error("Failed to create AirshipCache") return nil } init(appKey: String) { self.init( coreData: CoreDataAirshipCache.makeCoreData(appKey: appKey) ) } init( coreData: UACoreData?, appVersion: String = AirshipUtils.bundleShortVersionString() ?? "0.0.0", sdkVersion: String = AirshipVersion.version, date: any AirshipDateProtocol = AirshipDate.shared ) { self.coreData = coreData self.appVersion = appVersion self.sdkVersion = sdkVersion self.date = date self.cleanUpTask = Task { [appVersion, sdkVersion, date] in guard let coreData = coreData else { return } do { let predicate = AirshipCoreDataPredicate( format: "appVersion != %@ || sdkVersion != %@ || expiry <= %@", args: [ appVersion, sdkVersion, date.now ] ) try await coreData.perform(skipIfStoreNotCreated: true) { context in try context.delete( predicate: predicate, useBatch: !coreData.inMemory ) } } catch { AirshipLogger.error("Failed to cleanup cache \(error)") } } } func getCachedValue<T>( key: String ) async -> T? where T : Decodable, T : Encodable, T : Sendable { await self.cleanUpTask.value do { let result: T? = try await requireCoreData().performWithResult { context in let entity = try context.getAirshipCacheEntity(key: key) guard let data = entity?.data, let expiry = entity?.expiry, entity?.appVersion == self.appVersion, entity?.sdkVersion == self.sdkVersion else { AirshipLogger.trace("Invalid cache data, deleting") try? context.deleteCacheEntity(key: key) return nil } guard expiry > self.date.now else { AirshipLogger.trace("Value expired, deleting") try? context.deleteCacheEntity(key: key) return nil } return try JSONDecoder().decode(T.self, from: data) } return result } catch { AirshipLogger.error("Failed to fetch cached value \(key) \(error)") return nil } } func deleteCachedValue(key: String) async { await self.cleanUpTask.value do { try await requireCoreData().perform { context in try context.deleteCacheEntity(key: key) } } catch { AirshipLogger.error("Failed to delete cached value for key \(key) \(error)") } } func setCachedValue<T>( _ value: T?, key: String, ttl: TimeInterval ) async where T : Decodable, T : Encodable, T : Sendable { await self.cleanUpTask.value do { try await requireCoreData().perform { context in let entity = try context.getOrCreateAirshipCacheEntity(key: key) entity.key = key entity.sdkVersion = self.sdkVersion entity.appVersion = self.appVersion entity.expiry = self.date.now + ttl entity.data = try JSONEncoder().encode(value) } } catch { AirshipLogger.error("Failed to cache value for key \(key) \(error)") } } private func requireCoreData() throws -> UACoreData { guard let coreData = self.coreData else { throw AirshipErrors.error("Coredata does not exist") } return coreData } } fileprivate extension NSManagedObjectContext { func getOrCreateAirshipCacheEntity( key: String ) throws -> AirshipCacheData { return try getAirshipCacheEntity(key: key) ?? createAirshipCacheEntity() } func deleteCacheEntity( key: String ) throws { let predicate = AirshipCoreDataPredicate( format: "key == %@", args: [key] ) try? delete(predicate: predicate, useBatch: false) } func getAirshipCacheEntity( key: String ) throws -> AirshipCacheData? { let request = NSFetchRequest<AirshipCacheData>( entityName: AirshipCacheData.entityName ) let predicate = AirshipCoreDataPredicate( format: "key == %@", args: [key] ) request.fetchLimit = 1 request.predicate = predicate.toNSPredicate() let fetchResult = try fetch(request) return fetchResult.first } func createAirshipCacheEntity() throws -> AirshipCacheData { let entity = NSEntityDescription.insertNewObject( forEntityName: AirshipCacheData.entityName, into: self ) as? AirshipCacheData guard let entity = entity else { throw AirshipErrors.error("Failed to create AirshipCacheData") } return entity } func delete( predicate: AirshipCoreDataPredicate, useBatch: Bool ) throws { if useBatch { let request = NSFetchRequest<any NSFetchRequestResult>( entityName: AirshipCacheData.entityName ) request.predicate = predicate.toNSPredicate() let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try execute(deleteRequest) } else { let request = NSFetchRequest<AirshipCacheData>( entityName: AirshipCacheData.entityName ) request.predicate = predicate.toNSPredicate() request.includesPropertyValues = false let fetched = try fetch(request) fetched.forEach { entity in delete(entity) } } } } // Internal core data entity @objc(UAirshipCacheData) fileprivate class AirshipCacheData: NSManagedObject { static let entityName: String = "UAirshipCacheData" @NSManaged public dynamic var data: Data? @objc @NSManaged public dynamic var key: String? @objc @NSManaged public dynamic var appVersion: String? @objc @NSManaged public dynamic var sdkVersion: String? @objc @NSManaged public dynamic var expiry: Date? } ================================================ FILE: Airship/AirshipCore/Source/AirshipCancellable.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public protocol AirshipCancellable: Sendable { func cancel() } /// - Note: for internal use only. :nodoc: public protocol AirshipMainActorCancellable: Sendable { @MainActor func cancel() } /// - Note: for internal use only. :nodoc: public final class AirshipMainActorCancellableBlock: AirshipMainActorCancellable, Sendable { private let block: AirshipAtomicValue<(@Sendable @MainActor () -> Void)?> = AirshipAtomicValue<(@Sendable @MainActor () -> Void)?>(nil) public init(block: @escaping @MainActor @Sendable () -> Void) { self.block.value = block } @MainActor public func cancel() { self.block.value?() self.block.value = nil } } ================================================ FILE: Airship/AirshipCore/Source/AirshipChannel.swift ================================================ /* Copyright Airship and Contributors */ public import Combine import Foundation #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) && !os(macOS) public import ActivityKit #endif /// Airship Channel protocol. public protocol AirshipChannel: AnyObject, Sendable { /** * The Channel ID. */ var identifier: String? { get } /** * Device tags */ var tags: [String] { get set } /** * Allows setting tags from the device. Tags can be set from either the server or the device, but * not both (without synchronizing the data), so use this flag to explicitly enable or disable * the device-side flags. * * Set this to `false` to prevent the device from sending any tag information to the server when using * server-side tagging. Defaults to `true`. */ var isChannelTagRegistrationEnabled: Bool { get set } /** * Edits channel tags. * - Returns: Tag editor. */ func editTags() -> TagEditor /** * Edits channel tags. * - Parameters: * - editorBlock: The editor block with the editor. The editor will `apply` will be called after the block is executed. */ func editTags(_ editorBlock: (TagEditor) -> Void) /** * Edits channel tags groups. * - Returns: Tag group editor. */ func editTagGroups() -> TagGroupsEditor /** * Edits channel tag groups tags. * - Parameters: * - editorBlock: The editor block with the editor. The editor will `apply` will be called after the block is executed. */ func editTagGroups(_ editorBlock: (TagGroupsEditor) -> Void) /** * Edits channel subscription lists. * - Returns: Subscription list editor. */ func editSubscriptionLists() -> SubscriptionListEditor /** * Edits channel subscription lists. * - Parameters: * - editorBlock: The editor block with the editor. The editor will `apply` will be called after the block is executed. */ func editSubscriptionLists(_ editorBlock: (SubscriptionListEditor) -> Void) /** * Fetches current subscription lists. * - Returns: The subscription lists */ func fetchSubscriptionLists() async throws -> [String] /** * Edits channel attributes. * - Returns: Attribute editor. */ func editAttributes() -> AttributesEditor /** * Edits channel attributes. * - Parameters: * - editorBlock: The editor block with the editor. The editor will `apply` will be called after the block is executed. */ func editAttributes(_ editorBlock: (AttributesEditor) -> Void) /** * Enables channel creation if channelCreationDelayEnabled was set to `YES` in the config. */ func enableChannelCreation() /// Async stream of channel ID updates. var identifierUpdates: AsyncStream<String> { get } /// Publishes edits made to the subscription lists through the SDK var subscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { get } #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) && !os(macOS) /// Gets an AsyncSequence of `LiveActivityRegistrationStatus` updates for a given live acitvity name. /// - Parameters: /// - name: The live activity name /// - Returns A `LiveActivityRegistrationStatusUpdates` @available(iOS 16.1, *) func liveActivityRegistrationStatusUpdates( name: String ) -> LiveActivityRegistrationStatusUpdates /// Gets an AsyncSequence of `LiveActivityRegistrationStatus` updates for a given live acitvity ID. /// - Parameters: /// - activity: The live activity /// - Returns A `LiveActivityRegistrationStatusUpdates` @available(iOS 16.1, *) func liveActivityRegistrationStatusUpdates<T: ActivityAttributes>( activity: Activity<T> ) -> LiveActivityRegistrationStatusUpdates /// Tracks a live activity with Airship for the given name. /// Airship will monitor the push token and status and automatically /// add and remove it from the channel for the App. If an activity is already /// tracked with the given name it will be replaced with the new activity. /// /// The name will be used to send updates through Airship. It can be unique /// for the device or shared across many devices. /// /// - Parameters: /// - activity: The live activity /// - name: The name of the activity @available(iOS 16.1, *) func trackLiveActivity<T: ActivityAttributes>( _ activity: Activity<T>, name: String ) /// Called to restore live activity tracking. This method needs to be called exactly once /// during `application(_:didFinishLaunchingWithOptions:)` right /// after takeOff. Any activities not restored will stop being tracked by Airship. /// - Parameters: /// - callback: Callback with the restorer. @available(iOS 16.1, *) func restoreLiveActivityTracking( callback: @escaping @Sendable (any LiveActivityRestorer) async -> Void ) #endif } /// NOTE: For internal use only. :nodoc: public protocol InternalAirshipChannel: AirshipChannel { @MainActor func addRegistrationExtender( _ extender: @Sendable @escaping (inout ChannelRegistrationPayload) async -> Void ) func updateRegistration() func updateRegistration(forcefully: Bool) func clearSubscriptionListsCache() } ================================================ FILE: Airship/AirshipCore/Source/AirshipCheckboxToggleStyle.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct AirshipCheckboxToggleStyle: ToggleStyle { let viewConstraints: ViewConstraints let info: ThomasToggleStyleInfo.Checkbox func makeBody(configuration: Self.Configuration) -> some View { SwitchView(configuration: configuration, info: info, viewConstraints: viewConstraints) } struct SwitchView: View { let configuration: ToggleStyle.Configuration let info: ThomasToggleStyleInfo.Checkbox let viewConstraints: ViewConstraints @Environment(\.isEnabled) var isEnabled @Environment(\.colorScheme) var colorScheme var body: some View { let binding = configuration.isOn ? info.bindings.selected : info.bindings.unselected var constraints = self.viewConstraints constraints.width = constraints.width ?? 24 constraints.height = constraints.height ?? 24 constraints.isVerticalFixedSize = true constraints.isHorizontalFixedSize = true return Button(action: { configuration.isOn.toggle() }) { ZStack { if let shapes = binding.shapes { if binding == info.bindings.selected { ForEach(0..<shapes.count, id: \.self) { index in Shapes.shape( info: shapes[index], constraints: constraints, colorScheme: colorScheme ) } .airshipApplyIf(!isEnabled) { view in view.colorMultiply(ThomasConstants.disabledColor) } } else { ForEach(0..<shapes.count, id: \.self) { index in Shapes.shape( info: shapes[index], constraints: constraints, colorScheme: colorScheme ) } } } if let iconModel = binding.icon { Icons.icon(info: iconModel, colorScheme: colorScheme) } } .constraints(constraints, fixedSize: true) .animation(Animation.easeInOut(duration: 0.05), value: configuration.isOn) } #if os(tvOS) .buttonStyle(TVButtonStyle()) #endif } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipColor.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI #if canImport(UIKit) public import UIKit #elseif canImport(AppKit) public import AppKit #endif public enum AirshipColorError: Error { /// Thrown when a color cannot be converted to an RGB space case incompatibleColorSpace(AirshipNativeColor) } /// Airship Color utility. public struct AirshipColor { /// Resolves a SwiftUI Color from hex. /// - Parameters: /// - string: The hex string. /// - Returns: The resolved Color or nil. public static func resolveHexColor(_ string: String) -> Color? { if isHexString(string) { if let native = resolveNativeColor(string) { return Color(native) } } return nil } /// Resolves a SwiftUI Color from hex or named string /// - Parameters: /// - string: The hex or named string /// - bundle: The bundle to look for the named color in. Defaults to `.main`. /// - Returns: The resolved Color public static func resolveColor(_ string: String, bundle: Bundle = .main) -> Color { if isHexString(string) { if let native = resolveNativeColor(string) { return Color(native) } } return Color(string, bundle: bundle) } /// Resolves a Native Color (UIColor or NSColor) from hex /// - Parameters: /// - hexString: The hex string, can be with or without #, 6 or 8 characters. /// - Returns: The resolved native color, or nil if the string is invalid. public static func resolveNativeColor(_ hexString: String) -> AirshipNativeColor? { let string = normalize(hexString) let width = 8 * (string.count / 2) guard width == 32 || width == 24 else { return nil } var component: UInt64 = 0 let scanner = Scanner(string: string) guard scanner.scanHexInt64(&component) else { return nil } let red = CGFloat((component & 0xFF0000) >> 16) / 255.0 let green = CGFloat((component & 0xFF00) >> 8) / 255.0 let blue = CGFloat((component & 0xFF)) / 255.0 let alpha: CGFloat = if width == 24 { 1.0 } else { CGFloat((component & 0xFF00_0000) >> 24) / 255.0 } return AirshipNativeColor(red: red, green: green, blue: blue, alpha: alpha) } /// Converts a Native Color back to an ARGB hex string (#AARRGGBB) /// - Parameter color: The color to convert. /// - Throws: `AirshipColorError.incompatibleColorSpace` if the color components cannot be extracted /// - Returns: The hex string in #AARRGGBB format. public static func hexString(_ color: AirshipNativeColor) throws -> String { #if os(macOS) // Convert to sRGB to ensure getRed works safely. // Colors in non-RGB spaces (CMYK, pattern, etc.) will crash getRed otherwise. guard let convertedColor = color.usingColorSpace(.sRGB) else { throw AirshipColorError.incompatibleColorSpace(color) } #else let convertedColor = color // On iOS, getRed returns a Bool. guard convertedColor.getRed(nil, green: nil, blue: nil, alpha: nil) else { throw AirshipColorError.incompatibleColorSpace(color) } #endif var r: CGFloat = 0 var g: CGFloat = 0 var b: CGFloat = 0 var a: CGFloat = 0 convertedColor.getRed(&r, green: &g, blue: &b, alpha: &a) return String( format: "#%02X%02X%02X%02X", Int(round(a * 255)), Int(round(r * 255)), Int(round(g * 255)), Int(round(b * 255)) ) } private static func isHexString(_ string: String) -> Bool { let hexPattern = "^#?([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6})$" return string.range(of: hexPattern, options: .regularExpression) != nil } private static func normalize(_ hexString: String) -> String { var string = hexString.trimmingCharacters(in: .whitespacesAndNewlines) if string.hasPrefix("#") { string.removeFirst() } return string } } public extension String { /// - Note: For internal use only. :nodoc: func airshipToColor(_ bundle:Bundle = Bundle.main) -> Color { return AirshipColor.resolveColor(self, bundle: bundle) } /// - Note: For internal use only. :nodoc: func airshipHexToNativeColor() -> AirshipNativeColor? { return AirshipColor.resolveNativeColor(self) } } public extension ColorScheme { /// Resolves a SwiftUI Color based on the scheme /// - Parameters: /// - light: The light color /// - dark: The dark color /// - Returns: The resolved color for the current scheme /// - Note: For internal use only. :nodoc: func airshipResolveColor(light: Color?, dark: Color?) -> Color? { switch self { case .dark: return dark ?? light case .light: return light @unknown default: return light } } /// Resolves the Native Color (NSColor or UIColor) based on the scheme /// - Parameters: /// - light: The light color /// - dark: The dark color /// - Returns: The resolved native color for the current scheme /// - Note: For internal use only. :nodoc: func airshipResolveNativeColor( light: AirshipNativeColor?, dark: AirshipNativeColor? ) -> AirshipNativeColor? { switch self { case .dark: return dark ?? light case .light: return light @unknown default: return light } } /// Bridge helper to resolve Native colors as SwiftUI Colors /// - Parameters: /// - light: The light color /// - dark: The dark color /// - Returns: The resolved color for the current scheme /// - Note: For internal use only. :nodoc: func airshipResolveColor( light: AirshipNativeColor?, dark: AirshipNativeColor? ) -> Color? { let resolved = self.airshipResolveNativeColor(light: light, dark: dark) return resolved.map { Color($0) } } } public extension AirshipColor { static var systemBackground: Color { #if os(macOS) return Color(NSColor.windowBackgroundColor) #elseif os(watchOS) return .black #elseif os(tvOS) return Color(UIColor { trait in return trait.userInterfaceStyle == .dark ? .black : .white }) #else return Color(UIColor.systemBackground) #endif } } ================================================ FILE: Airship/AirshipCore/Source/AirshipComponent.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Airship component. /// - Note: For internal use only. :nodoc: public protocol AirshipComponent: Sendable { /// Called once the Airship instance is ready. @MainActor func airshipReady() /// Called to handle `uairship://` deep links. The first component that /// returns true will prevent others from receiving the deep link. /// - Parameters: /// - deepLink: The deep link. /// - Returns: true if the deep link was handled, otherwise false. @MainActor func deepLink(_ deepLink: URL) -> Bool } public extension AirshipComponent { func airshipReady() {} func deepLink(_ deepLink: URL) -> Bool { return false } } ================================================ FILE: Airship/AirshipCore/Source/AirshipConfig.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// The Config object provides an interface for passing common configurable values to `Airship`. public struct AirshipConfig: Decodable, Sendable { /// The default app key. Used as the default value for `developmentAppKey` or `productionAppKey`. public var defaultAppKey: String? /// The default app secret. Used as the default value for `developmentAppSecret` or `productionAppSecret`. public var defaultAppSecret: String? /// The app key used when `inProduction` is `false`. /// /// The development credentials are generally used to point to a Test Airship project which will send to /// the development APNS sandbox. public var developmentAppKey: String? /// The app secret used when `inProduction` is `false`. public var developmentAppSecret: String? /// The log level used when `inProduction` is `false`. public var developmentLogLevel: AirshipLogLevel = .debug /// The log privacy level used when `inProduction` is `false`. Allows logging to public console. /// Defaults to `private`. public var developmentLogPrivacyLevel: AirshipLogPrivacyLevel = .private /// The app key used when `inProduction` is `true`. /// /// The production credentails are generally used to point to a Live Airship project which will send to /// the production APNS sandbox. public var productionAppKey: String? /// The app secret used when `inProduction` is `true`. public var productionAppSecret: String? /// The log privacy level used when `inProduction` is `true`. Allows logging to public console. /// Defaults to `error`. public var productionLogLevel: AirshipLogLevel = .error /// The log privacy level used when `inProduction` is `true`. Allows logging to public console. /// Only used by the default log handler. /// Defaults to `private`. public var productionLogPrivacyLevel: AirshipLogPrivacyLevel = .private /// Custom log handler to be used instead of the default Airship log handler. public var logHandler: (any AirshipLogHandler)? = nil /// Auto pause InAppAutomation on launch. Defaults to `false` public var autoPauseInAppAutomationOnLaunch: Bool = false /// Flag to enable or disable web view inspection on Airship created web views. Applies only to iOS 16.4+. /// Defaults to `false` public var isWebViewInspectionEnabled: Bool = false // Overrides the input validation used by Preference Center and Scenes. public var inputValidationOverrides: AirshipInputValidation.OverridesClosure? /// Optional closure for auth challenge certificate validation. public var connectionChallengeResolver: ChallengeResolveClosure? /// A closure that can be used to manually recover the channel ID instead of having /// Airship recover or generate an ID automatically. /// /// This is a delicate API that should only be used if the application can ensure the channel ID was previously created and by recovering /// it will only be used by a single device. Having multiple devices with the same channel ID will cause unpredictable behavior. /// /// When the method is set to `restore`, the user must provide a previously generated, unique /// If the closure throws an error, Airship will delay channel registration until a successful execution. public var restoreChannelID: AirshipChannelCreateOptionClosure? /// The airship cloud site. Defaults to `us`. public var site: CloudSite = .us /// Default enabled Airship features for the app. For more details, see `PrivacyManager`. /// Defaults to `all`. public var enabledFeatures: AirshipFeature = .all /// Allows resetting enabled features to match the runtime config defaults on each takeOff /// Defaults to `false` public var resetEnabledFeatures: Bool = false /// Used to select between the either production (`true`) or development (`false`) credentails /// and logging. /// /// If not set, Airship will pick the credentials based on the APNS sandbox by inspecting the profile on the /// device. If Airship fails to resolve the APNS environment `inProduction` will default to `true`. public var inProduction: Bool? /// If enabled, the Airship library automatically registers for remote notifications when push is enabled /// and intercepts incoming notifications in both the foreground and upon launch. /// /// If disabled, the app needs to forward methods to Airship. See https://docs.airship.com/platform/mobile/setup/sdk/ios/#automatic-integration /// for more details. /// /// Defaults to `true`. public var isAutomaticSetupEnabled: Bool = true /// An array of `UAURLAllowList` entry strings. /// This url allow list is used for validating which URLs can be opened or load the JavaScript native bridge. /// It affects landing pages, the open external URL and wallet actions, /// deep link actions (if a delegate is not set), and HTML in-app messages. /// /// - NOTE: See `UAURLAllowList` for pattern entry syntax. public var urlAllowList: [String]? = nil /// An array of` UAURLAllowList` entry strings. /// This url allow list is used for validating which URLs can load the JavaScript native bridge, /// It affects Landing Pages, Message Center and HTML In-App Messages. /// /// - NOTE: See `UAURLAllowList` for pattern entry syntax. public var urlAllowListScopeJavaScriptInterface: [String]? = nil /// An array of UAURLAllowList entry strings. /// This url allow list is used for validating which URLs can be opened. /// It affects landing pages, the open external URL and wallet actions, /// deep link actions (if a delegate is not set), and HTML in-app messages. /// /// - NOTE: See `UAURLAllowList` for pattern entry syntax. public var urlAllowListScopeOpenURL: [String]? = nil /// The iTunes ID used for Rate App Actions. public var itunesID: String? /// Toggles Airship analytics. Defaults to `true`. If set to `false`, many Airship features will not be /// available to this application. public var isAnalyticsEnabled: Bool = true /// The Airship default message center style configuration file. public var messageCenterStyleConfig: String? /// If set to `true`, the Airship user will be cleared if the application is /// restored on a different device from an encrypted backup. /// /// Defaults to `false`. public var clearUserOnAppRestore: Bool = false /// If set to `true`, the application will clear the previous named user ID on a /// re-install. Defaults to `false`. public var clearNamedUserOnAppRestore: Bool = false /// Flag indicating whether channel capture feature is enabled or not. /// /// Defaults to `true`. public var isChannelCaptureEnabled: Bool = true /// Flag indicating whether delayed channel creation is enabled. If set to `true` channel /// creation will not occur until channel creation is manually enabled. /// /// Defaults to `false`. public var isChannelCreationDelayEnabled: Bool = false /// Flag indicating whether extended broadcasts are enabled. If set to `true` the AirshipReady NSNotification /// will contain additional data: the channel identifier and the app key. /// /// Defaults to `false`. public var isExtendedBroadcastsEnabled: Bool = false /// If set to `true`, the Airship SDK will request authorization to use /// notifications from the user. Apps that set this flag to `false` are /// required to request authorization themselves. /// /// Defaults to `true`. public var requestAuthorizationToUseNotifications: Bool = true /// If set to `true`, the SDK will wait for an initial remote config instead of falling back on default API URLs. /// /// Defaults to `true`. public var requireInitialRemoteConfigEnabled: Bool = true /// **For apps using the Swift 5 language mode:** It is **strongly recommended** to leave /// this value as `false` (the default). /// /// A suspected compiler bug in **Xcode 16.1 and newer** can cause fatal runtime crashes /// in this specific configuration. This flag disables the problematic code path. /// For more details, see: https://github.com/urbanairship/ios-library/issues/434. /// /// --- /// /// **For apps using Swift 6 or newer:** You can set this to `true` to enable dynamic /// background wait time calculation. This helps the SDK send off pending operations /// before the app is fully backgrounded. /// /// If `false`, the SDK will wait a short, fixed amount of time. /// /// Defaults to `false`. public var isDynamicBackgroundWaitTimeEnabled: Bool = false /// The Airship URL used to pull the initial config. This should only be set if you are using custom domains /// that forward to Airship. public var initialConfigURL: String? /// If set to `true`, the SDK will use the preferred locale. Otherwise it will use the current locale. /// /// Defaults to `false`. public var useUserPreferredLocale: Bool = false /// If set to `true`, Message Center will attempt to be restored between reinstalls. If `false`, /// the Message Center user will be reset and the Channel will not be able to use the user /// as an identity hint to recover the past Channel ID. /// /// Defaults to `true`. public var restoreMessageCenterOnReinstall: Bool = true /// Enables Airship Debug features. When enabled, the debug manager becomes available /// for programmatic access to the Airship Debug view which provides insights into /// channel information, events, and other debugging data. /// /// - Note: This flag alone does not enable shake-to-debug functionality. You must also /// add shake detection to your views using `.airshipDebugOnShake()` (SwiftUI) or /// implement shake gesture handling in UIKit. /// - Note: This should typically be disabled in production builds for security reasons. /// - Note: Requires the AirshipDebug module to be included in your app. /// /// Defaults to `false`. public var isAirshipDebugEnabled: Bool = false enum CodingKeys: String, CodingKey { case defaultAppKey case defaultAppSecret case developmentAppKey case developmentAppSecret case productionAppKey case productionAppSecret case developmentLogLevel case developmentLogPrivacyLevel case productionLogLevel case productionLogPrivacyLevel case resetEnabledFeatures case enabledFeatures case site case messageCenterStyleConfig case isExtendedBroadcastsEnabled case isChannelCreationDelayEnabled case requireInitialRemoteConfigEnabled case urlAllowListScopeOpenURL case inProduction case autoPauseInAppAutomationOnLaunch case isWebViewInspectionEnabled case isAutomaticSetupEnabled case urlAllowList case urlAllowListScopeJavaScriptInterface case itunesID case isAnalyticsEnabled case clearUserOnAppRestore case clearNamedUserOnAppRestore case isChannelCaptureEnabled case requestAuthorizationToUseNotifications case initialConfigURL case deviceAPIURL case analyticsURL case remoteDataAPIURL case useUserPreferredLocale case restoreMessageCenterOnReinstall case isAirshipDebugEnabled case isDynamicBackgroundWaitTimeEnabled // legacy keys case LOG_LEVEL case PRODUCTION_APP_KEY case PRODUCTION_APP_SECRET case DEVELOPMENT_APP_KEY case DEVELOPMENT_APP_SECRET case APP_STORE_OR_AD_HOC_BUILD case isInProduction case whitelist case analyticsEnabled case extendedBroadcastsEnabled case channelCaptureEnabled case channelCreationDelayEnabled case automaticSetupEnabled } /// Creates an instance with empty values. /// - Returns: A config with empty values. public init() { } /// Creates an instance using the values set in the `AirshipConfig.plist` file. /// - Returns: A config with values from `AirshipConfig.plist` file. public static func `default`() throws -> AirshipConfig { guard let path = Bundle.main.path( forResource: "AirshipConfig", ofType: "plist" ) else { throw AirshipErrors.error("AirshipConfig.plist file is missing.") } return try AirshipConfig(fromPlist: path) } /** * Creates an instance using the values found in the specified `.plist` file. * - Parameter fromPlist: The path of the specified plist file. * - Returns: A config with values from the specified file. */ public init(fromPlist path: String) throws { guard let data = FileManager.default.contents(atPath: path) else { throw AirshipErrors.error("Failed to load contents of the plist file \(path)") } let decoder = PropertyListDecoder() self = try decoder.decode(AirshipConfig.self, from: data) } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) // Development self.developmentAppKey = try container.decodeFirst( String.self, forKeys: [.developmentAppKey, .DEVELOPMENT_APP_KEY] ) self.developmentAppSecret = try container.decodeFirst( String.self, forKeys: [.developmentAppSecret, .DEVELOPMENT_APP_SECRET] ) self.developmentLogLevel = try container.decodeFirst( AirshipLogLevel.self, forKeys: [.developmentLogLevel, .LOG_LEVEL] ) ?? self.developmentLogLevel self.developmentLogPrivacyLevel = try container.decodeIfPresent( AirshipLogPrivacyLevel.self, forKey: .developmentLogPrivacyLevel ) ?? self.developmentLogPrivacyLevel // Production self.productionAppKey = try container.decodeFirst( String.self, forKeys: [.productionAppKey, .PRODUCTION_APP_KEY] ) self.productionAppSecret = try container.decodeFirst( String.self, forKeys: [.productionAppSecret, .PRODUCTION_APP_SECRET] ) self.productionLogLevel = try container.decodeIfPresent( AirshipLogLevel.self, forKey: .productionLogLevel ) ?? self.productionLogLevel self.productionLogPrivacyLevel = try container.decodeIfPresent( AirshipLogPrivacyLevel.self, forKey: .productionLogPrivacyLevel ) ?? self.productionLogPrivacyLevel // In production self.inProduction = try container.decodeFirst( Bool.self, forKeys: [.inProduction, .isInProduction, .APP_STORE_OR_AD_HOC_BUILD] ) // Site self.site = try container.decodeIfPresent( CloudSite.self, forKey: .site ) ?? self.site // Default credentials self.defaultAppKey = try container.decodeIfPresent( String.self, forKey: .defaultAppKey ) self.defaultAppSecret = try container.decodeIfPresent( String.self, forKey: .defaultAppSecret ) // Allow lists self.urlAllowList = try container.decodeFirst( [String].self, forKeys: [.urlAllowList, .whitelist] ) self.urlAllowListScopeOpenURL = try container.decodeIfPresent( [String].self, forKey: .urlAllowListScopeOpenURL ) self.urlAllowListScopeJavaScriptInterface = try container.decodeIfPresent( [String].self, forKey: .urlAllowListScopeJavaScriptInterface ) // Features self.resetEnabledFeatures = try container.decodeIfPresent( Bool.self, forKey: .resetEnabledFeatures ) ?? self.resetEnabledFeatures self.enabledFeatures = try container.decodeIfPresent( AirshipFeature.self, forKey: .enabledFeatures ) ?? self.enabledFeatures self.isAnalyticsEnabled = try container.decodeFirst( Bool.self, forKeys: [.isAnalyticsEnabled, .analyticsEnabled] ) ?? self.isAnalyticsEnabled // Message Center self.messageCenterStyleConfig = try container.decodeIfPresent( String.self, forKey: .messageCenterStyleConfig ) self.restoreMessageCenterOnReinstall = try container.decodeIfPresent( Bool.self, forKey: .restoreMessageCenterOnReinstall ) ?? self.restoreMessageCenterOnReinstall // Core self.initialConfigURL = try container.decodeIfPresent( String.self, forKey: .initialConfigURL ) self.itunesID = try container.decodeIfPresent( String.self, forKey: .itunesID ) self.isExtendedBroadcastsEnabled = try container.decodeFirst( Bool.self, forKeys: [.isExtendedBroadcastsEnabled, .extendedBroadcastsEnabled] ) ?? self.isExtendedBroadcastsEnabled self.isChannelCreationDelayEnabled = try container.decodeFirst( Bool.self, forKeys: [.isChannelCreationDelayEnabled, .channelCreationDelayEnabled] ) ?? self.isChannelCreationDelayEnabled self.requireInitialRemoteConfigEnabled = try container.decodeIfPresent( Bool.self, forKey: .requireInitialRemoteConfigEnabled ) ?? self.requireInitialRemoteConfigEnabled self.autoPauseInAppAutomationOnLaunch = try container.decodeIfPresent( Bool.self, forKey: .autoPauseInAppAutomationOnLaunch ) ?? self.autoPauseInAppAutomationOnLaunch self.isWebViewInspectionEnabled = try container.decodeIfPresent( Bool.self, forKey: .isWebViewInspectionEnabled ) ?? self.isWebViewInspectionEnabled self.isAutomaticSetupEnabled = try container.decodeFirst( Bool.self, forKeys: [.isAutomaticSetupEnabled, .automaticSetupEnabled] ) ?? self.isAutomaticSetupEnabled self.clearUserOnAppRestore = try container.decodeIfPresent( Bool.self, forKey: .clearUserOnAppRestore ) ?? false self.clearNamedUserOnAppRestore = try container.decodeIfPresent( Bool.self, forKey: .clearNamedUserOnAppRestore ) ?? self.clearNamedUserOnAppRestore self.isChannelCaptureEnabled = try container.decodeFirst( Bool.self, forKeys: [.isChannelCaptureEnabled, .channelCaptureEnabled] ) ?? self.isChannelCaptureEnabled self.requestAuthorizationToUseNotifications = try container.decodeIfPresent( Bool.self, forKey: .requestAuthorizationToUseNotifications ) ?? self.requestAuthorizationToUseNotifications self.useUserPreferredLocale = try container.decodeIfPresent( Bool.self, forKey: .useUserPreferredLocale ) ?? self.useUserPreferredLocale self.isAirshipDebugEnabled = try container.decodeIfPresent( Bool.self, forKey: .isAirshipDebugEnabled ) ?? self.isAirshipDebugEnabled self.isDynamicBackgroundWaitTimeEnabled = try container.decodeIfPresent( Bool.self, forKey: .isDynamicBackgroundWaitTimeEnabled ) ?? self.isDynamicBackgroundWaitTimeEnabled } /// Validates credentails /// - Parameters: /// - inProduction: To validate production or development credentials public func validateCredentials(inProduction: Bool) throws { _ = try self.resolveCredentails(inProduction) } func logIssues() { if (inProduction == nil) { do { try validateCredentials(inProduction: true) } catch { AirshipLogger.warn("Airship will automatically pick between production and development credentials, but production credentials are invalid \(error)") } do { try validateCredentials(inProduction: false) } catch { AirshipLogger.warn("Airship will automatically pick between production and development credentials, but development credentials are invalid \(error)") } if productionAppKey == developmentAppKey { AirshipLogger.warn("Production & Development app keys match") } if productionAppSecret == developmentAppSecret { AirshipLogger.warn("Production & Development app secrets match") } } if (urlAllowList == nil && urlAllowListScopeOpenURL == nil) { AirshipLogger.impError( "The Airship config options is missing URL allow list rules for SCOPE_OPEN " + "that controls what external URLs are able to be opened externally or loaded " + "in a web view by Airship. By default, all URLs will be allowed. " + "To suppress this error, specify the config urlAllowListScopeOpenURL = [*] " + "to keep the defaults, or by providing a list of rules that your app expects. " + "See https://docs.airship.com/platform/mobile/setup/sdk/ios/#url-allow-list " + "for more information." ) } } } public extension AirshipConfig { /// Resolves the inProduction flag. The value will be resolved with: /// - `inProduction` if set /// - `false` if the target environment is a simulator /// - by inspecting the `embedded.mobileprovision` file to look up the APNS environment. /// /// - returns The resolved in production flag. /// - throws If the APNS fails to resolve to an environment. Airship will fallback to assuming its inProduction during /// takeOff. func resolveInProduction() throws -> Bool { if let inProduction { return inProduction } #if targetEnvironment(simulator) return false #else return try APNSEnvironment.isProduction() #endif } } // The Channel generation method. In `automatic` mode Airship will generate a new channelID and create a new channel. // If the restore option is specified and `channelID` is a correct ID, Airship will try to restore a channel with the specified ID public enum ChannelGenerationMethod: Sendable { case automatic case restore(channelID: String) } public typealias AirshipChannelCreateOptionClosure = (@Sendable () async throws -> ChannelGenerationMethod) extension AirshipConfig { func resolveCredentails(_ inProduction: Bool) throws -> AirshipAppCredentials { let appKey = (inProduction ? productionAppKey : developmentAppKey) ?? defaultAppKey let appSecret = (inProduction ? productionAppSecret : developmentAppSecret) ?? defaultAppSecret let matchPred = NSPredicate(format: "SELF MATCHES %@", "^\\S{22}+$") guard let appKey, matchPred.evaluate(with: appKey), let appSecret, matchPred.evaluate(with: appSecret), appKey != appSecret else { throw AirshipErrors.error( "Invalid app credentials \(appKey ?? ""):\(appSecret ?? "")" ) } return AirshipAppCredentials( appKey: appKey, appSecret: appSecret ) } } fileprivate extension KeyedDecodingContainerProtocol { func decodeFirst<T: Decodable>(_ type: T.Type, forKeys: [Self.Key]) throws -> T? where T : Decodable { for (_, key) in forKeys.enumerated() { if let value = try self.decodeIfPresent(type, forKey: key) { return value } } return nil } } ================================================ FILE: Airship/AirshipCore/Source/AirshipContact.swift ================================================ /* Copyright Airship and Contributors */ public import Combine /// Airship contact. A contact is distinct from a channel and represents a "user" /// within Airship. Contacts may be named and have channels associated with it. public protocol AirshipContact: AnyObject, Sendable { /// Current named user ID var namedUserID: String? { get async } /// The named user ID current value publisher. var namedUserIDPublisher: AnyPublisher<String?, Never> { get } /// Conflict event publisher. var conflictEventPublisher: AnyPublisher<ContactConflictEvent, Never> { get } /// Notifies any edits to the subscription lists. var subscriptionListEdits: AnyPublisher<ScopedSubscriptionListEdit, Never> { get } /// Fetches subscription lists. /// - Returns: Subscriptions lists. func fetchSubscriptionLists() async throws -> [String: [ChannelScope]] /** * 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<ContactChannelsResult> { get } /// Contact channel updates publisher. var contactChannelPublisher: AnyPublisher<ContactChannelsResult, Never> { get } /** * Associates the contact with the given named user identifier. * The named user ID must be between 1 and 128 characters * - Parameters: * - namedUserID: The named user ID. */ func identify(_ namedUserID: String) /** * Disassociate the channel from its current contact, and create a new * un-named contact. */ func 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. */ func notifyRemoteLogin() /** * Edits tags. * - Returns: A tag groups editor. */ func editTagGroups() -> TagGroupsEditor /** * Edits tags. * - Parameters: * - editorBlock: The editor block with the editor. The editor will `apply` will be called after the block is executed. */ func editTagGroups(_ editorBlock: (TagGroupsEditor) -> Void) /** * Edits attributes. * - Returns: An attributes editor. */ func editAttributes() -> AttributesEditor /** * Edits attributes. * - Parameters: * - editorBlock: The editor block with the editor. The editor will `apply` will be called after the block is executed. */ func editAttributes(_ editorBlock: (AttributesEditor) -> Void) /** * Associates an Email channel to the contact. * - Parameters: * - address: The email address. * - options: The email channel registration options. */ func registerEmail(_ address: String, options: EmailRegistrationOptions) /** * Associates a SMS channel to the contact. * - Parameters: * - msisdn: The SMS msisdn. * - options: The SMS channel registration options. */ func registerSMS(_ msisdn: String, options: SMSRegistrationOptions) /** * Associates an Open channel to the contact. * - Parameters: * - address: The open channel address. * - options: The open channel registration options. */ func registerOpen(_ address: String, options: OpenRegistrationOptions) /** * Associates a channel to the contact. * - Parameters: * - channelID: The channel ID. * - type: The channel type. */ func associateChannel(_ channelID: String, type: ChannelType) /// Begins a subscription list editing session /// - Returns: A Scoped subscription list editor func editSubscriptionLists() -> ScopedSubscriptionListEditor /// Begins a subscription list editing session /// - Parameter editorBlock: A scoped subscription list editor block. func editSubscriptionLists( _ editorBlock: (ScopedSubscriptionListEditor) -> Void ) } protocol InternalAirshipContact: AirshipContact { var contactID: String? { get async } var authTokenProvider: any AuthTokenProvider { get } func getStableContactID() async -> String var contactIDInfo: ContactIDInfo? { get async } var contactIDUpdates: AnyPublisher<ContactIDInfo, Never> { get } func getStableContactInfo() async -> StableContactInfo } ================================================ FILE: Airship/AirshipCore/Source/AirshipCoreDataPredicate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: for internal use only. :nodoc: public struct AirshipCoreDataPredicate: Sendable { private let format: String private let args: [any Sendable]? public init(format: String, args: [any Sendable]? = nil) { self.format = format self.args = args } public func toNSPredicate() -> NSPredicate { guard let args = args else { return NSPredicate(format: format) } return NSPredicate(format: format, argumentArray: args) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipCoreResources.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Resources for AirshipCore public final class AirshipCoreResources { /// Module bundle public static let bundle: Bundle = resolveBundle() private static func resolveBundle() -> Bundle { #if SWIFT_PACKAGE AirshipLogger.trace("Using Bundle.module for AirshipCore") let bundle = Bundle.module #if DEBUG if bundle.resourceURL == nil { assertionFailure(""" AirshipCore module was built with SWIFT_PACKAGE but no resources were found. Check your build configuration. """) } #endif return bundle #endif return Bundle.airshipFindModule( moduleName: "AirshipCore", sourceBundle: Bundle(for: Self.self) ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipDate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: For internal use only. :nodoc: public final class AirshipDate: AirshipDateProtocol { public static let shared: AirshipDate = AirshipDate() public init() {} public var now: Date { return Date() } } /// - Note: For internal use only. :nodoc: public protocol AirshipDateProtocol: Sendable { var now: Date { get } } extension Date { var millisecondsSince1970: Int64 { Int64((self.timeIntervalSince1970 * 1000.0).rounded()) } init(milliseconds: Int64) { self = Date(timeIntervalSince1970: TimeInterval(milliseconds / 1000)) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipDateFormatter.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: for internal use only. :nodoc: public final class AirshipDateFormatter { public enum Format: Int { /// ISO 8601 case iso /// ISO 8601 with delimitter case isoDelimitter /// Short date & time format case relativeShort /// Short date format case relativeShortDate /// Full date & time format case relativeFull /// Full date format case relativeFullDate } private static let dateFormatterISO: DateFormatter = createDateFormatter { formatter in formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeStyle = .full formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.timeZone = TimeZone(secondsFromGMT: 0) } private static let dateFormatterISOWithDelimiter: DateFormatter = createDateFormatter { formatter in formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeStyle = .full formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" formatter.timeZone = TimeZone(secondsFromGMT: 0) } private static let dateFormatterRelativeFull: DateFormatter = createDateFormatter { formatter in formatter.timeStyle = .full formatter.dateStyle = .full formatter.doesRelativeDateFormatting = true } private static let dateFormatterRelativeShort: DateFormatter = createDateFormatter { formatter in formatter.timeStyle = .short formatter.dateStyle = .short formatter.doesRelativeDateFormatting = true } private static let dateFormatterRelativeShortDate: DateFormatter = createDateFormatter { formatter in formatter.timeStyle = .none formatter.dateStyle = .short formatter.doesRelativeDateFormatting = true } private static let dateFormatterRelativeFullDate: DateFormatter = createDateFormatter { formatter in formatter.timeStyle = .none formatter.dateStyle = .full formatter.doesRelativeDateFormatting = true } /// Parses ISO 8601 date strings. /// /// Supports timestamps with just year all the way up to seconds with and without the optional `T` delimiter. /// /// - Parameter from: The ISO 8601 timestamp. /// /// - Returns: A parsed Date object, or nil if the timestamp is not a valid format. public class func date(fromISOString from: String) -> Date? { if let date = dateFormatterISO.date(from: from) { return date } if let date = dateFormatterISOWithDelimiter.date(from: from) { return date } let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) // All the various formats let formats = [ "yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm", "yyyy-MM-dd'T'HH", "yyyy-MM-dd HH", "yyyy-MM-dd", "yyyy-MM", "yyyy", ] for format in formats { formatter.dateFormat = format if let date = formatter.date(from: from) { return date } } return nil } public static func string(fromDate date: Date, format: Format) -> String { switch format { case .iso: return self.dateFormatterISO.string(from: date) case .isoDelimitter: return self.dateFormatterISOWithDelimiter.string(from: date) case .relativeShortDate: return self.dateFormatterRelativeShortDate.string(from: date) case .relativeFullDate: return self.dateFormatterRelativeFullDate.string(from: date) case .relativeFull: return self.dateFormatterRelativeFull.string(from: date) case .relativeShort: return self.dateFormatterRelativeShort.string(from: date) } } private static func createDateFormatter(editBlock: (DateFormatter) -> Void) -> DateFormatter { let formatter = DateFormatter() editBlock(formatter) return formatter } } ================================================ FILE: Airship/AirshipCore/Source/AirshipDevice.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif #if canImport(WatchKit) import WatchKit #endif /// Internal helper for platform-specific device info. /// NOTE: For internal use only. :nodoc: public struct AirshipDevice: Sendable { /// Returns the device model name (e.g., "iPhone14,3" or "MacBookPro18,1") public static let modelIdentifier: String = { #if targetEnvironment(macCatalyst) return "mac" #elseif os(macOS) // Native macOS var size = 0 sysctlbyname("hw.model", nil, &size, nil, 0) var model = [CChar](repeating: 0, count: size) sysctlbyname("hw.model", &model, &size, nil, 0) let bytes = model.map { UInt8(bitPattern: $0) } return String(decoding: bytes.dropLast(), as: UTF8.self) #else // iOS / tvOS / etc var systemInfo = utsname() uname(&systemInfo) // Use withUnsafePointer to convert the C-char tuple to a String safely return withUnsafePointer(to: &systemInfo.machine) { $0.withMemoryRebound(to: CChar.self, capacity: Int(_SYS_NAMELEN)) { String(cString: $0) } } #endif }() /// The generic device category (e.g., "iPhone", "iPad"). /// Matches the legacy UIDevice.current.model. @MainActor public static var model: String { #if os(iOS) || os(tvOS) || os(visionOS) return UIDevice.current.model #elseif os(watchOS) return WKInterfaceDevice.current().model #else return "Mac" #endif } /// Returns the system name (e.g., "iOS", "tvOS", "watchOS"). /// This matches UIDevice.current.systemName on Apple platforms. @MainActor public static var deviceFamily: String { #if os(watchOS) return WKInterfaceDevice.current().systemName #elseif canImport(UIKit) return UIDevice.current.systemName #elseif os(macOS) return "macOS" #else return "Unknown" #endif } /// Returns the OS name public static var osName: String { #if os(macOS) return "macOS" #elseif targetEnvironment(macCatalyst) // Catalyst returns true for os(iOS), so check this first return "macOS" #elseif os(visionOS) return "visionOS" #elseif os(tvOS) return "tvOS" #elseif os(iOS) return "iOS" #else return "Unknown" #endif } /// Returns the OS Version string @MainActor public static var osVersion: String { #if os(watchOS) return WKInterfaceDevice.current().systemVersion #elseif os(macOS) let version = ProcessInfo.processInfo.operatingSystemVersion return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" #elseif canImport(UIKit) return UIDevice.current.systemVersion #else return "0.0.0" #endif } } ================================================ FILE: Airship/AirshipCore/Source/AirshipDeviceAudienceResult.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshipDeviceAudienceResult: Sendable, Codable, Equatable { public var isMatch: Bool public var reportingMetadata: [AirshipJSON]? init(isMatch: Bool, reportingMetadata: [AirshipJSON]? = nil) { self.isMatch = isMatch self.reportingMetadata = reportingMetadata } mutating func negate() { isMatch = !isMatch } public static let match: AirshipDeviceAudienceResult = .init(isMatch: true) public static let miss: AirshipDeviceAudienceResult = .init(isMatch: false) } ================================================ FILE: Airship/AirshipCore/Source/AirshipDeviceID.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol AirshipDeviceIDProtocol: Actor { var value: String { get async } } /** * Access to a generated UUID stored in the keychain for this device only. Used to detect app restore only. */ actor AirshipDeviceID: AirshipDeviceIDProtocol { private static let deviceKeychainID: String = "com.urbanairship.deviceID" private var cached: String? = nil private let keychain: any AirshipKeychainAccessProtocol private var queue: AirshipSerialQueue = AirshipSerialQueue() private let appKey: String init( appKey: String, keychain: any AirshipKeychainAccessProtocol = AirshipKeychainAccess.shared ) { self.appKey = appKey self.keychain = keychain } var value: String { get async { return await queue.runSafe { if let cached = await self.cached { return cached } else if let fromKeychain = await self.keychain.readCredentails( identifier: AirshipDeviceID.deviceKeychainID, appKey: self.appKey ) { await self.cacheDeviceId(fromKeychain.password) return fromKeychain.password } else { let deviceID = UUID().uuidString await self.cacheDeviceId(deviceID) let result = await self.keychain.writeCredentials( AirshipKeychainCredentials(username: "airship", password: deviceID), identifier: AirshipDeviceID.deviceKeychainID, appKey: self.appKey ) if (!result) { AirshipLogger.error("Failed to device ID to the keychain") } return deviceID } } } } private func cacheDeviceId(_ deviceID: String) { self.cached = deviceID } } ================================================ FILE: Airship/AirshipCore/Source/AirshipDisplayTarget.swift ================================================ /* Copyright Airship and Contributors */ #if !os(watchOS) import Foundation public import SwiftUI /// A factory for creating display targets that manage the presentation of views in windows. /// /// `AirshipDisplayTarget` provides a unified interface for displaying content as either /// banners or modals. It abstracts the window management logic and provides appropriate /// display implementations based on the requested display type. /// /// ## Usage /// /// ```swift /// let displayTarget = AirshipDisplayTarget { scene in /// return scene /// } /// /// let displayable = displayTarget.prepareDisplay(for: .modal) /// try displayable.display { windowInfo in /// let viewController = MyViewController() /// return viewController /// } /// ``` /// /// /// - Note: for internal use only. :nodoc: @MainActor public final class AirshipDisplayTarget { /// The type of display presentation. /// /// Different display types have different behaviors: /// - `.banner`: Displays content as an overlay on top of existing content /// - `.modal`: Displays content in a new window that appears above all other content public enum DisplayType { /// Banner display that overlays on top of existing content. case banner /// Modal display that appears in a new window above all other content. case modal } /// Information about the window where content will be displayed. /// /// This struct provides metadata about the target window, such as its size, /// which can be used to configure the view controller appropriately. public struct WindowInfo: Sendable { /// The size of the window in points. public var size: CGSize } /// A protocol for objects that can display and dismiss view controllers. /// /// Implementations of this protocol handle the lifecycle of displaying content /// in a window, including creating the appropriate window hierarchy and managing /// the view controller's presentation. /// @MainActor public protocol Displayable: AnyObject, Sendable { #if os(macOS) /// Displays a view controller provided by the given closure. /// /// - Parameter viewControllerProvider: A closure that creates and returns /// a view controller to display. The closure receives `WindowInfo` containing /// information about the target window. /// - Throws: An error if the view controller cannot be displayed (e.g., if /// a window cannot be found or created). func display(viewControllerProvider: @MainActor (WindowInfo) -> NSViewController) throws #else /// Displays a view controller provided by the given closure. /// /// - Parameter viewControllerProvider: A closure that creates and returns /// a view controller to display. The closure receives `WindowInfo` containing /// information about the target window. /// - Throws: An error if the view controller cannot be displayed (e.g., if /// a window cannot be found or created). func display(viewControllerProvider: @MainActor (WindowInfo) -> UIViewController) throws #endif /// Dismisses the currently displayed view controller and cleans up resources. /// /// This method should be called when the displayed content should be removed /// from the screen. It handles animation and cleanup of the window hierarchy. func dismiss() } #if os(macOS) /// Creates a new display target public init() {} /// Prepares a displayable object for the specified display type. /// /// This method creates and returns an appropriate `Displayable` implementation /// based on the requested display type. The returned object can then be used to /// display view controllers. /// /// - Parameter displayType: The type of display to prepare (`.banner` or `.modal`). /// - Returns: A `Displayable` instance configured for the specified display type. public func prepareDisplay(for displayType: DisplayType) -> any Displayable { return switch(displayType) { case .banner: BannerDisplayable() case .modal: ModalDisplayable() } } #else /// A closure that provides the `UIWindowScene` to use for displaying content. /// /// This closure is called when a display operation needs to determine which /// window scene to use. It should return the appropriate scene or throw an error /// if no scene is available. public let sceneProvider: @MainActor () throws -> UIWindowScene /// Creates a new display target with the given scene provider. /// /// - Parameter sceneProvider: A closure that returns the `UIWindowScene` to use /// for displaying content. The closure may throw if no scene is available. public init( sceneProvider: @escaping @MainActor () throws -> UIWindowScene = { try AirshipSceneManager.shared.lastActiveScene } ) { self.sceneProvider = sceneProvider } /// Prepares a displayable object for the specified display type. /// /// This method creates and returns an appropriate `Displayable` implementation /// based on the requested display type. The returned object can then be used to /// display view controllers. /// /// - Parameter displayType: The type of display to prepare (`.banner` or `.modal`). /// - Returns: A `Displayable` instance configured for the specified display type. public func prepareDisplay(for displayType: DisplayType) -> any Displayable { return switch(displayType) { case .banner: BannerDisplayable(sceneProvider: sceneProvider) case .modal: ModalDisplayable(sceneProvider: sceneProvider) } } #endif } #if os(macOS) @MainActor class ModalDisplayable: AirshipDisplayTarget.Displayable { private var window: NSWindow? private var parentWindow: NSWindow? private var frameObserver: (any NSObjectProtocol)? private var moveObserver: (any NSObjectProtocol)? func display(viewControllerProvider: @MainActor (AirshipDisplayTarget.WindowInfo) -> NSViewController) throws { // Dismiss any existing modal first self.dismiss() // Get the parent window (main window) guard let parentWindow = NSApplication.shared.keyWindow ?? NSApplication.shared.mainWindow else { throw AirshipErrors.error("Failed to find parent window") } self.parentWindow = parentWindow // Create a new window for the modal let window = AirshipWindowFactory.shared.makeWindow() window.styleMask = [.titled] window.isOpaque = false window.backgroundColor = .clear window.hasShadow = false window.contentView?.alphaValue = 0 window.level = .modalPanel window.ignoresMouseEvents = false // Capture all mouse events window.acceptsMouseMovedEvents = true window.titleVisibility = .hidden // Hides the title text window.titlebarAppearsTransparent = true // Transparent title bar window.styleMask.insert(.fullSizeContentView) self.window = window // Get view controller and set it as content view controller let viewController = viewControllerProvider(window.airshipInfo) window.contentViewController = viewController // Set frame to match parent window's frame (in screen coordinates) // This must be done before adding as child window let parentFrame = parentWindow.frame window.setFrame(parentFrame, display: false) // Add as child window - this will make it move/resize with parent parentWindow.addChildWindow(window, ordered: .above) // Update frame to match parent (after adding as child) updateChildWindowFrame(window: window, parent: parentWindow) // Observe parent window frame and move changes to resize/reposition child window frameObserver = NotificationCenter.default.addObserver( forName: NSWindow.didResizeNotification, object: parentWindow, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { guard let self = self, let window = self.window, let parent = self.parentWindow else { return } self.updateChildWindowFrame(window: window, parent: parent) } } moveObserver = NotificationCenter.default.addObserver( forName: NSWindow.didMoveNotification, object: parentWindow, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { guard let self = self, let window = self.window, let parent = self.parentWindow else { return } self.updateChildWindowFrame(window: window, parent: parent) } } // Make key and order front (after setting parent and frame) window.makeKeyAndOrderFront(nil) // Animate in window.airshipAnimateIn() } private func updateChildWindowFrame(window: NSWindow, parent: NSWindow) { // When using addChildWindow, set the frame to match the parent's frame in screen coordinates // The child window will automatically move with the parent let parentFrame = parent.frame window.setFrame(parentFrame, display: false) } func dismiss() { if let observer = frameObserver { NotificationCenter.default.removeObserver(observer) frameObserver = nil } if let observer = moveObserver { NotificationCenter.default.removeObserver(observer) moveObserver = nil } if let window = window, let parent = parentWindow { parent.removeChildWindow(window) } window?.airshipAnimateOut() window = nil parentWindow = nil } } @MainActor class BannerDisplayable: AirshipDisplayTarget.Displayable { private let holder: AirshipStrongValueHolder<NSViewController> = AirshipStrongValueHolder() private var observers: [(any NSObjectProtocol)] = [] func display(viewControllerProvider: @MainActor (AirshipDisplayTarget.WindowInfo) -> NSViewController) throws { // 1. Dismiss any existing banner/window first to prevent stacking self.dismiss() // 2. Get the main host window guard let hostWindow = NSApplication.shared.keyWindow ?? NSApplication.shared.mainWindow else { throw AirshipErrors.error("Failed to find window") } // 3. Create the Satellite Window via factory so app customizations apply let overlayWindow = AirshipWindowFactory.shared.makeWindow() overlayWindow.setFrame(hostWindow.contentLayoutRect, display: false) overlayWindow.styleMask = [.titled] overlayWindow.backgroundColor = .clear overlayWindow.isOpaque = false overlayWindow.hasShadow = false overlayWindow.isReleasedWhenClosed = false // AppKit holds a strong reference to the window, so it will manage its lifecycle overlayWindow.acceptsMouseMovedEvents = true overlayWindow.titleVisibility = .hidden // Hides the title text overlayWindow.titlebarAppearsTransparent = true // Transparent title bar overlayWindow.styleMask.insert(.fullSizeContentView) // 5. Load the View Controller let viewController = viewControllerProvider(hostWindow.airshipInfo) let wrapperViewController = FillWindowViewController(content: viewController) // 6. Set Root (Standard AppKit) // We set it as the contentViewController of our *new* window. // This handles lifecycle methods (viewWillAppear) automatically. overlayWindow.contentViewController = wrapperViewController self.updateChildWindowFrame(window: overlayWindow, parent: hostWindow) // 7. Attach to Host Window // This ensures the banner moves, minimizes, and closes with the main app window. hostWindow.addChildWindow(overlayWindow, ordered: .above) // 8. Setup Auto-Resizing // Child windows do not stick to parent size automatically. We must observe. observers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResizeNotification, object: hostWindow, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } self.updateChildWindowFrame(window: overlayWindow, parent: hostWindow) } }) observers.append(NotificationCenter.default.addObserver( forName: NSWindow.didMoveNotification, object: hostWindow, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } self.updateChildWindowFrame(window: overlayWindow, parent: hostWindow) } }) // 9. Store Reference holder.value = viewController } func dismiss() { // Extract the window from the view controller before we dump the reference if let viewController = holder.value, let overlayWindow = viewController.view.window { // 1. Detach from parent (Critical to avoid memory leaks/crashes) overlayWindow.parent?.removeChildWindow(overlayWindow) // 2. Close the overlay overlayWindow.close() } // 3. Cleanup references cleanupObservers() holder.value = nil } private func cleanupObservers() { observers.forEach(NotificationCenter.default.removeObserver) observers.removeAll() } private func updateChildWindowFrame(window: NSWindow, parent: NSWindow) { // When using addChildWindow, set the frame to match the parent's frame in screen coordinates // The child window will automatically move with the parent let parentFrame = parent.frame window.setFrame(parentFrame, display: false) } } class FillWindowViewController: NSViewController { private let contentViewController: NSViewController init(content: NSViewController) { self.contentViewController = content super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { let containerView = NSView() containerView.autoresizingMask = [.width, .height] self.view = containerView } override func viewDidLoad() { super.viewDidLoad() contentViewController.view.translatesAutoresizingMaskIntoConstraints = false addChild(contentViewController) view.addSubview(contentViewController.view) NSLayoutConstraint.activate([ contentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -10) ]) } } extension NSWindow { /// Returns window information suitable for use with `AirshipDisplayTarget`. /// /// This property provides a `WindowInfo` struct containing metadata about /// the window, such as its size. The size is calculated based on the platform: /// - iOS/tvOS: Uses the screen bounds /// - visionOS: Uses a standard window size (1280x720) per Apple's guidelines /// - watchOS: Uses the device's screen bounds var airshipInfo: AirshipDisplayTarget.WindowInfo { return .init(size: self.frame.size) } /// Animates the window in with a fade effect. @MainActor func airshipAnimateIn() { self.level = .modalPanel self.makeKeyAndOrderFront(nil) NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 self.contentView?.animator().alphaValue = 1 }, completionHandler: nil) } /// Animates the window out with a fade effect and hides it. @MainActor func airshipAnimateOut() { NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 self.contentView?.animator().alphaValue = 0 }, completionHandler: { Task { @MainActor in self.orderOut(nil) } }) } } #else @MainActor class ModalDisplayable: AirshipDisplayTarget.Displayable { private let sceneProvider: @MainActor () throws -> UIWindowScene private var window: UIWindow? init(sceneProvider: @escaping @MainActor () throws -> UIWindowScene) { self.sceneProvider = sceneProvider } func display(viewControllerProvider: @MainActor (AirshipDisplayTarget.WindowInfo) -> UIViewController) throws { self.dismiss() let scene = try sceneProvider() let window: UIWindow = UIWindow.airshipMakeModalReadyWindow(scene: scene) self.window = window let viewController = viewControllerProvider(window.airshipInfo) window.rootViewController = viewController window.airshipAnimateIn() } func dismiss() { window?.airshipAnimateOut() window = nil } } @MainActor class BannerDisplayable: AirshipDisplayTarget.Displayable { private let sceneProvider: @MainActor () throws -> UIWindowScene private let holder: AirshipStrongValueHolder<UIViewController> = AirshipStrongValueHolder() init(sceneProvider: @escaping @MainActor () throws -> UIWindowScene) { self.sceneProvider = sceneProvider } func display(viewControllerProvider: @MainActor (AirshipDisplayTarget.WindowInfo) -> UIViewController) throws { self.dismiss() let scene = try sceneProvider() guard let window = AirshipUtils.mainWindow(scene: scene) else { throw AirshipErrors.error("Failed to find window") } guard window.rootViewController != nil else { throw AirshipErrors.error("Window missing rootViewController") } let viewController = viewControllerProvider(window.airshipInfo) holder.value = viewController if let view = viewController.view { view.willMove(toWindow: window) window.addSubview(view) view.didMoveToWindow() } } func dismiss() { holder.value?.view.removeFromSuperview() holder.value?.removeFromParent() holder.value = nil } } extension UIWindow { /// Returns window information suitable for use with `AirshipDisplayTarget`. /// /// This property provides a `WindowInfo` struct containing metadata about /// the window, such as its size. The size is calculated based on the platform: /// - iOS/tvOS: Uses the screen bounds /// - visionOS: Uses a standard window size (1280x720) per Apple's guidelines /// - watchOS: Uses the device's screen bounds var airshipInfo: AirshipDisplayTarget.WindowInfo { return .init(size: Self.windowSize(self)) } /// Calculates the appropriate window size for the given window. /// /// The size calculation varies by platform to account for different /// display characteristics and guidelines. /// /// - Parameter window: The window to calculate the size for. /// - Returns: The size of the window in points. @MainActor private class func windowSize(_ window: UIWindow) -> CGSize { #if os(iOS) || os(tvOS) return window.screen.bounds.size #elseif os(visionOS) // https://developer.apple.com/design/human-interface-guidelines/windows#visionOS return CGSize( width: 1280, height: 720 ) #elseif os(watchOS) return CGSize( width: WKInterfaceDevice.current().screenBounds.width, height: WKInterfaceDevice.current().screenBounds.height ) #endif } static func airshipMakeModalReadyWindow( scene: UIWindowScene ) -> UIWindow { let window: UIWindow = AirshipWindowFactory.shared.makeWindow(windowScene: scene) window.accessibilityViewIsModal = true window.alpha = 0 window.makeKeyAndVisible() window.isUserInteractionEnabled = false return window } func airshipAnimateIn() { self.makeKeyAndVisible() self.isUserInteractionEnabled = true UIView.animate( withDuration: 0.3, animations: { self.alpha = 1 }, completion: { _ in } ) } func airshipAnimateOut() { UIView.animate( withDuration: 0.3, animations: { self.alpha = 0 }, completion: { _ in self.isHidden = true self.isUserInteractionEnabled = false self.removeFromSuperview() } ) } } #endif #endif ================================================ FILE: Airship/AirshipCore/Source/AirshipEmbeddedInfo.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshipEmbeddedInfo: Equatable, Hashable, Sendable { /// A generated instance ID. public let instanceID: String /// Embedded ID. This is the ID used to place the embedded view. public let embeddedID: String /// The message extras public let extras: AirshipJSON? /// View priority. Lower is higher priority. public let priority: Int init(instanceID: String, embeddedID: String, extras: AirshipJSON?, priority: Int) { self.instanceID = instanceID self.embeddedID = embeddedID self.extras = extras self.priority = priority } } ================================================ FILE: Airship/AirshipCore/Source/AirshipEmbeddedObserver.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI public import Combine /// Observable model for Airship embedded views @MainActor public final class AirshipEmbeddedObserver : ObservableObject { /// An array of embedded infos @Published public var embeddedInfos: [AirshipEmbeddedInfo] = [] private var subscription: AnyCancellable? /// Creates a new view model for the given embedded ID . /// /// - Parameters: /// - embeddedID: The embedded ID to filter the embeddedInfos on. public convenience init(embeddedID: String) { self.init { info in info.embeddedID == embeddedID } } /// Creates a new view model for the given embedded IDs. /// /// - Parameters: /// - embeddedID: An array of embedded IDs to filter the embeddedInfos on. public convenience init(embeddedIDs: [String]) { let set: Set<String> = Set(embeddedIDs) self.init { info in set.contains(info.embeddedID) } } /// Creates a new view model for embedded infos. public convenience init() { self.init { info in return true } } /// Creates a new view model with the given predicate. /// /// - Parameters: /// - predicate: A predicate to filter out AirshipEmbeddedInfo. public init(predicate: @escaping @MainActor (AirshipEmbeddedInfo) -> Bool) { subscription = AirshipEmbeddedViewManager.shared.publisher .map { array in array.filter { predicate($0.embeddedInfo) } .map { $0.embeddedInfo } } .removeDuplicates() .assign(to: \.embeddedInfos, on: self) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipEmbeddedSize.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI public import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshipEmbeddedSize: Equatable, Hashable, Sendable { /// The parent's width public var parentWidth: CGFloat? /// The parent's height public var parentHeight: CGFloat? /// Creates a new AirshipEmbeddedSize /// - Parameters: /// - parentWidth: The parent's width in points.. This is required for horizontal scroll views to size correctly when using percent based sizing. /// - parentHeight: The parent's height in points. This is required for vertical scroll views to size correctly when using percent based sizing. public init(parentWidth: CGFloat? = nil, parentHeight: CGFloat? = nil) { self.parentWidth = parentWidth self.parentHeight = parentHeight } /// Creates a new AirshipEmbeddedSize /// - Parameters: /// - maxSize: The max size that the view can grow to in points. public init(parentBounds: CGSize) { self.parentWidth = parentBounds.width self.parentHeight = parentBounds.height } } ================================================ FILE: Airship/AirshipCore/Source/AirshipEmbeddedView.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI import Combine /// Airship embedded view - a scene that can be embedded in an app and managed remotely public struct AirshipEmbeddedView<PlaceHolder: View>: View { @Environment(\.airshipEmbeddedViewStyle) private var style @StateObject private var viewModel: EmbeddedViewModel private let placeholder: () -> PlaceHolder private let embeddedID: String private let embeddedSize: AirshipEmbeddedSize? /// Creates a new AirshipEmbeddedView. /// /// - Parameters: /// - embeddedID: The embedded ID. /// - size: The embedded size info. This is needed in a scroll view to determine proper percent based sizing. /// - placeholder: The place holder block. public init( embeddedID: String, embeddedSize: AirshipEmbeddedSize? = nil, @ViewBuilder placeholder: @escaping () -> PlaceHolder = { EmptyView()} ) { self.embeddedID = embeddedID self.embeddedSize = embeddedSize self.placeholder = placeholder self._viewModel = StateObject(wrappedValue: EmbeddedViewModel(embeddedID: embeddedID)) } /// Creates a new AirshipEmbeddedView. /// /// - Parameters: /// - embeddedID: The embedded ID. /// - size: The embedded size info. This is needed in a scroll view to determine proper percent based sizing. public init( embeddedID: String, embeddedSize: AirshipEmbeddedSize? = nil ) where PlaceHolder == EmptyView { self.embeddedID = embeddedID self.embeddedSize = embeddedSize self.placeholder = { EmptyView() } self._viewModel = StateObject(wrappedValue: EmbeddedViewModel(embeddedID: embeddedID)) } public var body: some View { let pending = viewModel.pending let configuration = AirshipEmbeddedViewStyleConfiguration( embeddedID: embeddedID, pending: pending.map { item in AirshipEmbeddedViewStyleConfiguration.Pending( content: AirshipEmbeddedContentView( embeddedInfo: item.embeddedInfo, view: { EmbeddedView( presentation: item.presentation, layout: item.layout, thomasEnvironment: item.environment, embeddedSize: embeddedSize ) }, dismissHandle: item.dismissHandle ), onDismiss: { item.dismissHandle.dismiss() } ) }, placeHolder: AnyView(self.placeholder()) ) return self.style.makeBody(configuration: configuration) } } @MainActor private class EmbeddedViewModel: ObservableObject { @Published var pending: [PendingEmbedded] = [] private var cancellable: AnyCancellable? private var timer: AnyCancellable? private var viewManager: AirshipEmbeddedViewManager init(embeddedID: String, manager: AirshipEmbeddedViewManager = AirshipEmbeddedViewManager.shared) { self.viewManager = manager cancellable = viewManager .publisher(embeddedViewID: embeddedID) .receive(on: DispatchQueue.main) .sink(receiveValue: onNewViewReceived) } private func onNewViewReceived(_ pending: [PendingEmbedded]) { withAnimation { self.pending = pending } } } /** * Internal only * :nodoc: */ public struct AirshipEmbeddedContentView : View, Identifiable { public let embeddedInfo: AirshipEmbeddedInfo nonisolated public var id: String { embeddedInfo.instanceID } private let view: () -> EmbeddedView private let dismissHandle: ThomasDismissHandle internal init( embeddedInfo: AirshipEmbeddedInfo, view: @escaping () -> EmbeddedView, dismissHandle: ThomasDismissHandle ) { self.embeddedInfo = embeddedInfo self.view = view self.dismissHandle = dismissHandle } public func dismiss() { self.dismissHandle.dismiss() } @ViewBuilder public var body: some View { view().onAppear { EmbeddedViewSelector.shared.onViewDisplayed(embeddedInfo) } .id(embeddedInfo.instanceID) } } public struct AirshipEmbeddedViewStyleConfiguration { public struct Pending: Identifiable { public let content: AirshipEmbeddedContentView public let onDismiss: @MainActor () -> Void public var id: String { content.id } } public let embeddedID: String public let pending: [Pending] public let placeHolder: AnyView /// Deprecated: Use `pending` instead. @available(*, deprecated, message: "Use `pending` which includes dismissal logic per-view.") public var views: [AirshipEmbeddedContentView] { return pending.map { $0.content } } internal init( embeddedID: String, pending: [Pending], placeHolder: AnyView ) { self.embeddedID = embeddedID self.pending = pending self.placeHolder = placeHolder } } /// Protocol for customizing an Airship embedded view with a style public protocol AirshipEmbeddedViewStyle: Sendable { associatedtype Body: View typealias Configuration = AirshipEmbeddedViewStyleConfiguration @preconcurrency @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension AirshipEmbeddedViewStyle where Self == DefaultAirshipEmbeddedViewStyle { /// Default style public static var defaultStyle: Self { return .init() } } /// Default style for embedded views public struct DefaultAirshipEmbeddedViewStyle: AirshipEmbeddedViewStyle { @MainActor private func nextView(configuration: Configuration) -> AirshipEmbeddedContentView? { return EmbeddedViewSelector.shared.selectView( embeddedID: configuration.embeddedID, views: configuration.pending.map { $0.content } ) } @ViewBuilder @MainActor public func makeBody(configuration: Configuration) -> some View { if let view = nextView(configuration: configuration) { view } else { configuration.placeHolder } } } struct AnyAirshipEmbeddedViewStyle: AirshipEmbeddedViewStyle { @ViewBuilder private let _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: AirshipEmbeddedViewStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct AirshipEmbeddedViewStyleKey: EnvironmentKey { static let defaultValue = AnyAirshipEmbeddedViewStyle(style: .defaultStyle) } extension EnvironmentValues { var airshipEmbeddedViewStyle: AnyAirshipEmbeddedViewStyle { get { self[AirshipEmbeddedViewStyleKey.self] } set { self[AirshipEmbeddedViewStyleKey.self] = newValue } } } extension View { /// Setter for applying a style to an Airship embedded view public func setAirshipEmbeddedStyle<S>( _ style: S ) -> some View where S: AirshipEmbeddedViewStyle { self.environment( \.airshipEmbeddedViewStyle, AnyAirshipEmbeddedViewStyle(style: style) ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipEmbeddedViewManager.swift ================================================ /* Copyright Airship and Contributors */ @preconcurrency import Combine import SwiftUI protocol AirshipEmbeddedViewManagerProtocol: Sendable { @MainActor func addPending( presentation: ThomasPresentationInfo.Embedded, layout: AirshipLayout, extensions: ThomasExtensions?, delegate: any ThomasDelegate, extras: AirshipJSON?, priority: Int ) -> any AirshipMainActorCancellable var publisher: AnyPublisher<[PendingEmbedded], Never> { get } func publisher(embeddedViewID: String) -> AnyPublisher<[PendingEmbedded], Never> } final class AirshipEmbeddedViewManager: AirshipEmbeddedViewManagerProtocol { public static let shared = AirshipEmbeddedViewManager() @MainActor private var pending: [PendingEmbedded] = [] private let viewSubject = CurrentValueSubject<[PendingEmbedded], Never>([]) var publisher: AnyPublisher<[PendingEmbedded], Never> { viewSubject.eraseToAnyPublisher() } @MainActor func addPending( presentation: ThomasPresentationInfo.Embedded, layout: AirshipLayout, extensions: ThomasExtensions?, delegate: any ThomasDelegate, extras: AirshipJSON?, priority: Int ) -> any AirshipMainActorCancellable { let id = UUID().uuidString let dismissHandle = ThomasDismissHandle() let environment = ThomasEnvironment(delegate: delegate, extensions: extensions, dismissHandle: dismissHandle) { self.pending.removeAll { $0.id == id } self.viewSubject.send(self.pending) } self.pending.append( PendingEmbedded( id: id, presentation: presentation, layout: layout, environment: environment, embeddedInfo: AirshipEmbeddedInfo( instanceID: id, embeddedID: presentation.embeddedID, extras: extras, priority: priority ), dismissHandle: dismissHandle ) ) self.viewSubject.send(self.pending) return AirshipMainActorCancellableBlock { [weak environment] in environment?.dismiss() } } func publisher(embeddedViewID: String) -> AnyPublisher<[PendingEmbedded], Never> { return viewSubject .map { array in array.filter { value in value.presentation.embeddedID == embeddedViewID } } .eraseToAnyPublisher() } } struct PendingEmbedded: Sendable { fileprivate let id: String let presentation: ThomasPresentationInfo.Embedded let layout: AirshipLayout let environment: ThomasEnvironment let embeddedInfo: AirshipEmbeddedInfo let dismissHandle: ThomasDismissHandle } ================================================ FILE: Airship/AirshipCore/Source/AirshipErrors.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public final class AirshipErrors { public class func parseError(_ message: String) -> any Error { return NSError( domain: "com.urbanairship.parse_error", code: 1, userInfo: [ NSLocalizedDescriptionKey: message ] ) } public class func error(_ message: String) -> any Error { return NSError( domain: "com.urbanairship.error", code: 1, userInfo: [ NSLocalizedDescriptionKey: message ] ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: For Internal use only :nodoc: public enum AirshipEventPriority: Sendable { case normal case high } /// - Note: For Internal use only :nodoc: public struct AirshipEvent: Sendable { public var priority: AirshipEventPriority public var eventType: EventType public var eventData: AirshipJSON public init( priority: AirshipEventPriority = .normal, eventType: EventType, eventData: AirshipJSON ) { self.priority = priority self.eventType = eventType self.eventData = eventData } } ================================================ FILE: Airship/AirshipCore/Source/AirshipEventData.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Full airship event data public struct AirshipEventData: Sendable, Equatable { // The event body public let body: AirshipJSON /// The event ID public let id: String /// The event date public let date: Date /// The SessionID public let sessionID: String /// The event type public let type: EventType } ================================================ FILE: Airship/AirshipCore/Source/AirshipEventType.swift ================================================ /* 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 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 .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" } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipEvents.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(WatchKit) import WatchKit #endif struct AirshipEvents { #if !os(tvOS) static func interactiveNotificationEvent( action: UNNotificationAction, category: String, notification: [AnyHashable: Any], responseText: String? ) -> AirshipEvent { let pushID = notification["_"] as? String return AirshipEvent( priority: .high, eventType: .interactiveNotificationAction, eventData: AirshipJSON.makeObject { object in object.set(string: category, key: "button_group") object.set(string: action.identifier, key: "button_id") object.set(string: action.title, key: "button_description") object.set(string: action.isForeground.toString(), key: "foreground") object.set(string: pushID, key: "send_id") object.set(string: responseText?.truncate(maxCount: 255), key: "user_input") } ) } #endif static func screenTrackingEvent( screen: String, previousScreen: String?, startDate: Date, duration: TimeInterval ) throws -> AirshipEvent { guard duration > 0 else { throw AirshipErrors.error("Invalid screen event \(screen), duration is zero.") } guard screen.count >= 1, screen.count <= 255 else { throw AirshipErrors.error("Invalid screen name \(screen), Must be between 1 and 255 characters.") } let startTime = startDate.timeIntervalSince1970 let endTime = startDate.advanced(by: duration).timeIntervalSince1970 return AirshipEvent( priority: .normal, eventType: .screenTracking, eventData: AirshipJSON.makeObject { object in object.set(string: screen, key: "screen") object.set(string: previousScreen, key: "previous_screen") object.set(string: startTime.toString(), key: "entered_time") object.set(string: endTime.toString(), key: "exited_time") object.set(string: duration.toString(), key: "duration") } ) } static func associatedIdentifiersEvent( identifiers: AssociatedIdentifiers? ) throws -> AirshipEvent { let identifiers = identifiers?.allIDs ?? [:] guard identifiers.count <= AssociatedIdentifiers.maxCount else { throw AirshipErrors.error( "Associated identifiers count exceed \(AssociatedIdentifiers.maxCount)" ) } try identifiers.forEach { if $0.key.count > AssociatedIdentifiers.maxCharacterCount { throw AirshipErrors.error( "Associated identifier \($0) key exceeds \(AssociatedIdentifiers.maxCharacterCount) characters" ) } if $0.value.count > AssociatedIdentifiers.maxCharacterCount { throw AirshipErrors.error( "Associated identifier \($0) value exceeds \(AssociatedIdentifiers.maxCharacterCount) characters" ) } } return AirshipEvent( priority: .normal, eventType: .associateIdentifiers, eventData: try AirshipJSON.wrap(identifiers) ) } static func installAttirbutionEvent( appPurchaseDate: Date? = nil, iAdImpressionDate: Date? = nil ) -> AirshipEvent { return AirshipEvent( priority: .normal, eventType: .installAttribution, eventData: AirshipJSON.makeObject { object in object.set( string: appPurchaseDate?.timeIntervalSince1970.toString(), key: "app_store_purchase_date" ) object.set( string: iAdImpressionDate?.timeIntervalSince1970.toString(), key: "app_store_ad_impression_date" ) } ) } @MainActor static func sessionEvent( sessionEvent: SessionEvent, push: any AirshipPush ) -> AirshipEvent { return AirshipEvent( priority: .normal, eventType: sessionEvent.eventType, eventData: sessionEvent.eventData(push: push) ) } } fileprivate extension TimeInterval { func toString() -> String { String( format: "%0.3f", self ) } } fileprivate extension Bool { func toString() -> String { return self ? "true" : "false" } } #if !os(tvOS) fileprivate extension UNNotificationAction { var isForeground: Bool { return (self.options.rawValue & UNNotificationActionOptions.foreground.rawValue) > 0 } } #endif fileprivate extension String { func truncate(maxCount: Int) -> String { return if self.count > maxCount { String(self.prefix(maxCount)) } else { self } } } fileprivate extension SessionEvent { var isAppInit: Bool { switch self.type { case .foregroundInit, .backgroundInit: return true case .background, .foreground: return false } } var eventType: EventType { switch self.type { case .foregroundInit, .backgroundInit: return .appInit case .background: return .appBackground case .foreground: return .appForeground } } @MainActor func eventData( push: any AirshipPush ) -> AirshipJSON { return AirshipJSON.makeObject { object in /// Common object.set(string: sessionState.conversionSendID, key: "push_id") object.set(string: sessionState.conversionMetadata, key: "metadata") /// App init if self.isAppInit { let isForeground = self.type == .foregroundInit object.set(string: isForeground.toString(), key: "foreground") } /// App init or foreground if self.isAppInit || self.type == .foreground { object.set(string: AirshipVersion.version, key: "lib_version") object.set(string: AirshipUtils.bundleShortVersionString() ?? "", key: "package_version") object.set(string: AirshipDevice.osVersion, key:"os_version") let localtz = TimeZone.current object.set(double: Double(localtz.secondsFromGMT()), key:"time_zone") object.set(string: localtz.isDaylightSavingTime().toString(), key: "daylight_savings") let notificationTypes = EventUtils.notificationTypes( authorizedSettings: push.authorizedNotificationSettings )?.map { AirshipJSON.string($0) } let authroizedStatus = EventUtils.notificationAuthorization( authorizationStatus: push.authorizationStatus ) object.set(array: notificationTypes, key: "notification_types") object.set(string: authroizedStatus, key: "notification_authorization") } } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipFont.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(UIKit) public import UIKit #elseif canImport(AppKit) public import AppKit #endif public struct AirshipFont { /// Resolves a SwiftUI Font @MainActor public static func resolveFont( size: Double, families: [String]? = nil, weight: Double? = nil, isItalic: Bool = false, isBold: Bool = false ) -> Font { let scaledSize = self.scaledSize(size) let fontWeight = self.resolveSwiftWeight(weight: weight, isBold: isBold) var font: Font if let fontFamily = resolveFontFamily(families: families) { font = Font.custom(fontFamily, fixedSize: scaledSize).weight(fontWeight) } else { font = Font.system(size: scaledSize, weight: fontWeight) } return isItalic ? font.italic() : font } /// Resolves a Native Font (UIFont or NSFont) @MainActor public static func resolveNativeFont( size: Double, families: [String]? = nil, weight: Double? = nil, isItalic: Bool = false, isBold: Bool = false ) -> AirshipNativeFont { let scaledSize = CGFloat(self.scaledSize(size)) let nativeWeight = self.resolveNativeWeight(weight: weight, isBold: isBold) var font: AirshipNativeFont if let fontFamily = resolveFontFamily(families: families) { #if os(macOS) font = NSFont(name: fontFamily, size: scaledSize) ?? NSFont.systemFont(ofSize: scaledSize, weight: nativeWeight) #else font = UIFont(name: fontFamily, size: scaledSize) ?? UIFont.systemFont(ofSize: scaledSize, weight: nativeWeight) #endif } else { #if os(macOS) font = NSFont.systemFont(ofSize: scaledSize, weight: nativeWeight) #else font = UIFont.systemFont(ofSize: scaledSize, weight: nativeWeight) #endif } if isItalic { #if os(macOS) let descriptor = font.fontDescriptor.withSymbolicTraits(.italic) font = NSFont(descriptor: descriptor, size: scaledSize) ?? font #else let descriptor = font.fontDescriptor.withSymbolicTraits(.traitItalic) font = UIFont(descriptor: descriptor ?? font.fontDescriptor, size: 0) #endif } return font } // MARK: - Scaling & Weights public static func scaledSize(_ size: Double) -> Double { #if os(macOS) return size #else return UIFontMetrics.default.scaledValue(for: size) #endif } private static func resolveSwiftWeight(weight: Double?, isBold: Bool) -> Font.Weight { if let weight = weight { return swiftWeight(from: roundFontWeight(weight)) } return isBold ? .bold : .regular } #if os(macOS) private static func resolveNativeWeight(weight: Double?, isBold: Bool) -> NSFont.Weight { if let weight = weight { return nativeWeight(from: roundFontWeight(weight)) } return isBold ? .bold : .regular } #else private static func resolveNativeWeight(weight: Double?, isBold: Bool) -> UIFont.Weight { if let weight = weight { return nativeWeight(from: roundFontWeight(weight)) } return isBold ? .bold : .regular } #endif // MARK: - Internal Mappers private static func roundFontWeight(_ fontWeight: Double) -> Int { let rounded = Int(round(fontWeight / 100.0) * 100.0) return max(100, min(900, rounded)) } private static func swiftWeight(from roundedWeight: Int) -> Font.Weight { let map: [Int: Font.Weight] = [ 100: .ultraLight, 200: .thin, 300: .light, 400: .regular, 500: .medium, 600: .semibold, 700: .bold, 800: .heavy, 900: .black ] return map[roundedWeight] ?? .regular } #if os(macOS) private static func nativeWeight(from roundedWeight: Int) -> NSFont.Weight { let map: [Int: NSFont.Weight] = [ 100: .ultraLight, 200: .thin, 300: .light, 400: .regular, 500: .medium, 600: .semibold, 700: .bold, 800: .heavy, 900: .black ] return map[roundedWeight] ?? .regular } #else private static func nativeWeight(from roundedWeight: Int) -> UIFont.Weight { let map: [Int: UIFont.Weight] = [ 100: .ultraLight, 200: .thin, 300: .light, 400: .regular, 500: .medium, 600: .semibold, 700: .bold, 800: .heavy, 900: .black ] return map[roundedWeight] ?? .regular } #endif public static func resolveFontFamily(families: [String]?) -> String? { guard let families = families else { return nil } for family in families { let lower = family.lowercased() if lower == "serif" { return "Times New Roman" } if lower == "sans-serif" { return nil } #if os(macOS) if NSFontManager.shared.availableFontFamilies.contains(family) { return family } #else if !UIFont.fontNames(forFamilyName: family).isEmpty { return family } #endif } return nil } } ================================================ FILE: Airship/AirshipCore/Source/AirshipImageLoader.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public actor AirshipImageLoader { private static let retryDelay: UInt64 = 10 * 1_000_000_000 private static let maxRetries: Int = 10 private let imageProvider: (any AirshipImageProvider)? public init( imageProvider: (any AirshipImageProvider)? = nil ) { self.imageProvider = imageProvider } func load( url urlString: String ) async throws -> AirshipImageData { guard let url = URL(string: urlString) else { throw AirshipErrors.error("Invalid URL") } // Check Cache/Provider first if let cachedData = imageProvider?.get(url: url) { return cachedData } // Route to appropriate loading logic if url.isFileURL { return try await loadImageFromFile(url: url) } else { return try await fetchImageWithRetry(url: url) } } private func loadImageFromFile( url: URL ) async throws -> AirshipImageData { // Moving file I/O to a background task to avoid blocking the actor return try await Task.detached(priority: .userInitiated) { let data = try Data(contentsOf: url) return try AirshipImageData(data: data) }.value } private func fetchImageWithRetry( url: URL ) async throws -> AirshipImageData { var lastError: (any Error)? for attempt in 0..<Self.maxRetries { do { let (data, response) = try await URLSession.airshipSecureSession.data(from: url) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw AirshipErrors.error("Invalid server response") } return try AirshipImageData(data: data) } catch { lastError = error AirshipLogger.debug("Failed to fetch image \(url) attempt \(attempt + 1)/\(Self.maxRetries): \(error)") if attempt < Self.maxRetries - 1 { try await Task.sleep(nanoseconds: Self.retryDelay) } } } AirshipLogger.debug("Failed to fetch image \(url) after \(Self.maxRetries) attempts") throw lastError ?? AirshipErrors.error("Failed to fetch after \(Self.maxRetries) attempts") } } ================================================ FILE: Airship/AirshipCore/Source/AirshipImageProvider.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Image provider to extend image loading. /// - Note: for internal use only. :nodoc: public protocol AirshipImageProvider: Sendable { /// Gets the an image. /// - Parameters: /// - url: The image URL. /// - Returns: The image or nil to let the image loader fetch it. func get(url: URL) -> AirshipImageData? } ================================================ FILE: Airship/AirshipCore/Source/AirshipInputValidator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine /// A struct that encapsulates input validation logic for different request types such as email and SMS. public struct AirshipInputValidation { /// A closure type used for overriding validation logic. public typealias OverridesClosure = (@Sendable (Request) async throws -> Override) private init() {} /// Enum representing the result of validation. /// It indicates whether an input is valid or invalid. public enum Result: Sendable, Equatable { /// Indicates a valid input with the associated address (e.g., email or phone number). case valid(address: String) /// Indicates an invalid input. case invalid } /// Enum representing the override options for input validation. public enum Override: Sendable, Equatable { /// Override the result of validation with a custom validation result. case override(Result) /// Skip the override and use the default validation method. case useDefault } /// Enum representing the types of requests to be validated (e.g., Email or SMS). public enum Request: Sendable, Equatable { case email(Email) case sms(SMS) /// A struct representing an SMS request for validation. public struct SMS: Sendable, Equatable { public var rawInput: String public var validationOptions: ValidationOptions public var validationHints: ValidationHints? /// Enum specifying the options for validating an SMS, such as sender ID or prefix. public enum ValidationOptions: Sendable, Equatable { case sender(senderID: String, prefix: String? = nil) case prefix(prefix: String) } /// A struct for defining validation hints like min/max digit requirements. public struct ValidationHints: Sendable, Equatable { var minDigits: Int? var maxDigits: Int? public init(minDigits: Int? = nil, maxDigits: Int? = nil) { self.minDigits = minDigits self.maxDigits = maxDigits } } /// Initializes the SMS validation request. /// - Parameters: /// - rawInput: The raw input string to be validated. /// - validationOptions: The validation options to be applied. /// - validationHints: Optional validation hints such as min/max digit constraints. public init( rawInput: String, validationOptions: ValidationOptions, validationHints: ValidationHints? = nil ) { self.rawInput = rawInput self.validationOptions = validationOptions self.validationHints = validationHints } } /// A struct representing an email request for validation. public struct Email: Sendable, Equatable { public var rawInput: String /// Initializes the Email validation request. /// - Parameter rawInput: The raw email input to be validated. public init(rawInput: String) { self.rawInput = rawInput } } } /// Protocol for validators that perform validation of input requests. /// NOTE: For internal use only. :nodoc: public protocol Validator: AnyObject, Sendable { /// Validates the provided request and returns a result. /// - Parameter request: The request to be validated (either SMS or Email). /// - Throws: Can throw errors if validation fails. /// - Returns: The validation result, either valid or invalid. func validateRequest(_ request: Request) async throws -> Result } } extension AirshipInputValidation { /// A default implementation of the `Validator` protocol that uses a standard SMS validation API. /// /// NOTE: For internal use only. :nodoc: final class DefaultValidator: Validator { // Regular expression for validating email addresses. private static let emailRegex: String = #"^[^@\s]+@[^@\s]+\.[^@\s.]+$"# private let overrides: OverridesClosure? private let smsValidatorAPIClient: any SMSValidatorAPIClientProtocol /// Initializes the validator with custom overrides and a SMS validation API client. /// - Parameters: /// - smsValidatorAPIClient: The client used to validate SMS numbers. /// - overrides: An optional closure for overriding validation logic. public init( smsValidatorAPIClient: any SMSValidatorAPIClientProtocol, overrides: OverridesClosure? = nil ) { self.overrides = overrides self.smsValidatorAPIClient = smsValidatorAPIClient } /// Initializes the validator using a configuration object. /// - Parameter config: The runtime configuration used for initializing the validator. public convenience init(config: RuntimeConfig) { self.init( smsValidatorAPIClient: CachingSMSValidatorAPIClient( client: SMSValidatorAPIClient(config: config) ), overrides: config.airshipConfig.inputValidationOverrides ) } /// Validates the provided request asynchronously. /// - Parameter request: The request to be validated (either SMS or Email). /// - Throws: Can throw errors if validation fails or on cancellation. /// - Returns: The validation result, either valid or invalid. public func validateRequest(_ request: Request) async throws -> Result { try Task.checkCancellation() AirshipLogger.debug("Validating input request \(request)") if let overrides { AirshipLogger.trace("Attempting to use overrides for request \(request)") switch(try await overrides(request)) { case .override(let result): AirshipLogger.debug("Overrides result \(result) for request \(request)") return result case .useDefault: AirshipLogger.trace("Overrides skipped, using default method for request \(request)") break } } try Task.checkCancellation() let result = switch(request) { case .sms(let sms): try await validateSMS(sms, request: request) case .email(let email): try await validateEmail(email, request: request) } AirshipLogger.debug("Result \(result) for request \(request)") return result } /// Validates an email address. /// - Parameter email: The email to be validated. /// - Parameter request: The original request associated with the email. /// - Throws: Can throw errors during validation or cancellation. /// - Returns: The result of the email validation, either valid or invalid. private func validateEmail(_ email: Request.Email, request: Request) async throws -> Result { let address = email.rawInput.trimmingCharacters(in: .whitespacesAndNewlines) let predicate = NSPredicate(format: "SELF MATCHES %@", Self.emailRegex) guard predicate.evaluate(with: address) else { return .invalid } return .valid(address: address) } /// Validates an SMS number. /// - Parameter sms: The SMS object containing validation information. /// - Parameter request: The original request associated with the SMS. /// - Throws: Can throw errors during validation or cancellation. /// - Returns: The result of the SMS validation, either valid or invalid. private func validateSMS(_ sms: Request.SMS, request: Request) async throws -> Result { guard sms.validationHints?.matches(sms.rawInput) != false else { AirshipLogger.trace("SMS validation failed for \(request), did not pass validation hints") return .invalid } // Airship SMS validation let result = switch(sms.validationOptions) { case .sender(let sender, _): try await smsValidatorAPIClient.validateSMS(msisdn: sms.rawInput, sender: sender) case .prefix(let prefix): try await smsValidatorAPIClient.validateSMS(msisdn: sms.rawInput, prefix: prefix) } // Assume client errors are not valid guard result.isClientError == false else { return .invalid } // Make sure we have a result, if not throw an error guard result.isSuccess, let value = result.result else { throw AirshipErrors.error("Failed to validate SMS \(result)") } // Convert the result return switch (value) { case .invalid: .invalid case .valid(let address): .valid(address: address) } } } } /// Extension to add matching logic for SMS validation hints (e.g., minimum or maximum digits). fileprivate extension AirshipInputValidation.Request.SMS.ValidationHints { func matches(_ rawInput: String) -> Bool { let digits = rawInput.filter { $0.isNumber } guard digits.count >= (self.minDigits ?? 0), digits.count <= (self.maxDigits ?? Int.max) else { return false } return true } } ================================================ FILE: Airship/AirshipCore/Source/AirshipInstance.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol AirshipInstance: Sendable { var config: RuntimeConfig { get } var preferenceDataStore: PreferenceDataStore { get } var permissionsManager: any AirshipPermissionsManager { get } var actionRegistry: any AirshipActionRegistry { get } var urlOpener: any URLOpenerProtocol { get } #if !os(tvOS) && !os(watchOS) var javaScriptCommandDelegate: (any JavaScriptCommandDelegate)? { get set } var channelCapture: any AirshipChannelCapture { get } #endif var deepLinkDelegate: (any DeepLinkDelegate)? { get set } @MainActor var onDeepLink: (@MainActor @Sendable (URL) async -> Void)? { get set } var urlAllowList: any AirshipURLAllowList { get } var localeManager: any AirshipLocaleManager { get } var inputValidator: any AirshipInputValidation.Validator { get } var privacyManager: any InternalAirshipPrivacyManager { get } var components: [any AirshipComponent] { get } func component<E>(ofType componentType: E.Type) -> E? @MainActor func airshipReady() } final class DefaultAirshipInstance: AirshipInstance { public let config: RuntimeConfig public let preferenceDataStore: PreferenceDataStore let inputValidator: any AirshipInputValidation.Validator public let permissionsManager: any AirshipPermissionsManager public let actionRegistry: any AirshipActionRegistry #if !os(tvOS) && !os(watchOS) private let _jsDelegateHolder: AirshipAtomicValue<(any JavaScriptCommandDelegate)?> = AirshipAtomicValue<(any JavaScriptCommandDelegate)?>(nil) public var javaScriptCommandDelegate: (any JavaScriptCommandDelegate)? { get { return _jsDelegateHolder.value } set { _jsDelegateHolder.value = newValue } } public let channelCapture: any AirshipChannelCapture #endif private let _deeplinkDelegateHolder: AirshipAtomicValue<(any DeepLinkDelegate)?> = AirshipAtomicValue<(any DeepLinkDelegate)?>(nil) public var deepLinkDelegate: (any DeepLinkDelegate)? { get { _deeplinkDelegateHolder.value } set { _deeplinkDelegateHolder.value = newValue } } @MainActor public var onDeepLink: (@MainActor @Sendable (URL) async -> Void)? public let urlAllowList: any AirshipURLAllowList public let localeManager: any AirshipLocaleManager public let privacyManager: any InternalAirshipPrivacyManager public let components: [any AirshipComponent] private let remoteConfigManager: RemoteConfigManager private let experimentManager: any ExperimentDataProvider private let componentMap: AirshipAtomicValue<[String: any AirshipComponent]> = AirshipAtomicValue([String: any AirshipComponent]()) //it's accessed with the lock below private let lock: AirshipLock = AirshipLock() public let urlOpener: any URLOpenerProtocol = DefaultURLOpener() @MainActor init(airshipConfig: AirshipConfig, appCredentials: AirshipAppCredentials) { let requestSession = DefaultAirshipRequestSession( appKey: appCredentials.appKey, appSecret: appCredentials.appSecret ) let dataStore = PreferenceDataStore(appKey: appCredentials.appKey) self.preferenceDataStore = dataStore self.permissionsManager = DefaultAirshipPermissionsManager() self.config = RuntimeConfig( airshipConfig: airshipConfig, appCredentials: appCredentials, dataStore: dataStore, requestSession: requestSession ) self.inputValidator = AirshipInputValidation.DefaultValidator( config: config ) self.privacyManager = DefaultAirshipPrivacyManager( dataStore: dataStore, config: self.config, defaultEnabledFeatures: airshipConfig.enabledFeatures ) self.actionRegistry = DefaultAirshipActionRegistry() self.urlAllowList = DefaultAirshipURLAllowList(airshipConfig: airshipConfig) self.localeManager = DefaultAirshipLocaleManager( dataStore: dataStore, config: self.config ) let apnsRegistrar = DefaultAPNSRegistrar() let audienceOverridesProvider = DefaultAudienceOverridesProvider() let channel = DefaultAirshipChannel( dataStore: dataStore, config: self.config, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, localeManager: self.localeManager, audienceOverridesProvider: audienceOverridesProvider ) requestSession.channelAuthTokenProvider = ChannelAuthTokenProvider( channel: channel, runtimeConfig: self.config ) let cache = CoreDataAirshipCache(appKey: appCredentials.appKey) let audienceChecker = DefaultDeviceAudienceChecker(cache: cache) let analytics = DefaultAirshipAnalytics( config: self.config, dataStore: dataStore, channel: channel, localeManager: localeManager, privacyManager: privacyManager, permissionsManager: permissionsManager ) let push = DefaultAirshipPush( config: self.config, dataStore: dataStore, channel: channel, analytics: analytics, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, apnsRegistrar: apnsRegistrar, badger: Badger.shared ) let contact = DefaultAirshipContact( dataStore: dataStore, config: self.config, channel: channel, privacyManager: self.privacyManager, audienceOverridesProvider: audienceOverridesProvider, localeManager: self.localeManager ) requestSession.contactAuthTokenProvider = contact.authTokenProvider let remoteData = RemoteData( config: self.config, dataStore: dataStore, localeManager: self.localeManager, privacyManager: self.privacyManager, contact: contact ) self.experimentManager = ExperimentManager( dataStore: dataStore, remoteData: remoteData, audienceChecker: audienceChecker ) let meteredUsage = DefaultAirshipMeteredUsage( config: self.config, dataStore: dataStore, channel: channel, contact: contact, privacyManager: privacyManager ) #if !os(tvOS) && !os(watchOS) self.channelCapture = DefaultAirshipChannelCapture( config: self.config, channel: channel ) #endif let deferredResolver = AirshipDeferredResolver( config: self.config, audienceOverrides: audienceOverridesProvider ) let moduleLoader = ModuleLoader( config: self.config, dataStore: dataStore, channel: channel, contact: contact, push: push, remoteData: remoteData, analytics: analytics, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, audienceOverrides: audienceOverridesProvider, experimentsManager: experimentManager, meteredUsage: meteredUsage, deferredResolver: deferredResolver, cache: cache, audienceChecker: audienceChecker, inputValidator: inputValidator ) var components: [any AirshipComponent] = [ contact, channel, analytics, remoteData, push ] components.append(contentsOf: moduleLoader.components) self.components = components self.remoteConfigManager = RemoteConfigManager( config: self.config, remoteData: remoteData, privacyManager: self.privacyManager ) self.actionRegistry.registerActions( actionsManifests: moduleLoader.actionManifests + [DefaultActionsManifest()] ) } public func component<E>(ofType componentType: E.Type) -> E? { var component: E? lock.sync { let key = "Type:\(componentType)" var stored = componentMap.value[key] if stored == nil { componentMap.update { current in var mutable = current stored = self.components.first { ($0 as? E) != nil } mutable[key] = stored return mutable } } component = stored as? E } return component } @MainActor func airshipReady() { self.components.forEach { $0.airshipReady() } self.remoteConfigManager.airshipReady() } } ================================================ FILE: Airship/AirshipCore/Source/AirshipIvyVersionMatcher.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshipIvyVersionMatcher: Sendable { private static let exactVersionPattern: String = "^([0-9]+)(\\.([0-9]+)((\\.([0-9]+))?(.*)))?$" private static let subVersionPattern: String = "^(.*)\\+$" private static let startInclusive: String = "[" private static let startExclusive: String = "]" private static let startInfinite: String = "(" private static let endInclusive: String = "]" private static let endExclusive: String = "[" private static let endInfinite: String = ")" private static let rangeSeparator: String = "," private static let escapeChar: String = "\\" private static let startTokens: String = escapeChar + startInclusive + escapeChar + startExclusive + escapeChar + startInfinite private static let endTokens: String = escapeChar + endInclusive + escapeChar + endExclusive + escapeChar + endInfinite private static let startEndTokens: String = startTokens + endTokens private static let startPattern: String = "([" + startTokens + "])" private static let endPattern: String = "([" + endTokens + "])" private static let separatorPattern: String = "(" + rangeSeparator + ")" private static let versionPattern: String = "([^" + startEndTokens + rangeSeparator + "]*)" private static let versionRangePattern: String = startPattern + versionPattern + separatorPattern + versionPattern + endPattern private enum Constraint: Sendable { case exactVersion(String) case subVersion(String) case versionRange(start: Boundary, end: Boundary) } private enum Boundary: Sendable { case inclusive(String) case exclusive(String) case infinite var isInfinite: Bool { switch self { case .infinite: return true default: return false } } } private let contraint: Constraint public init(versionConstraint: String) throws { let strippedVersionConstraint = String(versionConstraint.filter { !$0.isWhitespace }) let parsed = Self.parseSubVersionConstraint(strippedVersionConstraint) ?? Self.parseExactVersionConstraint(strippedVersionConstraint) ?? Self.parseVersionRangeConstraint(strippedVersionConstraint) guard let parsed else { throw AirshipErrors.error("Invalid version matcher constraint \(versionConstraint)") } self.contraint = parsed } public func evaluate(version: String) -> Bool { let checkVersion = version.filter { !$0.isWhitespace } return switch self.contraint { case .exactVersion(let exactVersion): checkVersion.normalizeVersionString == exactVersion.normalizeVersionString case .subVersion(let subVersion): Self.evaluateSubversion(subVersion: subVersion, checkVersion: version) case .versionRange(let start, let end): Self.evaluateVersionRange(start: start, end: end, checkVersion: version) } } private static func parseExactVersionConstraint( _ versionConstraint: String ) -> Constraint? { guard let matches = try? Self.getMatchesForPattern( exactVersionPattern, on: versionConstraint ), matches.count == 1 else { return nil } return .exactVersion(versionConstraint) } private static func parseSubVersionConstraint( _ versionConstraint: String ) -> Constraint? { guard let matches = try? self.getMatchesForPattern( subVersionPattern, on: versionConstraint ), matches.count == 1 else { return nil } let nsRange = matches[0].range(at: 1) guard let range = Range(nsRange, in: versionConstraint) else { return nil } let versionNumberPart: String = String(versionConstraint[range]) return .subVersion(versionNumberPart) } private static func parseVersionRangeConstraint( _ versionConstraint: String ) -> Constraint? { guard let matches = try? Self.getMatchesForPattern( versionRangePattern, on: versionConstraint ), matches.count == 1 else { return nil } /// extract tokens from version constraint let match = matches[0] let numberOfTokens = (match.numberOfRanges) - 1 guard numberOfTokens == 5 else { return nil } var tokens: [String] = [] for index in 1...numberOfTokens { let nsRange = match.range(at: index) guard let range = Range(nsRange, in: versionConstraint) else { return nil } tokens.append(String(versionConstraint[range])) } guard let start = parseStartRangeBoundary(tokens[0], constraint: tokens[1]), let end = parseEndRangeBoundary(tokens[4], constraint: tokens[3]), !(end.isInfinite && start.isInfinite) else { return nil } return .versionRange(start: start, end: end) } private static func evaluateSubversion(subVersion: String, checkVersion: String) -> Bool { if subVersion == "*" { return true } return checkVersion.hasPrefix(subVersion.normalizeVersionString) } private static func evaluateVersionRange(start: Boundary, end: Boundary, checkVersion: String) -> Bool { switch(start) { case .inclusive(let constraint): let result = AirshipUtils.compareVersion( constraint, toVersion: checkVersion, maxVersionParts: 3 ) if result != .orderedAscending && result != .orderedSame { return false } case .exclusive(let constraint): let result = AirshipUtils.compareVersion( constraint, toVersion: checkVersion, maxVersionParts: 3 ) if result != .orderedAscending { return false } case .infinite: break } switch(end) { case .inclusive(let constraint): let result = AirshipUtils.compareVersion( checkVersion, toVersion: constraint, maxVersionParts: 3 ) if result != .orderedAscending && result != .orderedSame { return false } case .exclusive(let constraint): let result = AirshipUtils.compareVersion( checkVersion, toVersion: constraint, maxVersionParts: 3 ) if result != .orderedAscending { return false } case .infinite: break } return true } private static func getMatchesForPattern( _ pattern: String, on string: String ) throws -> [NSTextCheckingResult] { let regex = try NSRegularExpression( pattern: pattern, options: .caseInsensitive ) return regex.matches( in: string, options: [], range: NSRange(location: 0, length: string.count) ) } private static func parseStartRangeBoundary(_ boundary: String, constraint: String) -> Boundary? { return switch(boundary) { case startInfinite: constraint.isEmpty ? .infinite : nil case startExclusive: constraint.isEmpty ? nil : .exclusive(constraint) case startInclusive: constraint.isEmpty ? nil : .inclusive(constraint) default: nil } } private static func parseEndRangeBoundary(_ boundary: String, constraint: String) -> Boundary? { return switch(boundary) { case endInfinite: constraint.isEmpty ? .infinite : nil case endExclusive: constraint.isEmpty ? nil : .exclusive(constraint) case endInclusive: constraint.isEmpty ? nil : .inclusive(constraint) default: nil } } } fileprivate extension String { var normalizeVersionString: String { let trimmed = self.filter { !$0.isWhitespace } // Find the first occurrence of "-" if let index = trimmed.firstIndex(of: "-") { // If the index is not the very first character... if index > trimmed.startIndex { // Get the substring before the "-" let baseVersion = trimmed[..<index] // Check for a "+" suffix and append it if it exists if trimmed.hasSuffix("+") { return String(baseVersion) + "+" } else { return String(baseVersion) } } } return trimmed } } ================================================ FILE: Airship/AirshipCore/Source/AirshipJSON.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /** * Airship JSON. */ public enum AirshipJSON: Codable, Equatable, Sendable, Hashable { public static var defaultEncoder: JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return encoder } public static var defaultDecoder: JSONDecoder { return JSONDecoder() } case string(String) case number(Double) case object([String: AirshipJSON]) case array([AirshipJSON]) case bool(Bool) case null public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .array(let array): try container.encode(array) case .object(let object): try container.encode(object) case .number(let number): try container.encode(number) case .string(let string): try container.encode(string) case .bool(let bool): try container.encode(bool) case .null: try container.encodeNil() } } public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let object = try? container.decode([String: AirshipJSON].self) { self = .object(object) } else if let array = try? container.decode([AirshipJSON].self) { self = .array(array) } else if let string = try? container.decode(String.self) { self = .string(string) } else if let bool = try? container.decode(Bool.self) { self = .bool(bool) } else if let number = try? container.decode(Double.self) { self = .number(number) } else if container.decodeNil() { self = .null } else { throw AirshipErrors.error("Invalid JSON") } } public func unWrap() -> AnyHashable? { switch self { case .string(let value): return value case .number(let value): return value case .bool(let value): return value case .null: return nil case .object(let value): var dict: [String: AnyHashable] = [:] value.forEach { dict[$0.key] = $0.value.unWrap() } return dict case .array(let value): var array: [AnyHashable] = [] value.forEach { if let item = $0.unWrap() { array.append(item) } } return array } } public static func from( json: String?, decoder: JSONDecoder = AirshipJSON.defaultDecoder ) throws -> AirshipJSON { guard let json = json else { return .null } guard let data = json.data(using: .utf8) else { throw AirshipErrors.error("Invalid encoding: \(json)") } return try decoder.decode(AirshipJSON.self, from: data) } public static func from( data: Data?, decoder: JSONDecoder = AirshipJSON.defaultDecoder ) throws -> AirshipJSON { guard let data = data else { return .null } return try decoder.decode(AirshipJSON.self, from: data) } public static func wrap(_ value: Any?, encoder: @autoclosure () -> JSONEncoder = AirshipJSON.defaultEncoder) throws -> AirshipJSON { guard let value = value else { return .null } if let json = value as? AirshipJSON { return json } if let string = value as? String { return .string(string) } if let url = value as? URL { return .string(url.absoluteString) } if let number = value as? NSNumber { guard CFBooleanGetTypeID() == CFGetTypeID(number) else { return .number(number.doubleValue) } return .bool(number.boolValue) } if let bool = value as? Bool { return .bool(bool) } if let number = value as? Double { return .number(number) } if let array = value as? [Any?] { let mapped: [AirshipJSON] = try array.map { child in try wrap(child, encoder: encoder()) } return .array(mapped) } if let object = value as? [String: Any?] { let mapped: [String: AirshipJSON] = try object.mapValues { child in try wrap(child, encoder: encoder()) } return .object(mapped) } if let codable = value as? (any Encodable) { let encoder = encoder() return try wrap( JSONSerialization.jsonObject(with: try encoder.encode(codable), options: .fragmentsAllowed), encoder: encoder ) } throw AirshipErrors.error("Invalid JSON \(value)") } public func toData(encoder: JSONEncoder = AirshipJSON.defaultEncoder) throws -> Data { return try encoder.encode(self) } public func toString(encoder: JSONEncoder = AirshipJSON.defaultEncoder) throws -> String { return String( decoding: try encoder.encode(self), as: UTF8.self ) } public func decode<T: Decodable>( decoder: JSONDecoder = AirshipJSON.defaultDecoder, encoder: JSONEncoder = AirshipJSON.defaultEncoder ) throws -> T { let data = try toData(encoder: encoder) return try decoder.decode(T.self, from: data) } } public extension AirshipJSON { var isNull: Bool { if case .null = self { return true } return false } var isObject: Bool { if case .object(_) = self { return true } return false } var isArray: Bool { if case .array(_) = self { return true } return false } var isNumber: Bool { if case .number(_) = self { return true } return false } var isString: Bool { if case .string(_) = self { return true } return false } var isBool: Bool { if case .bool(_) = self { return true } return false } var string: String? { guard case .string(let value) = self else { return nil } return value } var number: Double? { guard case .number(let value) = self else { return nil } return value } var object: [String: AirshipJSON]? { guard case .object(let value) = self else { return nil } return value } var array: [AirshipJSON]? { guard case .array(let value) = self else { return nil } return value } var double: Double? { guard case .number(let value) = self else { return nil } return value } var bool: Bool? { guard case .bool(let value) = self else { return nil } return value } static func makeObject(builderBlock: (inout AirshipJSONObjectBuilder) -> Void) -> AirshipJSON { var builder: AirshipJSONObjectBuilder = AirshipJSONObjectBuilder() builderBlock(&builder) return builder.build() } } extension AirshipJSON { /// Decodes an ``AirshipJSON`` value from raw JSON data, logging any decode error and returning `nil` on failure. /// /// Unlike ``from(data:decoder:)``, this method never throws. Decode errors are logged via `AirshipLogger` /// and `nil` is returned instead. Passing `nil` data returns ``AirshipJSON/null``. /// /// - Parameters: /// - data: The raw JSON data to decode. Passing `nil` returns ``AirshipJSON/null``. /// - decoder: The decoder to use. Defaults to ``defaultDecoder``. /// - Returns: The decoded value, or `nil` if decoding failed. public static func fromDataLoggingError(data: Data?, decoder: JSONDecoder = AirshipJSON.defaultDecoder) -> AirshipJSON? { do { return try AirshipJSON.from(data: data, decoder: decoder) } catch { AirshipLogger.error("Failed to decode AirshipJSON: \(error)") return nil } } /// Encodes the value to JSON data, logging any encode error and returning `nil` on failure. /// /// Unlike ``toData(encoder:)``, this method never throws. Encode errors are logged via `AirshipLogger` /// and `nil` is returned instead. /// /// - Returns: The encoded data, or `nil` if encoding failed. public func toDataLoggingError(encoder: JSONEncoder = AirshipJSON.defaultEncoder) -> Data? { do { return try self.toData(encoder: encoder) } catch { AirshipLogger.error("Failed to encode AirshipJSON: \(error)") return nil } } } public struct AirshipJSONObjectBuilder { var data: [String: AirshipJSON] = [:] public mutating func set(string: String?, key: String) { guard let string = string else { data[key] = nil return } data[key] = .string(string) } public mutating func set(array: [AirshipJSON]?, key: String) { guard let array = array else { data[key] = nil return } data[key] = .array(array) } public mutating func set(object: [String: AirshipJSON]?, key: String) { guard let object = object else { data[key] = nil return } data[key] = .object(object) } public mutating func set(json: AirshipJSON?, key: String) { guard let json = json else { data[key] = nil return } data[key] = json } public mutating func set(double: Double?, key: String) { guard let double = double else { data[key] = nil return } data[key] = .number(double) } public mutating func set(bool: Bool?, key: String) { guard let bool = bool else { data[key] = nil return } data[key] = .bool(bool) } func build() -> AirshipJSON { return .object(data) } } extension AirshipJSON: ExpressibleByStringLiteral { public init(stringLiteral value: StringLiteralType) { self = .string(value) } } extension AirshipJSON: ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = .bool(value) } } extension AirshipJSON: ExpressibleByIntegerLiteral { public init(integerLiteral value: IntegerLiteralType) { self = .number(Double(value)) } } extension AirshipJSON: ExpressibleByFloatLiteral { public init(floatLiteral value: FloatLiteralType) { self = .number(Double(value)) } } extension AirshipJSON: ExpressibleByArrayLiteral { public init(arrayLiteral elements: AirshipJSON...) { self = .array(elements) } } extension AirshipJSON: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (String, AirshipJSON)...) { var dict: [String: AirshipJSON] = [:] for (key, value) in elements { dict[key] = value } self = .object(dict) } } extension AirshipJSON: ExpressibleByNilLiteral { public init(nilLiteral: Void) { self = .null } } ================================================ FILE: Airship/AirshipCore/Source/AirshipJSONUtils.swift ================================================ // Copyright Airship and Contributors public import Foundation /// - NOTE: Internal use only :nodoc: public class AirshipJSONUtils: NSObject { public class func data( _ obj: Any, options: JSONSerialization.WritingOptions = [] ) throws -> Data { try validateJSONObject(obj, options: options) return try JSONSerialization.data(withJSONObject: obj, options: options) } public class func toData( _ obj: Any? ) -> Data? { guard let obj = obj else { return nil } do { return try AirshipJSONUtils.data( obj, options: JSONSerialization.WritingOptions.prettyPrinted ) } catch { AirshipLogger.error( "Failed to transform value: \(obj), error: \(error)" ) return nil } } public class func json(_ data: Data?) -> Any? { guard let data = data, !data.isEmpty else { return nil } do { return try JSONSerialization.jsonObject( with: data, options: .mutableContainers ) } catch { AirshipLogger.error("Converting data \(data) failed with error \(error)") return nil } } public class func string( _ obj: Any, options: JSONSerialization.WritingOptions ) throws -> String { try validateJSONObject(obj, options: options) let data = try self.data(obj, options: options) guard let string = String(data: data, encoding: .utf8) else { throw AirshipErrors.error("Invalid JSON \(obj)") } return string } public class func string(_ obj: Any) -> String? { return try? self.string(obj, options: []) } public class func object(_ string: String) -> Any? { return try? self.object(string, options: []) } public class func object( _ string: String, options: JSONSerialization.ReadingOptions ) throws -> Any { guard let data = string.data(using: .utf8) else { throw AirshipErrors.error("Invalid JSON \(string)") } return try JSONSerialization.jsonObject(with: data, options: options) } public class func decode<T: Decodable>( data: Data? ) throws -> T { guard let data = data else { throw AirshipErrors.parseError("data missing response body.") } return try JSONDecoder() .decode( T.self, from: data ) } public class func encode<T: Encodable>( object: T? ) throws -> Data { guard let object = object else { throw AirshipErrors.parseError("data missing.") } return try JSONEncoder().encode(object) } private class func validateJSONObject( _ object: Any, options: JSONSerialization.WritingOptions ) throws { var valid = false if options.contains(.fragmentsAllowed) { valid = JSONSerialization.isValidJSONObject([object]) } else { valid = JSONSerialization.isValidJSONObject(object) } guard valid else { throw AirshipErrors.error("Invalid JSON: \(object)") } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipKeychainAccess.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Keychain credentials /// - Note: for internal use only. :nodoc: public struct AirshipKeychainCredentials: Sendable { /// The username public let username: String /// The password public let password: String /// Constructor /// - Parameters: /// - username: The username /// - password: The password public init(username: String, password: String) { self.username = username self.password = password } } /// Keychain access /// - Note: for internal use only. :nodoc: public protocol AirshipKeychainAccessProtocol: Sendable { /// Writes credentials to the keychain for the given identifier. /// - Parameters: /// - credentials: The credentials to save /// - identifier: The credential's identifier /// - appKey: The app key /// - Returns: `true` if the data was written, otherwise `false`. func writeCredentials( _ credentials: AirshipKeychainCredentials, identifier: String, appKey: String ) async -> Bool /// Deletes credentials for the given identifier. /// - Parameters: /// - identifier: The credential's identifier /// - appKey: The app key func deleteCredentials( identifier: String, appKey: String ) async /// Reads credentials from the keychain synchronously. /// /// - NOTE: This method could take a long time to call, it should not /// be called on the main queue. /// /// - Parameters: /// - identifier: The credential's identifier /// - appKey: The app key /// - Returns: The credentials if found. func readCredentails( identifier: String, appKey: String ) async -> AirshipKeychainCredentials? } /// Keychain access /// - Note: for internal use only. :nodoc: public final class AirshipKeychainAccess: AirshipKeychainAccessProtocol { public static let shared: AirshipKeychainAccess = AirshipKeychainAccess() // Dispatch queue to prevent blocking any tasks private let dispatchQueue: AirshipUnsafeSendableWrapper<DispatchQueue> = AirshipUnsafeSendableWrapper( DispatchQueue( label: "com.urbanairship.dispatcher.keychain", qos: .utility ) ) public func writeCredentials( _ credentials: AirshipKeychainCredentials, identifier: String, appKey: String ) async -> Bool { let service = service(appKey: appKey) return await self.dispatch { [service] in let result = Keychain.writeCredentials( credentials, identifier: identifier, service: service ) // Write to old location in case of a downgrade if let bundleID = Bundle.main.bundleIdentifier { let _ = Keychain.writeCredentials( credentials, identifier: identifier, service: bundleID ) } return result } } public func deleteCredentials(identifier: String, appKey: String) async { let service = service(appKey: appKey) await self.dispatch { [service] in Keychain.deleteCredentials( identifier: identifier, service: service ) // Delete old if let bundleID = Bundle.main.bundleIdentifier { Keychain.deleteCredentials( identifier: identifier, service: bundleID ) } } } public func readCredentails( identifier: String, appKey: String ) async -> AirshipKeychainCredentials? { let service = service(appKey: appKey) return await self.dispatch { [service] in if let credentials = Keychain.readCredentials( identifier: identifier, service: service ) { return credentials } // If we do not have a new value, check // the old service location if let bundleID = Bundle.main.bundleIdentifier { let old = Keychain.readCredentials( identifier: identifier, service: bundleID ) if let old = old { // Migrate old data to new service location let _ = Keychain.writeCredentials( old, identifier: identifier, service: service ) return old } } return nil } } private func dispatch<T>(block: @escaping @Sendable () -> T) async -> T { return await withCheckedContinuation { continuation in dispatchQueue.value.async { continuation.resume(returning: block()) } } } private func service(appKey: String) -> String { return "\(Bundle.main.bundleIdentifier ?? "").airship.\(appKey)" } } /// Helper that wraps the actual keychain calls private struct Keychain { static func writeCredentials( _ credentials: AirshipKeychainCredentials, identifier: String, service: String ) -> Bool { guard let identifierData = identifier.data(using: .utf8), let passwordData = credentials.password.data(using: .utf8) else { return false } deleteCredentials(identifier: identifier, service: service) let addquery: [String: Any] = [ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrGeneric as String: identifierData, kSecAttrAccount as String: credentials.username, kSecValueData as String: passwordData, ] let status = SecItemAdd(addquery as CFDictionary, nil) return status == errSecSuccess } static func deleteCredentials( identifier: String, service: String ) { guard let identifierData = identifier.data(using: .utf8) else { return } let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrGeneric as String: identifierData, ] SecItemDelete(deleteQuery as CFDictionary) } static func readCredentials( identifier: String, service: String ) -> AirshipKeychainCredentials? { guard let identifierData = identifier.data(using: .utf8) else { return nil } let searchQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecMatchLimit as String: kSecMatchLimitOne, kSecAttrService as String: service, kSecAttrGeneric as String: identifierData, kSecReturnAttributes as String: true, kSecReturnData as String: true, ] var item: CFTypeRef? let status = SecItemCopyMatching(searchQuery as CFDictionary, &item) guard status == errSecSuccess else { return nil } guard let existingItem = item as? [String: Any] else { return nil } guard let passwordData = existingItem[kSecValueData as String] as? Data, let password = String( data: passwordData, encoding: String.Encoding.utf8 ), let username = existingItem[kSecAttrAccount as String] as? String else { return nil } let credentials = AirshipKeychainCredentials( username: username, password: password ) let attrAccessible = existingItem[kSecAttrAccessible as String] as? String if attrAccessible != (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) { updateThisDeviceOnly(credentials: credentials, identifier: identifier, service: service) } return credentials } static func updateThisDeviceOnly(credentials: AirshipKeychainCredentials, identifier: String, service: String) { guard let identifierData = identifier.data(using: .utf8), let passwordData = credentials.password.data(using: .utf8) else { return } let updateQuery: [String: Any] = [ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecValueData as String: passwordData ] let searchQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrGeneric as String: identifierData, kSecAttrAccount as String: credentials.username ] let updateStatus = SecItemUpdate(searchQuery as CFDictionary, updateQuery as CFDictionary) if (updateStatus == errSecSuccess) { AirshipLogger.trace("Updated keychain value \(identifier) to this device only") } else { AirshipLogger.debug("Failed to update keychain value \(identifier) status:\(updateStatus)") } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipLayout.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI /// AirshipLayout public struct AirshipLayout: ThomasSerializable { /// The view DSL let view: ThomasViewInfo /// Layout DSL version let version: Int /// Presentation configuration let presentation: ThomasPresentationInfo public var isEmbedded: Bool { guard case .embedded(_) = presentation else { return false } return true } } extension ThomasViewInfo { func extractDescendants<T>(extractor: (ThomasViewInfo) -> T?) -> [T] { var infos: [ThomasViewInfo] = [self] var result: [T] = [] while (!infos.isEmpty) { let info = infos.removeFirst() if let children = info.immediateChildren { infos.append(contentsOf: children) } if let value = extractor(info) { result.append(value) } } return result } var immediateChildren: [ThomasViewInfo]? { return switch self { case .container(let info): info.properties.items.map { $0.view } case .linearLayout(let info): info.properties.items.map { $0.view } case .pager(let info): info.properties.items.map { $0.view } case .scrollLayout(let info): [info.properties.view] case .checkboxController(let info): [info.properties.view] case .radioInputController(let info): [info.properties.view] case .formController(let info): [info.properties.view] case .npsController(let info): [info.properties.view] case .pagerController(let info): [info.properties.view] case .media: nil case .imageButton: nil case .stackImageButton: nil #if !os(tvOS) && !os(watchOS) case .webView: nil #endif case .label: nil case .labelButton(let info): [.label(info.properties.label)] case .emptyView: nil case .pagerIndicator(_): nil case .storyIndicator(_): nil case .checkbox(_): nil case .radioInput(_): nil case .textInput(_): nil case .score(_): nil case .toggle(_): nil case .stateController(let info): [info.properties.view] case .customView: nil case .buttonLayout(let info): [info.properties.view] case .basicToggleLayout(let info): [info.properties.view] case .checkboxToggleLayout(let info): [info.properties.view] case .radioInputToggleLayout(let info): [info.properties.view] case .iconView: nil case .scoreController(let info): [info.properties.view] case .scoreToggleLayout(let info): [info.properties.view] case .videoController(let info): [info.properties.view] } } } extension AirshipLayout { static let minLayoutVersion = 1 static let maxLayoutVersion = 2 public func validate() -> Bool { guard self.version >= Self.minLayoutVersion && self.version <= Self.maxLayoutVersion else { return false } return true } func extract<T>(extractor: (ThomasViewInfo) -> T?) -> [T] { return self.view.extractDescendants(extractor: extractor) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipLocalizationUtils.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - NOTE: Internal use only :nodoc: public final class AirshipLocalizationUtils { private static func sanitizedLocalizedString( _ localizedString: String, withTable table: String?, primaryBundle: Bundle, secondaryBundle: Bundle, tertiaryBundle: Bundle? ) -> String? { var string: String? /// This "empty" string has a space in it, so as not to be treated as equivalent to nil by the NSBundle method let missing = " " string = NSLocalizedString( localizedString, tableName: table, bundle: primaryBundle, value: missing, comment: "" ) if string == nil || (string == missing) { string = NSLocalizedString( localizedString, tableName: table, bundle: secondaryBundle, value: missing, comment: "" ) } if string == nil || (string == missing) { string = NSLocalizedString( localizedString, tableName: table, bundle: tertiaryBundle!, value: missing, comment: "" ) } if string == nil || (string == missing) { return nil } return string } public static func localizedString( _ string: String, withTable table: String, moduleBundle: Bundle? ) -> String? { let mainBundle = Bundle.main let coreBundle = AirshipCoreResources.bundle return sanitizedLocalizedString( string, withTable: table, primaryBundle: mainBundle, secondaryBundle: coreBundle, tertiaryBundle: moduleBundle ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipLock.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: For internal use only. :nodoc: public final class AirshipLock: Sendable { private let _lock: NSRecursiveLock = NSRecursiveLock() public init() {} public func sync(closure: () -> Void) { self._lock.withLock { closure() } } public func sync<T>(closure: () -> T) -> T { return self._lock.withLock { return closure() } } public func lock() { self._lock.lock() } public func unlock() { self._lock.unlock() } } ================================================ FILE: Airship/AirshipCore/Source/AirshipMeteredUsage.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine /// NOTE: For internal use only. :nodoc: public protocol AirshipMeteredUsage: Sendable { func addEvent(_ event: AirshipMeteredUsageEvent) async throws } /// NOTE: For internal use only. :nodoc: final class DefaultAirshipMeteredUsage: AirshipMeteredUsage { private static let workID: String = "MeteredUsage.upload" private static let configKey: String = "MeteredUsage.config" private static let rateLimitID: String = "MeteredUsage.rateLimit" private static let defaultRateLimit: TimeInterval = 30.0 private static let defaultInitialDelay: TimeInterval = 15.0 private let config: RuntimeConfig private let dataStore: PreferenceDataStore private let channel: any AirshipChannel private let contact: any InternalAirshipContact private let client: any MeteredUsageAPIClientProtocol private let workManager: any AirshipWorkManagerProtocol private let store: MeteredUsageStore private let privacyManager: any AirshipPrivacyManager @MainActor convenience init( config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any AirshipChannel, contact: any InternalAirshipContact, privacyManager: any AirshipPrivacyManager ) { self.init( config: config, dataStore: dataStore, channel: channel, contact: contact, privacyManager: privacyManager, client: MeteredUsageAPIClient(config: config), store: MeteredUsageStore(appKey: config.appCredentials.appKey) ) } @MainActor init( config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any AirshipChannel, contact: any InternalAirshipContact, privacyManager: any AirshipPrivacyManager, client: any MeteredUsageAPIClientProtocol, store: MeteredUsageStore, workManager: any AirshipWorkManagerProtocol = AirshipWorkManager.shared, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared ) { self.config = config self.dataStore = dataStore self.channel = channel self.contact = contact self.privacyManager = privacyManager self.client = client self.store = store self.workManager = workManager self.workManager.registerWorker( Self.workID ) { [weak self] _ in guard let self else { return .success } return try await self.performWork() } self.workManager.autoDispatchWorkRequestOnBackground( AirshipWorkRequest( workID: Self.workID, requiresNetwork: true, conflictPolicy: .replace ) ) self.config.addRemoteConfigListener { [weak self] old, new in self?.updateConfig( old: old?.meteredUsageConfig, new: new.meteredUsageConfig ) } } @MainActor private func updateConfig(old: RemoteConfig.MeteredUsageConfig?, new: RemoteConfig.MeteredUsageConfig?) { self.workManager.setRateLimit( Self.rateLimitID, rate: 1, timeInterval: new?.interval ?? Self.defaultRateLimit ) if old?.isEnabled != true && new?.isEnabled == true { self.scheduleWork( initialDelay: new?.intialDelay ?? Self.defaultInitialDelay ) } } private func performWork() async throws -> AirshipWorkResult { guard self.isEnabled else { return .success } var events = try await self.store.getEvents() guard events.count != 0 else { return .success } var channelID: String? = nil if (privacyManager.isEnabled(.analytics)) { channelID = self.channel.identifier } else { events = events.map( { $0.withDisabledAnalytics() }) } let result = try await self.client.uploadEvents( events, channelID: channelID ) guard result.isSuccess else { return .failure } try await self.store.deleteEvents(events) return .success } func addEvent(_ event: AirshipMeteredUsageEvent) async throws { guard self.isEnabled else { return } var eventToStore = event if (privacyManager.isEnabled(.analytics)) { if eventToStore.contactID == nil { eventToStore.contactID = await contact.contactID } } else { eventToStore = event.withDisabledAnalytics() } try await self.store.saveEvent(eventToStore) scheduleWork() } func scheduleWork(initialDelay: TimeInterval = 0.0) { guard self.isEnabled else { return } self.workManager.dispatchWorkRequest( AirshipWorkRequest( workID: Self.workID, initialDelay: initialDelay, requiresNetwork: true, conflictPolicy: .keepIfNotStarted ) ) } private var isEnabled: Bool { return self.config.remoteConfig.meteredUsageConfig?.isEnabled ?? false } } ================================================ FILE: Airship/AirshipCore/Source/AirshipMeteredUsageEvent.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /** * Internal only * :nodoc: */ public enum AirshipMeteredUsageType: String, Codable, Sendable { case inAppExperienceImpression = "iax_impression" } /** * Internal only * :nodoc: */ public struct AirshipMeteredUsageEvent: Codable, Sendable, Equatable { var eventID: String var entityID: String? var usageType: AirshipMeteredUsageType var product: String var reportingContext: AirshipJSON? var timestamp: Date? var contactID: String? public init( eventID: String, entityID: String?, usageType: AirshipMeteredUsageType, product: String, reportingContext: AirshipJSON?, timestamp: Date?, contactID: String? ) { self.eventID = eventID self.entityID = entityID self.usageType = usageType self.product = product self.reportingContext = reportingContext self.timestamp = timestamp self.contactID = contactID } enum CodingKeys: String, CodingKey { case eventID = "event_id" case usageType = "usage_type" case product case reportingContext = "reporting_context" case timestamp = "occurred" case entityID = "entity_id" case contactID = "contact_id" } func withDisabledAnalytics() -> AirshipMeteredUsageEvent { return AirshipMeteredUsageEvent( eventID: eventID, entityID: nil, usageType: usageType, product: product, reportingContext: nil, timestamp: nil, contactID: nil ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipNativePlatform.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI #if canImport(UIKit) public import UIKit public typealias AirshipNativeFont = UIFont public typealias AirshipNativeColor = UIColor #if !os(watchOS) public typealias AirshipNativeViewController = UIViewController public typealias AirshipNativeHostingController = UIHostingController public typealias AirshipNativeViewRepresentable = UIViewRepresentable #endif #elseif canImport(AppKit) public import AppKit public typealias AirshipNativeFont = NSFont public typealias AirshipNativeColor = NSColor public typealias AirshipNativeViewController = NSViewController public typealias AirshipNativeHostingController = NSHostingController public typealias AirshipNativeViewRepresentable = NSViewRepresentable #endif ================================================ FILE: Airship/AirshipCore/Source/AirshipNetworkChecker.swift ================================================ /* Copyright Airship and Contributors */ import Network /// - Note: For internal use only. :nodoc: public protocol AirshipNetworkCheckerProtocol: Sendable { @MainActor var isConnected: Bool { get } @MainActor var connectionUpdates: AsyncStream<Bool> { get } } #if os(watchOS) import WatchConnectivity /// - Note: For internal use only. :nodoc: public final class AirshipNetworkChecker: AirshipNetworkCheckerProtocol, Sendable { private let _isConnected: AirshipMainActorValue<Bool> @MainActor public var connectionUpdates: AsyncStream<Bool> { _isConnected.updates } @MainActor public var isConnected: Bool { return _isConnected.value } public init() { self._isConnected = AirshipMainActorValue(true) } } #else /// - Note: For internal use only. :nodoc: public final class AirshipNetworkChecker: AirshipNetworkCheckerProtocol, Sendable { private let pathMonitor: NWPathMonitor private let _isConnected: AirshipMainActorValue<Bool> private let updateQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() @MainActor public var connectionUpdates: AsyncStream<Bool> { _isConnected.updates } public static let shared: AirshipNetworkChecker = AirshipNetworkChecker() @MainActor public var isConnected: Bool { return _isConnected.value } public init() { self._isConnected = AirshipMainActorValue( AirshipUtils.hasNetworkConnection() ) let monitor = NWPathMonitor() self.pathMonitor = monitor monitor.pathUpdateHandler = { [updateQueue, _isConnected] path in updateQueue.enqueue { let connected = path.status == .satisfied if await (_isConnected.value != connected) { await _isConnected.set(path.status == .satisfied) } } } monitor.start(queue: DispatchQueue.global(qos: .utility)) } } #endif ================================================ FILE: Airship/AirshipCore/Source/AirshipNotificationCenter.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: For internal use only. :nodoc: public struct AirshipNotificationCenter: Sendable { public static let shared: AirshipNotificationCenter = AirshipNotificationCenter() private let notificationCenter: NotificationCenter public init(notificationCenter: NotificationCenter = NotificationCenter.default) { self.notificationCenter = notificationCenter } public func post(name: NSNotification.Name, object: Any? = nil, userInfo: [AnyHashable: Any]? = nil){ self.notificationCenter.post( name: name, object: object, userInfo: userInfo ) } @discardableResult public func addObserver( forName: NSNotification.Name, object: (any Sendable)? = nil, queue: OperationQueue? = nil, using: @Sendable @escaping (Notification) -> Void ) -> AnyObject { return self.notificationCenter.addObserver( forName: forName, object: object, queue: queue, using: using ) } public func postOnMain(name: NSNotification.Name, object: (any Sendable)? = nil, userInfo: [AnyHashable: Any]? = nil){ let wrapped = try? AirshipJSON.wrap(userInfo) DefaultDispatcher.main.dispatchAsyncIfNecessary { self.post( name: name, object: object, userInfo: wrapped?.unWrap() as? [AnyHashable: Any] ) } } public func addObserver(_ observer: Any, selector: Selector, name: NSNotification.Name, object: Any? = nil) { notificationCenter.addObserver(observer, selector: selector, name: name, object: object) } public func removeObserver(_ observer: Any) { notificationCenter.removeObserver(observer) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipNotificationStatus.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Airship push notification status public struct AirshipNotificationStatus: Sendable, Equatable { /// If user notifications are enabled on AirshipPush. public let isUserNotificationsEnabled: Bool /// If notifications are either ephemeral or granted and has at least one authorized type. public let areNotificationsAllowed: Bool /// If the push feature is enabled on `AirshipPrivacyManager`. public let isPushPrivacyFeatureEnabled: Bool /// If a push token is generated. public let isPushTokenRegistered: Bool /// Display notification status public let displayNotificationStatus: AirshipPermissionStatus /// If isUserNotificationsEnabled, isPushPrivacyFeatureEnabled, and areNotificationsAllowed are all true.. public var isUserOptedIn: Bool { return isUserNotificationsEnabled && isPushPrivacyFeatureEnabled && areNotificationsAllowed && displayNotificationStatus == .granted } /// If isUserOptedIn and isPushTokenRegistered are both true. public var isOptedIn: Bool { isUserOptedIn && isPushTokenRegistered } } ================================================ FILE: Airship/AirshipCore/Source/AirshipPasteboard.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif @available(tvOS, unavailable) @available(watchOS, unavailable) protocol AirshipPasteboardProtocol: Sendable { func copy(value: String, expiry: TimeInterval) func copy(value: String) } @available(tvOS, unavailable) @available(watchOS, unavailable) struct DefaultAirshipPasteboard: AirshipPasteboardProtocol { func copy(value: String, expiry: TimeInterval) { #if os(macOS) // macOS pasteboard doesn't support expiration dates natively for simple strings self.copy(value: value) #else // iOS, visionOS let expirationDate = Date().advanced(by: expiry) UIPasteboard.general.setItems( [[UIPasteboard.typeAutomatic: value]], options: [ UIPasteboard.OptionsKey.expirationDate: expirationDate ] ) #endif } func copy(value: String) { #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.declareTypes([.string], owner: nil) pasteboard.setString(value, forType: .string) #else // iOS, visionOS UIPasteboard.general.string = value #endif } } ================================================ FILE: Airship/AirshipCore/Source/AirshipPrivacyManager.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// The privacy manager allow enabling/disabling features in the SDK. /// The SDK will not make any network requests or collect data if all features are disabled, with /// a few exceptions when going from enabled -> disabled. To have the SDK opt-out of all features on startup, /// set the default enabled features in the Config to an empty option set, or in the /// airshipconfig.plist file with `enabledFeatures = none`. /// If any feature is enabled, the SDK will collect and send the following data: /// - Channel ID /// - Contact ID /// - Locale /// - TimeZone /// - Platform /// - Opt in state (push and notifications) /// - SDK version public protocol AirshipPrivacyManager: AnyObject, Sendable { /// The current set of enabled features. var enabledFeatures: AirshipFeature { get set } /// Enables features. /// This will append any features to the `enabledFeatures` property. /// - Parameter features: The features to enable. func enableFeatures(_ features: AirshipFeature) /// Disables features. /// This will remove any features to the `enabledFeatures` property. /// - Parameter features: The features to disable. func disableFeatures(_ features: AirshipFeature) /// Checks if a given feature is enabled. /// /// - Parameter feature: The features to check. /// - Returns: True if the provided features are enabled, otherwise false. func isEnabled(_ feature: AirshipFeature) -> Bool /// Checks if any feature is enabled. /// - Returns: `true` if a feature is enabled, otherwise `false`. func isAnyFeatureEnabled() -> Bool } protocol InternalAirshipPrivacyManager: AirshipPrivacyManager { /// Checks if any feature is enabled. /// - Parameters: /// - ignoringRemoteConfig: true to ignore any remotely disable features, false to include them. /// - Returns: `true` if a feature is enabled, otherwise `false`. /// * - Note: For internal use only. :nodoc: func isAnyFeatureEnabled(ignoringRemoteConfig: Bool) -> Bool } final class DefaultAirshipPrivacyManager: InternalAirshipPrivacyManager { private static let enabledFeaturesKey: String = "com.urbanairship.privacymanager.enabledfeatures" private let legacyIAAEnableFlag: String = "UAInAppMessageManagerEnabled" private let legacyChatEnableFlag: String = "AirshipChat.enabled" private let legacyLocationEnableFlag: String = "UALocationUpdatesEnabled" private let legacyAnalyticsEnableFlag: String = "UAAnalyticsEnabled" private let legacyPushTokenRegistrationEnableFlag: String = "UAPushTokenRegistrationEnabled" private let legacyDataCollectionEnableEnableFlag: String = "com.urbanairship.data_collection_enabled" private let dataStore: PreferenceDataStore private let config: RuntimeConfig private let defaultEnabledFeatures: AirshipFeature private let notificationCenter: AirshipNotificationCenter private let lock: AirshipLock = AirshipLock() private let lastUpdated: AirshipAtomicValue<AirshipFeature> = AirshipAtomicValue<AirshipFeature>([]) private var localEnabledFeatures: AirshipFeature { get { guard let fromStore = self.dataStore.unsignedInteger(forKey: DefaultAirshipPrivacyManager.enabledFeaturesKey) else { return self.defaultEnabledFeatures } return AirshipFeature( rawValue:(fromStore & AirshipFeature.all.rawValue) ) } set { self.dataStore.setValue( newValue.rawValue, forKey: DefaultAirshipPrivacyManager.enabledFeaturesKey ) } } public var enabledFeatures: AirshipFeature { get { self.localEnabledFeatures.subtracting(self.config.remoteConfig.disabledFeatures ?? []) } set { lock.sync { self.localEnabledFeatures = newValue notifyUpdate() } } } @MainActor init( dataStore: PreferenceDataStore, config: RuntimeConfig, defaultEnabledFeatures: AirshipFeature, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared ) { self.dataStore = dataStore self.config = config self.defaultEnabledFeatures = defaultEnabledFeatures self.notificationCenter = notificationCenter if config.airshipConfig.resetEnabledFeatures { self.dataStore.removeObject(forKey: DefaultAirshipPrivacyManager.enabledFeaturesKey) } self.lastUpdated.value = self.enabledFeatures self.migrateData() self.config.addRemoteConfigListener { [weak self] _, _ in self?.notifyUpdate() } } func enableFeatures(_ features: AirshipFeature) { self.enabledFeatures.insert(features) } func disableFeatures(_ features: AirshipFeature) { self.enabledFeatures.remove(features) } func isEnabled(_ feature: AirshipFeature) -> Bool { guard feature == [] else { return (enabledFeatures.rawValue & feature.rawValue) == feature.rawValue } return enabledFeatures == [] } func isAnyFeatureEnabled() -> Bool { return isAnyFeatureEnabled(ignoringRemoteConfig: false) } func isAnyFeatureEnabled(ignoringRemoteConfig: Bool) -> Bool { if ignoringRemoteConfig { return localEnabledFeatures != [] } else { return enabledFeatures != [] } } func migrateData() { if dataStore.keyExists(legacyDataCollectionEnableEnableFlag) { if dataStore.bool(forKey: legacyDataCollectionEnableEnableFlag) { self.enabledFeatures = .all } else { self.enabledFeatures = [] } dataStore.removeObject(forKey: legacyDataCollectionEnableEnableFlag) } if dataStore.keyExists(legacyPushTokenRegistrationEnableFlag) { if !(dataStore.bool(forKey: legacyPushTokenRegistrationEnableFlag)) { self.disableFeatures(.push) } dataStore.removeObject( forKey: legacyPushTokenRegistrationEnableFlag ) } if dataStore.keyExists(legacyAnalyticsEnableFlag) { if !(dataStore.bool(forKey: legacyAnalyticsEnableFlag)) { self.disableFeatures(.analytics) } dataStore.removeObject(forKey: legacyAnalyticsEnableFlag) } if dataStore.keyExists(legacyIAAEnableFlag) { if !(dataStore.bool(forKey: legacyIAAEnableFlag)) { self.disableFeatures(.inAppAutomation) } dataStore.removeObject(forKey: legacyIAAEnableFlag) } if dataStore.keyExists(legacyChatEnableFlag) { dataStore.removeObject(forKey: legacyChatEnableFlag) } if dataStore.keyExists(legacyLocationEnableFlag) { dataStore.removeObject(forKey: legacyLocationEnableFlag) } } private func notifyUpdate() { lock.sync { let enabledFeatures = self.enabledFeatures guard enabledFeatures != lastUpdated.value else { return } self.lastUpdated.value = enabledFeatures self.notificationCenter.postOnMain( name: AirshipNotifications.PrivacyManagerUpdated.name ) } } } /// Airship Features. public struct AirshipFeature: OptionSet, Sendable, CustomStringConvertible { public let rawValue: UInt /// In-App automation public static let inAppAutomation: AirshipFeature = AirshipFeature(rawValue: 1 << 0) /// Message Center public static let messageCenter: AirshipFeature = AirshipFeature(rawValue: 1 << 1) /// Push public static let push: AirshipFeature = AirshipFeature(rawValue: 1 << 2) /// Analytics public static let analytics: AirshipFeature = AirshipFeature(rawValue: 1 << 4) /// Tags, attributes, and subscription lists public static let tagsAndAttributes: AirshipFeature = AirshipFeature(rawValue: 1 << 5) /// Contacts public static let contacts: AirshipFeature = AirshipFeature(rawValue: 1 << 6) /* Do not use: UAFeaturesLocation = (1 << 7) */ /// Feature flags public static let featureFlags: AirshipFeature = AirshipFeature(rawValue: 1 << 8) /// All features public static let all: AirshipFeature = [ inAppAutomation, messageCenter, push, analytics, tagsAndAttributes, contacts, featureFlags ] public init(rawValue: UInt) { self.rawValue = rawValue } public var description: String { var descriptions = [String]() if self.contains(.inAppAutomation) { descriptions.append("In-App Automation") } if self.contains(.messageCenter) { descriptions.append("Message Center") } if self.contains(.push) { descriptions.append("Push") } if self.contains(.analytics) { descriptions.append("Analytics") } if self.contains(.tagsAndAttributes) { descriptions.append("Tags and Attributes") } if self.contains(.contacts) { descriptions.append("Contacts") } if self.contains(.featureFlags) { descriptions.append("Feature flags") } // add prefix indicating that these are enabled features return "Enabled features: " + descriptions.joined(separator: ", ") } } extension AirshipFeature: Codable { static let nameMap: [String: AirshipFeature] = [ "push": .push, "contacts": .contacts, "message_center": .messageCenter, "analytics": .analytics, "tags_and_attributes": .tagsAndAttributes, "in_app_automation": .inAppAutomation, "feature_flags": .featureFlags, "all": .all, "none": [] ] var names: [String] { var names: [String] = [] if (self == .all) { return AirshipFeature.nameMap.keys.filter { key in key != "none" && key != "all" } } if (self == []) { return [] } AirshipFeature.nameMap.forEach { key, value in if (value != [] && value != .all) { if (self.contains(value)) { names.append(key) } } } return names } static func parse(_ names: [Any]) throws -> AirshipFeature { guard let names = names as? [String] else { throw AirshipErrors.error("Invalid feature \(names)") } var features: AirshipFeature = [] try names.forEach { name in guard let feature = AirshipFeature.nameMap[name.lowercased()] else { throw AirshipErrors.error("Invalid feature \(name)") } features.update(with: feature) } return features } public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(self.names) } public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let names: [String] = try? container.decode([String].self) { self = try AirshipFeature.parse(names) } else { throw AirshipErrors.error("Failed to parse features") } } } public extension AirshipNotifications { /// NSNotification info when enabled feature changed on PrivacyManager. final class PrivacyManagerUpdated { /// NSNotification name. public static let name: NSNotification.Name = NSNotification.Name( "com.urbanairship.privacymanager.enabledfeatures_changed" ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipProgressView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Progress view struct AirshipProgressView: View { @State var isVisible = false var body: some View { ProgressView() } } ================================================ FILE: Airship/AirshipCore/Source/AirshipPush.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import UserNotifications public import Combine #if canImport(WatchKit) public import WatchKit #endif #if canImport(UIKit) public import UIKit #endif /// Airship Push protocol. public protocol AirshipPush: AnyObject, Sendable { /// If set, this block will be called when APNs registration succeeds or fails. /// This will be called in place of the `RegistrationDelegate` delegates `apnsRegistrationSucceeded` /// and `apnsRegistrationFailedWithError` methods. @MainActor var onAPNSRegistrationFinished: (@MainActor @Sendable (APNSRegistrationResult) -> Void)? { get set } /// If set, this block will be called when the user notifications registration finishes. /// This will be called in place of the `RegistrationDelegate` delegates `notificationRegistrationFinished` method. @MainActor var onNotificationRegistrationFinished: (@MainActor @Sendable (NotificationRegistrationResult) -> Void)? { get set } /// If set, this block will be called when the notification authorization settings change. /// This will be called in place of the of the `RegistrationDelegate` delegates `notificationAuthorizedSettingsDidChange` method. @MainActor var onNotificationAuthorizedSettingsDidChange: (@MainActor @Sendable (AirshipAuthorizedNotificationSettings) -> Void)? { get set } /// Checks to see if push notifications are opted in. @MainActor var isPushNotificationsOptedIn: Bool { get } /// Enables/disables background remote notifications on this device through Airship. /// Defaults to `true`. @MainActor var backgroundPushNotificationsEnabled: Bool { get set } /// Enables/disables user notifications on this device through Airship. /// Defaults to `false`. Once set to `true`, the user will be prompted for remote notifications. var userPushNotificationsEnabled: Bool { get set } /// When enabled, if the user has ephemeral notification authorization the SDK will prompt the user for /// notifications. Defaults to `false`. var requestExplicitPermissionWhenEphemeral: Bool { get set } /// The device token for this device, as a hex string. @MainActor var deviceToken: String? { get } /// User Notification options this app will request from APNS. /// /// Defaults to alert, sound and badge. var notificationOptions: UNAuthorizationOptions { get set } #if !os(tvOS) /// Custom notification categories. Airship default notification /// categories will be unaffected by this field. /// /// Changes to this value will not take effect until the next time the app registers /// with updateRegistration. @MainActor var customCategories: Set<UNNotificationCategory> { get set } /// The combined set of notification categories from `customCategories` set by the app /// and the Airship provided categories. @MainActor var combinedCategories: Set<UNNotificationCategory> { get } #endif /// Sets authorization required for the default Airship categories. Only applies /// to background user notification actions. @MainActor var requireAuthorizationForDefaultCategories: Bool { get set } /// Set a delegate that implements the PushNotificationDelegate protocol. @MainActor var pushNotificationDelegate: (any PushNotificationDelegate)? { get set } /// Set a delegate that implements the RegistrationDelegate protocol. @MainActor var registrationDelegate: (any RegistrationDelegate)? { get set } #if !os(tvOS) /// Notification response that launched the application. var launchNotificationResponse: UNNotificationResponse? { get } #endif /// The current authorized notification settings. /// If push is disabled in privacy manager, this value could be out of date. /// /// Note: this value reflects all the notification settings currently enabled in the /// Settings app and does not take into account which options were originally requested. var authorizedNotificationSettings: AirshipAuthorizedNotificationSettings { get } /// The current authorization status. /// If push is disabled in privacy manager, this value could be out of date. var authorizationStatus: UNAuthorizationStatus { get } /// Indicates whether the user has been prompted for notifications or not. /// If push is disabled in privacy manager, this value will be out of date. var userPromptedForNotifications: Bool { get } /// The default presentation options to use for foreground notifications. var defaultPresentationOptions: UNNotificationPresentationOptions { get set } /// Enables user notifications on this device through Airship. /// /// - Note: The completion handler will return the success state of system push authorization as it is defined by the /// user's response to the push authorization prompt. The completion handler success state does NOT represent the /// state of the userPushNotificationsEnabled flag, which will be invariably set to `true` after the completion of this call. /// /// - Parameter completionHandler: The completion handler with success flag representing the system authorization state. func enableUserPushNotifications() async -> Bool #if !os(watchOS) /// The current badge number used by the device and on the Airship server. /// /// - Note: This property must be accessed on the main thread and must be set asynchronously using setBadgeNumber. @MainActor var badgeNumber: Int { get } /// The current badge number used by the device and on the Airship server. func setBadgeNumber(_ newBadgeNumber: Int) async throws /// Resets the badge to zero (0) on both the device and on Airships servers. This is a /// convenience method for setting the `badgeNumber` property to zero. func resetBadge() async throws /// Toggle the Airship auto-badge feature. Defaults to `false` If enabled, this will update the /// badge number stored by Airship every time the app is started or foregrounded. var autobadgeEnabled: Bool { get set } #endif /// Time Zone for quiet time. If the time zone is not set, the current /// local time zone is returned. var timeZone: NSTimeZone? { get set } /// Enables/Disables quiet time var quietTimeEnabled: Bool { get set } /// Sets the quiet time start and end time. The start and end time does not change /// if the time zone changes. To set the time zone, see 'timeZone'. /// /// Update the server after making changes to the quiet time with the /// `updateRegistration` call. Batching these calls improves API and client performance. /// /// - Warning: This method does not automatically enable quiet time and does not /// automatically update the server. Please refer to `quietTimeEnabled` and /// `updateRegistration` for more information. /// /// - Parameters: /// - startHour: Quiet time start hour. Only 0-23 is valid. /// - startMinute: Quiet time start minute. Only 0-59 is valid. /// - endHour: Quiet time end hour. Only 0-23 is valid. /// - endMinute: Quiet time end minute. Only 0-59 is valid. func setQuietTimeStartHour( _ startHour: Int, startMinute: Int, endHour: Int, endMinute: Int ) /// Quiet time settings. Setting this value only sets the start/end time for quiet time. It still needs to be /// enabled with `quietTimeEnabled`. The timzone can be set with `timeZone`. var quietTime: QuietTimeSettings? { get set } /// Notification status updates @MainActor var notificationStatusPublisher: AnyPublisher<AirshipNotificationStatus, Never> { get } /// Notification status updates var notificationStatusUpdates: AsyncStream<AirshipNotificationStatus> { get async } /// Gets the current notification status var notificationStatus: AirshipNotificationStatus { get async } /// Enables user notifications on this device through Airship. /// /// - Note: The result of this method does NOT represent the state of the userPushNotificationsEnabled flag, /// which will be invariably set to `true` after the completion of this call. /// /// - Parameters: /// - fallback: The prompt permission fallback if the display notifications permission is already denied. /// /// - Returns: `true` if user notifications are enabled at the system level, otherwise`false`. @discardableResult func enableUserPushNotifications(fallback: PromptPermissionFallback) async -> Bool // MARK: Block-based notification callbacks /// Callback to be called when a notification is received in the foreground. /// This callback takes precedence over the delegate method if both are set. @MainActor var onForegroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> Void)? { get set } #if os(watchOS) /// Callback to be called when a notification is received in the background. /// This callback takes precedence over the delegate method if both are set. @MainActor var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> WKBackgroundFetchResult)? { get set } #elseif os(macOS) /// Callback to be called when a notification is received in the background. /// This callback takes precedence over the delegate method if both are set. @MainActor var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> Void)? { get set } #else /// Callback to be called when a notification is received in the background. /// This callback takes precedence over the delegate method if both are set. @MainActor var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> UIBackgroundFetchResult)? { get set } #endif #if !os(tvOS) /// Callback to be called when a notification response is received. /// This callback takes precedence over the delegate method if both are set. @MainActor var onNotificationResponseReceived: (@MainActor @Sendable (UNNotificationResponse) async -> Void)? { get set } #endif /// Callback to extend presentation options for foreground notifications. /// This callback takes precedence over the delegate method if both are set. @MainActor var onExtendPresentationOptions: (@MainActor @Sendable (UNNotificationPresentationOptions, UNNotification) async -> UNNotificationPresentationOptions)? { get set } } protocol InternalAirshipPush: Sendable { @MainActor var deviceToken: String? { get } func dispatchUpdateAuthorizedNotificationTypes() @MainActor func didRegisterForRemoteNotifications(_ deviceToken: Data) @MainActor func didFailToRegisterForRemoteNotifications(_ error: any Error) @MainActor func didReceiveRemoteNotification( _ notification: [AnyHashable: Any], isForeground: Bool ) async -> UABackgroundFetchResult @MainActor func presentationOptionsForNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions #if !os(tvOS) @MainActor func didReceiveNotificationResponse(_ response: UNNotificationResponse) async @MainActor var combinedCategories: Set<UNNotificationCategory> { get } #endif } ================================================ FILE: Airship/AirshipCore/Source/AirshipPushableComponent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) import UIKit #endif #if canImport(WatchKit) import WatchKit #endif public import UserNotifications /// Airship wrapper for background fetch results to provide platform-agnostic handling. public enum UABackgroundFetchResult : Sendable { case newData case noData case failed /// Merges two fetch results. /// Logic: .newData always wins. .failed wins over .noData to ensure the system /// knows an error occurred even if other components had no data. public func merge(_ other: UABackgroundFetchResult) -> UABackgroundFetchResult { switch (self, other) { case (.newData, _), (_, .newData): return .newData case (.failed, _), (_, .failed): return .failed default: return .noData } } /// Merges a collection of fetch results into a single result. public static func merged(_ results: [UABackgroundFetchResult]) -> UABackgroundFetchResult { return results.reduce(.noData) { $0.merge($1) } } #if !os(watchOS) && !os(macOS) var osFetchResult: UIBackgroundFetchResult { return switch(self) { case .newData: .newData case .noData: .noData case .failed: .failed } } init(from osResult: UIBackgroundFetchResult) { self = switch(osResult) { case .newData: .newData case .noData: .noData case .failed: .failed @unknown default: .noData } } #elseif os(watchOS) var osFetchResult: WKBackgroundFetchResult { return switch(self) { case .newData: .newData case .noData: .noData case .failed: .failed } } init(from osResult: WKBackgroundFetchResult) { self = switch(osResult) { case .newData: .newData case .noData: .noData case .failed: .failed @unknown default: .noData } } #endif } /// Internal protocol to fan out push handling to UAComponents. /// - Note: For internal use only. :nodoc: public protocol AirshipPushableComponent: Sendable { /** * Called when a remote notification is received. * - Parameters: * - notification: The notification. */ @MainActor func receivedRemoteNotification( _ notification: AirshipJSON // wrapped [AnyHashable: Any] ) async -> UABackgroundFetchResult #if !os(tvOS) /** * Called when a notification response is received. * - Parameters: * - response: The notification response. * - completionHandler: The completion handler that must be called after processing the response. */ @MainActor func receivedNotificationResponse(_ response: UNNotificationResponse) async #endif } ================================================ FILE: Airship/AirshipCore/Source/AirshipRequest.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Content encoding type for request body compression. /// - Note: For internal use only. :nodoc: public enum ContentEncoding: String, Sendable, Equatable { case deflate } /// AirshipRequest /// - Note: For internal use only. :nodoc: public struct AirshipRequest: Sendable { let url: URL? let headers: [String: String] let method: String? let auth: AirshipRequestAuth? let body: Data? let contentEncoding: ContentEncoding? public init( url: URL?, headers: [String: String] = [:], method: String? = nil, auth: AirshipRequestAuth? = nil, body: Data? = nil, contentEncoding: ContentEncoding? = nil ) { self.url = url self.headers = headers self.method = method self.auth = auth self.body = body self.contentEncoding = contentEncoding } public init<T: Encodable>( url: URL?, headers: [String: String] = [:], method: String? = nil, auth: AirshipRequestAuth? = nil, encodableBody: T, encoder: JSONEncoder = AirshipJSON.defaultEncoder, contentEncoding: ContentEncoding? = nil ) throws { self.url = url self.headers = headers self.method = method self.auth = auth self.body = try encoder.encode(encodableBody) self.contentEncoding = contentEncoding } } ================================================ FILE: Airship/AirshipCore/Source/AirshipRequestSession.swift ================================================ /* Copyright Airship and Contributors */ import CommonCrypto public import Foundation public protocol AirshipRequestSession: Sendable { /// Performs an HTTP request /// - Parameters: /// - request: The request /// - autoCancel: If the request should be cancelled if the task is cancelled. Defaults to false. /// - Returns: An AirshipHTTPResponse. func performHTTPRequest( _ request: AirshipRequest, autoCancel: Bool ) async throws -> AirshipHTTPResponse<Void> /// Performs an HTTP request /// - Parameters: /// - request: The request /// - autoCancel: If the request should be cancelled if the task is cancelled. Defaults to false. /// - Returns: An AirshipHTTPResponse. func performHTTPRequest( _ request: AirshipRequest ) async throws -> AirshipHTTPResponse<Void> /// Performs an HTTP request /// - Parameters: /// - request: The request /// - autoCancel: If the request should be cancelled if the task is cancelled. Defaults to false. /// - responseParser: A block that parses the response. /// - Returns: An AirshipHTTPResponse. func performHTTPRequest<T>( _ request: AirshipRequest, autoCancel: Bool, responseParser: (@Sendable (Data?, HTTPURLResponse) throws -> T?)? ) async throws -> AirshipHTTPResponse<T> /// Performs an HTTP request /// - Parameters: /// - request: The request /// - responseParser: A block that parses the response. /// - Returns: An AirshipHTTPResponse. func performHTTPRequest<T>( _ request: AirshipRequest, responseParser: (@Sendable (Data?, HTTPURLResponse) throws -> T?)? ) async throws -> AirshipHTTPResponse<T> } /// Airship request session. /// - Note: For internal use only. :nodoc: final class DefaultAirshipRequestSession: AirshipRequestSession, Sendable { private let session: any URLRequestSessionProtocol private let defaultHeaders: [String: String] private let appSecret: String private let appKey: String private let date: any AirshipDateProtocol private let nonceFactory: @Sendable () -> String private let authTasks: AirshipAtomicValue<[AirshipRequestAuth: Task<ResolvedAuth, any Error>]> = AirshipAtomicValue([AirshipRequestAuth: Task<ResolvedAuth, any Error>]()) @MainActor var channelAuthTokenProvider: (any AuthTokenProvider)? @MainActor var contactAuthTokenProvider: (any AuthTokenProvider)? private static let sharedURLSession: any URLRequestSessionProtocol = { let sessionConfig = URLSessionConfiguration.default sessionConfig.urlCache = nil sessionConfig.requestCachePolicy = .reloadIgnoringLocalCacheData sessionConfig.tlsMinimumSupportedProtocolVersion = .TLSv12 return URLSession( configuration: sessionConfig, delegate: ChallengeResolver.shared, delegateQueue: nil ) }() init( appKey: String, appSecret: String, session: any URLRequestSessionProtocol = DefaultAirshipRequestSession.sharedURLSession, date: any AirshipDateProtocol = AirshipDate.shared, nonceFactory: @Sendable @escaping () -> String = { return UUID().uuidString } ) { self.appKey = appKey self.appSecret = appSecret self.session = session self.date = date self.nonceFactory = nonceFactory self.defaultHeaders = [ "Accept-Encoding": "gzip;q=1.0, compress;q=0.5", "User-Agent": "(UALib \(AirshipVersion.version); \(appKey))", "X-UA-App-Key": appKey, ] } func performHTTPRequest( _ request: AirshipRequest ) async throws -> AirshipHTTPResponse<Void> { return try await self.performHTTPRequest( request, autoCancel: false, responseParser: nil ) } func performHTTPRequest( _ request: AirshipRequest, autoCancel: Bool ) async throws -> AirshipHTTPResponse<Void> { return try await self.performHTTPRequest( request, autoCancel: autoCancel, responseParser: nil ) } func performHTTPRequest<T>( _ request: AirshipRequest, responseParser: (@Sendable (Data?, HTTPURLResponse) throws -> T?)? ) async throws -> AirshipHTTPResponse<T> { return try await self.performHTTPRequest( request, autoCancel: false, responseParser: responseParser ) } func performHTTPRequest<T>( _ request: AirshipRequest, autoCancel: Bool = false, responseParser: (@Sendable (Data?, HTTPURLResponse) throws -> T?)? ) async throws -> AirshipHTTPResponse<T> { let result = try await self.doPerformHTTPRequest( request, autoCancel: autoCancel, responseParser: responseParser ) if (result.shouldRetry) { return try await self.doPerformHTTPRequest( request, autoCancel: autoCancel, responseParser: responseParser ).response } else { return result.response } } private func doPerformHTTPRequest<T>( _ request: AirshipRequest, autoCancel: Bool = false, responseParser: (@Sendable (Data?, HTTPURLResponse) throws -> T?)? ) async throws -> (shouldRetry: Bool, response: AirshipHTTPResponse<T>) { let cancellable = CancellableValueHolder<any AirshipCancellable>() { cancellable in cancellable.cancel() } guard let url = request.url else { throw AirshipErrors.error( "Attempted to perform request with a missing URL." ) } var urlRequest = URLRequest(url: url) urlRequest.httpShouldHandleCookies = false urlRequest.httpMethod = request.method ?? "" var headers = request.headers.merging(self.defaultHeaders) { (current, _) in current } let resolvedAuth = try await resolveAuth(requestAuth: request.auth) if let authHeaders = resolvedAuth?.headers { headers.merge(authHeaders) { (current, _) in current } } if let encoding = request.contentEncoding { if let compressed = request.body?.compress(encoding: encoding) { urlRequest.httpBody = compressed headers["Content-Encoding"] = encoding.rawValue } else { urlRequest.httpBody = request.body } } else { urlRequest.httpBody = request.body } for (k, v) in headers { urlRequest.setValue(v, forHTTPHeaderField: k) } let result: AirshipHTTPResponse<T> = try await withTaskCancellationHandler( operation: { return try await withCheckedThrowingContinuation { continuation in cancellable.value = self.session.dataTask(request: urlRequest) { (data, response, error) in if let error = error { continuation.resume(throwing: error) return } guard let response = response else { let error = AirshipErrors.error("Missing response") continuation.resume(throwing: error) return } guard let httpResponse = response as? HTTPURLResponse else { let error = AirshipErrors.error( "Unable to cast to HTTPURLResponse: \(response)" ) continuation.resume(throwing: error) return } do { let headers = DefaultAirshipRequestSession.parseHeaders(headers: httpResponse.allHeaderFields) let result = AirshipHTTPResponse( result: try responseParser?(data, httpResponse), statusCode: httpResponse.statusCode, headers: headers ) continuation.resume(with: .success(result)) } catch { continuation.resume(throwing: error) } } } }, onCancel: { if autoCancel { cancellable.cancel() } } ) if result.statusCode == 401, let onExpire = resolvedAuth?.onExpire { await onExpire() return (true, result) } return (false, result) } @MainActor private func resolveAuth( requestAuth: AirshipRequestAuth? ) async throws -> ResolvedAuth? { guard let requestAuth = requestAuth else { return nil } switch (requestAuth) { case .basic(let username, let password): let token = try DefaultAirshipRequestSession.basicAuthValue( username: username, password: password ) return ResolvedAuth( headers: [ "Authorization": "Basic \(token)" ] ) case .basicAppAuth: let token = try DefaultAirshipRequestSession.basicAuthValue( username: appKey, password: appSecret ) return ResolvedAuth( headers: [ "Authorization": "Basic \(token)" ] ) case .bearer(let token): return ResolvedAuth( headers: [ "Authorization": "Bearer \(token)" ] ) case .channelAuthToken(let identifier): return try await resolveTokenAuth( requestAuth: requestAuth, identifier: identifier, provider: self.channelAuthTokenProvider ) case .contactAuthToken(let identifier): return try await resolveTokenAuth( requestAuth: requestAuth, identifier: identifier, provider: self.contactAuthTokenProvider ) case .generatedChannelToken(let channelID): let nonce = self.nonceFactory() let timestamp = AirshipDateFormatter.string(fromDate: self.date.now, format: .iso) let token = try AirshipUtils.generateSignedToken( secret: self.appSecret, tokenParams: [ self.appKey, channelID, nonce, timestamp ] ) return ResolvedAuth( headers: [ "Authorization": "Bearer \(token)", "X-UA-Appkey": self.appKey, "X-UA-Channel-ID": channelID, "X-UA-Nonce": nonce, "X-UA-Timestamp": timestamp ] ) case .generatedAppToken: let nonce = self.nonceFactory() let timestamp = AirshipDateFormatter.string(fromDate: self.date.now, format: .iso) let token = try AirshipUtils.generateSignedToken( secret: self.appSecret, tokenParams: [ self.appKey, nonce, timestamp ] ) return ResolvedAuth( headers: [ "Authorization": "Bearer \(token)", "X-UA-Appkey": self.appKey, "X-UA-Nonce": nonce, "X-UA-Timestamp": timestamp ] ) } } private class func basicAuthValue(username: String, password: String) throws -> String { let credentials = "\(username):\(password)" guard let encodedCredentials = credentials.data(using: .utf8) else { throw AirshipErrors.error("Invalid basic auth for user \(username)") } return encodedCredentials.base64EncodedString(options: []) } @MainActor private func resolveTokenAuth( requestAuth: AirshipRequestAuth, identifier: String, provider: (any AuthTokenProvider)? ) async throws -> ResolvedAuth { guard let provider = provider else { throw AirshipErrors.error("Missing auth provider for auth \(requestAuth)") } if let existingTask = self.authTasks.value[requestAuth] { return try await existingTask.value } let task = Task { @MainActor in defer { self.authTasks.update { current in var mutable = current mutable[requestAuth] = nil return mutable } } let token = try await provider.resolveAuth( identifier: identifier ) return ResolvedAuth( headers: [ "Authorization": "Bearer \(token)", "X-UA-Appkey": self.appKey, "X-UA-Auth-Type": "SDK-JWT" ] ) { await provider.authTokenExpired(token: token) } } self.authTasks.update { current in var mutable = current mutable[requestAuth] = task return mutable } return try await task.value } private class func parseHeaders(headers: [AnyHashable: Any]) -> [String: String] { if let headers = headers as? [String : String] { return headers } return Dictionary( uniqueKeysWithValues: headers.compactMap { (key, value) in if let key = key as? String, let value = value as? String { return (key, value) } return nil } ) } } protocol AuthTokenProvider: Sendable { func resolveAuth(identifier: String) async throws -> String func authTokenExpired(token: String) async } /// - Note: For internal use only. :nodoc: public enum AirshipRequestAuth: Sendable, Equatable, Hashable { case basic(username: String, password: String) case bearer(token: String) case basicAppAuth case channelAuthToken(identifier: String) case contactAuthToken(identifier: String) case generatedChannelToken(identifier: String) case generatedAppToken } fileprivate struct ResolvedAuth: Sendable { let headers: [String: String] let onExpire: (@Sendable () async -> Void)? init(headers: [String: String], onExpire: (@Sendable () async -> Void)? = nil) { self.headers = headers self.onExpire = onExpire } } extension Data { fileprivate func compress(encoding: ContentEncoding) -> Data? { guard !self.isEmpty else { return nil } switch encoding { case .deflate: do { return try (self as NSData).compressed(using: .zlib) as Data } catch { return nil } } } } protocol URLRequestSessionProtocol: Sendable { @discardableResult func dataTask( request: URLRequest, completionHandler: @Sendable @escaping (Data?, URLResponse?, (any Error)?) -> Void ) -> any AirshipCancellable } extension URLSession: URLRequestSessionProtocol { @discardableResult func dataTask( request: URLRequest, completionHandler: @Sendable @escaping (Data?, URLResponse?, (any Error)?) -> Void ) -> any AirshipCancellable { let task = self.dataTask( with: request, completionHandler: completionHandler ) task.resume() return CancellableValueHolder(value: task) { task in task.cancel() } } } /** * URLSession with configured optional challenge resolver * @note For internal use only. :nodoc: */ public extension URLSession { static let airshipSecureSession: URLSession = .init( configuration: .default, delegate: ChallengeResolver.shared, delegateQueue: nil) } ================================================ FILE: Airship/AirshipCore/Source/AirshipResources.swift ================================================ /* Copyright Airship and Contributors */ import Foundation class AirshipResources { static let bundle: Bundle? = findBundle() /// Assumes AirshipResources class and UrbanAirship.string resource always exist in the same bundle private static func findBundle() -> Bundle? { return Bundle(for: AirshipResources.self) } public static func localizedString(key: String) -> String? { return AirshipLocalizationUtils.localizedString( key, withTable: "UrbanAirship", moduleBundle: bundle ) } } /** * @note For internal use only. :nodoc: */ extension String { public func airshipLocalizedString(fallback: String) -> String { return AirshipResources.localizedString(key: self) ?? fallback } } ================================================ FILE: Airship/AirshipCore/Source/AirshipResponse.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// AirshipHTTPResponse when using AirshipRequestSession /// - Note: For internal use only. :nodoc: public struct AirshipHTTPResponse<T: Sendable>: Sendable { public let result: T? public let statusCode: Int public let headers: [String: String] } extension AirshipHTTPResponse { public var isSuccess: Bool { return self.statusCode >= 200 && self.statusCode <= 299 } public var isClientError: Bool { return self.statusCode >= 400 && self.statusCode <= 499 } public var isServerError: Bool { return self.statusCode >= 500 && self.statusCode <= 599 } } extension AirshipHTTPResponse: Equatable where T: Equatable {} extension AirshipHTTPResponse { func map<R>(onMap: (AirshipHTTPResponse<T>) throws -> R?) throws -> AirshipHTTPResponse<R> { return AirshipHTTPResponse<R>( result: try onMap(self), statusCode: self.statusCode, headers: self.headers ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipSDKExtension.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Allowed SDK extension types. /// - Note: For internal use only. :nodoc: public enum AirshipSDKExtension: Int { /// The Cordova SDK extension. case cordova = 0 /// The Xamarin SDK extension. case xamarin = 1 /// The Unity SDK extension. case unity = 2 /// The Flutter SDK extension. case flutter = 3 /// The React Native SDK extension. case reactNative = 4 /// The Titanium SDK extension. case titanium = 5 /// The Capacitor SDK extension. case capacitor = 6 } extension AirshipSDKExtension { var name: String { switch self { case .cordova: return "cordova" case .xamarin: return "xamarin" case .unity: return "unity" case .flutter: return "flutter" case .reactNative: return "react-native" case .titanium: return "titanium" case .capacitor: return "capacitor" } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipSDKModule.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public protocol AirshipSDKModule: NSObject { var actionsManifest: (any ActionsManifest)? { get } var components: [any AirshipComponent] { get } @MainActor static func load(_ args: AirshiopModuleLoaderArgs) -> (any AirshipSDKModule)? } ================================================ FILE: Airship/AirshipCore/Source/AirshipSceneController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import Combine /// Exposes Scene controls to a custom view. /// /// This class is an `ObservableObject` that can be used to control navigation flow, /// such as moving forward and backward, and locking the navigation. It is designed /// to be used with SwiftUI and must be accessed on the main actor. @MainActor public class AirshipSceneController: ObservableObject { /// Dismisses the current scene. /// /// - Parameter cancelFutureDisplays: A Boolean value that, if `true`, /// should cancel any scheduled or future displays related to this scene. public func dismiss(cancelFutureDisplays: Bool = false) { environment?.dismiss(cancel: cancelFutureDisplays) } /// An enumeration representing a navigation request. public enum NaviagationRequest { /// A request to navigate to the next scene. case next /// A request to navigate to the previous scene. case back } private let environment: ThomasEnvironment? /// Exposes pager state and allows to dispatch navigation requests public let pager: PagerController init(pagerState: PagerState?, environment: ThomasEnvironment?) { self.pager = PagerController(pagerState: pagerState) self.environment = environment } public convenience init() { self.init(pagerState: nil, environment: nil) } @MainActor public class PagerController: ObservableObject { private let pagerState: PagerState? init(pagerState: PagerState?) { self.pagerState = pagerState } public convenience init() { self.init(pagerState: nil) } /// A Boolean value that indicates whether it is possible to navigate back. /// /// This property is published and read-only from outside the class. Observers /// can use this to update UI elements, such as disabling a "Back" button. public var canGoBack: Bool { return pagerState?.canGoBack ?? false } /// A Boolean value that indicates whether it is possible to navigate forward. /// /// This property is published and read-only from outside the class. Observers /// can use this to update UI elements, such as disabling a "Next" button. public var canGoNext: Bool { return pagerState?.canGoForward ?? false } /// Attempts to navigate based on the specified request. /// /// - Parameter request: The navigation request, either `.next` or `.back`. /// - Returns: A Boolean value indicating whether the navigation was successful. public func navigate(request: NaviagationRequest) -> Bool { switch(request) { case .back: return pagerState?.process(request: .back) != nil case .next: return pagerState?.process(request: .next) != nil } } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipSceneManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) && !os(watchOS) public import UIKit #endif /// - Note: for internal use only. :nodoc: public protocol AirshipSceneManagerProtocol: Sendable { #if !os(watchOS) && !os(macOS) @MainActor var lastActiveScene: UIWindowScene { get throws } #endif } /** * Scene manager * Monitors scene connection and disconnection notifications and associated scenes to allow retrieving the latest scene. */ /// - Note: for internal use only. :nodoc: public final class AirshipSceneManager: AirshipSceneManagerProtocol, Sendable { public static let shared: AirshipSceneManager = AirshipSceneManager() #if !os(watchOS) && !os(macOS) private let scenes: AirshipAtomicValue<[UIWindowScene]> = AirshipAtomicValue([UIWindowScene]()) private let notificationCenter: AirshipNotificationCenter internal init(notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter(notificationCenter: .default)) { self.notificationCenter = notificationCenter self.observeSceneEvents() } /** * Called to get the latest connected window scene * * @return A window scene */ @MainActor public var lastActiveScene: UIWindowScene { get throws { let lastActiveMessageScene = scenes .value .filter { $0.activationState == .foregroundActive && $0.session.role == .windowApplication } .last guard let scene = lastActiveMessageScene else { return try Self.findWindowScene() } return scene } } // MARK: Notifications private func observeSceneEvents() { notificationCenter.addObserver( self, selector: #selector(sceneAdded), name: UIScene.willConnectNotification, object: nil ) notificationCenter.addObserver( self, selector: #selector(sceneRemoved), name: UIScene.didDisconnectNotification, object: nil ) } // MARK: Helpers @objc @MainActor private func sceneAdded(_ notification: Notification) { guard let scene = notification.object as? UIWindowScene else { AirshipLogger.debug("Unable to cast UIWindowScene from notification UIScene.willConnectNotification") return } scenes.update { current in var mutableScenes = current mutableScenes.append(scene) return mutableScenes } } @objc @MainActor private func sceneRemoved(_ notification: Notification) { guard let scene = notification.object as? UIWindowScene else { AirshipLogger.debug("Unable to cast UIWindowScene from notification UIScene.didDisconnectNotification") return } scenes.update { current in var mutableScenes = current mutableScenes.removeAll { $0 == scene } return mutableScenes } } @MainActor fileprivate class func findWindowScene() throws -> UIWindowScene { guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.isKind(of: UIWindowScene.self) }) as? UIWindowScene else { throw AirshipErrors.error("Unable to find a window!") } return scene } #endif } ================================================ FILE: Airship/AirshipCore/Source/AirshipSimpleLayoutView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI /// Simple layout class that converts airship layout into a swiftui view /// - Note: for internal use only. :nodoc: public struct AirshipSimpleLayoutView: View { private let placement: ThomasPresentationInfo.Embedded.Placement = .init( margin: nil, size: .init(width: .percent(100), height: .percent(100)), border: nil, backgroundColor: nil ) @ObservedObject private var viewModel: AirshipSimpleLayoutViewModel private let layout: AirshipLayout @State private var viewConstraints: ViewConstraints? /// - Parameter viewModel: Owns the layout environment and state. Create one per layout session and reuse it so state is preserved across view updates. public init(layout: AirshipLayout, viewModel: AirshipSimpleLayoutViewModel) { self.layout = layout self.viewModel = viewModel } public var body: some View { RootView( thomasEnvironment: viewModel.environment, layout: layout ) { orientation, windowSize in AdoptLayout( placement: placement, viewConstraints: $viewConstraints, embeddedSize: nil ) { if let constraints = viewConstraints { createView( constraints: constraints, placement: placement ) } else { Color.clear } } } } @MainActor private func createView( constraints: ViewConstraints, placement: ThomasPresentationInfo.Embedded.Placement ) -> some View { return ViewFactory .createView(layout.view, constraints: constraints) .thomasBackground( color: placement.backgroundColor, border: placement.border ) .margin(placement.margin) .constraints(constraints) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipSimpleLayoutViewModel.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import Combine /// View model that owns the Thomas layout environment and its state. /// Create one per layout session (e.g. per message) and pass it to ``AirshipSimpleLayoutView``. /// Same view model = preserved state across view updates; new view model = fresh state. /// /// - Note: For internal use only. :nodoc: @MainActor public final class AirshipSimpleLayoutViewModel: ObservableObject { let environment: ThomasEnvironment public init( delegate: any ThomasDelegate, timer: (any AirshipTimerProtocol)? = nil, extensions: ThomasExtensions? = nil, dismissHandle: ThomasDismissHandle? = nil, stateStorage: (any LayoutDataStorage)? = nil ) { let dataStore: (any ThomasStateStorage)? = if let storage = stateStorage { DefaultThomasStateStorage(store: storage) } else { nil } self.environment = ThomasEnvironment( delegate: delegate, extensions: extensions, timer: timer, stateStorage: dataStore, dismissHandle: dismissHandle ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipStateOverrides.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshipStateOverrides: Encodable, Equatable, Sendable { let appVersion: String let sdkVersion: String let notificationOptIn: Bool let localeLangauge: String? let localeCountry: String? enum CodingKeys: String, CodingKey { case appVersion = "app_version" case sdkVersion = "sdk_version" case notificationOptIn = "notification_opt_in" case localeLangauge = "locale_language" case localeCountry = "locale_country" } } ================================================ FILE: Airship/AirshipCore/Source/AirshipSwitchToggleStyle.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct AirshipSwitchToggleStyle: ToggleStyle { let info: ThomasToggleStyleInfo.Switch func makeBody(configuration: Self.Configuration) -> some View { Button(action: { configuration.isOn.toggle() }) {} .buttonStyle( AirshipSwitchButtonStyle(info: info, isOn: configuration.$isOn) ) } struct AirshipSwitchButtonStyle: ButtonStyle { let info: ThomasToggleStyleInfo.Switch var isOn: Binding<Bool> func makeBody(configuration: Self.Configuration) -> some View { ButtonView(configuration: configuration, info: info, isOn: isOn) } struct ButtonView: View { let configuration: ButtonStyle.Configuration let info: ThomasToggleStyleInfo.Switch var isOn: Binding<Bool> @Environment(\.isFocused) var isFocused @Environment(\.isEnabled) var isEnabled @Environment(\.colorScheme) var colorScheme static let trackWidth = 50.0 static let thumbDiameter = 30.0 static let thumbPadding = 1.5 static let pressedThumbStretch = 4.0 static let offSet = (trackWidth - thumbDiameter) / 2 static let pressedOffset = offSet - (pressedThumbStretch / 2) @ViewBuilder func createOverlay(isPressed: Bool) -> some View { if isPressed { Capsule() .fill(Color.white) .shadow(radius: 1, x: 0, y: 1) .frame(width: Self.thumbDiameter + Self.pressedThumbStretch) .padding(Self.thumbPadding) .offset(x: isOn.wrappedValue ? Self.pressedOffset : -Self.pressedOffset) } else { Circle() .fill(Color.white) .shadow(radius: 1, x: 0, y: 1) .padding(Self.thumbPadding) .offset(x: isOn.wrappedValue ? Self.offSet : -Self.offSet) } } var body: some View { let fill = self.isOn.wrappedValue ? self.info.colors.on.toColor(colorScheme) : self.info.colors.off.toColor(colorScheme) Capsule() .fill(fill) .frame(width: Self.trackWidth, height: Self.thumbDiameter) .overlay(createOverlay(isPressed: configuration.isPressed)) .animation(Animation.easeInOut(duration: 0.05), value: self.isOn.wrappedValue) .colorMultiply(isEnabled ? Color.white : ThomasConstants.disabledColor) .saturation(isEnabled ? 1.0 : 0.5) #if os(tvOS) .hoverEffect(.highlight, isEnabled: isFocused) #endif } } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipSwizzler.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import ObjectiveC fileprivate struct SwizzlerEntry { let swizzledClass: AnyClass let originalImplementation: IMP let selectorString: String } @MainActor internal class AirshipSwizzler { @objc fileprivate protocol ForwardingCheck { @objc func forwardingTarget(for aSelector: Selector!) -> Any? } private static var entryMap: [String: SwizzlerEntry] = [:] @discardableResult func swizzleInstance( _ instance: any NSObjectProtocol, selector: Selector, protocol: Protocol? = nil, implementation: IMP ) -> Bool { let clazz: AnyClass = classForSelector(selector, target: instance) return swizzleClass(clazz, selector: selector, protocol: `protocol`, implementation: implementation) } @discardableResult func swizzleClass( _ clazz: AnyClass, selector: Selector, protocol: Protocol? = nil, implementation: IMP ) -> Bool { let selectorString = NSStringFromSelector(selector) let key = "\(String(describing: clazz)).\(selectorString)" if Self.entryMap[key] != nil { return true } guard let method = class_getInstanceMethod(clazz, selector) else { if let proto = `protocol` { let desc = protocol_getMethodDescription(proto, selector, false, true) return class_addMethod(clazz, selector, implementation, desc.types) } return false } let typeEncoding = method_getTypeEncoding(method) if class_addMethod(clazz, selector, implementation, typeEncoding) { let original = method_getImplementation(method) Self.entryMap[key] = SwizzlerEntry(swizzledClass: clazz, originalImplementation: original, selectorString: selectorString) } else { let existing = method_setImplementation(method, implementation) if implementation != existing { Self.entryMap[key] = SwizzlerEntry(swizzledClass: clazz, originalImplementation: existing, selectorString: selectorString) } } return true } func originalImplementation(_ selector: Selector, forClass clazz: AnyClass) -> IMP? { let key = "\(String(describing: clazz)).\(NSStringFromSelector(selector))" return Self.entryMap[key]?.originalImplementation } private func classForSelector(_ selector: Selector, target: any NSObjectProtocol) -> AnyClass { if class_getInstanceMethod(type(of: target), selector) != nil { return type(of: target) } if target.responds(to: #selector(NSObject.forwardingTarget(for:))), let forwarder = target as? any ForwardingCheck, let forwardingTarget = forwarder.forwardingTarget(for: selector) as? any NSObjectProtocol { return classForSelector(selector, target: forwardingTarget) } return type(of: target) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipTaskSleeper.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public protocol AirshipTaskSleeper: Sendable { func sleep(timeInterval: TimeInterval) async throws } /// NOTE: For internal use only. :nodoc: public final class DefaultAirshipTaskSleeper: AirshipTaskSleeper { fileprivate static let shared: DefaultAirshipTaskSleeper = DefaultAirshipTaskSleeper() private static let maxDelayInterval: TimeInterval = 30 private let date: any AirshipDateProtocol private let onSleep: @Sendable (TimeInterval) async throws -> Void init( date: any AirshipDateProtocol = AirshipDate.shared, onSleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(nanoseconds: UInt64($0 * 1_000_000_000)) } ) { self.date = date self.onSleep = onSleep } public func sleep(timeInterval: TimeInterval) async throws { let start: Date = date.now /// Its unclear what clock is being used for Task.sleep(nanoseconds:) and we have had issues /// with really long delays not firing at the right period of time. This works around those issues by /// breaking long sleeps into chunks. var remaining = timeInterval - date.now.timeIntervalSince(start) while remaining > 0, !Task.isCancelled { let interval = min(remaining, Self.maxDelayInterval) try await onSleep(interval) remaining = timeInterval - date.now.timeIntervalSince(start) } } } /// NOTE: For internal use only. :nodoc: public extension AirshipTaskSleeper where Self == DefaultAirshipTaskSleeper { /// Default style static var shared: Self { return DefaultAirshipTaskSleeper.shared } } ================================================ FILE: Airship/AirshipCore/Source/AirshipTimeCriteria.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshipTimeCriteria: Codable, Sendable, Equatable { private let start: Int64? private let end: Int64? enum CodingKeys: String, CodingKey { case start = "start_timestamp" case end = "end_timestamp" } public init(start: Date? = nil, end: Date? = nil) { self.start = start?.millisecondsSince1970 self.end = end?.millisecondsSince1970 } } /// NOTE: For internal use only. :nodoc: public extension AirshipTimeCriteria { func isActive(date: Date) -> Bool { let currentMS = date.millisecondsSince1970 if let startMS = self.start, currentMS < startMS { return false } if let endMS = self.end, currentMS >= endMS { return false } return true } } ================================================ FILE: Airship/AirshipCore/Source/AirshipTimerProtocol.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: for internal use only. :nodoc: public protocol AirshipTimerProtocol: Sendable { @MainActor var time : TimeInterval { get } @MainActor func start() @MainActor func stop() } /// - Note: for internal use only. :nodoc: @MainActor final public class AirshipTimer: AirshipTimerProtocol { private var isStarted: Bool = false private var elapsedTime: TimeInterval = 0 private var startDate: Date? = nil private let date: any AirshipDateProtocol public init(date: any AirshipDateProtocol = AirshipDate.shared) { self.date = date } public func start() { guard !self.isStarted else { return } self.startDate = date.now self.isStarted = true } public func stop() { guard self.isStarted else { return } self.elapsedTime += currentSessionTime() self.startDate = nil self.isStarted = false } private func currentSessionTime() -> TimeInterval { guard let date = self.startDate else { return 0 } return self.date.now.timeIntervalSince(date) } public var time: TimeInterval { return self.elapsedTime + currentSessionTime() } } ================================================ FILE: Airship/AirshipCore/Source/AirshipToggle.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct AirshipToggle: View { private let info: ThomasViewInfo.Toggle private let constraints: ViewConstraints @Environment(\.pageIdentifier) private var pageID @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver @State private var isOn: Bool = false init(info: ThomasViewInfo.Toggle, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .toggle, thomasState: thomasState ) } var body: some View { createToggle() .constraints(self.constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() .onAppear { restoreFormState() updateValue(self.isOn) } } @ViewBuilder private func createToggle() -> some View { let binding = Binding<Bool>( get: { self.isOn }, set: { self.isOn = $0 self.updateValue($0) } ) Toggle(isOn: binding.animation()) {} .thomasToggleStyle( self.info.properties.style, constraints: self.constraints ) } private var attributes: [ThomasFormField.Attribute]? { guard let name = self.info.properties.attributeName, let value = self.info.properties.attributeValue else { return nil } return [ ThomasFormField.Attribute( attributeName: name, attributeValue: value ) ] } private func checkValid(_ isOn: Bool) -> Bool { return isOn || self.info.validation.isRequired != true } private func updateValue(_ isOn: Bool) { let formValue: ThomasFormField.Value = .toggle(isOn) let field: ThomasFormField = if checkValid(isOn) { ThomasFormField.validField( identifier: self.info.properties.identifier, input: formValue, result: .init( value: formValue, attributes: self.attributes ) ) } else { ThomasFormField.invalidField( identifier: self.info.properties.identifier, input: formValue ) } self.formDataCollector.updateField(field, pageID: pageID) } private func restoreFormState() { guard case .toggle(let value) = self.formState.field( identifier: self.info.properties.identifier )?.input else { self.updateValue(self.isOn) return } self.isOn = value } } ================================================ FILE: Airship/AirshipCore/Source/AirshipUnsafeSendableWrapper.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public final class AirshipUnsafeSendableWrapper<T>: @unchecked Sendable { public var value: T public init(_ value: T) { self.value = value } } ================================================ FILE: Airship/AirshipCore/Source/AirshipUtils.swift ================================================ /* Copyright Airship and Contributors */ import CommonCrypto import Foundation public import SwiftUI #if !os(watchOS) import SystemConfiguration #endif #if os(iOS) && !targetEnvironment(macCatalyst) import CoreTelephony #endif /// The `Utils` object provides an interface for utility methods. public final class AirshipUtils { // MARK: Device Utilities /// Get the device model name (e.g.,` iPhone3,1`). /// /// - Returns: The device model name. @available(*, deprecated, message: "This method is no longer supported and will be removed in a future SDK version.") public class func deviceModelName() -> String? { return AirshipDevice.modelIdentifier } /// Gets the short bundle version string. /// /// - Returns: A short bundle version string value. public class func bundleShortVersionString() -> String? { return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } #if !os(watchOS) /// Checks if the device has network connection. /// /// - Returns: The true if it has connection, false otherwise. public class func hasNetworkConnection() -> Bool { var zeroAddress = sockaddr_in() zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) zeroAddress.sin_family = sa_family_t(AF_INET) guard let reachability = withUnsafePointer( to: &zeroAddress, { $0.withMemoryRebound( to: sockaddr.self, capacity: MemoryLayout<sockaddr>.size ) { ptr in SCNetworkReachabilityCreateWithAddress(nil, ptr) } } ) else { return false } var flags = SCNetworkReachabilityFlags() let isSuccess = SCNetworkReachabilityGetFlags(reachability, &flags) return isSuccess && flags.contains(.reachable) } #endif /// Compares two version strings and determines their order. /// /// - Parameters: /// - fromVersion: The first version. /// - toVersion: The second version. /// - maxVersionParts: Max number of version parts to compare. Use 3 to only compare major.minor.patch /// /// - Returns: a `ComparisonResult`. public class func compareVersion( _ fromVersion: String, toVersion: String, maxVersionParts: Int? = nil ) -> ComparisonResult { if let maxVersionParts, maxVersionParts <= 0 { return .orderedSame } let fromParts = fromVersion.components(separatedBy: ".").map { ($0 as NSString).integerValue } let toParts = toVersion.components(separatedBy: ".").map { ($0 as NSString).integerValue } var i = 0 while fromParts.count > i || toParts.count > i { let from: Int = fromParts.count > i ? fromParts[i] : 0 let to: Int = toParts.count > i ? toParts[i] : 0 if from < to { return .orderedAscending } else if from > to { return .orderedDescending } i += 1 if let maxVersionParts, maxVersionParts <= i { break } } return .orderedSame } // MARK: UI Utilities #if !os(watchOS) && !os(macOS) /// Returns the main window for the app. /// /// This window will be positioned underneath any other windows added and removed at runtime, /// by classes such a `UIAlertView` or `UIActionSheet`. /// /// - Returns: The main window, or `nil` if the window cannot be found. @MainActor public class func mainWindow() throws -> UIWindow? { let scene = try AirshipSceneManager.shared.lastActiveScene let sharedApp: UIApplication = UIApplication.shared for window in scene.windows { if window.isKeyWindow { return window } } return sharedApp.delegate?.window ?? nil } /// Returns the main window for the given `UIWindowScene`. /// /// This window will be positioned underneath any other windows added and removed at runtime, /// by classes such a `UIAlertView` or `UIActionSheet`. /// /// - Parameter scene: The `UIWindowScene`. /// /// - Returns: The main window, or `nil` if the window cannot be found. @MainActor public class func mainWindow(scene: UIWindowScene) -> UIWindow? { for w in scene.windows { if !w.isHidden { return w } } return try? self.mainWindow() } #endif // MARK: Fetch Results #if !os(watchOS) && !os(macOS) /// Takes an array of fetch results and returns the merged result. /// /// - Parameter results: An `Array` of fetch results. /// /// - Returns: The merged fetch result. public class func mergeFetchResults( _ results: [UInt] ) -> UIBackgroundFetchResult { var mergedResult: UIBackgroundFetchResult = .noData for r in results { if r == UIBackgroundFetchResult.newData.rawValue { return .newData } else if r == UIBackgroundFetchResult.failed.rawValue { mergedResult = .failed } } return mergedResult } #endif #if os(watchOS) /// Takes an array of fetch results and returns the merged result. /// /// - Parameter results: An `Array` of fetch results. /// /// - Returns: The merged fetch result. public class func mergeFetchResults(_ results: [UInt]) -> WKBackgroundFetchResult { var mergedResult: WKBackgroundFetchResult = .noData for r in results { if r == WKBackgroundFetchResult.newData.rawValue { return .newData } else if r == WKBackgroundFetchResult.failed.rawValue { mergedResult = .failed } } return mergedResult } #endif // MARK: Notification Payload /// Determine if the notification payload is a silent push (no notification elements). /// /// - Parameter notification The notification payload. /// /// - Returns: `true` the notification is a silent push, `false` otherwise. public class func isSilentPush(_ notification: [AnyHashable: Any]) -> Bool { guard let apsDict = notification["aps"] as? [AnyHashable: Any] else { return true } if apsDict["badge"] != nil { return false } if let soundName = apsDict["sound"] as? String { if !soundName.isEmpty { return false } } if isAlertingPush(notification) { return false } return true } /// Determine if the notification payload is an alerting push. /// /// - Parameter notification The notification payload. /// /// - Returns: `true` the notification is an alerting push, `false` otherwise. public class func isAlertingPush(_ notification: [AnyHashable: Any]) -> Bool { guard let apsDict = notification["aps"] as? [AnyHashable: Any] else { return false } if let alert = apsDict["alert"] as? [AnyHashable: Any] { if (alert["body"] as? String)?.isEmpty == false { return true } if (alert["loc-key"] as? String)?.isEmpty == false { return true } } else if let alert = apsDict["alert"] as? String { if !alert.isEmpty { return true } } return false } // MARK: Device Tokens /// Takes an APNS-provided device token and returns the decoded Airship device token. /// /// - Parameter token: An APNS-provided device token. /// /// - Returns: The decoded Airship device token. public class func deviceTokenStringFromDeviceToken(_ token: Data) -> String { var tokenString = "" let bytes = [UInt8](token) for byte in bytes { tokenString = tokenString.appendingFormat("%02x", byte) } return tokenString.lowercased() } // MARK: SHA256 Utilities /// Generates a `SHA256` digest for the input string. /// /// - Parameter input: `String` for which to calculate SHA. /// - Returns: The `SHA256` digest as `NSData`. public class func sha256Digest(input: String) -> NSData { guard let dataIn = input.data(using: .utf8) as NSData? else { return NSData() } let digestLength = Int(CC_SHA256_DIGEST_LENGTH) var digest = [UInt8](repeating: 0, count: digestLength) CC_SHA256(dataIn.bytes, CC_LONG(dataIn.count), &digest) return NSData(bytes: digest, length: digestLength) } /// Generates a `SHA256` hash for the input string. /// /// - Parameter input: Input string for which to calculate SHA. /// /// - Returns: SHA256 digest as a hex string public class func sha256Hash(input: String) -> String { let digestLength = Int(CC_SHA256_DIGEST_LENGTH) let digest = sha256Digest(input: input) var buffer = [UInt8](repeating: 0, count: digestLength) digest.getBytes(&buffer, length: digestLength) return buffer.map { String(format: "%02x", $0) }.joined(separator: "") } // MARK: UAHTTP Authenticated Request Helpers /// Returns a basic auth header string. /// /// - Parameters: /// - username: The username. /// - password: The password. /// - Returns: An HTTP Basic Auth header string value for the provided credentials in the form of: `Basic [Base64 Encoded "username:password"]` public class func authHeader(username: String, password: String) -> String? { guard let data = "\(username):\(password)".data(using: .utf8) else { return nil } guard let encodedData = AirshipBase64.string(from: data) else { return nil } let authString = encodedData //strip carriage return and linefeed characters .replacingOccurrences(of: "\n", with: "") .replacingOccurrences(of: "\r", with: "") return "Basic \(authString)" } // MARK: URL /// Parse url for the input string. /// /// - Parameter value: Input string for which to create the URL. /// /// - Returns: returns the created URL otherwise return nil. public class func parseURL(_ value: String) -> URL? { if let url = URL(string: value) { return url } /* Characters reserved for url */ let reserved = "!*'();:@&=+$,/?%#[]" /* Characters are not reserved for url but should not be encoded */ let unreserved = ":-._~/? " let allowed = NSMutableCharacterSet.alphanumeric() allowed.addCharacters(in: reserved) allowed.addCharacters(in: unreserved) if let encoded = value.addingPercentEncoding( withAllowedCharacters: allowed as CharacterSet ) { return URL(string: encoded) } return nil } class func generateSignedToken(secret: String, tokenParams: [String]) throws -> String { let secret = NSData(data: Data(secret.utf8)) let message = NSData(data: Data(tokenParams.joined(separator: ":").utf8)) let hash = NSMutableData(length: Int(CC_SHA256_DIGEST_LENGTH)) guard let hash else { throw AirshipErrors.error("Failed to generate signed token") } CCHmac(CCHmacAlgorithm(kCCHmacAlgSHA256), secret.bytes, secret.count, message.bytes, message.count, hash.mutableBytes) return hash.base64EncodedString(options: []) } } extension Locale { func getLanguageCode() -> String { return self.language.languageCode?.identifier ?? "" } func getRegionCode() -> String { return self.region?.identifier ?? "" } func getVariantCode() -> String { return self.variant?.identifier ?? "" } } internal extension Int { func airshipLocalizedForVoiceOver() -> String { let formatter = NumberFormatter() formatter.numberStyle = .spellOut return formatter.string(from: NSNumber(value: self)) ?? String(self) } } internal extension Collection { subscript(safe index: Index) -> Element? { indices.contains(index) ? self[index] : nil } } ================================================ FILE: Airship/AirshipCore/Source/AirshipVersion.swift ================================================ // Copyright Airship and Contributors import Foundation public struct AirshipVersion { public static let version = "20.6.2" public static func get() -> String { return version } } ================================================ FILE: Airship/AirshipCore/Source/AirshipViewSizeReader.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI import Foundation /// A view that wraps the view and returns the size without causing the view to expand. public struct AirshipViewSizeReader<Content> : View where Content : View { @State private var viewSize: CGSize? private let contentBlock: (CGSize?) -> Content /// Default constructor /// - Parameters: /// - contentBlock: The content block that will have the view size if available and returns the actual content. public init(@ViewBuilder contentBlock: @escaping (CGSize?) -> Content) { self.contentBlock = contentBlock } public var body: some View { return contentBlock(viewSize).airshipMeasureView($viewSize) } } public extension View { /// Adds a geometry reader to the background to fetch the size without causing the view to grow. /// - Parameter binding: The binding to store the size. @ViewBuilder func airshipMeasureView(_ binding: Binding<CGSize?>) -> some View { self.background( GeometryReader { geo -> Color in DispatchQueue.main.async { binding.wrappedValue = geo.size } return Color.clear } ) } } ================================================ FILE: Airship/AirshipCore/Source/AirshipViewUtils.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI public import Combine /// 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 /// - Parameters: /// - value: The value to observe for changes. /// - initial: A Boolean value that determines whether the action should be fired initially. /// - action: The action to perform when the value changes. /// NOTE: For internal use only. :nodoc: @ViewBuilder func airshipOnChangeOf<Value: Equatable>(_ value: Value, initial: Bool = false, _ action: @escaping (Value) -> Void) -> some View { if #available(iOS 17.0, macOS 14.0, watchOS 10.0, tvOS 17.0, *) { self.onChange(of: value, initial: initial, { action(value) }) } else { self.onChange(of: value, perform: action) } } @ViewBuilder func showing(isShowing: Bool) -> some View { if isShowing { self.opacity(1) } else { self.hidden().opacity(0) } } @ViewBuilder func airshipAddNub( isTopPlacement: Bool, nub: AnyView, itemSpacing: CGFloat ) -> some View { VStack(spacing: 0) { if isTopPlacement { self nub.padding(.vertical, itemSpacing / 2) } else { nub.padding(.vertical, itemSpacing / 2) self } } } @ViewBuilder func airshipApplyTransition( isTopPlacement: Bool ) -> some View { if isTopPlacement { self.transition( .asymmetric( insertion: .move(edge: .top), removal: .move(edge: .top).combined(with: .opacity) ) ) } else { self.transition( .asymmetric( insertion: .move(edge: .bottom), removal: .move(edge: .bottom).combined(with: .opacity) ) ) } } @ViewBuilder func airshipFocusableCompat( _ isFocusable: Bool = true ) -> some View { if #available(iOS 17.0, *) { self.focusable(isFocusable) } else { self } } @ViewBuilder func airshipApplyTransitioningPlacement( isTopPlacement: Bool ) -> some View { if isTopPlacement { VStack { self.airshipApplyTransition(isTopPlacement:isTopPlacement) Spacer() } } else { VStack { Spacer() self.airshipApplyTransition(isTopPlacement:isTopPlacement) } } } } /// NOTE: For internal use only. :nodoc: @MainActor public final class AirshipObservableTimer: ObservableObject { private static let tick: TimeInterval = 0.1 private var elapsedTime: TimeInterval = 0 private let duration: TimeInterval? private var isStarted: Bool = false private var task: Task<Void, any Error>? public var isPaused: Bool = false @Published public private(set) var isExpired: Bool = false public init(duration: TimeInterval?) { self.duration = duration } public func onAppear() { guard !isStarted, !isExpired, let duration else { return } self.isStarted = true self.task = Task { @MainActor [weak self] in while self?.isExpired == false, self?.isStarted == true { try await Task.sleep(nanoseconds: UInt64(Self.tick * 1_000_000_000)) guard let self, self.isStarted, !Task.isCancelled else { return } if !self.isPaused { self.elapsedTime += Self.tick if self.elapsedTime >= duration { self.isExpired = true self.task?.cancel() } } } } } public func onDisappear() { isStarted = false task?.cancel() } } ================================================ FILE: Airship/AirshipCore/Source/AirshipWeakValueHolder.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public class AirshipWeakValueHolder<T: AnyObject> { public weak var value: T? public init(value: T? = nil) { self.value = value } } public class AirshipStrongValueHolder<T: AnyObject> { public var value: T? public init(value: T? = nil) { self.value = value } } ================================================ FILE: Airship/AirshipCore/Source/AirshipWebview.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Foundation import SwiftUI import WebKit /// Airship Webview struct AirshipWebView: View { let info: ThomasViewInfo.WebView let constraints: ViewConstraints @State var isWebViewLoading: Bool = false @EnvironmentObject var thomasEnvironment: ThomasEnvironment @Environment(\.layoutState) var layoutState var body: some View { ZStack { WebViewView( url: self.info.properties.url, nativeBridgeExtension: self.thomasEnvironment.extensions? .nativeBridgeExtension, isWebViewLoading: self.$isWebViewLoading, onRunActions: { name, value, _ in return await thomasEnvironment.runAction(name, arguments: value, layoutState: layoutState) }, onDismiss: { thomasEnvironment.dismiss(layoutState: layoutState) } ) .opacity(self.isWebViewLoading ? 0.0 : 1.0) if self.isWebViewLoading { AirshipProgressView() } } .constraints(constraints) .thomasCommon(self.info) } } /// Webview struct WebViewView: AirshipNativeViewRepresentable { #if os(macOS) typealias NSViewType = WKWebView #else typealias UIViewType = WKWebView #endif let url: String let nativeBridgeExtension: (any NativeBridgeExtensionDelegate)? @Binding var isWebViewLoading: Bool let onRunActions: @MainActor (String, ActionArguments, WKWebView) async -> ActionResult let onDismiss: () -> Void @EnvironmentObject var thomasEnvironment: ThomasEnvironment #if os(macOS) func makeNSView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateNSView(_ nsView: WKWebView, context: Context) { updateView(context: context) } static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) { coordinator.teardown() } #else func makeUIView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateUIView(_ uiView: WKWebView, context: Context) { updateView(context: context) } static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { coordinator.teardown() } #endif private func updateView(context: Context) { if thomasEnvironment.isDismissed { context.coordinator.teardown() } } func makeWebView(context: Context) -> WKWebView { let webView = WKWebView() webView.navigationDelegate = context.coordinator.nativeBridge context.coordinator.configure(webView: webView) if #available(iOS 16.4, *) { webView.isInspectable = Airship.isFlying && Airship.config.airshipConfig.isWebViewInspectionEnabled } if let url = URL(string: self.url) { updateLoading(true) webView.load(URLRequest(url: url)) } return webView } func makeCoordinator() -> Coordinator { Coordinator(self, actionRunner: BlockNativeBridgeActionRunner(onRun: onRunActions)) } func updateLoading(_ isWebViewLoading: Bool) { DispatchQueue.main.async { self.isWebViewLoading = isWebViewLoading } } class Coordinator: NSObject, AirshipWKNavigationDelegate, JavaScriptCommandDelegate, NativeBridgeDelegate { private let parent: WebViewView private let challengeResolver: ChallengeResolver private weak var webView: WKWebView? let nativeBridge: NativeBridge init(_ parent: WebViewView, actionRunner: any NativeBridgeActionRunner, resolver: ChallengeResolver = .shared) { self.parent = parent self.nativeBridge = NativeBridge(actionRunner: actionRunner) self.challengeResolver = resolver super.init() AirshipLogger.trace("WebViewView Coordinator init") self.nativeBridge.nativeBridgeExtensionDelegate = self.parent.nativeBridgeExtension self.nativeBridge.forwardNavigationDelegate = self self.nativeBridge.javaScriptCommandDelegate = self self.nativeBridge.nativeBridgeDelegate = self } deinit { AirshipLogger.trace("WebViewView Coordinator deinit") } func configure(webView: WKWebView) { self.webView = webView } @MainActor func teardown() { self.webView?.stopLoading() self.webView?.navigationDelegate = nil self.webView?.pauseAllMediaPlayback() #if !os(macOS) if #unavailable(iOS 26.3) { if self.webView?.superview != nil { self.webView?.removeFromSuperview() } } #endif self.webView = nil } func webView( _ webView: WKWebView, didFinish navigation: WKNavigation! ) { parent.updateLoading(false) } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { parent.updateLoading(true) DispatchQueue.main.async { webView.reload() } } func webView( _ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error ) { parent.updateLoading(true) DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) { [weak webView] in webView?.reload() } } func webView( _ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await challengeResolver.resolve(challenge) } func performCommand(_ command: JavaScriptCommand, webView: WKWebView) -> Bool { return false } nonisolated func close() { DispatchQueue.main.async { self.parent.onDismiss() } } } } #endif ================================================ FILE: Airship/AirshipCore/Source/AirshipWindowFactory.swift ================================================ /* Copyright Airship and Contributors */ #if !os(watchOS) #if canImport(AppKit) && !targetEnvironment(macCatalyst) public import AppKit #endif #if canImport(UIKit) public import UIKit #endif /// A singleton factory class to create and configure UIWindow objects. /// This allows apps to override behaviors like dark mode and other window-specific settings. @MainActor public final class AirshipWindowFactory: Sendable { /// The shared instance of `AirshipWindowFactory` for centralized window creation. public static let shared = AirshipWindowFactory() private init() {} #if os(macOS) && !targetEnvironment(macCatalyst) /// A closure that allows apps to customize window creation. public var makeBlock: (@MainActor @Sendable () -> NSWindow)? /// Creates a new NSWindow. public func makeWindow() -> NSWindow { return makeBlock?() ?? NSWindow( contentRect: .zero, styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false ) } #else /// A closure that allows apps to customize window creation. public var makeBlock: (@MainActor @Sendable (UIWindowScene) -> UIWindow)? /// Creates a new UIWindow for the given window scene. /// /// - Parameter windowScene: The `UIWindowScene` in which the new window will be created. public func makeWindow(windowScene: UIWindowScene) -> UIWindow { return makeBlock?(windowScene) ?? UIWindow(windowScene: windowScene) } #endif } #endif ================================================ FILE: Airship/AirshipCore/Source/AirshipWorkManager.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation /// Manages work for the Airship SDK final class AirshipWorkManager: AirshipWorkManagerProtocol, Sendable { private let rateLimiter: WorkRateLimiter = WorkRateLimiter() private let conditionsMonitor: WorkConditionsMonitor = WorkConditionsMonitor() private let backgroundTasks: WorkBackgroundTasks = WorkBackgroundTasks() private let workers: Workers = Workers() private let startTask: Task<Void, Never> private let backgroundWaitTask: AirshipMainActorValue<(any AirshipCancellable)?> = AirshipMainActorValue(nil) private let queue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() private let backgroundWorkRequests: AirshipMainActorValue<[AirshipWorkRequest]> = AirshipMainActorValue([]) /// Shared instance static let shared: AirshipWorkManager = AirshipWorkManager() deinit { startTask.cancel() } init() { self.startTask = Task { [workers = self.workers] in await workers.run() } NotificationCenter.default.addObserver( self, selector: #selector(applicationDidEnterBackground), name: AppStateTracker.didEnterBackgroundNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive), name: AppStateTracker.didBecomeActiveNotification, object: nil ) } @objc @preconcurrency @MainActor private func applicationDidEnterBackground() { backgroundWaitTask.value?.cancel() guard Airship.isFlying else { return } let cancellable: CancellableValueHolder<Task<Void, Never>> = CancellableValueHolder { task in task.cancel() } let background = try? self.backgroundTasks.beginTask("AirshipWorkManager") { [cancellable] in cancellable.cancel() } let isDynamicBackgroundWaitTimeEnabled = Airship.config.airshipConfig.isDynamicBackgroundWaitTimeEnabled let pending = backgroundWorkRequests.value cancellable.value = Task { [workers, pending] in await withTaskCancellationHandler { for request in pending { await workers.dispatchWorkRequest(request) } guard isDynamicBackgroundWaitTimeEnabled else { try? await Task.sleep(nanoseconds: UInt64(5.0 * 1_000_000_000)) background?.cancel() return } let sleep = await workers.calculateBackgroundWaitTime(maxTime: 15.0) try? await Task.sleep(nanoseconds: UInt64(max(sleep, 5.0) * 1_000_000_000)) background?.cancel() } onCancel: { background?.cancel() } } backgroundWaitTask.set(cancellable) } @objc @preconcurrency @MainActor private func applicationDidBecomeActive() { backgroundWaitTask.value?.cancel() } public func registerWorker( _ workID: String, workHandler: @Sendable @escaping (AirshipWorkRequest) async throws -> AirshipWorkResult ) { let worker = Worker( workID: workID, conditionsMonitor: conditionsMonitor, rateLimiter: rateLimiter, backgroundTasks: backgroundTasks, workHandler: workHandler ) queue.enqueue { [workers = self.workers] in await workers.addWorker(worker) } } public func setRateLimit( _ rateLimitID: String, rate: Int, timeInterval: TimeInterval ) { Task { [rateLimiter = self.rateLimiter] in try? await rateLimiter.set( rateLimitID, rate: rate, timeInterval: timeInterval ) } } public func dispatchWorkRequest(_ request: AirshipWorkRequest) { queue.enqueue { [workers = self.workers] in await workers.dispatchWorkRequest(request) } } @MainActor func autoDispatchWorkRequestOnBackground(_ request: AirshipWorkRequest) { backgroundWorkRequests.update { $0.append(request) } } } private actor Workers { private var workers: [Worker] = [] private let workerContinuation: AsyncStream<Worker>.Continuation private let workerStream: AsyncStream<Worker> private var isRunning: Bool = false init() { (self.workerStream, self.workerContinuation) = AsyncStream<Worker>.airshipMakeStreamWithContinuation() } deinit { workerContinuation.finish() } func addWorker(_ worker: Worker) { workers.append(worker) workerContinuation.yield(worker) } func calculateBackgroundWaitTime(maxTime: TimeInterval) async -> TimeInterval { let workersToProcess = self.workers guard !workersToProcess.isEmpty else { return 0.0 } let maxWaitTime = await withTaskGroup( of: TimeInterval.self, returning: TimeInterval.self ) { group in for worker in workersToProcess { group.addTask { return await worker.calculateBackgroundWaitTime(maxTime: maxTime) } } var currentMax: TimeInterval = 0.0 for await result in group { currentMax = max(currentMax, result) } return currentMax } return maxWaitTime } func dispatchWorkRequest(_ request: AirshipWorkRequest) async { for worker in workers where worker.workID == request.workID { await worker.addWork(request: request) } } func run() async { guard !isRunning else { return } isRunning = true defer { isRunning = false } await withTaskGroup(of: Void.self) { group in for await worker in self.workerStream { guard !Task.isCancelled else { break } group.addTask { await worker.run() } } } } } ================================================ FILE: Airship/AirshipCore/Source/AirshipWorkManagerProtocol.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public protocol AirshipWorkManagerProtocol: Sendable { func registerWorker( _ workID: String, workHandler: @Sendable @escaping (AirshipWorkRequest) async throws -> AirshipWorkResult ) func setRateLimit( _ limitID: String, rate: Int, timeInterval: TimeInterval ) func dispatchWorkRequest( _ request: AirshipWorkRequest ) @MainActor func autoDispatchWorkRequestOnBackground( _ request: AirshipWorkRequest ) } ================================================ FILE: Airship/AirshipCore/Source/AirshipWorkRequest.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public enum AirshipWorkRequestConflictPolicy: Sendable { case append case replace case keepIfNotStarted } /// NOTE: For internal use only. :nodoc: public struct AirshipWorkRequest: Equatable, Sendable, Hashable { public let workID: String public let extras: [String: String]? public let initialDelay: TimeInterval public let requiresNetwork: Bool public let rateLimitIDs: Set<String>? public let conflictPolicy: AirshipWorkRequestConflictPolicy public init( workID: String, extras: [String: String]? = nil, initialDelay: TimeInterval = 0.0, requiresNetwork: Bool = true, rateLimitIDs: Set<String>? = nil, conflictPolicy: AirshipWorkRequestConflictPolicy = .replace ) { self.workID = workID self.extras = extras self.initialDelay = initialDelay self.requiresNetwork = requiresNetwork self.rateLimitIDs = rateLimitIDs self.conflictPolicy = conflictPolicy } } ================================================ FILE: Airship/AirshipCore/Source/AirshipWorkResult.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public enum AirshipWorkResult: Sendable { case success case failure } ================================================ FILE: Airship/AirshipCore/Source/AirsihpTriggerContext.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshipTriggerContext: Codable, Sendable, Equatable { let type: String let goal: Double let event: AirshipJSON public init(type: String, goal: Double, event: AirshipJSON) { self.type = type self.goal = goal self.event = event } } ================================================ FILE: Airship/AirshipCore/Source/AnonContactData.swift ================================================ /* Copyright Airship and Contributors */ 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: [Channel] var subscriptionLists: [String: [ChannelScope]] var isEmpty: Bool { return self.attributes.isEmpty && self.tags.isEmpty && self.channels.isEmpty && self.subscriptionLists.isEmpty } init(tags: [String : [String]], attributes: [String : AirshipJSON], channels: [Channel], subscriptionLists: [String : [ChannelScope]]) { self.tags = tags self.attributes = attributes self.channels = channels self.subscriptionLists = subscriptionLists } init(from decoder: any 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([Channel].self, forKey: .channels) self.subscriptionLists = try container.decode([String : [ChannelScope]].self, forKey: .subscriptionLists) do { self.attributes = try container.decode([String : AirshipJSON].self, forKey: .attributes) } catch { let legacy = try? container.decode([String : JsonValue].self, forKey: .attributes) guard let legacy = legacy else { throw error } if let decoder = decoder as? JSONDecoder { self.attributes = try legacy.mapValues { try AirshipJSON.from( json: $0.jsonEncodedValue, decoder: decoder ) } } else { self.attributes = try legacy.mapValues { try AirshipJSON.from( json: $0.jsonEncodedValue ) } } } } // Migration purposes fileprivate struct JsonValue : Decodable { let jsonEncodedValue: String? } } ================================================ FILE: Airship/AirshipCore/Source/AppIntegration.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(UIKit) public import UIKit #endif #if canImport(WatchKit) public import WatchKit #endif #if canImport(AppKit) public import AppKit #endif import Foundation @preconcurrency public import UserNotifications /// Application hooks required by Airship. If `automaticSetupEnabled` is enabled /// (enabled by default), Airship will automatically integrate these calls into /// the application by swizzling methods. If `automaticSetupEnabled` is disabled, /// the application must call through to every method provided by this class. public class AppIntegration { /// - Note: For internal use only. :nodoc: @MainActor static var integrationDelegate: (any AppIntegrationDelegate)? private class func logIgnoringCall(_ method: String = #function) { AirshipLogger.impError( "Ignoring call to \(method). Either takeOff is not called or automatic integration is enabled." ) } #if os(watchOS) /** * Must be called by the WKExtensionDelegate's * didRegisterForRemoteNotificationsWithDeviceToken:. * * - Parameters: * - deviceToken: The device token. */ @MainActor public class func didRegisterForRemoteNotificationsWithDeviceToken( deviceToken: Data ) { guard let delegate = integrationDelegate else { logIgnoringCall() return } delegate.didRegisterForRemoteNotifications(deviceToken: deviceToken) } /** * Must be called by the WKExtensionDelegate's * didFailToRegisterForRemoteNotificationsWithError:. * * - Parameters: * - error: The error. */ @MainActor public class func didFailToRegisterForRemoteNotificationsWithError( error: any Error ) { guard let delegate = integrationDelegate else { logIgnoringCall() return } delegate.didFailToRegisterForRemoteNotifications(error: error) } /** * Must be called by the WKExtensionDelegate's * didReceiveRemoteNotification:fetchCompletionHandler:. * * - Parameters: * - userInfo: The remote notification. * - completionHandler: The completion handler. */ @MainActor public class func didReceiveRemoteNotification( userInfo: [AnyHashable: Any] ) async -> WKBackgroundFetchResult { guard let delegate = integrationDelegate else { logIgnoringCall() return .noData } let isForeground = WKApplication.shared().applicationState == .active return await withCheckedContinuation { continuation in delegate.didReceiveRemoteNotification( userInfo: userInfo, isForeground: isForeground ) { result in let watchResult = WKBackgroundFetchResult(rawValue: result.rawValue) ?? .noData continuation.resume(returning: watchResult) } } } #elseif os(macOS) /** * Must be called by the NSApplicationDelegate's * application:didRegisterForRemoteNotificationsWithDeviceToken:. * * - Parameters: * - application: The application * - deviceToken: The device token. */ @MainActor public class func application( _ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { guard let delegate = integrationDelegate else { logIgnoringCall() return } delegate.didRegisterForRemoteNotifications(deviceToken: deviceToken) } /** * Must be called by the NSApplicationDelegate's * application:didFailToRegisterForRemoteNotificationsWithError:. * * - Parameters: * - application: The application * - error: The error. */ @MainActor public class func application( _ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error ) { guard let delegate = integrationDelegate else { logIgnoringCall() return } delegate.didFailToRegisterForRemoteNotifications(error: error) } /** * Must be called by the NSApplicationDelegate's * application:didReceiveRemoteNotification:. * * - Parameters: * - userInfo: The remote notification. */ @MainActor public class func didReceiveRemoteNotification( userInfo: [AnyHashable: Any] ) { guard let delegate = integrationDelegate else { logIgnoringCall() return } let isForeground = NSApplication.shared.isActive delegate.didReceiveRemoteNotification( userInfo: userInfo, isForeground: isForeground ) } #else /** * Must be called by the UIApplicationDelegate's * application:performFetchWithCompletionHandler:. * * - Parameters: * - application: The application * - completionHandler: The completion handler. */ @MainActor public class func application( _ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping ( UIBackgroundFetchResult ) -> Void ) { guard let delegate = integrationDelegate else { logIgnoringCall() completionHandler(.noData) return } delegate.onBackgroundAppRefresh() completionHandler(.noData) } /** * Must be called by the UIApplicationDelegate's * application:didRegisterForRemoteNotificationsWithDeviceToken:. * * - Parameters: * - application: The application * - deviceToken: The device token. */ @MainActor public class func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { guard let delegate = integrationDelegate else { logIgnoringCall() return } delegate.didRegisterForRemoteNotifications(deviceToken: deviceToken) } /** * Must be called by the UIApplicationDelegate's * application:didFailToRegisterForRemoteNotificationsWithError:. * * - Parameters: * - application: The application * - error: The error. */ @MainActor public class func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error ) { guard let delegate = integrationDelegate else { logIgnoringCall() return } delegate.didFailToRegisterForRemoteNotifications(error: error) } /** * Must be called by the UIApplicationDelegate's * application:didReceiveRemoteNotification:fetchCompletionHandler:. * * - Parameters: * - application: The application * - userInfo: The remote notification. * - completionHandler: The completion handler. */ @MainActor public class func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { guard let delegate = integrationDelegate else { logIgnoringCall() return .noData } let isForeground = application.applicationState == .active // Wrap the completion-handler-based delegate call in a continuation return await withCheckedContinuation { continuation in delegate.didReceiveRemoteNotification( userInfo: userInfo, isForeground: isForeground ) { result in continuation.resume(returning: result) } } } #endif /** * Must be called by the UNUserNotificationDelegate's * userNotificationCenter:willPresentNotification:withCompletionHandler. * * - Parameters: * - center: The notification center. * - notification: The notification. */ @MainActor public class func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { guard let delegate = integrationDelegate else { logIgnoringCall() return [] } let presentationOptions = await withCheckedContinuation { continuation in delegate.presentationOptions(for: notification) { options in continuation.resume(returning: options) } } await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in delegate.willPresentNotification( notification: notification, presentationOptions: presentationOptions ) { continuation.resume() } } return presentationOptions } /** * Must be called by the UNUserNotificationDelegate's * userNotificationCenter:willPresentNotification:withCompletionHandler. * * - Parameters: * - center: The notification center. * - notification: The notification. * - completionHandler: The completion handler. */ @MainActor public class func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping @Sendable (UNNotificationPresentationOptions) -> Void ) { // This one does not depend on any lifecycle events so we can just do the async underneath Task { @MainActor in let options = await self.userNotificationCenter(center, willPresent: notification) completionHandler(options) } } #if !os(tvOS) /** * Processes a user's response to a notification. * * - Warning: ⚠️ **Deprecated**. This asynchronous method is deprecated and will be removed in a future release. * It can cause critical application lifecycle issues due to changes in how Apple's modern User Notification * delegates operate. * * ### Lifecycle Issues Explained * * Apple's modern `async` notification delegate methods execute on a **background thread** by default instead of a the main * thread. This creates a race condition during app launch: * * 1. **Main Thread:** Proceeds with the standard launch sequence, making the app's UI active and visible. * 2. **Background Thread:** Runs this notification code. By the time it can switch back to the main * thread, the app is often already active. * * This breaks the critical assumption that code for a "direct open" notification runs *before* the app is fully * interactive. This can lead to incorrect direct open counts. * * ### Migration * * To fix this, you must migrate to the synchronous version of this method, which accepts and forwards a `completionHandler`. * This guarantees your code runs on the main thread at the correct point in the lifecycle, before the app becomes active. * * - SeeAlso: `userNotificationCenter(_:didReceive:withCompletionHandler:)` * - Parameters: * - center: The notification center that delivered the notification. * - response: The user's response to the notification. */ @available(*, deprecated, message: "Use the synchronous version with a completionHandler to avoid lifecycle issues.") @MainActor public class func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in self.userNotificationCenter( center, didReceive: response ) { continuation.resume() } } } /** * Must be called by the UNUserNotificationDelegate's * userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler. * * - Parameters: * - center: The notification center. * - response: The notification response. * - completionHandler: The completion handler */ @MainActor public class func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping @Sendable () -> Void ) { guard let delegate = integrationDelegate else { logIgnoringCall() completionHandler() return } delegate.didReceiveNotificationResponse( response: response, completionHandler: completionHandler ) } #endif } ================================================ FILE: Airship/AirshipCore/Source/AppRemoteDataProviderDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct AppRemoteDataProviderDelegate: RemoteDataProviderDelegate { let source: RemoteDataSource = .app let storeName: String private let config: RuntimeConfig private let apiClient: any RemoteDataAPIClientProtocol init(config: RuntimeConfig, apiClient: any RemoteDataAPIClientProtocol) { self.config = config self.apiClient = apiClient self.storeName = "RemoteData-\(config.appCredentials.appKey).sqlite" } private func makeURL(locale: Locale, randomValue: Int) throws -> URL { return try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data/app/\(config.appCredentials.appKey)/ios", locale: locale, randomValue: randomValue ) } func isRemoteDataInfoUpToDate(_ remoteDataInfo: RemoteDataInfo, locale: Locale, randomValue: Int) async -> Bool { let url = try? makeURL(locale: locale, randomValue: randomValue) return remoteDataInfo.url == url } func fetchRemoteData( locale: Locale, randomValue: Int, lastRemoteDataInfo: RemoteDataInfo? ) async throws -> AirshipHTTPResponse<RemoteDataResult> { let url = try makeURL(locale: locale, randomValue: randomValue) var lastModified: String? = nil if (lastRemoteDataInfo?.url == url) { lastModified = lastRemoteDataInfo?.lastModifiedTime } return try await self.apiClient.fetchRemoteData( url: url, auth: .generatedAppToken, lastModified: lastModified ) { newLastModified in return RemoteDataInfo( url: url, lastModifiedTime: newLastModified, source: .app ) } } } ================================================ FILE: Airship/AirshipCore/Source/AppStateTracker.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation import Combine /// NOTE: For internal use only. :nodoc: public protocol AppStateTrackerProtocol: Sendable { /** * Current application state. */ @MainActor var state: ApplicationState { get } /** * Waits for active */ func waitForActive() async /** * State updates */ @MainActor var stateUpdates: AsyncStream<ApplicationState> { get } } /// NOTE: For internal use only. :nodoc: public final class AppStateTracker: AppStateTrackerProtocol, Sendable { public static let didBecomeActiveNotification: NSNotification.Name = NSNotification.Name( "com.urbanairship.application_did_become_active" ) public static let willEnterForegroundNotification: NSNotification.Name = NSNotification.Name( "com.urbanairship.application_will_enter_foreground" ) public static let didEnterBackgroundNotification: NSNotification.Name = NSNotification.Name( "com.urbanairship.application_did_enter_background" ) public static let willResignActiveNotification: NSNotification.Name = NSNotification.Name( "com.urbanairship.application_will_resign_active" ) public static let willTerminateNotification: NSNotification.Name = NSNotification.Name( "com.urbanairship.application_will_terminate" ) public static let didTransitionToBackground: NSNotification.Name = NSNotification.Name( "com.urbanairship.application_did_transition_to_background" ) public static let didTransitionToForeground: NSNotification.Name = NSNotification.Name( "com.urbanairship.application_did_transition_to_foreground" ) @MainActor public static let shared: AppStateTracker = AppStateTracker() private let notificationCenter: NotificationCenter private let adapter: any AppStateTrackerAdapter private let stateValue: AirshipMainActorValue<ApplicationState> @MainActor private var _isForegrounded: Bool? = nil @MainActor public var isForegrounded: Bool { ensureForegroundSet() return _isForegrounded == true } @MainActor public var state: ApplicationState { return stateValue.value } @MainActor init( adapter: any AppStateTrackerAdapter = DefaultAppStateTrackerAdapter(), notificationCenter: NotificationCenter = NotificationCenter.default ) { self.adapter = adapter self.notificationCenter = notificationCenter self.stateValue = AirshipMainActorValue(adapter.state) Task { @MainActor in self.ensureForegroundSet() } self.adapter.watchAppLifeCycleEvents { event in self.ensureForegroundSet() if (self.stateValue.value != adapter.state) { self.stateValue.set(adapter.state) } switch(event) { case .didBecomeActive: self.postNotificaition(name: AppStateTracker.didBecomeActiveNotification) if self._isForegrounded == false { self._isForegrounded = true self.postNotificaition(name: AppStateTracker.didTransitionToForeground) } case .willResignActive: self.postNotificaition(name: AppStateTracker.willResignActiveNotification) case .willEnterForeground: self.postNotificaition(name: AppStateTracker.willEnterForegroundNotification) case .didEnterBackground: self.postNotificaition(name: AppStateTracker.didEnterBackgroundNotification) if self._isForegrounded == true { self._isForegrounded = false self.postNotificaition(name: AppStateTracker.didTransitionToBackground) } case .willTerminate: self.postNotificaition(name: AppStateTracker.willTerminateNotification) } } } private func postNotificaition(name: Notification.Name) { notificationCenter.post( name: name, object: nil ) } @MainActor private func ensureForegroundSet() { if _isForegrounded == nil { _isForegrounded = self.state == .active } } @MainActor public func waitForActive() async { var subscription: AnyCancellable? await withCheckedContinuation { continuation in subscription = self.notificationCenter.publisher( for: AppStateTracker.didBecomeActiveNotification ) .first() .sink { _ in continuation.resume() } } subscription?.cancel() } @MainActor public var stateUpdates: AsyncStream<ApplicationState> { self.stateValue.updates } } ================================================ FILE: Airship/AirshipCore/Source/AppStateTrackerAdapter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol AppStateTrackerAdapter: Sendable { @MainActor var state: ApplicationState { get } @MainActor func watchAppLifeCycleEvents( eventHandler: @MainActor @Sendable @escaping (AppLifeCycleEvent) -> Void ) } enum AppLifeCycleEvent: Sendable { case didBecomeActive case willResignActive case willEnterForeground case didEnterBackground case willTerminate } #if os(watchOS) import WatchKit final class DefaultAppStateTrackerAdapter: AppStateTrackerAdapter { var state: ApplicationState { let appState = WKExtension.shared().applicationState switch appState { case .active: return .active case .inactive: return .inactive case .background: return .background @unknown default: AirshipLogger.error("Unknown application state \(appState)") return .background } } func watchAppLifeCycleEvents( eventHandler: @MainActor @escaping (AppLifeCycleEvent) -> Void ) { let notificationCenter = NotificationCenter.default // active notificationCenter.addObserver( forName: WKExtension.applicationDidBecomeActiveNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.didBecomeActive) } } ) // inactive notificationCenter.addObserver( forName: WKExtension.applicationWillResignActiveNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willResignActive) } } ) // foreground notificationCenter.addObserver( forName: WKExtension.applicationWillEnterForegroundNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willEnterForeground) } } ) // background notificationCenter.addObserver( forName: WKExtension.applicationDidEnterBackgroundNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.didEnterBackground) } } ) } } #endif #if !os(macOS) && !os(watchOS) import UIKit final class DefaultAppStateTrackerAdapter: AppStateTrackerAdapter { var state: ApplicationState { let appState = UIApplication.shared.applicationState switch appState { case .active: return .active case .inactive: return .inactive case .background: return .background @unknown default: AirshipLogger.error("Unknown application state \(appState)") return .background } } func watchAppLifeCycleEvents( eventHandler: @MainActor @Sendable @escaping (AppLifeCycleEvent) -> Void ) { let notificationCenter = NotificationCenter.default // active notificationCenter.addObserver( forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.didBecomeActive) } } ) // inactive notificationCenter.addObserver( forName: UIApplication.willResignActiveNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willResignActive) } } ) // foreground notificationCenter.addObserver( forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willEnterForeground) } } ) // background notificationCenter.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.didEnterBackground) } } ) // terminate notificationCenter.addObserver( forName: UIApplication.willTerminateNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willTerminate) } } ) } } #endif #if os(macOS) import AppKit final class DefaultAppStateTrackerAdapter: AppStateTrackerAdapter { var state: ApplicationState { // macOS is active if it's the frontmost app and not hidden. if NSApplication.shared.isActive { return .active } else if NSApplication.shared.isHidden { return .background } else { return .inactive } } func watchAppLifeCycleEvents( eventHandler: @MainActor @Sendable @escaping (AppLifeCycleEvent) -> Void ) { let notificationCenter = NotificationCenter.default // Active notificationCenter.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.didBecomeActive) } } ) // Resign Active (Inactive/Background transition) notificationCenter.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willResignActive) } } ) // Hide (Maps well to Enter Background) notificationCenter.addObserver( forName: NSApplication.didHideNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.didEnterBackground) } } ) // Unhide (Maps well to Enter Foreground) notificationCenter.addObserver( forName: NSApplication.willUnhideNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willEnterForeground) } } ) // Terminate notificationCenter.addObserver( forName: NSApplication.willTerminateNotification, object: nil, queue: nil, using: { _ in MainActor.assumeIsolated { eventHandler(.willTerminate) } } ) } } #endif ================================================ FILE: Airship/AirshipCore/Source/ApplicationState.swift ================================================ /* Copyright Airship and Contributors */ /// Platform independent representation of application state. /// - Note: For internal use only. :nodoc: public enum ApplicationState: Int, Sendable { /// The active state. case active /// The inactive state. case inactive /// The background state. case background } ================================================ FILE: Airship/AirshipCore/Source/ArishipCustomViewManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI /// Type alias for the CustomView builder block public typealias AirshipCustomViewBuilder = @MainActor @Sendable (AirshipCustomViewArguments) -> any View /// Custom view arguments public struct AirshipCustomViewArguments: Sendable { /// The view's name. public var name: String /// Optional properties public var properties: AirshipJSON? /// Sizing info public var sizeInfo: SizeInfo public struct SizeInfo: Sendable { /// If the height is `auto` or not. public var isAutoHeight: Bool /// If the width is `auto` or not. public var isAutoWidth: Bool } } /// Airship custom view manager for displaying an app view in a Scene based layout. @MainActor public final class AirshipCustomViewManager: Sendable { /// Shared instance public static let shared = AirshipCustomViewManager() private var builders: [String: AirshipCustomViewBuilder] = [:] /// Builder that is used when a view is requested that does not have a registered builder. /// The default behavior is to return an empty view. public var fallbackBuilder: AirshipCustomViewBuilder = { args in return EmptyView() } /// Registers a custom view builder. /// - Parameters: /// - name: The name of the view /// - builder: The builder block public func register(name: String, @ViewBuilder builder: @escaping AirshipCustomViewBuilder) { self.builders[name] = builder } /// Unregisters a custom view builder. /// - Parameters: /// - name: The name of the view public func unregister(name: String) { self.builders.removeValue(forKey: name) } @ViewBuilder internal func makeView(args: AirshipCustomViewArguments) -> some View { if let block = builders[args.name] { AnyView(block(args)) } else { makeFallbackView(args: args) } } private func makeFallbackView(args: AirshipCustomViewArguments) -> some View { AirshipLogger.error("Failed to make custom view for name '\(args.name)'") return AnyView(fallbackBuilder(args)) } } ================================================ FILE: Airship/AirshipCore/Source/AssociatedIdentifiers.swift ================================================ /* Copyright Airship and Contributors */ /// Defines analytics identifiers to be associated with /// the device. public class AssociatedIdentifiers { /** * Maximum number of associated IDs that can be set. */ public static let maxCount: Int = 100 /** * Character limit for associated IDs or keys. */ public static let maxCharacterCount: Int = 255 /** * The advertising ID. */ public var advertisingID: String? { get { return self.identifiers["com.urbanairship.idfa"] } set { self.identifiers["com.urbanairship.idfa"] = newValue } } /** * The application's vendor ID. */ public var vendorID: String? { get { return self.identifiers["com.urbanairship.vendor"] } set { self.identifiers["com.urbanairship.vendor"] = newValue } } /** * Indicates whether the user has limited ad tracking. */ public var advertisingTrackingEnabled: Bool { get { return self.identifiers["com.urbanairship.limited_ad_tracking_enabled"] == "false" } set { self.identifiers["com.urbanairship.limited_ad_tracking_enabled"] = newValue ? "false" : "true" } } /** * A map of all the associated identifiers. */ public var allIDs: [String: String] { return identifiers } private var identifiers: [String: String] public init(identifiers: [String: String]? = nil) { self.identifiers = identifiers ?? [:] } /** * Factory method to create an empty identifiers object. * - Returns: The created associated identifiers. */ public class func identifiers() -> AssociatedIdentifiers { return AssociatedIdentifiers() } /** * Factory method to create an associated identifiers instance with a dictionary * of custom identifiers (containing strings only). * - Returns: The created associated identifiers. */ public class func identifiers(identifiers: [String: String]?) -> AssociatedIdentifiers { return AssociatedIdentifiers(identifiers: identifiers) } /** * Sets an identifier mapping. * - Parameter identifier: The value of the identifier, or `nil` to remove the identifier. * @parm key The key for the identifier */ public func set(identifier: String?, key: String) { self.identifiers[key] = identifier } } ================================================ FILE: Airship/AirshipCore/Source/AsyncSerialQueue.swift ================================================ /* Copyright Airship and Contributors */ /// A class that will run blocks in a FIFO order /// NOTE: For internal use only. :nodoc: public final class AirshipAsyncSerialQueue : Sendable { private let continuation: AsyncStream<@Sendable () async -> Void>.Continuation private let stream: AsyncStream<@Sendable () async -> Void> public init(priority: TaskPriority? = nil) { ( self.stream, self.continuation ) = AsyncStream<@Sendable () async -> Void>.airshipMakeStreamWithContinuation() Task.detached(priority: priority) { [stream] in for await next in stream { await next() } } } deinit { self.stop() } public func enqueue(work: @Sendable @escaping () async -> Void) { self.continuation.yield(work) } public func stop() { self.continuation.finish() } // Waits for all the current operations to be cleared public func waitForCurrentOperations() async { await withCheckedContinuation{ continuation in self.enqueue { continuation.resume() } } } } ================================================ FILE: Airship/AirshipCore/Source/AsyncStream.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public extension AsyncStream { static func airshipMakeStreamWithContinuation( _ type: Element.Type = Element.self ) -> (Self, AsyncStream.Continuation) { var escapee: Continuation! let stream = Self(type) { continuation in escapee = continuation } return (stream, escapee!) } } ================================================ FILE: Airship/AirshipCore/Source/Atomic.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public final class AirshipAtomicValue<T: Sendable>: @unchecked Sendable { fileprivate let lock: AirshipLock = AirshipLock() fileprivate var _value: T public init(_ value: T) { self._value = value } public var value: T { get { var result: T! lock.sync { result = self._value } return result } set { lock.sync { self._value = newValue } } } public func update(onModify: (T) -> T) { lock.sync { self._value = onModify(self._value) } } } public extension AirshipAtomicValue where T: Equatable { @discardableResult func setValue(_ value: T, onChange:(() -> Void)? = nil) -> Bool { return lock.sync { guard self._value != value else { return false } self._value = value onChange?() return true } } @discardableResult func compareAndSet(expected: T, value: T) -> Bool { return lock.sync { guard self._value == expected else { return false } self._value = value return true } } } ================================================ FILE: Airship/AirshipCore/Source/AttributePendingMutations.swift ================================================ /* Copyright Airship and Contributors */ import Foundation // Legacy attribute mutation. Used for migration to AttributeUpdates. @objc(UAAttributePendingMutations) class AttributePendingMutations: NSObject, NSSecureCoding { static let codableKey: String = "com.urbanairship.attributes" public static let supportsSecureCoding: Bool = true private let mutationsPayload: [[AnyHashable: Any]] init(mutationsPayload: [[AnyHashable: Any]]) { self.mutationsPayload = mutationsPayload super.init() } public var attributeUpdates: [AttributeUpdate] { return self.mutationsPayload.compactMap { update -> (AttributeUpdate?) in guard let attribute = update["key"] as? String, let action = update["action"] as? String, let dateString = update["timestamp"] as? String else { AirshipLogger.error("Invalid pending attribute \(update)") return nil } guard let date = AirshipDateFormatter.date(fromISOString: dateString) else { AirshipLogger.error("Unexpected date \(dateString)") return nil } if action == "set" { guard let valueJSON = update["value"], let value = try? AirshipJSON.wrap(valueJSON) else { return nil } return AttributeUpdate( attribute: attribute, type: .set, jsonValue: value, date: date ) } else if action == "remove" { return AttributeUpdate( attribute: attribute, type: .remove, jsonValue: nil, date: date ) } else { AirshipLogger.error("Unexpected action \(action)") return nil } } } func encode(with coder: NSCoder) { coder.encode( mutationsPayload as NSArray, forKey: AttributePendingMutations.codableKey ) } required init?(coder: NSCoder) { self.mutationsPayload = coder.decodeObject( of: [ NSNull.self, NSNumber.self, NSArray.self, NSDictionary.self, NSString.self, ], forKey: AttributePendingMutations.codableKey ) as? [[AnyHashable: Any]] ?? [] } } ================================================ FILE: Airship/AirshipCore/Source/AttributeUpdate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: enum AttributeUpdateType: Int, Codable, Sendable, Equatable { case remove case set } /// NOTE: For internal use only. :nodoc: struct AttributeUpdate: Codable, Sendable, Equatable { let attribute: String let type: AttributeUpdateType let jsonValue: AirshipJSON? let date: Date static func remove( attribute: String, date: Date = Date() ) -> AttributeUpdate { return AttributeUpdate( attribute: attribute, type: .remove, jsonValue: nil, date: date ) } static func set( attribute: String, value: AirshipJSON, date: Date = Date() ) -> AttributeUpdate { return AttributeUpdate( attribute: attribute, type: .set, jsonValue: value, date: date ) } init(attribute: String, type: AttributeUpdateType, jsonValue: AirshipJSON?, date: Date) { self.attribute = attribute self.type = type self.jsonValue = jsonValue self.date = date } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.attribute = try container.decode(String.self, forKey: .attribute) self.type = try container.decode(AttributeUpdateType.self, forKey: .type) self.date = try container.decode(Date.self, forKey: .date) do { self.jsonValue = try container.decodeIfPresent(AirshipJSON.self, forKey: .jsonValue) } catch { let legacy = try? container.decodeIfPresent(JsonValue.self, forKey: .jsonValue) guard let legacy = legacy else { throw error } if let decoder = decoder as? JSONDecoder { self.jsonValue = try AirshipJSON.from( json: legacy.jsonEncodedValue, decoder: decoder ) } else { self.jsonValue = try AirshipJSON.from( json: legacy.jsonEncodedValue ) } } } // Migration purposes fileprivate struct JsonValue : Decodable { let jsonEncodedValue: String? } } extension AttributeUpdate { var operation: AttributeOperation { let timestamp = AirshipDateFormatter.string(fromDate: date, format: .isoDelimitter) switch self.type { case .set: return AttributeOperation( action: .set, key: self.attribute, timestamp: timestamp, value: self.jsonValue ) case .remove: return AttributeOperation( action: .remove, key: self.attribute, timestamp: timestamp, value: nil ) } } } /// NOTE: For internal use only. :nodoc: // Used by ChannelBulkAPIClient and DeferredAPIClient struct AttributeOperation: Encodable { enum AttributeAction: String, Encodable { case set case remove } var action: AttributeAction var key: String var timestamp: String var value: AirshipJSON? } ================================================ FILE: Airship/AirshipCore/Source/Attributes.swift ================================================ /* Copyright Airship and Contributors */ /// Predefined attributes. public struct Attributes: Sendable { /** * Title attribute. */ public static let title: String = "title" /** * First name attribute. */ public static let firstName: String = "first_name" /** * Last name attribute. */ public static let lastName: String = "last_name" /** * Full name attribute. */ public static let fullName: String = "full_name" /** * Gender attribute. */ public static let gender: String = "gender" /** * Zip code attribute. */ public static let zipCode: String = "zip_code" /** * City attribute. */ public static let city: String = "city" /** * Region attribute. */ public static let region: String = "region" /** * Country attribute. */ public static let country: String = "country" /** * Birthdate attribute. */ public static let birthdate: String = "birthdate" /** * Age attribute. */ public static let age: String = "age" /** * Mobile phone attribute. */ public static let mobilePhone: String = "mobile_phone" /** * Home phone attribute. */ public static let homePhone: String = "home_phone" /** * Work phone attribute. */ public static let workPhone: String = "work_phone" /** * Loyalty tier attribute. */ public static let loyaltyTier: String = "loyalty_tier" /** * Company attribute. */ public static let company: String = "company" /** * Username attribute. */ public static let username: String = "username" /** * Account creation attribute. */ public static let accountCreation: String = "account_creation" /** * Email attribute. */ public static let email: String = "email" /** * Advertising id attribute. */ public static let advertisingId: String = "advertising_id" private init() {} } ================================================ FILE: Airship/AirshipCore/Source/AttributesEditor.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Attributes editor. public final class AttributesEditor { private let date: any AirshipDateProtocol private var sets: [String: AirshipJSON] = [:] private var removes: [String] = [] private let completionHandler: ([AttributeUpdate]) -> Void private static let JSON_EXPIRY_KEY: String = "exp" init( date: any AirshipDateProtocol, completionHandler: @escaping ([AttributeUpdate]) -> Void ) { self.completionHandler = completionHandler self.date = date } convenience init( completionHandler: @escaping ([AttributeUpdate]) -> Void ) { self.init(date: AirshipDate(), completionHandler: completionHandler) } /** * Removes an attribute. * - Parameters: * - attribute: The attribute. */ public func remove(_ attribute: String) { tryRemoveAttribute(attribute) } /** * Sets the attribute. * - Parameters: * - date: The value * - attribute: The attribute */ public func set(date: Date, attribute: String) { trySetAttribute( attribute, value: .string( AirshipDateFormatter.string(fromDate: date, format: .isoDelimitter) ) ) } /** * Sets the attribute. * - Parameters: * - number: The value. * - attribute: The attribute. */ @available(*, deprecated, message: "Use set(number:number) with Double type instead") public func set(number: NSNumber, attribute: String) { trySetAttribute(attribute, value: .number(number.doubleValue)) } /** * Sets the attribute. * - Parameters: * - number: The value. * - attribute: The attribute. */ public func set(number: Double, attribute: String) { trySetAttribute(attribute, value: .number(number)) } /** * Sets the attribute. * - Parameters: * - number: The value. * - attribute: The attribute. */ public func set(number: Int, attribute: String) { trySetAttribute(attribute, value: .number(Double(number))) } /** * Sets the attribute. * - Parameters: * - number: The value. * - attribute: The attribute. */ public func set(number: UInt, attribute: String) { trySetAttribute(attribute, value: .number(Double(number))) } /** * Sets the attribute. * - Parameters: * - string: The value. * - attribute: The attribute. */ public func set(string: String, attribute: String) { guard string.count >= 1 && string.count <= 1024 else { AirshipLogger.error( "Invalid attribute value \(string). Must be between 1-1024 characters." ) return } trySetAttribute(attribute, value: .string(string)) } /// Sets a custom attribute with a JSON payload and optional expiration. /// /// - Parameters: /// - json: A dictionary of key-value pairs (`[String: AirshipJSON]`) representing the custom payload. /// - attribute: The name of the attribute to be set. /// - instanceID: An identifier used to differentiate instances of the attribute. /// - expiration: An optional expiration `Date`. If provided, it must be greater than 0 and less than or equal to 731 days from now. /// /// - Throws: /// - `AirshipErrors.error` if: /// - The expiration is invalid (more than 731 days or not in the future). /// - The input `json` is empty. /// - The JSON contains a top-level `"exp"` key (reserved for expiration). /// - The attribute contains `"#"`or is empty /// - The instanceID contains `"#"`or is empty /// public func set( json: [String: AirshipJSON], attribute: String, instanceID: String, expiration: Date? = nil ) throws { if let expiration, expiration.timeIntervalSinceNow > 63158400, expiration.timeIntervalSinceNow <= 0 { throw AirshipErrors.error("The expiration is invalid (more than 731 days or not in the future).") } guard json.isEmpty == false else { throw AirshipErrors.error("The input `json` is empty.") } guard json[Self.JSON_EXPIRY_KEY] == nil else { throw AirshipErrors.error("The JSON contains a top-level `\(Self.JSON_EXPIRY_KEY)` key (reserved for expiration).") } let json = AirshipJSON.makeObject { builder in json.forEach { key, value in builder.set(json: value, key: key) } if let expiration { builder.set(double: expiration.timeIntervalSince1970, key: Self.JSON_EXPIRY_KEY) } } try setAttribute(attribute, instanceID: instanceID, value: json) } /// Removes a JSON attribute. /// - attribute: The name of the attribute to be set. /// - instanceID: An identifier used to differentiate instances of the attribute. /// /// - Throws: /// - `AirshipErrors.error` if: /// - The attribute contains `"#"`or is empty /// - The instanceID contains `"#"`or is empty public func remove(attribute: String, instanceID: String) throws { try removeAttribute(attribute, instanceID: instanceID) } /** * Sets the attribute. * - Parameters: * - float: The value. * - attribute: The attribute. */ public func set(float: Float, attribute: String) { trySetAttribute(attribute, value: .number(Double(float))) } /** * Sets the attribute. * - Parameters: * - double: The value. * - attribute: The attribute. */ public func set(double: Double, attribute: String) { trySetAttribute(attribute, value: .number(double)) } /** * Sets the attribute. * - Parameters: * - int: The value. * - attribute: The attribute. */ public func set(int: Int, attribute: String) { trySetAttribute(attribute, value: .number(Double(int))) } /** * Sets the attribute. * - Parameters: * - uint: The value. * - attribute: The attribute. */ public func set(uint: UInt, attribute: String) { trySetAttribute(attribute, value: .number(Double(uint))) } /** * Applies the attribute changes. */ public func apply() { let removeOperations: [AttributeUpdate] = removes.compactMap { AttributeUpdate.remove(attribute: $0, date: self.date.now) } let setOperations: [AttributeUpdate] = sets.compactMap { AttributeUpdate.set( attribute: $0.key, value: $0.value, date: self.date.now ) } self.completionHandler(removeOperations + setOperations) removes.removeAll() sets.removeAll() } private func setAttribute(_ attribute: String, instanceID: String? = nil, value: AirshipJSON) throws { let key = try formatKey(attribute: attribute, instanceID: instanceID) sets[key] = value removes.removeAll(where: { $0 == key }) } private func removeAttribute(_ attribute: String, instanceID: String? = nil) throws { let key = try formatKey(attribute: attribute, instanceID: instanceID) sets[key] = nil removes.append(key) } private func trySetAttribute(_ attribute: String, instanceID: String? = nil, value: AirshipJSON) { do { try setAttribute(attribute, instanceID: instanceID, value: value) } catch { AirshipLogger.error("Failed to update attribute \(attribute): \(error)") } } private func tryRemoveAttribute(_ attribute: String, instanceID: String? = nil) { do { try removeAttribute(attribute, instanceID: instanceID) } catch { AirshipLogger.error("Failed to remove attribute \(attribute): \(error)") } } private func formatKey(attribute: String, instanceID: String? = nil) throws -> String { guard !attribute.isEmpty, !attribute.contains("#") else { throw AirshipErrors.error( "Invalid attribute \(attribute). Must not be empty or contain '#'." ) } if let instanceID { guard !instanceID.isEmpty, !instanceID.contains("#") else { throw AirshipErrors.error( "Invalid instanceID \(instanceID). Must not be empty or contain '#'." ) } return "\(attribute)#\(instanceID)" } else { return attribute } } } ================================================ FILE: Airship/AirshipCore/Source/AudienceDeviceInfoProvider.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public protocol AudienceDeviceInfoProvider: AnyObject, Sendable { var isAirshipReady: Bool { get } var tags: Set<String> { get } var channelID: String { get async throws } var locale: Locale { get } var appVersion: String? { get } var sdkVersion: String { get } var permissions: [AirshipPermission: AirshipPermissionStatus] { get async } var isUserOptedInPushNotifications: Bool { get async } var analyticsEnabled: Bool { get } var installDate: Date { get } var stableContactInfo: StableContactInfo { get async } var isChannelCreated: Bool { get } } /// NOTE: For internal use only. :nodoc: public final class CachingAudienceDeviceInfoProvider: AudienceDeviceInfoProvider, Sendable { private let deviceInfoProvider: any AudienceDeviceInfoProvider private let cachedTags: OneTimeValue<Set<String>> private let cachedLocale: OneTimeValue<Locale> private let cachedStableContactInfo: OneTimeAsyncValue<StableContactInfo> private let cachedChannelID: ThrowingOneTimeAsyncValue<String> private let cachedPermissions: OneTimeAsyncValue<[AirshipPermission : AirshipPermissionStatus]> private let cachedIsUserOptedInPushNotifications: OneTimeAsyncValue<Bool> private let cachedAnalyticsEnabled: OneTimeValue<Bool> private let cachedIsChannelCreated: OneTimeValue<Bool> public convenience init(contactID: String?) { self.init(deviceInfoProvider: DefaultAudienceDeviceInfoProvider(contactID: contactID)) } public init(deviceInfoProvider: any AudienceDeviceInfoProvider = DefaultAudienceDeviceInfoProvider()) { self.deviceInfoProvider = deviceInfoProvider self.cachedTags = OneTimeValue { return deviceInfoProvider.tags } self.cachedLocale = OneTimeValue { return deviceInfoProvider.locale } self.cachedStableContactInfo = OneTimeAsyncValue { return await deviceInfoProvider.stableContactInfo } self.cachedPermissions = OneTimeAsyncValue { return await deviceInfoProvider.permissions } self.cachedIsUserOptedInPushNotifications = OneTimeAsyncValue { return await deviceInfoProvider.isUserOptedInPushNotifications } self.cachedAnalyticsEnabled = OneTimeValue { return deviceInfoProvider.analyticsEnabled } self.cachedIsChannelCreated = OneTimeValue { return deviceInfoProvider.isChannelCreated } self.cachedChannelID = ThrowingOneTimeAsyncValue { return try await deviceInfoProvider.channelID } } public var installDate: Date { deviceInfoProvider.installDate } public var stableContactInfo: StableContactInfo { get async { return await cachedStableContactInfo.getValue() } } public var sdkVersion: String { return deviceInfoProvider.sdkVersion } public var appVersion: String? { return deviceInfoProvider.appVersion } public var isAirshipReady: Bool { return deviceInfoProvider.isAirshipReady } public var tags: Set<String> { return cachedTags.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.getValue() } } public var isUserOptedInPushNotifications: Bool { get async { return await cachedIsUserOptedInPushNotifications.getValue() } } public var analyticsEnabled: Bool { return cachedAnalyticsEnabled.value } } /// NOTE: For internal use only. :nodoc: public final class DefaultAudienceDeviceInfoProvider: AudienceDeviceInfoProvider { private let contactID: String? public init(contactID: String? = nil) { self.contactID = contactID } public var installDate: Date { Airship.shared.installDate } public var sdkVersion: String { AirshipVersion.version } public var stableContactInfo: StableContactInfo { get async { let stableInfo = await Airship.requireComponent( ofType: (any InternalAirshipContact).self ).getStableContactInfo() if let contactID { if (stableInfo.contactID == contactID) { return stableInfo } return StableContactInfo(contactID: contactID, namedUserID: nil) } return stableInfo } } public var appVersion: String? { return AirshipUtils.bundleShortVersionString() } public var isAirshipReady: Bool { return Airship.isFlying } public var tags: Set<String> { return Set(Airship.channel.tags) } 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 { return Airship.localeManager.currentLocale } public var permissions: [AirshipPermission : AirshipPermissionStatus] { get async { var results: [AirshipPermission : AirshipPermissionStatus] = [:] for permission in Airship.permissionsManager.configuredPermissions { results[permission] = await Airship.permissionsManager.checkPermissionStatus(permission) } return results } } public var isUserOptedInPushNotifications: Bool { get async { return await Airship.push.notificationStatus.isUserOptedIn } } public var analyticsEnabled: Bool { return Airship.privacyManager.isEnabled(.analytics) } } fileprivate final class OneTimeValue<T: Equatable & Sendable>: @unchecked Sendable { private let lock: AirshipLock = AirshipLock() private var atomicValue: AirshipAtomicValue<T?> = AirshipAtomicValue(nil) private let provider: () -> T var cachedValue: T? { get { return atomicValue.value } } init(provider: @escaping () -> T) { self.provider = provider } var value: T { get { lock.sync { if let cachedValue = atomicValue.value { return cachedValue } let value = provider() atomicValue.value = value return value } } } } fileprivate actor OneTimeAsyncValue<T: Equatable & Sendable> { private let provider: @Sendable () async -> T private var task: Task<T, Never>? init(provider: @Sendable @escaping () async -> T) { self.provider = provider } func getValue() async -> T { if let task, !task.isCancelled { return await task.value } let newTask = Task { return await provider() } task = newTask return await newTask.value } } fileprivate actor ThrowingOneTimeAsyncValue<T: Equatable & Sendable> { private var provider: @Sendable () async throws -> T private var task: Task<T, any Error>? private var value: T? init(provider: @Sendable @escaping () async throws -> T) { self.provider = provider } 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 } } ================================================ FILE: Airship/AirshipCore/Source/AudienceHashSelector.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct AudienceHashSelector: Codable, Sendable, Equatable { let hash: Hash let bucket: Bucket var sticky: Sticky? init(hash: Hash, bucket: Bucket, sticky: Sticky? = nil) { self.hash = hash self.bucket = bucket self.sticky = sticky } enum CodingKeys: String, CodingKey { case hash = "audience_hash" case bucket = "audience_subset" case sticky } struct Hash: Codable, Sendable, Equatable { enum Identifier: String, Codable, Equatable { case channel, contact } enum Algorithm: String, Codable, Equatable { case farm = "farm_hash" } let prefix: String let property: Identifier let algorithm: Algorithm let seed: UInt? let numberOfBuckets: UInt64 let overrides: [String: String]? enum CodingKeys: String, CodingKey { case prefix = "hash_prefix" case property = "hash_identifier" case algorithm = "hash_algorithm" case seed = "hash_seed" case numberOfBuckets = "num_hash_buckets" case overrides = "hash_identifier_overrides" } } struct Bucket: Codable, Sendable, Equatable { let min: UInt64 let max: UInt64 enum CodingKeys: String, CodingKey { case min = "min_hash_bucket" case max = "max_hash_bucket" } init(min: UInt64, max: UInt64) { self.min = min self.max = max } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.min = try container.decodeIfPresent(UInt64.self, forKey: .min) ?? 0 self.max = try container.decodeIfPresent(UInt64.self, forKey: .max) ?? UInt64.max } func contains(_ value: UInt64) -> Bool { return value >= min && value <= max } } /// Sticky has will cache the result under the `id` for the length of the `lastAccessTTL`. struct Sticky: Codable, Sendable, Equatable { /// The sticky ID. let id: String /// Reporting metadata. let reportingMetadata: AirshipJSON? /// Time to cache the result. var lastAccessTTL: TimeInterval enum CodingKeys: String, CodingKey { case id case reportingMetadata = "reporting_metadata" case lastAccessTTLMilliseconds = "last_access_ttl" } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(reportingMetadata, forKey: .reportingMetadata) try container.encode((lastAccessTTL * 1000.0), forKey: .lastAccessTTLMilliseconds) } init(id: String, reportingMetadata: AirshipJSON?, lastAccessTTL: TimeInterval) { self.id = id self.reportingMetadata = reportingMetadata self.lastAccessTTL = lastAccessTTL } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) reportingMetadata = try container.decode(AirshipJSON?.self, forKey: .reportingMetadata) lastAccessTTL = TimeInterval(try container.decode(Double.self, forKey: .lastAccessTTLMilliseconds)/1000.0) } } func evaluate(channelID: String, contactID: String) -> Bool { let param = self.hashParameter(channelID: channelID, contactID: contactID) let hash = self.hashFunction(param) let result: UInt64 = hash % self.hash.numberOfBuckets return bucket.contains(result) } private func hashParameter(channelID: String, contactID: String) -> String { var property: String! switch(self.hash.property) { case .channel: property = channelID case .contact: property = contactID } let resolved: String = self.hash.overrides?[property] ?? property return "\(self.hash.prefix)\(resolved)" } private var hashFunction: (String) -> UInt64 { switch(self.hash.algorithm) { case .farm: return FarmHashFingerprint64.fingerprint } } } ================================================ FILE: Airship/AirshipCore/Source/AudienceOverridesProvider.swift ================================================ import Foundation protocol AudienceOverridesProvider: Actor { func setStableContactIDProvider( _ provider: @escaping @Sendable () async -> String ) func setPendingChannelOverridesProvider( _ provider: @escaping @Sendable (String) async -> ChannelAudienceOverrides? ) func setPendingContactOverridesProvider( _ provider: @escaping @Sendable (String) async -> ContactAudienceOverrides? ) func contactUpdated( contactID: String, tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, subscriptionLists: [ScopedSubscriptionListUpdate]?, channels: [ContactChannelUpdate]? ) async func channelUpdated( channelID: String, tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, subscriptionLists: [SubscriptionListUpdate]? ) async func channelOverrides( channelID: String, contactID: String? ) async -> ChannelAudienceOverrides func channelOverrides( channelID: String ) async -> ChannelAudienceOverrides func contactOverrides( contactID: String? ) async -> ContactAudienceOverrides func contactOverrides() async -> ContactAudienceOverrides func notifyPendingChanged() async func contactOverrideUpdates( contactID: String? ) async -> AsyncStream<ContactAudienceOverrides> } actor DefaultAudienceOverridesProvider: AudienceOverridesProvider { private let updates: CachedList<UpdateRecord> 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<Bool> = AirshipAsyncChannel() private static let maxRecordAge: TimeInterval = 600 // 10 minutes init(date: any AirshipDateProtocol = AirshipDate.shared) { self.updates = CachedList(date: date) } func setPendingChannelOverridesProvider( _ provider: @escaping @Sendable (String) async -> ChannelAudienceOverrides? ) { self.pendingChannelOverridesProvider = provider } func setPendingContactOverridesProvider( _ provider: @escaping @Sendable (String) async -> ContactAudienceOverrides? ) { self.pendingContactOverridesProvider = provider } func setStableContactIDProvider( _ provider: @escaping @Sendable () async -> String ) { self.stableContactIDProvider = provider } func pendingOverrides(channelID: String) async -> ChannelAudienceOverrides? { return await self.pendingChannelOverridesProvider?(channelID) } func pendingOverrides(contactID: String) async -> ContactAudienceOverrides? { return await self.pendingContactOverridesProvider?(contactID) } func contactUpdated( contactID: String, tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, subscriptionLists: [ScopedSubscriptionListUpdate]?, channels: [ContactChannelUpdate]? ) async { self.updates.append( UpdateRecord( recordType: .contact(contactID), tags: tags, attributes: attributes, subscriptionLists: nil, scopedSubscriptionLists: subscriptionLists, channels: channels ), expiresIn: DefaultAudienceOverridesProvider.maxRecordAge ) await self.notifyPendingChanged() } func channelUpdated( channelID: String, tags: [TagGroupUpdate]?, attributes: [AttributeUpdate]?, subscriptionLists: [SubscriptionListUpdate]? ) async { self.updates.append( UpdateRecord( recordType: .channel(channelID), tags: tags, attributes: attributes, subscriptionLists: subscriptionLists, scopedSubscriptionLists: nil, channels: nil ), expiresIn: DefaultAudienceOverridesProvider.maxRecordAge ) await self.notifyPendingChanged() } func convertAppScopes(scoped: [ScopedSubscriptionListUpdate]) -> [SubscriptionListUpdate] { return scoped.compactMap { update in if (update.scope == .app) { return SubscriptionListUpdate(listId: update.listId, type: update.type) } else { return nil } } } func channelOverrides( channelID: String ) async -> ChannelAudienceOverrides { return await channelOverrides( channelID: channelID, contactID: nil ) } func channelOverrides( channelID: String, contactID: String? ) async -> ChannelAudienceOverrides { let contactID = await resolveContactID(contactID: contactID) let pendingChannel = await self.pendingOverrides(channelID: channelID) var pendingContact: ContactAudienceOverrides? if let contactID = contactID { pendingContact = await self.pendingOverrides(contactID: contactID) } var tags: [TagGroupUpdate] = [] var attributes: [AttributeUpdate] = [] var subscriptionLists: [SubscriptionListUpdate] = [] /// Apply updates first self.updates.values.forEach { update in switch (update.recordType) { case .contact(let identifier): if let contactID = contactID, contactID == identifier { if let updateTags = update.tags { tags += updateTags } if let updateAttributes = update.attributes { attributes += updateAttributes } if let updateSubscriptionLists = update.subscriptionLists { subscriptionLists += updateSubscriptionLists } if let updateScopedSubscriptionLists = update.scopedSubscriptionLists { subscriptionLists += convertAppScopes(scoped: updateScopedSubscriptionLists) } } case .channel(let identifier): if channelID == identifier { if let updateTags = update.tags { tags += updateTags } if let updateAttributes = update.attributes { attributes += updateAttributes } if let updateSubscriptionLists = update.subscriptionLists { subscriptionLists += updateSubscriptionLists } } } } // Pending channel if let pendingChannel = pendingChannel { tags += pendingChannel.tags attributes += pendingChannel.attributes subscriptionLists += pendingChannel.subscriptionLists } // Pending contact if let pendingContact = pendingContact { tags += pendingContact.tags attributes += pendingContact.attributes subscriptionLists += convertAppScopes(scoped: pendingContact.subscriptionLists) } return ChannelAudienceOverrides( tags: tags, attributes: attributes, subscriptionLists: subscriptionLists ) } func contactOverrides() async -> ContactAudienceOverrides { return await contactOverrides(contactID: nil) } func contactOverrides( contactID: String? ) async -> ContactAudienceOverrides { let contactID = await resolveContactID(contactID: contactID) guard let contactID = contactID else { return ContactAudienceOverrides() } let pendingContactOverrides = await self.pendingOverrides(contactID: contactID) var tags: [TagGroupUpdate] = [] var attributes: [AttributeUpdate] = [] var scopedSubscriptionLists: [ScopedSubscriptionListUpdate] = [] var channels: [ContactChannelUpdate] = [] // Contact updates self.updates.values.forEach { update in if case let .contact(identifier) = update.recordType, identifier == contactID { if let updateTags = update.tags { tags += updateTags } if let updateAttributes = update.attributes { attributes += updateAttributes } if let updateScopedSubscriptionLists = update.scopedSubscriptionLists { scopedSubscriptionLists += updateScopedSubscriptionLists } if let updateChannel = update.channels { channels += updateChannel } } } // Pending contact if let pendingContactOverrides = pendingContactOverrides { tags += pendingContactOverrides.tags attributes += pendingContactOverrides.attributes scopedSubscriptionLists += pendingContactOverrides.subscriptionLists channels += pendingContactOverrides.channels } return ContactAudienceOverrides( tags: tags, attributes: attributes, subscriptionLists: scopedSubscriptionLists, channels: channels ) } func contactOverrideUpdates( contactID: String? ) async -> AsyncStream<ContactAudienceOverrides> { 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? { guard let contactID = contactID else { return await self.stableContactIDProvider?() } return contactID } fileprivate struct UpdateRecord { enum RecordType { case channel(String) case contact(String) } let recordType: RecordType let tags: [TagGroupUpdate]? let attributes: [AttributeUpdate]? let subscriptionLists: [SubscriptionListUpdate]? let scopedSubscriptionLists: [ScopedSubscriptionListUpdate]? let channels: [ContactChannelUpdate]? } fileprivate struct ContactRecord: Sendable { let contactID: String let tags: [TagGroupUpdate] let attributes: [AttributeUpdate] let subscriptionLists: [ScopedSubscriptionListUpdate] let channels: [ContactChannelUpdate] } } struct ContactAudienceOverrides: Sendable { let tags: [TagGroupUpdate] let attributes: [AttributeUpdate] let subscriptionLists: [ScopedSubscriptionListUpdate] let channels: [ContactChannelUpdate] init(tags: [TagGroupUpdate] = [], attributes: [AttributeUpdate] = [], subscriptionLists: [ScopedSubscriptionListUpdate] = [], channels: [ContactChannelUpdate] = []) { self.tags = tags self.attributes = attributes self.subscriptionLists = subscriptionLists self.channels = channels } } struct ChannelAudienceOverrides: Sendable, Equatable { let tags: [TagGroupUpdate] let attributes: [AttributeUpdate] let subscriptionLists: [SubscriptionListUpdate] init(tags: [TagGroupUpdate] = [], attributes: [AttributeUpdate] = [], subscriptionLists: [SubscriptionListUpdate] = []) { self.tags = tags self.attributes = attributes self.subscriptionLists = subscriptionLists } } ================================================ FILE: Airship/AirshipCore/Source/AudienceUtils.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: final class AudienceUtils { class func collapse( _ updates: [ScopedSubscriptionListUpdate] ) -> [ScopedSubscriptionListUpdate] { var handled = Set<String>() var collapsed: [ScopedSubscriptionListUpdate] = [] updates.reversed() .forEach { update in let key = "\(update.scope.rawValue):\(update.listId)" if !handled.contains(key) { collapsed.append(update) handled.insert(key) } } return collapsed.reversed() } class func collapse( _ updates: [SubscriptionListUpdate] ) -> [SubscriptionListUpdate] { var handled = Set<String>() var collapsed: [SubscriptionListUpdate] = [] updates.reversed() .forEach { update in if !handled.contains(update.listId) { collapsed.append(update) handled.insert(update.listId) } } return collapsed.reversed() } class func collapse( _ updates: [TagGroupUpdate] ) -> [TagGroupUpdate] { var adds: [String: [String]] = [:] var removes: [String: [String]] = [:] var sets: [String: [String]] = [:] updates.forEach { update in switch update.type { case .add: if sets[update.group] != nil { update.tags.forEach { sets[update.group]?.append($0) } } else { removes[update.group]? .removeAll(where: { update.tags.contains($0) }) if adds[update.group] == nil { adds[update.group] = [] } update.tags.forEach { adds[update.group]?.append($0) } } case .remove: if sets[update.group] != nil { sets[update.group]? .removeAll(where: { update.tags.contains($0) }) } else { adds[update.group]? .removeAll(where: { update.tags.contains($0) }) if removes[update.group] == nil { removes[update.group] = [] } update.tags.forEach { removes[update.group]?.append($0) } } case .set: removes[update.group] = nil adds[update.group] = nil sets[update.group] = update.tags } } let setUpdates = sets.map { TagGroupUpdate(group: $0.key, tags: Array($0.value), type: .set) } let addUpdates = adds.compactMap { $0.value.isEmpty ? nil : TagGroupUpdate( group: $0.key, tags: Array($0.value), type: .add ) } let removeUpdates = removes.compactMap { $0.value.isEmpty ? nil : TagGroupUpdate( group: $0.key, tags: Array($0.value), type: .remove ) } return setUpdates + addUpdates + removeUpdates } class func collapse( _ updates: [AttributeUpdate] ) -> [AttributeUpdate] { var found: [String] = [] let latest: [AttributeUpdate] = updates.reversed() .compactMap { update in guard !found.contains(update.attribute) else { return nil } found.append(update.attribute) return update } return latest.reversed() } class func applyTagUpdates( _ tagGroups: [String: [String]]?, updates: [TagGroupUpdate]? ) -> [String: [String]] { var updated = tagGroups ?? [:] updates? .forEach { update in switch update.type { case .add: if updated[update.group] == nil { updated[update.group] = [] } updated[update.group]?.append(contentsOf: update.tags) case .remove: updated[update.group]? .removeAll(where: { update.tags.contains($0) }) case .set: updated[update.group] = update.tags } } return updated.compactMapValues({ $0.isEmpty ? nil : $0 }) } class func normalizeTags( _ tags: [String] ) -> [String] { var normalized: [String] = [] for tag in tags { let trimmed = tag.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty || trimmed.count > 128 { AirshipLogger.error( "Tag \(trimmed) must be between 1-128 characters. Ignoring" ) continue } if !normalized.contains(trimmed) { normalized.append(trimmed) } } return normalized } class func normalizeTagGroup(_ group: String) -> String { return group.trimmingCharacters(in: .whitespacesAndNewlines) } class func applyAttributeUpdates( _ attributes: [String: AirshipJSON]?, updates: [AttributeUpdate]? ) -> [String: AirshipJSON] { var updated = attributes ?? [:] updates? .forEach { update in switch update.type { case .set: updated[update.attribute] = update.jsonValue case .remove: updated[update.attribute] = nil } } return updated } class func applySubscriptionListsUpdates( _ subscriptionLists: [String: [ChannelScope]]?, updates: [ScopedSubscriptionListUpdate]? ) -> [String: [ChannelScope]] { var updated = subscriptionLists ?? [:] updates? .forEach { update in var scopes = updated[update.listId] ?? [] switch update.type { case .subscribe: if !scopes.contains(update.scope) { scopes.append(update.scope) updated[update.listId] = scopes } case .unsubscribe: scopes.removeAll(where: { $0 == update.scope }) updated[update.listId] = scopes.isEmpty ? nil : scopes } } return updated } class func normalize(_ updates: [LiveActivityUpdate]) -> [LiveActivityUpdate] { var timeStamps: Set<Int64> = Set() var normalized: [LiveActivityUpdate] = [] updates.forEach { update in var timeStamp = update.actionTimeMS while timeStamps.contains(timeStamp) { timeStamp = timeStamp + 1 } if (timeStamp != update.actionTimeMS) { var mutable = update mutable.actionTimeMS = timeStamp normalized.append(mutable) } else { normalized.append(update) } timeStamps.insert(timeStamp) } return normalized } } ================================================ FILE: Airship/AirshipCore/Source/AuthToken.swift ================================================ import Foundation struct AuthToken { let identifier: String let token: String let expiration: Date init(identifier: String, token: String, expiration: Date) { self.identifier = identifier self.token = token self.expiration = expiration } } ================================================ FILE: Airship/AirshipCore/Source/AutoIntegration.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(WatchKit) import WatchKit #endif #if canImport(AppKit) import AppKit #endif @MainActor final class AutoIntegration { public static let shared = AutoIntegration() private let swizzler: AirshipSwizzler = AirshipSwizzler() private let dummyNotificationDelegate = UAAutoIntegrationDummyDelegate() weak var delegate: (any AppIntegrationDelegate)? public func integrate(with delegate: any AppIntegrationDelegate) { self.delegate = delegate #if os(watchOS) performWatchIntegration(delegate: delegate) #elseif os(macOS) performMacIntegration(delegate: delegate) #else performMobileIntegration(delegate: delegate) #endif // Notification Center swizzling (Platform independent) swizzleNotificationCenter(delegate: delegate) } private func swizzleNotificationCenter(delegate: any AppIntegrationDelegate) { self.swizzler.swizzleNotificationCenterDelegateSetter( delegate: delegate, dummyDelegate: self.dummyNotificationDelegate ) if let current = UNUserNotificationCenter.current().delegate { self.swizzler.swizzleNotificationCenterDelegate(current, delegate: delegate) } else { UNUserNotificationCenter.current().delegate = dummyNotificationDelegate } } // MARK: - Platform Specific Integration Logic #if os(watchOS) private func performWatchIntegration(delegate: any AppIntegrationDelegate) { // Access via WKApplication (Modern/SwiftUI friendly) guard let appDelegate = WKApplication.shared().delegate else { AirshipLogger.info("Watch app delegate not set, deferring until didFinishLaunching.") NotificationCenter.default.addObserver( forName: WKApplication.didFinishLaunchingNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.performWatchIntegration(delegate: delegate) } } return } AirshipLogger.debug("Integrating Airship Auto-Integration (watchOS).") self.swizzler.swizzleWatchDidRegister(appDelegate, delegate: delegate) self.swizzler.swizzleWatchDidFailToRegister(appDelegate, delegate: delegate) self.swizzler.swizzleWatchDidReceiveRemoteNotification(appDelegate, delegate: delegate) } #elseif os(macOS) private func performMacIntegration(delegate: any AppIntegrationDelegate) { guard let appDelegate = NSApplication.shared.delegate else { AirshipLogger.info("macOS app delegate not set, deferring until didFinishLaunching.") NotificationCenter.default.addObserver( forName: NSApplication.didFinishLaunchingNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.performMacIntegration(delegate: delegate) } } return } AirshipLogger.debug("Integrating Airship Auto-Integration (macOS).") self.swizzler.swizzleMacDidRegister(appDelegate, delegate: delegate) self.swizzler.swizzleMacDidFailToRegister(appDelegate, delegate: delegate) self.swizzler.swizzleMacDidReceiveRemoteNotification(appDelegate, delegate: delegate) } #else private func performMobileIntegration(delegate: any AppIntegrationDelegate) { guard let appDelegate = UIApplication.shared.delegate else { AirshipLogger.info("App delegate not set, deferring until didFinishLaunching.") NotificationCenter.default.addObserver( forName: UIApplication.didFinishLaunchingNotification, object: nil, queue: .main ) { [weak self] _ in MainActor.assumeIsolated { self?.performMobileIntegration(delegate: delegate) } } return } AirshipLogger.debug("Integrating Airship Auto-Integration (iOS/tvOS/visionOS).") self.swizzler.swizzleDidRegister(appDelegate, delegate: delegate) self.swizzler.swizzleDidFailToRegister(appDelegate, delegate: delegate) self.swizzler.swizzleDidReceiveRemoteNotification(appDelegate, delegate: delegate) #if !os(visionOS) self.swizzler.swizzleBackgroundFetch(appDelegate, delegate: delegate) #endif } #endif } // MARK: - Default delegate fileprivate class UAAutoIntegrationDummyDelegate: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([]) } #if !os(tvOS) func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { completionHandler() } #endif } // MARK: - Notification Center (common) fileprivate extension AirshipSwizzler { private typealias NotificationCenterDelegateSetterBlock = @convention(block) (UNUserNotificationCenter, (any UNUserNotificationCenterDelegate)?) -> Void private typealias WillPresentNotificationBlock = @convention(block) (NSObject, UNUserNotificationCenter, UNNotification, @Sendable @escaping (UNNotificationPresentationOptions) -> Void) -> Void #if !os(tvOS) private typealias DidReceiveNotificationResponseBlock = @convention(block) (NSObject, UNUserNotificationCenter, UNNotificationResponse, @Sendable @escaping () -> Void) -> Void #endif func swizzleNotificationCenterDelegateSetter(delegate: any AppIntegrationDelegate, dummyDelegate: UAAutoIntegrationDummyDelegate) { let setter = #selector(setter: UNUserNotificationCenter.delegate) let block: NotificationCenterDelegateSetterBlock = { [weak self] (center, newDelegate) in guard let self else { return } if let original = self.originalImplementation(setter, forClass: UNUserNotificationCenter.self) { let fn = unsafeBitCast(original, to: (@convention(c) (UNUserNotificationCenter, Selector, (any UNUserNotificationCenterDelegate)?) -> Void).self) fn(center, setter, newDelegate) } if let newDelegate { self.swizzleNotificationCenterDelegate(newDelegate, delegate: delegate) } else { UNUserNotificationCenter.current().delegate = dummyDelegate } } self.swizzleClass(UNUserNotificationCenter.self, selector: setter, implementation: imp_implementationWithBlock(block)) } func swizzleNotificationCenterDelegate(_ delegate: any UNUserNotificationCenterDelegate, delegate integrationDelegate: any AppIntegrationDelegate) { swizzleNotificationCenterWillPresent(delegate, delegate: integrationDelegate) #if !os(tvOS) swizzleNotificationCenterDidReceive(delegate, delegate: integrationDelegate) #endif } private func swizzleNotificationCenterWillPresent(_ delegate: any UNUserNotificationCenterDelegate, delegate integrationDelegate: any AppIntegrationDelegate) { let willPresentSelector = #selector((any UNUserNotificationCenterDelegate).userNotificationCenter(_:willPresent:withCompletionHandler:)) let willPresentBlock: WillPresentNotificationBlock = { [weak self] (receiver, center, notification, handler) in guard receiver === UNUserNotificationCenter.current().delegate else { handler([]); return } let group = DispatchGroup() let result: AirshipAtomicValue<UNNotificationPresentationOptions> = AirshipAtomicValue([]) if let strongSelf = self, let original = strongSelf.originalImplementation(willPresentSelector, forClass: type(of: receiver)) { group.enter() let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, UNUserNotificationCenter, UNNotification, @escaping (UNNotificationPresentationOptions) -> Void) -> Void).self) let safeCompletion = strongSelf.ensureOnce(selector: willPresentSelector) { options in result.update { $0.union(options) } group.leave() } fn(receiver, willPresentSelector, center, notification, safeCompletion) } group.enter() integrationDelegate.presentationOptions(for: notification) { options in result.update { $0.union(options) } group.leave() } group.notify(queue: .main) { integrationDelegate.willPresentNotification(notification: notification, presentationOptions: result.value) { handler(result.value) } } } self.swizzleInstance( delegate, selector: willPresentSelector, protocol: (any UNUserNotificationCenterDelegate).self, implementation: imp_implementationWithBlock(willPresentBlock) ) } #if !os(tvOS) private func swizzleNotificationCenterDidReceive(_ delegate: any UNUserNotificationCenterDelegate, delegate integrationDelegate: any AppIntegrationDelegate) { let responseSelector = #selector((any UNUserNotificationCenterDelegate).userNotificationCenter(_:didReceive:withCompletionHandler:)) let responseBlock: DidReceiveNotificationResponseBlock = { [weak self] (receiver, center, response, handler) in guard receiver === UNUserNotificationCenter.current().delegate else { handler(); return } let group = DispatchGroup() if let strongSelf = self, let original = strongSelf.originalImplementation(responseSelector, forClass: type(of: receiver)) { group.enter() let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, UNUserNotificationCenter, UNNotificationResponse, @escaping () -> Void) -> Void).self) let safeCompletion = strongSelf.ensureOnce(selector: responseSelector) { group.leave() } fn(receiver, responseSelector, center, response, safeCompletion) } group.enter() integrationDelegate.didReceiveNotificationResponse(response: response) { group.leave() } group.notify(queue: .main) { handler() } } self.swizzleInstance( delegate, selector: responseSelector, protocol: (any UNUserNotificationCenterDelegate).self, implementation: imp_implementationWithBlock(responseBlock) ) } #endif } #if os(watchOS) // MARK: - App Delegate watchOS extension AirshipSwizzler { private typealias WatchDidRegisterForRemoteNotificationsBlock = @convention(block) (NSObject, Data) -> Void private typealias WatchDidReceiveRemoteNotificationBlock = @convention(block) (NSObject, [AnyHashable: Any], @escaping (WKBackgroundFetchResult) -> Void) -> Void private typealias WatchDidFailToRegisterBlock = @convention(block) (NSObject, any Error) -> Void func swizzleWatchDidRegister(_ delegate: any NSObjectProtocol, delegate integrationDelegate: any AppIntegrationDelegate) { let regSelector = #selector((any WKExtensionDelegate).didRegisterForRemoteNotifications(withDeviceToken:)) let regBlock: WatchDidRegisterForRemoteNotificationsBlock = { [weak self] (receiver, token) in integrationDelegate.didRegisterForRemoteNotifications(deviceToken: token) if let strongSelf = self, let original = strongSelf.originalImplementation(regSelector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, Data) -> Void).self) fn(receiver, regSelector, token) } } self.swizzleInstance( delegate, selector: regSelector, protocol: (any WKExtensionDelegate).self, implementation: imp_implementationWithBlock(regBlock) ) } func swizzleWatchDidReceiveRemoteNotification(_ delegate: any NSObjectProtocol, delegate integrationDelegate: any AppIntegrationDelegate) { let selector = #selector((any WKExtensionDelegate).didReceiveRemoteNotification(_:fetchCompletionHandler:)) let block: WatchDidReceiveRemoteNotificationBlock = { [weak self] (receiver, userInfo, handler) in let resultValue: AirshipAtomicValue<WKBackgroundFetchResult> = AirshipAtomicValue(.noData) let group = DispatchGroup() let updateResult: @Sendable (WKBackgroundFetchResult) -> Void = { next in resultValue.update { current in // Logic: .newData wins, otherwise .failed wins over .noData return (current == .newData || next == .newData) ? .newData : (next == .failed ? .failed : current) } } if let strongSelf = self, let original = strongSelf.originalImplementation(selector, forClass: type(of: receiver)) { group.enter() let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, [AnyHashable: Any], @escaping (WKBackgroundFetchResult) -> Void) -> Void).self) let safeCompletion = strongSelf.ensureOnce(selector: selector) { res in updateResult(res) group.leave() } fn(receiver, selector, userInfo, safeCompletion) } group.enter() // watchOS doesn't have applicationState == .active in the same way, // but you can check if the app is in the foreground via WKApplication let isForeground = WKApplication.shared().applicationState == .active integrationDelegate.didReceiveRemoteNotification(userInfo: userInfo, isForeground: isForeground) { res in updateResult(res) group.leave() } group.notify(queue: .main) { handler(resultValue.value) } } self.swizzleInstance( delegate, selector: selector, protocol: (any WKExtensionDelegate).self, implementation: imp_implementationWithBlock(block) ) } func swizzleWatchDidFailToRegister(_ delegate: any NSObjectProtocol, delegate integrationDelegate: any AppIntegrationDelegate) { let selector = #selector((any WKExtensionDelegate).didFailToRegisterForRemoteNotificationsWithError(_:)) let block: WatchDidFailToRegisterBlock = { [weak self] (receiver, error) in integrationDelegate.didFailToRegisterForRemoteNotifications(error: error) if let strongSelf = self, let original = strongSelf.originalImplementation(selector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, any Error) -> Void).self) fn(receiver, selector, error) } } self.swizzleInstance( delegate, selector: selector, protocol: (any WKExtensionDelegate).self, implementation: imp_implementationWithBlock(block) ) } } #elseif os(macOS) // MARK: - App Delegate macOS extension AirshipSwizzler { private typealias MacDidRegisterBlock = @convention(block) (NSObject, NSApplication, Data) -> Void private typealias MacDidFailBlock = @convention(block) (NSObject, NSApplication, any Error) -> Void private typealias MacDidReceiveRemoteNotificationBlock = @convention(block) (NSObject, NSApplication, [AnyHashable: Any]) -> Void func swizzleMacDidRegister(_ appDelegate: any NSApplicationDelegate, delegate: any AppIntegrationDelegate) { let selector = #selector((any NSApplicationDelegate).application(_:didRegisterForRemoteNotificationsWithDeviceToken:)) let block: MacDidRegisterBlock = { [weak self] (receiver, app, token) in delegate.didRegisterForRemoteNotifications(deviceToken: token) if let strongSelf = self, let original = strongSelf.originalImplementation(selector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, NSApplication, Data) -> Void).self) fn(receiver, selector, app, token) } } self.swizzleInstance(appDelegate, selector: selector, protocol: (any NSApplicationDelegate).self, implementation: imp_implementationWithBlock(block)) } func swizzleMacDidFailToRegister(_ appDelegate: any NSApplicationDelegate, delegate: any AppIntegrationDelegate) { let selector = #selector((any NSApplicationDelegate).application(_:didFailToRegisterForRemoteNotificationsWithError:)) let block: MacDidFailBlock = { [weak self] (receiver, app, error) in delegate.didFailToRegisterForRemoteNotifications(error: error) if let strongSelf = self, let original = strongSelf.originalImplementation(selector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, NSApplication, any Error) -> Void).self) fn(receiver, selector, app, error) } } self.swizzleInstance(appDelegate, selector: selector, protocol: (any NSApplicationDelegate).self, implementation: imp_implementationWithBlock(block)) } func swizzleMacDidReceiveRemoteNotification(_ appDelegate: any NSApplicationDelegate, delegate: any AppIntegrationDelegate) { let selector = #selector((any NSApplicationDelegate).application(_:didReceiveRemoteNotification:)) let block: MacDidReceiveRemoteNotificationBlock = { [weak self] (receiver, app, userInfo) in let isForeground = NSApplication.shared.isActive delegate.didReceiveRemoteNotification(userInfo: userInfo, isForeground: isForeground) if let strongSelf = self, let original = strongSelf.originalImplementation(selector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, NSApplication, [AnyHashable: Any]) -> Void).self) fn(receiver, selector, app, userInfo) } } self.swizzleInstance( appDelegate, selector: selector, protocol: (any NSApplicationDelegate).self, implementation: imp_implementationWithBlock(block) ) } } #else // MARK: - App Delegate tvOS, ipadOS, iOS fileprivate extension AirshipSwizzler { private typealias DidRegisterForRemoteNotificationsBlock = @convention(block) (NSObject, UIApplication, Data) -> Void private typealias DidFailToRegisterForRemoteNotificationsBlock = @convention(block) (NSObject, UIApplication, any Error) -> Void private typealias DidReceiveRemoteNotificationFetchBlock = @convention(block) (NSObject, UIApplication, [AnyHashable: Any], @Sendable @escaping (UIBackgroundFetchResult) -> Void) -> Void private typealias BackgroundFetchBlock = @convention(block) (NSObject, UIApplication, @Sendable @escaping (UIBackgroundFetchResult) -> Void) -> Void func swizzleDidRegister(_ appDelegate: any UIApplicationDelegate, delegate: any AppIntegrationDelegate) { let regSelector = #selector((any UIApplicationDelegate).application(_:didRegisterForRemoteNotificationsWithDeviceToken:)) let regBlock: DidRegisterForRemoteNotificationsBlock = { [weak self] (receiver, app, token) in delegate.didRegisterForRemoteNotifications(deviceToken: token) if let strongSelf = self, let original = strongSelf.originalImplementation(regSelector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, UIApplication, Data) -> Void).self) fn(receiver, regSelector, app, token) } } self.swizzleInstance( appDelegate, selector: regSelector, protocol: (any UIApplicationDelegate).self, implementation: imp_implementationWithBlock(regBlock) ) } func swizzleDidFailToRegister(_ appDelegate: any UIApplicationDelegate, delegate: any AppIntegrationDelegate) { let failSelector = #selector((any UIApplicationDelegate).application(_:didFailToRegisterForRemoteNotificationsWithError:)) let failBlock: DidFailToRegisterForRemoteNotificationsBlock = { [weak self] (receiver, app, error) in delegate.didFailToRegisterForRemoteNotifications(error: error) if let strongSelf = self, let original = strongSelf.originalImplementation(failSelector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, UIApplication, any Error) -> Void).self) fn(receiver, failSelector, app, error) } } self.swizzleInstance( appDelegate, selector: failSelector, protocol: (any UIApplicationDelegate).self, implementation: imp_implementationWithBlock(failBlock) ) } func swizzleDidReceiveRemoteNotification(_ appDelegate: any UIApplicationDelegate, delegate: any AppIntegrationDelegate) { let fetchSelector = #selector((any UIApplicationDelegate).application(_:didReceiveRemoteNotification:fetchCompletionHandler:)) let fetchBlock: DidReceiveRemoteNotificationFetchBlock = { [weak self] (receiver, app, userInfo, handler) in let resultValue: AirshipAtomicValue<UIBackgroundFetchResult> = AirshipAtomicValue(.noData) let group = DispatchGroup() let updateResult: @Sendable (UIBackgroundFetchResult) -> Void = { next in resultValue.update { current in return (current == .newData || next == .newData) ? .newData : (next == .failed ? .failed : current) } } if let strongSelf = self, let original = strongSelf.originalImplementation(fetchSelector, forClass: type(of: receiver)) { group.enter() typealias RawFetchFunc = @convention(c) (NSObject, Selector, UIApplication, [AnyHashable: Any], @escaping (UIBackgroundFetchResult) -> Void) -> Void let fn = unsafeBitCast(original, to: RawFetchFunc.self) let safeCompletion = strongSelf.ensureOnce(selector: fetchSelector) { res in updateResult(res) group.leave() } fn(receiver, fetchSelector, app, userInfo, safeCompletion) } group.enter() delegate.didReceiveRemoteNotification(userInfo: userInfo, isForeground: app.applicationState == .active) { res in updateResult(res) group.leave() } group.notify(queue: .main) { handler(resultValue.value) } } self.swizzleInstance( appDelegate, selector: fetchSelector, protocol: (any UIApplicationDelegate).self, implementation: imp_implementationWithBlock(fetchBlock) ) } #if !os(visionOS) func swizzleBackgroundFetch(_ appDelegate: any UIApplicationDelegate, delegate: any AppIntegrationDelegate) { let backgroundSelector = #selector((any UIApplicationDelegate).application(_:performFetchWithCompletionHandler:)) let backgroundBlock: BackgroundFetchBlock = { [weak self] (receiver, app, handler) in delegate.onBackgroundAppRefresh() if let strongSelf = self, let original = strongSelf.originalImplementation(backgroundSelector, forClass: type(of: receiver)) { let fn = unsafeBitCast(original, to: (@convention(c) (NSObject, Selector, UIApplication, @escaping (UIBackgroundFetchResult) -> Void) -> Void).self) let safeCompletion = strongSelf.ensureOnce(selector: backgroundSelector, completion: handler) fn(receiver, backgroundSelector, app, safeCompletion) } else { handler(.noData) } } self.swizzleInstance( appDelegate, selector: backgroundSelector, protocol: (any UIApplicationDelegate).self, implementation: imp_implementationWithBlock(backgroundBlock) ) } #endif } #endif fileprivate extension AirshipSwizzler { func ensureOnce<T>(selector: Selector, completion: @escaping (T) -> Void) -> (T) -> Void { let called = AirshipAtomicValue(false) return { value in if called.compareAndSet(expected: false, value: true) { completion(value) } else { AirshipLogger.error( """ Completion handler for \(selector) was called multiple times. Airship has ignored the extra calls to prevent a crash, but you should check your delegate implementation to ensure the handler is called exactly once. """ ) } } } func ensureOnce(selector: Selector, completion: @escaping () -> Void) -> () -> Void { let called = AirshipAtomicValue(false) return { if called.compareAndSet(expected: false, value: true) { completion() } else { AirshipLogger.error( """ Completion handler for \(selector) was called multiple times. Airship has ignored the extra calls to prevent a crash, but you should check your delegate implementation to ensure the handler is called exactly once. """ ) } } } } ================================================ FILE: Airship/AirshipCore/Source/BackgroundColorViewModifier.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct BackgroundViewModifier: ViewModifier { @Environment(\.colorScheme) var colorScheme @EnvironmentObject var state: ThomasState var backgroundColor: ThomasColor? var backgroundColorOverrides: [ThomasPropertyOverride<ThomasColor>]? var border: ThomasBorder? var borderOverrides: [ThomasPropertyOverride<ThomasBorder>]? var shadow: ThomasShadow? func body(content: Content) -> some View { let resolvedBorder = ThomasPropertyOverride<ThomasBorder>.resolveOptional( state: state, overrides: borderOverrides, defaultValue: border ) let resolvedBackgroundColor = ThomasPropertyOverride<ThomasColor>.resolveOptional( state: state, overrides: backgroundColorOverrides, defaultValue: backgroundColor ) let innerCornerRadii = CustomCornerRadii(innerRadiiFor: resolvedBorder) return content .clipContent(border: resolvedBorder) .applyPadding(padding: resolvedBorder?.strokeWidth) .applyBackground( color: resolvedBackgroundColor, border: resolvedBorder, colorScheme: colorScheme ) .applyShadow( shadow, border: resolvedBorder, colorScheme: colorScheme ) .contentShape( CustomRoundedRectangle( cornerRadii: innerCornerRadii, style: .continuous ) ) } } fileprivate extension View { @ViewBuilder func applyPadding(padding: Double?) -> some View { if let padding { self.padding(padding) } else { self } } @ViewBuilder func applyShadow(_ shadow: ThomasShadow?, border: ThomasBorder?, colorScheme: ColorScheme) -> some View { if let boxShadow = shadow?.resolvedBoxShadow { if let borderShape = border?.borderShape { self.background( ZStack { borderShape .fill(boxShadow.color.toColor(colorScheme)) .padding(.all, -boxShadow.radius/2.0) .blur(radius: boxShadow.blurRadius, opaque: false) .offset( x: boxShadow.offsetX ?? 0, y: boxShadow.offsetY ?? 0 ) borderShape.blendMode(.destinationOut) } .compositingGroup() .allowsHitTesting(false) ) } else { self.background( ZStack { Rectangle() .fill(boxShadow.color.toColor(colorScheme)) .padding(.all, -boxShadow.radius/2.0) .blur(radius: boxShadow.blurRadius, opaque: false) .offset( x: boxShadow.offsetX ?? 0, y: boxShadow.offsetY ?? 0 ) Rectangle().blendMode(.destinationOut) } .compositingGroup() .allowsHitTesting(false) ) } } else { self } } @ViewBuilder func applyBackground( color: ThomasColor?, border: ThomasBorder?, colorScheme: ColorScheme ) -> some View { // Defaults to black to match Android & Web let strokeColor: Color = border?.strokeColor?.toColor(colorScheme) ?? .black let backgroundColor: Color = color?.toColor(colorScheme) ?? .clear if let strokeWidth = border?.strokeWidth, strokeWidth > 0 { if let borderShape = border?.borderShape { self.background( borderShape .strokeBorder(strokeColor, lineWidth: strokeWidth) .background(borderShape.inset(by: strokeWidth).fill(backgroundColor)) ) .clipShape(borderShape) } else { self.background( Rectangle() .strokeBorder(strokeColor, lineWidth: strokeWidth) .background(Rectangle().inset(by: strokeWidth).fill(backgroundColor)) ) } } else if let borderShape = border?.borderShape { self.background(backgroundColor) .clipShape(borderShape) } else { self.background(backgroundColor) } } @ViewBuilder func clipContent( border: ThomasBorder? ) -> some View { if let cornerRadius = border?.maxRadius, let width = border?.strokeWidth, cornerRadius > width { let cornerRadii = CustomCornerRadii(innerRadiiFor: border) ZStack { let shape = CustomRoundedRectangle( cornerRadii: cornerRadii, style: .continuous) self.clipShape(shape) } } else { self } } } fileprivate extension ThomasShadow { var resolvedBoxShadow: ThomasShadow.BoxShadow? { selectors?.first { $0.platform == nil || $0.platform == .ios }?.shadow.boxShadow } } fileprivate extension ThomasBorder { var borderShape: CustomRoundedRectangle? { let cornerRadii = CustomCornerRadii(outerRadiiFor: self) return CustomRoundedRectangle( cornerRadii: cornerRadii, style: .continuous ) } } extension View { @ViewBuilder func thomasBackground( color: ThomasColor? = nil, colorOverrides: [ThomasPropertyOverride<ThomasColor>]? = nil, border: ThomasBorder? = nil, borderOverrides: [ThomasPropertyOverride<ThomasBorder>]? = nil, shadow: ThomasShadow? = nil ) -> some View { if border != nil || shadow != nil || color != nil || (borderOverrides?.isEmpty == false) || (colorOverrides?.isEmpty == false) { self.modifier( BackgroundViewModifier( backgroundColor: color, backgroundColorOverrides: colorOverrides, border: border, borderOverrides: borderOverrides, shadow: shadow ) ) } else { self } } } struct CustomCornerRadii: Equatable, Animatable { var topLeading: CGFloat var topTrailing: CGFloat var bottomLeading: CGFloat var bottomTrailing: CGFloat /// Initializes CustomCornerRadii representing the INNER radii /// calculated from a ThomasBorder, subtracting the stroke width. init(innerRadiiFor border: ThomasBorder?) { // Use guard let for safe unwrapping guard let border = border else { // If border is nil, initialize with all zeros self.init(topLeading: 0, topTrailing: 0, bottomLeading: 0, bottomTrailing: 0) return } // Convert properties to CGFloat for calculations let strokeWidth = CGFloat(border.strokeWidth ?? 0.0) if let corners = border.cornerRadius { // Per-corner radii defined, calculate inner values using max(0, ...) self.init( // Call the memberwise initializer topLeading: max(0, CGFloat(corners.topLeft ?? 0.0) - strokeWidth), topTrailing: max(0, CGFloat(corners.topRight ?? 0.0) - strokeWidth), bottomLeading: max(0, CGFloat(corners.bottomLeft ?? 0.0) - strokeWidth), bottomTrailing: max(0, CGFloat(corners.bottomRight ?? 0.0) - strokeWidth) ) } else { // Fallback to single radius let radius = CGFloat(border.radius ?? 0.0) let innerRadius = max(0, radius - strokeWidth) // Ensure non-negative // Call the memberwise initializer with the single inner radius self.init( topLeading: innerRadius, topTrailing: innerRadius, bottomLeading: innerRadius, bottomTrailing: innerRadius ) } } init(outerRadiiFor border: ThomasBorder?) { guard let border = border else { self.init(topLeading: 0, topTrailing: 0, bottomLeading: 0, bottomTrailing: 0) return } if let corners = border.cornerRadius { self.init( topLeading: CGFloat(corners.topLeft ?? 0.0), topTrailing: CGFloat(corners.topRight ?? 0.0), bottomLeading: CGFloat(corners.bottomLeft ?? 0.0), bottomTrailing: CGFloat(corners.bottomRight ?? 0.0) ) } else { let radius = CGFloat(border.radius ?? 0.0) self.init( topLeading: radius, topTrailing: radius, bottomLeading: radius, bottomTrailing: radius ) } } init( topLeading: CGFloat = 0, topTrailing: CGFloat = 0, bottomLeading: CGFloat = 0, bottomTrailing: CGFloat = 0 ) { self.topLeading = topLeading self.topTrailing = topTrailing self.bottomLeading = bottomLeading self.bottomTrailing = bottomTrailing } var animatableData: AnimatablePair< AnimatablePair<CGFloat, CGFloat>, AnimatablePair<CGFloat, CGFloat> > { get { AnimatablePair( AnimatablePair(topLeading, bottomLeading), AnimatablePair(bottomTrailing, topTrailing) ) } set { topLeading = newValue.first.first bottomLeading = newValue.first.second bottomTrailing = newValue.second.first topTrailing = newValue.second.second } } } struct CustomRoundedRectangle: InsettableShape { let cornerRadii: CustomCornerRadii let style: RoundedCornerStyle var insetAmount: CGFloat = 0 func path(in rect: CGRect) -> Path { let insetRect = rect.insetBy(dx: insetAmount, dy: insetAmount) return UnevenRoundedRectangle( topLeadingRadius: cornerRadii.topLeading, bottomLeadingRadius: cornerRadii.bottomLeading, bottomTrailingRadius: cornerRadii.bottomTrailing, topTrailingRadius: cornerRadii.topTrailing, style: .continuous ).path(in: insetRect) } func inset(by amount: CGFloat) -> CustomRoundedRectangle { let adjustedRadii = CustomCornerRadii( topLeading: max(0, cornerRadii.topLeading - amount), topTrailing: max(0, cornerRadii.topTrailing - amount), bottomLeading: max(0, cornerRadii.bottomLeading - amount), bottomTrailing: max(0, cornerRadii.bottomTrailing - amount) ) return CustomRoundedRectangle( cornerRadii: adjustedRadii, style: self.style, insetAmount: self.insetAmount + amount ) } } extension ThomasBorder { var maxRadius: Double? { if let cornerRadius = self.cornerRadius { return [ cornerRadius.bottomLeft ?? 0, cornerRadius.bottomRight ?? 0, cornerRadius.topLeft ?? 0, cornerRadius.topRight ?? 0, ].max() } else { return self.radius } } } ================================================ FILE: Airship/AirshipCore/Source/Badger.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif protocol BadgerProtocol: AnyObject, Sendable { func setBadgeNumber(_ newBadgeNumber: Int) async throws @MainActor var badgeNumber: Int { get } } final class Badger: Sendable, BadgerProtocol { public static let shared: Badger = Badger() func setBadgeNumber(_ newBadgeNumber: Int) async throws { #if os(watchOS) // watchOS does not support app icon badges return #else AirshipLogger.debug("Updating badge \(newBadgeNumber)") try await UNUserNotificationCenter.current().setBadgeCount(newBadgeNumber) #endif } @MainActor var badgeNumber: Int { #if os(watchOS) return 0 #elseif os(macOS) return Int(NSApplication.shared.dockTile.badgeLabel ?? "") ?? 0 #else // Covers iOS, tvOS, and visionOS return UIApplication.shared.applicationIconBadgeNumber #endif } } ================================================ FILE: Airship/AirshipCore/Source/BannerView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if canImport(UIKit) import UIKit #endif struct BannerView: View { @Environment(\.layoutState) private var layoutState @Environment(\.windowSize) private var windowSize @Environment(\.orientation) private var orientation @Environment(\.colorScheme) private var colorScheme static let animationInOutDuration = 0.2 private let viewControllerOptions: ThomasViewControllerOptions private let presentation: ThomasPresentationInfo.Banner private let layout: AirshipLayout @ObservedObject private var thomasEnvironment: ThomasEnvironment @ObservedObject private var bannerConstraints: ThomasBannerConstraints @StateObject private var timer: AirshipObservableTimer /// The dismiss action callback private let onDismiss: () -> Void @State private var isShowing: Bool = false @State private var swipeOffset: CGFloat = 0 @State private var isButtonTapsDisabled: Bool = false @State private var contentSize: CGSize? = nil init( viewControllerOptions: ThomasViewControllerOptions, presentation: ThomasPresentationInfo.Banner, layout: AirshipLayout, thomasEnvironment: ThomasEnvironment, bannerConstraints: ThomasBannerConstraints, onDismiss: @escaping () -> Void ) { self.viewControllerOptions = viewControllerOptions self.presentation = presentation self.layout = layout self.thomasEnvironment = thomasEnvironment self.bannerConstraints = bannerConstraints self._timer = StateObject( wrappedValue: AirshipObservableTimer( duration: TimeInterval(presentation.duration ?? Int(INT_MAX)) ) ) self.onDismiss = onDismiss } var body: some View { ZStack { GeometryReader { metrics in RootView( thomasEnvironment: thomasEnvironment, layout: layout ) { orientation, windowSize in let placement = resolvePlacement( orientation: orientation, windowSize: windowSize ) let banner = createBanner( placement: placement, metrics: metrics ) Group { if isShowing { banner } else { banner.opacity(0) } } .airshipApplyTransition( isTopPlacement: placement.position == .top ) } .airshipOnChangeOf(thomasEnvironment.isDismissed) { _ in setShowing(state: false) { self.swipeOffset = 0 onDismiss() } timer.onDisappear() } .onAppear { timer.onAppear() if contentSize != nil { setShowing(state: true) } } .airshipOnChangeOf(contentSize) { size in if size != nil && !isShowing { setShowing(state: true) } } .airshipOnChangeOf(swipeOffset) { value in self.isButtonTapsDisabled = value != 0 self.timer.isPaused = value != 0 } .onReceive(timer.$isExpired) { expired in if expired { self.thomasEnvironment.dismiss() } } // Invalidate cached content size on orientation change .airshipOnChangeOf(orientation) { _ in self.contentSize = nil } } .id(orientation) .ignoresSafeArea(ignoreKeyboardSafeArea ? [.keyboard] : []) } } private var ignoreKeyboardSafeArea: Bool { presentation.ios?.keyboardAvoidance == .overTheTop } @ViewBuilder private func nub(placement: ThomasPresentationInfo.Banner.Placement) -> some View { if let nubInfo = placement.nubInfo { Capsule() .frame( width: nubInfo.size.width.calculateSize(nil) ?? 36, height: nubInfo.size.height.calculateSize(nil) ?? 4 ) .foregroundColor(nubInfo.color.toColor(colorScheme)) .margin(nubInfo.margin) } else { Capsule() .frame(width: 36, height: 4) .foregroundColor(Color.red.opacity(0.42)) } } private func createBanner( placement: ThomasPresentationInfo.Banner.Placement, metrics: GeometryProxy ) -> some View { let alignment = Alignment( horizontal: .center, vertical: placement.position == .top ? .top : .bottom ) let constraints = ViewConstraints( size: self.bannerConstraints.windowSize, safeAreaInsets: placement.ignoreSafeArea != true ? EdgeInsets() : metrics.safeAreaInsets ) let contentConstraints = constraints.contentConstraints( placement.size, contentSize: self.contentSize, margin: placement.margin ) /** * Banners rely on the viewController to reduce the parent view to avoid blocking the underlying view from recieving taps outside of the * banner. When we adjust the view controller size, it also adjusts the GeometryReader metrics making them inaccurate. We still use the metrics to get safe area insets, * but when calculating the size we need to use the window size in the shared bannerConstraints. Placement margins are also handled by the * viewController to avoid margins being touchable dead areas. */ return VStack { ViewFactory.createView( layout.view, constraints: contentConstraints ) .airshipAddNub( isTopPlacement: placement.position == .top, nub: AnyView(nub(placement: placement)), itemSpacing: 16 ) .thomasBackground( color: placement.backgroundColor, border: placement.border ) .offset(x: 0, y: swipeOffset) #if !os(tvOS) .simultaneousGesture(swipeGesture(placement: placement)) #endif .background( GeometryReader(content: { contentMetrics -> Color in let size = contentMetrics.size DispatchQueue.main.async { self.bannerConstraints.updateContentSize( size, constraints: contentConstraints, placement: placement ) if self.contentSize != size { // Update cached size if constraints match self.contentSize = size } } return Color.airshipTappableClear }) ) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) .edgesIgnoringSafeArea(.all) .accessibilityElement(children: .contain) .accessibilityAction(.escape) { onDismiss() } } private func resolvePlacement( orientation: ThomasOrientation, windowSize: ThomasWindowSize ) -> ThomasPresentationInfo.Banner.Placement { var placement = presentation.defaultPlacement for placementSelector in presentation.placementSelectors ?? [] { if let requiredSize = placementSelector.windowSize, requiredSize != windowSize { continue } if let requiredOrientation = placementSelector.orientation, requiredOrientation != orientation { continue } // its a match! placement = placementSelector.placement } viewControllerOptions.bannerPlacement = placement return placement } private func setShowing(state: Bool, completion: (() -> Void)? = nil) { withAnimation(.easeInOut(duration: BannerView.animationInOutDuration)) { self.isShowing = state } DispatchQueue.main.asyncAfter( deadline: .now() + BannerView.animationInOutDuration ) { completion?() } } #if !os(tvOS) private func swipeGesture(placement: ThomasPresentationInfo.Banner.Placement) -> some Gesture { let minSwipeDistance: CGFloat = if let height = self.contentSize?.height, height > 0 { min(100.0, height * 0.5) } else { 100.0 } return DragGesture(minimumDistance: 10) .onChanged { gesture in withAnimation(.interpolatingSpring(stiffness: 300, damping: 20)) { let offset = gesture.translation.height let upwardSwipeTopPlacement = (placement.position == .top && offset < 0) let downwardSwipeBottomPlacement = (placement.position == .bottom && offset > 0) if upwardSwipeTopPlacement || downwardSwipeBottomPlacement { self.swipeOffset = offset } } } .onEnded { gesture in withAnimation(.interpolatingSpring(stiffness: 300, damping: 20)) { let offset = gesture.translation.height swipeOffset = offset let upwardSwipeTopPlacement = (placement.position == .top && offset < -minSwipeDistance) let downwardSwipeBottomPlacement = (placement.position == .bottom && offset > minSwipeDistance) if upwardSwipeTopPlacement || downwardSwipeBottomPlacement { thomasEnvironment.dismiss() } else { // Return to origin swipeOffset = 0 } } } } #endif } ================================================ FILE: Airship/AirshipCore/Source/BaseCachingRemoteDataProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine protocol CachingRemoteDataProviderResult: Sendable, Equatable { var isSuccess: Bool { get } static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult } final actor BaseCachingRemoteDataProvider<Output: CachingRemoteDataProviderResult, Overrides: Sendable> { private let remoteFetcher: @Sendable (String) async throws -> AirshipHTTPResponse<Output> private let cacheTtl: TimeInterval private let overridesProvider: @Sendable (String) async -> AsyncStream<Overrides> private let overridesApplier: @Sendable (Output, Overrides) async -> Output private let isEnabled: @Sendable () -> Bool private let taskSleeper: any AirshipTaskSleeper private var resolvers: [String: Resolver] = [:] private let date: any AirshipDateProtocol init( remoteFetcher: @Sendable @escaping (String) async throws -> AirshipHTTPResponse<Output>, overridesProvider: @Sendable @escaping (String) async -> AsyncStream<Overrides>, overridesApplier: @Sendable @escaping (Output, Overrides) async -> Output, isEnabled: @Sendable @escaping () -> Bool, date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = .shared, cacheTtl: TimeInterval = 600 ) { self.remoteFetcher = remoteFetcher self.overridesProvider = overridesProvider self.overridesApplier = overridesApplier self.taskSleeper = taskSleeper self.cacheTtl = cacheTtl self.isEnabled = isEnabled self.date = date } private func getResolver(identifier: String, lastKnownIdentifier: String?) -> Resolver { // The resolver for the lastKnownIdentifier can always be dropped, but we // can't assume the identifier (channel or contact id) is the current stable contact ID or channel ID since // its an async stream and we might not be on the last element. if let lastKnownIdentifier, lastKnownIdentifier != identifier { resolvers[lastKnownIdentifier] = nil } if let resolver = resolvers[identifier] { return resolver } let resolver = Resolver( identifier: identifier, overridesProvider: overridesProvider, remoteFetcher: remoteFetcher, cacheTtl: cacheTtl, taskSleeper: taskSleeper, overridesApplier: overridesApplier, isEnabled: isEnabled, date: self.date ) resolvers[identifier] = resolver return resolver } func refresh() async { for resolver in resolvers.values { await resolver.expireCache() } } /// Returns the latest channel result stream from the latest stable identifier nonisolated func updates(identifierUpdates: AsyncStream<String>) -> AsyncStream<Output> { return AsyncStream { continuation in let fetchTask = Task { [weak self] in var resolverTask: Task<Void, Never>? var lastKnownIdentifier: String? = nil for await identifier in identifierUpdates { resolverTask?.cancel() guard !Task.isCancelled else { return } guard let resolver = await self?.getResolver( identifier: identifier, lastKnownIdentifier: lastKnownIdentifier ) else { return } resolverTask = Task { for await update in await resolver.updates() { guard !Task.isCancelled else { return } continuation.yield(update) } } lastKnownIdentifier = identifier } } continuation.onTermination = { _ in fetchTask.cancel() } } } /// Manages the contact update API calls including backoff and override application fileprivate actor Resolver { private let identifier: String private let overridesProvider: @Sendable (String) async -> AsyncStream<Overrides> private let remoteFetcher: @Sendable (String) async throws -> AirshipHTTPResponse<Output> private let cachedValue: CachedValue<Output> private let fetchQueue: AirshipSerialQueue = AirshipSerialQueue() private let cacheTtl: TimeInterval private let taskSleeper: any AirshipTaskSleeper private let overridesApplier: (Output, Overrides) async -> Output private let isEnabled: () -> Bool private let initialBackoff: TimeInterval = 8.0 private let maxBackoff: TimeInterval = 64.0 private var lastResults: [String: Output] = [:] private var waitTask: Task<Void, Never>? = nil func expireCache() { cachedValue.expire() waitTask?.cancel() } init( identifier: String, overridesProvider: @Sendable @escaping (String) async -> AsyncStream<Overrides>, remoteFetcher: @Sendable @escaping (String) async throws -> AirshipHTTPResponse<Output>, cacheTtl: TimeInterval, taskSleeper: any AirshipTaskSleeper, overridesApplier: @escaping (Output, Overrides) async -> Output, isEnabled: @escaping () -> Bool, date: any AirshipDateProtocol ) { self.identifier = identifier self.overridesProvider = overridesProvider self.remoteFetcher = remoteFetcher self.cacheTtl = cacheTtl self.taskSleeper = taskSleeper self.overridesApplier = overridesApplier self.isEnabled = isEnabled self.cachedValue = CachedValue(date: date) } func updates() -> AsyncStream<Output> { let id = UUID().uuidString return AsyncStream { continuation in let refreshTask = Task { var backoff = self.initialBackoff repeat { let fetched = await self.fetch() let workingResult = if fetched.isSuccess { fetched } else if let lastResult = lastResults[id], lastResult.isSuccess { lastResult } else { fetched } guard !Task.isCancelled else { return } let overrideUpdates = await self.overridesProvider(identifier) let updateTask = Task { for await overrides in overrideUpdates { guard !Task.isCancelled else { return } let result = await overridesApplier(workingResult, overrides) if (lastResults[id] != result) { continuation.yield(result) lastResults[id] = result } } } let timeToWait: TimeInterval if (fetched.isSuccess) { timeToWait = cachedValue.timeRemaining backoff = self.initialBackoff } else { timeToWait = backoff if backoff < self.maxBackoff { backoff = backoff * 2 } } waitTask = Task { try? await self.taskSleeper.sleep(timeInterval: timeToWait) } await waitTask?.value updateTask.cancel() } while (!Task.isCancelled) } continuation.onTermination = { _ in refreshTask.cancel() } } } private func fetch() async -> Output { guard isEnabled() else { return Output.error(.disabled) as! Output } return await self.fetchQueue.runSafe { [cachedValue, remoteFetcher, identifier, cacheTtl] in if let cached = cachedValue.value { return cached } do { let response = try await remoteFetcher(identifier) guard response.isSuccess, let outputData = response.result else { throw AirshipErrors.error("Failed to fetch associated channels list") } cachedValue.set(value: outputData, expiresIn: cacheTtl) return outputData } catch { AirshipLogger.warn( "Received error when fetching contact channels \(error))" ) return Output.error(.failedToFetch) as! Output } } } } } enum CachingRemoteDataError: Error, Equatable, Sendable, Hashable { case disabled case failedToFetch } ================================================ FILE: Airship/AirshipCore/Source/BasementImport.swift ================================================ #if canImport(AirshipBasement) // In SDK 21, look into public import AirshipBasement as an alternative @_exported import AirshipBasement #endif ================================================ FILE: Airship/AirshipCore/Source/BasicToggleLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI @MainActor struct BasicToggleLayout: View { @Environment(\.pageIdentifier) private var pageID @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @State private var isOn: Bool = false @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .basicToggleLayout, thomasState: thomasState ) } private let info: ThomasViewInfo.BasicToggleLayout private let constraints: ViewConstraints init(info: ThomasViewInfo.BasicToggleLayout, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var body: some View { ToggleLayout( isOn: $isOn, onToggleOn: self.info.properties.onToggleOn, onToggleOff: self.info.properties.onToggleOff ) { ViewFactory.createView( self.info.properties.view, constraints: constraints ) } .constraints(self.constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: self.associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() .airshipOnChangeOf(self.isOn, initial: true) { value in updateFormState(value) } .onAppear { restoreFormState() } } private var attributes: [ThomasFormField.Attribute]? { guard let name = self.info.properties.attributeName, let value = self.info.properties.attributeValue else { return nil } return [ ThomasFormField.Attribute( attributeName: name, attributeValue: value ) ] } private func checkValid(_ isOn: Bool) -> Bool { return isOn || self.info.validation.isRequired != true } private func updateFormState(_ isOn: Bool) { let formValue: ThomasFormField.Value = .toggle(isOn) let field: ThomasFormField = if checkValid(isOn) { ThomasFormField.validField( identifier: self.info.properties.identifier, input: formValue, result: .init( value: formValue, attributes: self.attributes ) ) } else { ThomasFormField.invalidField( identifier: self.info.properties.identifier, input: formValue ) } self.formDataCollector.updateField(field, pageID: pageID) } private func restoreFormState() { guard case .toggle(let value) = self.formState.field( identifier: self.info.properties.identifier )?.input else { self.updateFormState(self.isOn) return } self.isOn = value } } ================================================ FILE: Airship/AirshipCore/Source/BlockAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Action that runs a block. public final class BlockAction: AirshipAction { private let block: @Sendable (ActionArguments) async throws -> AirshipJSON? private let predicate: (@Sendable(ActionArguments) async -> Bool)? /** * Block action constructor. * - Parameters: * - predicate: Optional predicate. * - block: The action block. */ public init( predicate:(@Sendable(ActionArguments) async -> Bool)? = nil, block: @escaping @Sendable (ActionArguments) async throws -> AirshipJSON?) { self.predicate = predicate self.block = block } public func accepts(arguments: ActionArguments) async -> Bool { return (await self.predicate?(arguments)) ?? true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { return try await self.block(arguments) } } ================================================ FILE: Airship/AirshipCore/Source/BundleExtensions.swift ================================================ public import Foundation public extension Bundle { /// Returns the bundle for the AirshipModule. /// NOTE: For internal use only. :nodoc: static func airshipFindModule( moduleName: String, sourceBundle: Bundle ) -> Bundle { AirshipLogger.trace("Searching for module \(moduleName) Bundle") let candidates = [ "Airship_\(moduleName)", // SPM/Tuist "\(moduleName)Resources", // Cocoapods "\(moduleName)_\(moduleName)" // Fallback for SPM/Tuist ] for searchContainer in [sourceBundle, Bundle.main] { for candidate in candidates { guard let path = searchContainer.path(forResource: candidate, ofType: "bundle"), let bundle = Bundle(path: path) else { continue } AirshipLogger.trace("Found Bundle for \(moduleName) in \(searchContainer) with path \(candidate)") return bundle } } // Fallback to source bundle (XCFrameworks) AirshipLogger.trace("Using source Bundle for \(moduleName)") return sourceBundle } } ================================================ FILE: Airship/AirshipCore/Source/ButtonLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Button layout view. struct ButtonLayout : View { @Environment(\.isVoiceOverRunning) private var isVoiceOverRunning @Environment(\.layoutState) private var layoutState @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var pagerState: PagerState @EnvironmentObject private var videoState: VideoState @EnvironmentObject private var thomasState: ThomasState @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @State private var actionTask: Task<Void, Never>? private let info: ThomasViewInfo.ButtonLayout private let constraints: ViewConstraints init(info: ThomasViewInfo.ButtonLayout, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var isButtonForAccessibility: Bool { guard let role = info.properties.accessibilityRole else { // Default to button return true } return switch(role) { case .container: false case .button: true } } var body: some View { if isVoiceOverRunning, !isButtonForAccessibility { // Container mode if let contentDescription = info.accessible.resolveContentDescription { // Container WITH content description: Add accessibility action ViewFactory.createView(self.info.properties.view, constraints: constraints) .thomasCommon(self.info, scope: [.background, .visibility]) .accessibilityElement(children: .contain) .accessibilityLabel(contentDescription) .accessibilityAction(named: contentDescription) { let previousTask = actionTask actionTask = Task { @MainActor in await previousTask?.value await performButtonAction() } } .accessibilityHidden(info.accessible.accessibilityHidden ?? false) } else { // Container WITHOUT content description: Transparent parent ViewFactory.createView(self.info.properties.view, constraints: constraints) .thomasCommon(self.info, scope: [.background, .visibility]) .accessibilityHidden(info.accessible.accessibilityHidden ?? false) } } else { AirshipButton( identifier: self.info.properties.identifier, reportingMetadata: self.info.properties.reportingMetadata, description: self.info.accessible.resolveContentDescription, clickBehaviors: self.info.properties.clickBehaviors, eventHandlers: self.info.commonProperties.eventHandlers, actions: self.info.properties.actions, tapEffect: self.info.properties.tapEffect ) { ViewFactory.createView(self.info.properties.view, constraints: constraints) .thomasCommon(self.info, scope: [.background]) .background(Color.airshipTappableClear) } .thomasCommon(self.info, scope: [.enableBehaviors, .visibility]) .environment( \.layoutState, layoutState.override( buttonState: ButtonState(identifier: self.info.properties.identifier) ) ) .accessibilityHidden(info.accessible.accessibilityHidden ?? false) } } @MainActor private func performButtonAction() async { // Form validation if info.properties.clickBehaviors?.contains(.formSubmit) == true || info.properties.clickBehaviors?.contains(.formValidate) == true { guard await formState.validate() else { return } } // Tap event handlers let taps = info.commonProperties.eventHandlers?.filter { $0.type == .tap } if let taps, !taps.isEmpty { taps.forEach { tap in thomasState.processStateActions(tap.stateActions) } await Task.yield() } // Button reporting thomasEnvironment.buttonTapped( buttonIdentifier: info.properties.identifier, reportingMetadata: info.properties.reportingMetadata, layoutState: layoutState ) // Click behaviors await handleBehaviors(info.properties.clickBehaviors ?? []) // Actions handleActions(info.properties.actions) } private func handleBehaviors(_ behaviors: [ThomasButtonClickBehavior]) async { for behavior in behaviors { switch(behavior) { case .dismiss: thomasEnvironment.dismiss( buttonIdentifier: info.properties.identifier, buttonDescription: info.accessible.resolveContentDescription ?? info.properties.identifier, cancel: false, layoutState: layoutState ) case .cancel: thomasEnvironment.dismiss( buttonIdentifier: info.properties.identifier, buttonDescription: info.accessible.resolveContentDescription ?? info.properties.identifier, cancel: true, layoutState: layoutState ) case .pagerNext: pagerState.process(request: .next) case .pagerPrevious: pagerState.process(request: .back) case .pagerNextOrDismiss: if pagerState.isLastPage { thomasEnvironment.dismiss( buttonIdentifier: info.properties.identifier, buttonDescription: info.accessible.resolveContentDescription ?? info.properties.identifier, cancel: false, layoutState: layoutState ) } else { pagerState.process(request: .next) } case .pagerNextOrFirst: if pagerState.isLastPage { pagerState.process(request: .first) } else { pagerState.process(request: .next) } case .pagerPause: pagerState.pause() case .pagerResume: pagerState.resume() case .pagerPauseToggle: pagerState.togglePause() case .formValidate: break case .formSubmit: do { try await formState.submit(layoutState: layoutState) } catch { AirshipLogger.error("Failed to submit \(error)") } case .videoPlay: videoState.play() case .videoPause: videoState.pause() case .videoTogglePlay: videoState.togglePlay() case .videoMute: videoState.mute() case .videoUnmute: videoState.unmute() case .videoToggleMute: videoState.toggleMute() } } } private func handleActions(_ actionPayload: ThomasActionsPayload?) { if let actionPayload { thomasEnvironment.runActions(actionPayload, layoutState: layoutState) } } } ================================================ FILE: Airship/AirshipCore/Source/ButtonState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @MainActor class ButtonState: ObservableObject { let identifier: String init(identifier: String) { self.identifier = identifier } } ================================================ FILE: Airship/AirshipCore/Source/CachedList.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// A class that manages a list of cached values, where each value has its own expiry. final class CachedList<Value> where Value: Any { private let date: any AirshipDateProtocol private let lock: AirshipLock = AirshipLock() private var cachedValues: [(Value, Date)] = [] var values: [Value] { var result: [Value]! lock.sync { self.trim() result = self.cachedValues.map { $0.0 } } return result } func append(_ value: Value, expiresIn: TimeInterval) { let expiration = self.date.now.advanced(by: expiresIn) lock.sync { self.cachedValues.append((value, expiration)) } } private func trim() { self.cachedValues.removeAll(where: { return self.date.now >= $0.1 }) } init(date: any AirshipDateProtocol = AirshipDate.shared) { self.date = date } } ================================================ FILE: Airship/AirshipCore/Source/CachedValue.swift ================================================ /* Copyright Airship and Contributors */ import Foundation final class CachedValue<Value>: @unchecked Sendable where Value: Any { private let date: any AirshipDateProtocol private let lock: AirshipLock = AirshipLock() private var expiration: Date? private var _value: Value? var value: Value? { get { var cachedValue: Value? lock.sync { guard let expiration = expiration, self.date.now < expiration else { self.expiration = nil self._value = nil return } cachedValue = _value } return cachedValue } } var timeRemaining: TimeInterval { var timeRemaining: TimeInterval = 0 lock.sync { if let expiration = self.expiration { timeRemaining = max(0, expiration.timeIntervalSince(self.date.now)) } } return timeRemaining } func set(value: Value, expiresIn: TimeInterval) { lock.sync { self.expiration = self.date.now.advanced(by: expiresIn) self._value = value } } func set(value: Value, expiration: Date) { lock.sync { self.expiration = expiration self._value = value } } func expireIf(predicate: (Value) -> Bool) { lock.sync { if let value = self._value, predicate(value) { self._value = nil } } } func expire() { lock.sync { self._value = nil } } init(date: any AirshipDateProtocol = AirshipDate.shared) { self.date = date } } ================================================ FILE: Airship/AirshipCore/Source/CachingSMSValidatorAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation actor CachingSMSValidatorAPIClient: SMSValidatorAPIClientProtocol { private struct CacheEntry: Sendable, Equatable{ let msisdn: String let sender: String? let prefix: String? let result: AirshipHTTPResponse<SMSValidatorAPIClientResult> } private let client: any SMSValidatorAPIClientProtocol private var cache: [CacheEntry] = [] private let maxCachedEntries: UInt init( client: any SMSValidatorAPIClientProtocol, maxCachedEntries: UInt = 10 ) { self.client = client self.maxCachedEntries = maxCachedEntries } func validateSMS(msisdn: String, sender: String) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> { let result = if let cached = cachedResult(msisdn: msisdn, sender: sender) { cached } else { try await client.validateSMS(msisdn: msisdn, sender: sender) } cacheResult(result, msisdn: msisdn, prefix: nil, sender: sender) return result } func validateSMS(msisdn: String, prefix: String) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> { let result = if let cached = cachedResult(msisdn: msisdn, prefix: prefix) { cached } else { try await client.validateSMS(msisdn: msisdn, prefix: prefix) } cacheResult(result, msisdn: msisdn, prefix: prefix, sender: nil) return result } private func cachedResult(msisdn: String, sender: String) -> AirshipHTTPResponse<SMSValidatorAPIClientResult>? { return cache.first { entry in entry.msisdn == msisdn && entry.sender == sender }?.result } private func cachedResult(msisdn: String, prefix: String) -> AirshipHTTPResponse<SMSValidatorAPIClientResult>? { return cache.first { entry in entry.msisdn == msisdn && entry.prefix == prefix }?.result } private func cacheResult( _ result: AirshipHTTPResponse<SMSValidatorAPIClientResult>, msisdn: String, prefix: String?, sender: String? ) { guard result.isSuccess else { return } let entry = CacheEntry( msisdn: msisdn, sender: sender, prefix: prefix, result: result ) cache.removeAll { $0 == entry } cache.append( entry ) if cache.count > self.maxCachedEntries { cache.remove(at: 0) } } } ================================================ FILE: Airship/AirshipCore/Source/CancellableValueHolder.swift ================================================ /* Copyright Airship and Contributors */ /// Utility class that holds a value in a thread safe way. Once cancelled, setting a value /// on the holder will cause it to immediately be cancelled with the block and the value to be /// cleared. /// - Note: for internal use only. :nodoc: public final class CancellableValueHolder<T: Sendable>: AirshipCancellable, @unchecked Sendable { private let lock: AirshipLock = AirshipLock() private let onCancel: (T) -> Void private var isCancelled: Bool = false private var _value: T? public var value: T? { get { var value: T? = nil lock.sync { value = _value } return value } set { lock.sync { if isCancelled { if let value = newValue { onCancel(value) } } else { _value = newValue } } } } public init(value: T, onCancel: @escaping @Sendable (T) -> Void) { self._value = value self.onCancel = onCancel } public init(onCancel: @escaping @Sendable (T) -> Void) { self.onCancel = onCancel } public func cancel() { lock.sync { guard isCancelled == false else { return } isCancelled = true if let value = value { onCancel(value) _value = nil } } } public static func cancellableHolder() -> CancellableValueHolder<any AirshipCancellable> { return CancellableValueHolder<any AirshipCancellable>() { cancellable in cancellable.cancel() } } } ================================================ FILE: Airship/AirshipCore/Source/ChallengeResolver.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /** * Authentication challenge resolver class * @note For internal use only. :nodoc: */ public final class ChallengeResolver: NSObject, Sendable { public static let shared: ChallengeResolver = ChallengeResolver() @MainActor var resolver: ChallengeResolveClosure? private override init() {} public func resolve(_ challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { guard let resolver = await self.resolver, challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, challenge.protectionSpace.serverTrust != nil else { return (.performDefaultHandling, nil) } return resolver(challenge) } } extension ChallengeResolver: URLSessionTaskDelegate { public func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await self.resolve(challenge) } public func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await self.resolve(challenge) } } public typealias ChallengeResolveClosure = @Sendable (URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) ================================================ FILE: Airship/AirshipCore/Source/ChannelAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: For internal use only. :nodoc: final class ChannelAPIClient: ChannelAPIClientProtocol, Sendable { private let channelPath: String = "/api/channels/" private let config: RuntimeConfig private let session: any AirshipRequestSession init( config: RuntimeConfig, session: any AirshipRequestSession ) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init( config: config, session: config.requestSession ) } 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 ChannelAPIClient URL: \(String(describing: urlString))") } return url } func makeChannelLocation(channelID: String) throws -> URL { return try makeURL(path: "\(self.channelPath)\(channelID)") } func createChannel( payload: ChannelRegistrationPayload ) async throws -> AirshipHTTPResponse<ChannelAPIResponse> { let url = try makeURL(path: self.channelPath) let data = try JSONEncoder().encode(payload) AirshipLogger.debug( "Creating channel with: \(payload)" ) let request = AirshipRequest( url: url, headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json" ], method: "POST", auth: .generatedAppToken, body: data ) return try await session.performHTTPRequest( request ) { data, response in AirshipLogger.debug("Channel creation finished with response: \(response)") let status = response.statusCode guard status == 200 || status == 201 else { return nil } guard let data = data else { throw AirshipErrors.parseError("Missing body") } let json = try JSONSerialization.jsonObject( with: data, options: .allowFragments ) as? [AnyHashable: Any] guard let channelID = json?["channel_id"] as? String else { throw AirshipErrors.parseError("Missing channel_id") } return ChannelAPIResponse( channelID: channelID, location: try self.makeChannelLocation(channelID: channelID) ) } } func updateChannel( _ channelID: String, payload: ChannelRegistrationPayload ) async throws -> AirshipHTTPResponse<ChannelAPIResponse> { let url = try makeChannelLocation(channelID: channelID) let data = try JSONEncoder().encode(payload) AirshipLogger.debug( "Updating channel \(channelID) with: \(payload)" ) let request = AirshipRequest( url: url, headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json" ], method: "PUT", auth: .channelAuthToken(identifier: channelID), body: data ) return try await session.performHTTPRequest(request) { data, response in AirshipLogger.debug("Update channel finished with response: \(response)") return ChannelAPIResponse( channelID: channelID, location: url ) } } var isURLConfigured: Bool { return self.config.deviceAPIURL?.isEmpty == false } } /// - Note: For internal use only. :nodoc: protocol ChannelAPIClientProtocol: Sendable { func makeChannelLocation(channelID: String) throws -> URL func createChannel( payload: ChannelRegistrationPayload ) async throws -> AirshipHTTPResponse<ChannelAPIResponse> func updateChannel( _ channelID: String, payload: ChannelRegistrationPayload ) async throws -> AirshipHTTPResponse<ChannelAPIResponse> var isURLConfigured: Bool { get } } struct ChannelAPIResponse { let channelID: String let location: URL } ================================================ FILE: Airship/AirshipCore/Source/ChannelAudienceManager.swift ================================================ /* Copyright Airship and Contributors */ @preconcurrency import Combine import Foundation protocol ChannelAudienceManagerProtocol: AnyObject, Sendable { var channelID: String? { get set } var enabled: Bool { get set } var subscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { get } func addLiveActivityUpdate(_ update: LiveActivityUpdate) func editSubscriptionLists() -> SubscriptionListEditor func editTagGroups(allowDeviceGroup: Bool) -> TagGroupsEditor func editAttributes() -> AttributesEditor func fetchSubscriptionLists() async throws -> [String] func clearSubscriptionListCache() var liveActivityUpdates: AsyncStream<[LiveActivityUpdate]> { get } } /// NOTE: For internal use only. :nodoc: final class ChannelAudienceManager: ChannelAudienceManagerProtocol { static let updateTaskID: String = "ChannelAudienceManager.update" static let updatesKey: String = "UAChannel.audienceUpdates" static let legacyPendingTagGroupsKey: String = "com.urbanairship.tag_groups.pending_channel_tag_groups_mutations" static let legacyPendingAttributesKey: String = "com.urbanairship.channel_attributes.registrar_persistent_queue_key" static let maxCacheTime: TimeInterval = 600 // 10 minutes private let dataStore: PreferenceDataStore private let privacyManager: any AirshipPrivacyManager private let workManager: any AirshipWorkManagerProtocol private let subscriptionListProvider: any ChannelSubscriptionListProviderProtocol private let updateClient: any ChannelBulkUpdateAPIClientProtocol private let audienceOverridesProvider: any AudienceOverridesProvider private let date: any AirshipDateProtocol private let updateLock: AirshipLock = AirshipLock() private let cachedSubscriptionLists: CachedValue<[String]> private let subscriptionListEditsSubject: PassthroughSubject< SubscriptionListEdit, Never > = PassthroughSubject<SubscriptionListEdit, Never>() var subscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { self.subscriptionListEditsSubject.eraseToAnyPublisher() } private let _channelID: AirshipAtomicValue<String?> = AirshipAtomicValue(nil) var channelID: String? { get { _channelID.value } set { if (_channelID.setValue(newValue)) { self.enqueueTask() } } } private let _enabled: AirshipAtomicValue<Bool> = AirshipAtomicValue(false) var enabled: Bool { get { _enabled.value } set { if (_enabled.setValue(newValue)) { self.enqueueTask() } } } let liveActivityUpdates: AsyncStream<[LiveActivityUpdate]> private let liveActivityUpdatesContinuation: AsyncStream<[LiveActivityUpdate]>.Continuation @MainActor init( dataStore: PreferenceDataStore, workManager: any AirshipWorkManagerProtocol, subscriptionListProvider: any ChannelSubscriptionListProviderProtocol, updateClient: any ChannelBulkUpdateAPIClientProtocol, privacyManager: any AirshipPrivacyManager, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, date: any AirshipDateProtocol = AirshipDate.shared, audienceOverridesProvider: any AudienceOverridesProvider ) { self.dataStore = dataStore self.workManager = workManager self.privacyManager = privacyManager self.subscriptionListProvider = subscriptionListProvider self.updateClient = updateClient self.date = date self.cachedSubscriptionLists = CachedValue(date: date) self.audienceOverridesProvider = audienceOverridesProvider (self.liveActivityUpdates, self.liveActivityUpdatesContinuation) = AsyncStream<[LiveActivityUpdate]>.airshipMakeStreamWithContinuation() self.workManager.registerWorker( ChannelAudienceManager.updateTaskID ) { [weak self] _ in return try await self?.handleUpdateTask() ?? .success } self.workManager.autoDispatchWorkRequestOnBackground( AirshipWorkRequest(workID: ChannelAudienceManager.updateTaskID) ) self.migrateMutations() notificationCenter.addObserver( self, selector: #selector(checkPrivacyManager), name: AirshipNotifications.PrivacyManagerUpdated.name, object: nil ) notificationCenter.addObserver( self, selector: #selector(enqueueTask), name: RuntimeConfig.configUpdatedEvent, object: nil ) self.checkPrivacyManager() Task { await self.audienceOverridesProvider.setPendingChannelOverridesProvider { channelID in return self.pendingOverrides(channelID: channelID) } } } @MainActor convenience init( dataStore: PreferenceDataStore, config: RuntimeConfig, privacyManager: any AirshipPrivacyManager, audienceOverridesProvider: any AudienceOverridesProvider ) { self.init( dataStore: dataStore, workManager: AirshipWorkManager.shared, subscriptionListProvider: ChannelSubscriptionListProvider( audienceOverrides: audienceOverridesProvider, apiClient: SubscriptionListAPIClient(config: config)), updateClient: ChannelBulkUpdateAPIClient(config: config), privacyManager: privacyManager, audienceOverridesProvider: audienceOverridesProvider ) } func editSubscriptionLists() -> SubscriptionListEditor { return SubscriptionListEditor { updates in guard !updates.isEmpty else { return } guard self.privacyManager.isEnabled(.tagsAndAttributes) else { AirshipLogger.warn( "Tags and attributes are disabled. Enable to apply subscription list edits." ) return } self.addUpdate( AudienceUpdate(subscriptionListUpdates: updates) ) Task { @MainActor in updates.forEach { switch $0.type { case .subscribe: self.subscriptionListEditsSubject.send( .subscribe($0.listId) ) case .unsubscribe: self.subscriptionListEditsSubject.send( .unsubscribe($0.listId) ) } } } self.enqueueTask() } } func editTagGroups(allowDeviceGroup: Bool) -> TagGroupsEditor { return TagGroupsEditor(allowDeviceTagGroup: allowDeviceGroup) { updates in guard !updates.isEmpty else { return } guard self.privacyManager.isEnabled(.tagsAndAttributes) else { AirshipLogger.warn( "Tags and attributes are disabled. Enable to apply tag group edits." ) return } self.addUpdate( AudienceUpdate(tagGroupUpdates: updates) ) self.enqueueTask() } } func editAttributes() -> AttributesEditor { return AttributesEditor { updates in guard !updates.isEmpty else { return } guard self.privacyManager.isEnabled(.tagsAndAttributes) else { AirshipLogger.warn( "Tags and attributes are disabled. Enable to apply attribute edits." ) return } self.addUpdate( AudienceUpdate(attributeUpdates: updates) ) self.enqueueTask() } } func fetchSubscriptionLists() async throws -> [String] { guard let channelID = self.channelID else { throw AirshipErrors.error("Channel not created yet") } return try await subscriptionListProvider.fetch(channelID: channelID) } func pendingOverrides(channelID: String) -> ChannelAudienceOverrides { guard self.channelID == channelID else { return ChannelAudienceOverrides() } var tags: [TagGroupUpdate] = [] var attributes: [AttributeUpdate] = [] var subscriptionLists: [SubscriptionListUpdate] = [] self.updateLock.sync { self.getUpdates().forEach { update in attributes += update.attributeUpdates tags += update.tagGroupUpdates subscriptionLists += update.subscriptionListUpdates } } return ChannelAudienceOverrides( tags: tags, attributes: attributes, subscriptionLists: subscriptionLists ) } @objc private func checkPrivacyManager() { if !self.privacyManager.isEnabled(.tagsAndAttributes) { updateLock.sync { self.dataStore.removeObject( forKey: ChannelAudienceManager.updatesKey ) } } } @objc private func enqueueTask() { if self.enabled && self.channelID != nil { self.workManager.dispatchWorkRequest( AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID, requiresNetwork: true ) ) } } private func handleUpdateTask() async throws -> AirshipWorkResult { guard self.enabled, let channelID = self.channelID, let update = self.prepareNextUpdate() else { return .success } let response = try await self.updateClient.update( update, channelID: channelID ) AirshipLogger.debug( "Update finished with response: \(response)" ) guard response.isSuccess else { return response.isServerError ? .failure : .success } if (!update.liveActivityUpdates.isEmpty) { self.liveActivityUpdatesContinuation.yield(update.liveActivityUpdates) } await self.audienceOverridesProvider.channelUpdated( channelID: channelID, tags: update.tagGroupUpdates, attributes: update.attributeUpdates, subscriptionLists: update.subscriptionListUpdates ) self.popFirstUpdate() self.enqueueTask() return .success } private func addUpdate(_ update: AudienceUpdate) { guard !update.isEmpty else { return } self.updateLock.sync { var updates = getUpdates() updates.append(update) self.storeUpdates(updates) } } func addLiveActivityUpdate(_ update: LiveActivityUpdate) { AirshipLogger.debug("Live activity update: \(update)") self.addUpdate( AudienceUpdate(liveActivityUpdates: [update]) ) self.enqueueTask() } private func getUpdates() -> [AudienceUpdate] { var result: [AudienceUpdate]? updateLock.sync { if let data = self.dataStore.data( forKey: ChannelAudienceManager.updatesKey ) { result = try? JSONDecoder().decode( [AudienceUpdate].self, from: data ) } } return result ?? [] } private func storeUpdates(_ operations: [AudienceUpdate]) { updateLock.sync { if let data = try? JSONEncoder().encode(operations) { self.dataStore.setObject( data, forKey: ChannelAudienceManager.updatesKey ) } } } private func popFirstUpdate() { updateLock.sync { var updates = getUpdates() if !updates.isEmpty { updates.removeFirst() storeUpdates(updates) } } } private func prepareNextUpdate() -> AudienceUpdate? { var nextUpdate: AudienceUpdate? = nil updateLock.sync { let updates = self.getUpdates() if let collapsed = AudienceUpdate.collapse(updates) { self.storeUpdates([collapsed]) nextUpdate = collapsed } else { self.storeUpdates([]) } } if !self.privacyManager.isEnabled(.tagsAndAttributes) { nextUpdate?.attributeUpdates = [] nextUpdate?.tagGroupUpdates = [] nextUpdate?.attributeUpdates = [] } guard nextUpdate?.isEmpty == false else { return nil } return nextUpdate } func migrateMutations() { defer { self.dataStore.removeObject( forKey: ChannelAudienceManager.legacyPendingTagGroupsKey ) self.dataStore.removeObject( forKey: ChannelAudienceManager.legacyPendingAttributesKey ) } if self.privacyManager.isEnabled(.tagsAndAttributes) { var pendingTagUpdates: [TagGroupUpdate]? var pendingAttributeUpdates: [AttributeUpdate]? if let pendingTagGroupsData = self.dataStore.data( forKey: ChannelAudienceManager.legacyPendingTagGroupsKey ) { let classes = [NSArray.self, TagGroupsMutation.self] let pendingTagGroups = try? NSKeyedUnarchiver.unarchivedObject( ofClasses: classes, from: pendingTagGroupsData ) if let pendingTagGroups = pendingTagGroups as? [TagGroupsMutation] { pendingTagUpdates = pendingTagGroups.map { $0.tagGroupUpdates } .reduce([], +) } } if let pendingAttributesData = self.dataStore.data( forKey: ChannelAudienceManager.legacyPendingAttributesKey ) { let classes = [NSArray.self, AttributePendingMutations.self] let pendingAttributes = try? NSKeyedUnarchiver.unarchivedObject( ofClasses: classes, from: pendingAttributesData ) if let pendingAttributes = pendingAttributes as? [AttributePendingMutations] { pendingAttributeUpdates = pendingAttributes.map { $0.attributeUpdates } .reduce([], +) } } let update = AudienceUpdate( tagGroupUpdates: pendingTagUpdates ?? [], attributeUpdates: pendingAttributeUpdates ?? [] ) addUpdate(update) } } func clearSubscriptionListCache() { self.cachedSubscriptionLists.expire() } } internal struct AudienceUpdate: Codable { var subscriptionListUpdates: [SubscriptionListUpdate] var tagGroupUpdates: [TagGroupUpdate] var attributeUpdates: [AttributeUpdate] var liveActivityUpdates: [LiveActivityUpdate] init( subscriptionListUpdates: [SubscriptionListUpdate] = [], tagGroupUpdates: [TagGroupUpdate] = [], attributeUpdates: [AttributeUpdate] = [], liveActivityUpdates: [LiveActivityUpdate] = [] ) { self.subscriptionListUpdates = subscriptionListUpdates self.tagGroupUpdates = tagGroupUpdates self.attributeUpdates = attributeUpdates self.liveActivityUpdates = liveActivityUpdates } var isEmpty: Bool { return subscriptionListUpdates.isEmpty && tagGroupUpdates.isEmpty && attributeUpdates.isEmpty && liveActivityUpdates.isEmpty } static func collapse(_ updates: [AudienceUpdate]) -> AudienceUpdate? { var subscriptionListUpdates: [SubscriptionListUpdate] = [] var tagGroupUpdates: [TagGroupUpdate] = [] var attributeUpdates: [AttributeUpdate] = [] var liveActivityUpdates: [LiveActivityUpdate] = [] updates.forEach { update in subscriptionListUpdates.append( contentsOf: update.subscriptionListUpdates ) tagGroupUpdates.append(contentsOf: update.tagGroupUpdates) attributeUpdates.append(contentsOf: update.attributeUpdates) liveActivityUpdates.append(contentsOf: update.liveActivityUpdates) } let collapsed = AudienceUpdate( subscriptionListUpdates: AudienceUtils.collapse( subscriptionListUpdates ), tagGroupUpdates: AudienceUtils.collapse(tagGroupUpdates), attributeUpdates: AudienceUtils.collapse(attributeUpdates), liveActivityUpdates: AudienceUtils.normalize(liveActivityUpdates) ) return collapsed.isEmpty ? nil : collapsed } } ================================================ FILE: Airship/AirshipCore/Source/ChannelAuthTokenAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation final class ChannelAuthTokenAPIClient: ChannelAuthTokenAPIClientProtocol, Sendable { private let tokenPath: String = "/api/auth/device" private let config: RuntimeConfig private let session: any AirshipRequestSession init( config: RuntimeConfig, session: any AirshipRequestSession ) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init( config: config, session: config.requestSession ) } 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 ChannelAPIClient URL: \(String(describing: urlString))") } return url } /// /// Retrieves the token associated with the provided channel ID. /// - Parameters: /// - channelID: The channel ID. /// - Returns: AuthToken if succeed otherwise it throws an error func fetchToken( channelID: String ) async throws -> AirshipHTTPResponse<ChannelAuthTokenResponse> { let url = try makeURL(path: self.tokenPath) let request = AirshipRequest( url: url, headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", ], method: "GET", auth: .generatedChannelToken(identifier: channelID) ) return try await session.performHTTPRequest(request) { data, response in AirshipLogger.debug("Channel auth token request finished with response: \(response)") guard response.statusCode == 200 else { return nil } return try AirshipJSONUtils.decode(data: data) } } } struct ChannelAuthTokenResponse: Decodable, Sendable { let token: String let expiresInMillseconds: UInt enum CodingKeys: String, CodingKey { case token = "token" case expiresInMillseconds = "expires_in" } } /// - Note: For internal use only. :nodoc: protocol ChannelAuthTokenAPIClientProtocol: Sendable { func fetchToken( channelID: String ) async throws -> AirshipHTTPResponse<ChannelAuthTokenResponse> } ================================================ FILE: Airship/AirshipCore/Source/ChannelAuthTokenProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation final class ChannelAuthTokenProvider: AuthTokenProvider { private let cachedAuthToken: CachedValue<AuthToken> private let channel: any AirshipChannel private let apiClient: any ChannelAuthTokenAPIClientProtocol private let date: any AirshipDateProtocol init( channel: any AirshipChannel, apiClient: any ChannelAuthTokenAPIClientProtocol, date: any AirshipDateProtocol = AirshipDate.shared ) { self.channel = channel self.apiClient = apiClient self.cachedAuthToken = CachedValue(date: date) self.date = date } convenience init( channel: any AirshipChannel, runtimeConfig: RuntimeConfig ) { self.init(channel: channel, apiClient: ChannelAuthTokenAPIClient(config: runtimeConfig)) } func resolveAuth(identifier: String) async throws -> String { guard self.channel.identifier == identifier else { throw AirshipErrors.error("Unable to generate auth for stale channel \(identifier)") } if let token = self.cachedAuthToken.value, token.identifier == identifier, self.cachedAuthToken.timeRemaining >= 30 { return token.token } let response = try await self.apiClient.fetchToken(channelID: identifier) guard response.isSuccess, let result = response.result else { throw AirshipErrors.error("Failed to fetch auth token for channel \(identifier)") } let token = AuthToken( identifier: identifier, token: result.token, expiration: self.date.now.advanced(by: Double(result.expiresInMillseconds/1000) ) ) self.cachedAuthToken.set(value: token, expiration: token.expiration) return result.token } func authTokenExpired(token: String) async { self.cachedAuthToken.expireIf { auth in return auth.token == token } } } ================================================ FILE: Airship/AirshipCore/Source/ChannelBulkUpdateAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: protocol ChannelBulkUpdateAPIClientProtocol: Sendable { func update( _ update: AudienceUpdate, channelID: String ) async throws -> AirshipHTTPResponse<Void> } /// NOTE: For internal use only. :nodoc: final class ChannelBulkUpdateAPIClient: ChannelBulkUpdateAPIClientProtocol { private static let path: String = "/api/channels/sdk/batch/" private let config: RuntimeConfig private let session: any AirshipRequestSession init(config: RuntimeConfig, session: any AirshipRequestSession) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init( config: config, session: config.requestSession ) } func update( _ update: AudienceUpdate, channelID: String ) async throws -> AirshipHTTPResponse<Void> { let url = try makeURL(channelID: channelID) let payload = update.clientPayload let encoder = JSONEncoder() let data = try encoder.encode(payload) AirshipLogger.debug( "Updating channel with url \(url.absoluteString) payload \(String(data: data, encoding: .utf8) ?? "")" ) let request = AirshipRequest( url: url, headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json" ], method: "PUT", auth: .channelAuthToken(identifier: channelID), body: try encoder.encode(payload) ) let result = try await session.performHTTPRequest(request) AirshipLogger.debug( "Updating channel finished with result \(result)" ) return result } func makeURL(channelID: String) throws -> URL { guard let deviceUrl = config.deviceAPIURL else { throw AirshipErrors.error("URL config not downloaded.") } var urlComps = URLComponents( string: "\(deviceUrl)\(ChannelBulkUpdateAPIClient.path)\(channelID)" ) urlComps?.queryItems = [URLQueryItem(name: "platform", value: "ios")] guard let url = urlComps?.url else { throw AirshipErrors.error("Invalid url from \(String(describing: urlComps)).") } return url } } extension AudienceUpdate { fileprivate var clientPayload: ClientPayload { var subscriptionLists: [SubscriptionListOperation]? if (!self.subscriptionListUpdates.isEmpty) { subscriptionLists = self.subscriptionListUpdates.map { $0.operation } } var attributes: [AttributeOperation]? if (!self.attributeUpdates.isEmpty) { attributes = self.attributeUpdates.map { $0.operation } } var tags: TagGroupOverrides? if (!self.tagGroupUpdates.isEmpty) { tags = TagGroupOverrides.from(updates: self.tagGroupUpdates) } var liveActivities: [LiveActivityUpdate]? if (!self.liveActivityUpdates.isEmpty) { liveActivities = self.liveActivityUpdates } return ClientPayload( tags: tags, subscriptionLists: subscriptionLists, attributes: attributes, liveActivities: liveActivities ) } } private struct ClientPayload: Encodable { var tags: TagGroupOverrides? var subscriptionLists: [SubscriptionListOperation]? var attributes: [AttributeOperation]? var liveActivities: [LiveActivityUpdate]? enum CodingKeys: String, CodingKey { case tags = "tags" case subscriptionLists = "subscription_lists" case attributes = "attributes" case liveActivities = "live_activities" } } ================================================ FILE: Airship/AirshipCore/Source/ChannelCapture.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif #if !os(watchOS) @available(tvOS, unavailable) @MainActor public protocol AirshipChannelCapture: Sendable { /** * Flag indicating whether channel capture is enabled. Clear to disable. Set to enable. * Note: Does not persist through app launches. */ var enabled: Bool { get set } } /// Channel Capture copies the channelId to the device clipboard after a specific number of /// knocks (app foregrounds) within a specific timeframe. Channel Capture can be enabled /// or disabled in Airship Config. @available(tvOS, unavailable) final public class DefaultAirshipChannelCapture: AirshipChannelCapture { private static let knocksToTriggerChannelCapture: Int = 6 private static let knocksMaxTimeSeconds: TimeInterval = 30 private static let pasteboardExpirationSeconds: TimeInterval = 60 private let config: RuntimeConfig private let channel: any AirshipChannel private let notificationCenter: NotificationCenter private let date: any AirshipDateProtocol private let pasteboard: any AirshipPasteboardProtocol @MainActor private var knockTimes: [Date] = [] @MainActor public var enabled: Bool { didSet { AirshipLogger.trace("Channel capture enabled: \(enabled)") } } init( config: RuntimeConfig, channel: any AirshipChannel, notificationCenter: NotificationCenter = NotificationCenter.default, date: any AirshipDateProtocol = AirshipDate.shared, pasteboard: (any AirshipPasteboardProtocol) = DefaultAirshipPasteboard() ) { self.config = config self.channel = channel self.notificationCenter = notificationCenter self.date = date self.pasteboard = pasteboard self.enabled = config.airshipConfig.isChannelCaptureEnabled notificationCenter.addObserver( self, selector: #selector(applicationDidTransitionToForeground), name: AppStateTracker.didTransitionToForeground, object: nil ) } @objc @MainActor private func applicationDidTransitionToForeground() { guard enabled else { AirshipLogger.trace("Channel Capture disabled, ignoring foreground.") return } if knockTimes.count >= DefaultAirshipChannelCapture.knocksToTriggerChannelCapture { knockTimes.remove(at: 0) } AirshipLogger.trace("Channel Capture capturing foreground at time \(date.now)") knockTimes.append(date.now) if knockTimes.count < DefaultAirshipChannelCapture.knocksToTriggerChannelCapture { return } let firstKnock = knockTimes[0] let lastKnock = knockTimes[DefaultAirshipChannelCapture.knocksToTriggerChannelCapture - 1] if lastKnock.timeIntervalSince(firstKnock) > DefaultAirshipChannelCapture.knocksMaxTimeSeconds { return } knockTimes.removeAll() let identifier = "ua:\(channel.identifier ?? "")" AirshipLogger.debug( "Channel Capture setting channel ID:\(identifier) to pasteboard." ) AirshipLogger.debug("Channel Capture setting channel ID:\(identifier) to pasteboard.") self.pasteboard.copy( value: identifier, expiry: DefaultAirshipChannelCapture.pasteboardExpirationSeconds ) } } #endif ================================================ FILE: Airship/AirshipCore/Source/ChannelRegistrar.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: protocol ChannelRegistrarProtocol: AnyObject, Sendable { var channelID: String? { get } var registrationUpdates: AirshipAsyncChannel<ChannelRegistrationUpdate> { get } @MainActor var payloadCreateBlock: (@Sendable () async -> ChannelRegistrationPayload?)? { get set } func register(forcefully: Bool) } enum ChannelRegistrationUpdate: Equatable, Sendable { case created(channelID: String, isExisting: Bool) case updated(channelID: String) } /// The ChannelRegistrar class is responsible for device registrations. /// - Note: For internal use only. :nodoc: final class ChannelRegistrar: ChannelRegistrarProtocol, Sendable { static let workID: String = "UAChannelRegistrar.registration" private static let payloadCadence: TimeInterval = 24 * 60 * 60 fileprivate static let forcefullyKey: String = "forcefully" private static let channelIDKey: String = "UAChannelID" private static let lastRegistrationInfo: String = "ChannelRegistrar.lastRegistrationInfo" private let dataStore: PreferenceDataStore private let channelAPIClient: any ChannelAPIClientProtocol private let date: any AirshipDateProtocol private let workManager: any AirshipWorkManagerProtocol private let appStateTracker: any AppStateTrackerProtocol @MainActor private var checkAppRestoreTask: Task<Void, Never>? private let channelCreateMethod: (@Sendable () async throws -> ChannelGenerationMethod)? @MainActor var payloadCreateBlock: (@Sendable () async -> ChannelRegistrationPayload?)? private var lastRegistrationInfo: LastRegistrationInfo? { get { do { return try self.dataStore.codable( forKey: ChannelRegistrar.lastRegistrationInfo ) } catch { AirshipLogger.error("Unable to load last registration info \(error)") return nil } } set { do { try self.dataStore.setCodable( newValue, forKey: ChannelRegistrar.lastRegistrationInfo ) } catch { AirshipLogger.error("Unable to store last registration info \(error)") } } } /** * The channel ID for this device. */ var channelID: String? { get { self.dataStore.string(forKey: ChannelRegistrar.channelIDKey) } set { self.dataStore.setObject( newValue, forKey: ChannelRegistrar.channelIDKey ) } } let registrationUpdates: AirshipAsyncChannel<ChannelRegistrationUpdate> = .init() private let privacyManager: any AirshipPrivacyManager @MainActor init( dataStore: PreferenceDataStore, channelAPIClient: any ChannelAPIClientProtocol, date: any AirshipDateProtocol = AirshipDate.shared, workManager: any AirshipWorkManagerProtocol = AirshipWorkManager.shared, appStateTracker: (any AppStateTrackerProtocol)? = nil, channelCreateMethod: AirshipChannelCreateOptionClosure? = nil, privacyManager: any AirshipPrivacyManager ) { self.dataStore = dataStore self.channelAPIClient = channelAPIClient self.date = date self.workManager = workManager self.appStateTracker = appStateTracker ?? AppStateTracker.shared self.channelCreateMethod = channelCreateMethod self.privacyManager = privacyManager if self.channelID != nil { checkAppRestoreTask = Task { [weak self] in if await self?.dataStore.isAppRestore == true { self?.clearChannelData() } } } self.workManager.registerWorker( ChannelRegistrar.workID ) { [weak self] request in return try await self?.handleRegistrationWorkRequest(request) ?? .success } } @MainActor convenience init( config: RuntimeConfig, dataStore: PreferenceDataStore, privacyManager: any AirshipPrivacyManager ) { self.init( dataStore: dataStore, channelAPIClient: ChannelAPIClient(config: config), channelCreateMethod: config.airshipConfig.restoreChannelID, privacyManager: privacyManager ) } /** * Register the device with Airship. * * - Note: This method will execute asynchronously on the main thread. * * - Parameter forcefully: YES to force the registration. */ func register(forcefully: Bool) { guard self.channelAPIClient.isURLConfigured else { return } self.workManager.dispatchWorkRequest( AirshipWorkRequest( workID: ChannelRegistrar.workID, extras: [ ChannelRegistrar.forcefullyKey: String(forcefully) ], requiresNetwork: true, conflictPolicy: forcefully ? .replace : .keepIfNotStarted ) ) } private func handleRegistrationWorkRequest( _ workRequest: AirshipWorkRequest ) async throws -> AirshipWorkResult { _ = await self.checkAppRestoreTask?.value let payload = try await self.makePayload() guard let channelID = self.channelID else { return try await self.createChannel( payload: payload ) } let forcefully = workRequest.extras?[ ChannelRegistrar.forcefullyKey ]?.lowercased() == "true" let updatePayload = try await makeNextUpdatePayload( channelID: channelID, forcefully: forcefully, payload: payload, lastRegistrationInfo: self.lastRegistrationInfo ) guard let updatePayload = updatePayload else { AirshipLogger.debug( "Ignoring registration request, registration is up to date." ) return .success } return try await self.updateChannel( channelID, payload: payload, minimizedPayload: updatePayload ) } private func updateChannel( _ channelID: String, payload: ChannelRegistrationPayload, minimizedPayload: ChannelRegistrationPayload ) async throws -> AirshipWorkResult { let response = try await self.channelAPIClient.updateChannel( channelID, payload: minimizedPayload ) AirshipLogger.debug("Channel update request finished with response: \(response)") let fullPayloadUploadDate = payload == minimizedPayload ? self.date.now : self.lastRegistrationInfo?.lastFullPayloadSent if response.isSuccess, let result = response.result { await self.registrationSuccess( channelID: channelID, registrationInfo: LastRegistrationInfo( date: self.date.now, lastFullPayloadSent: fullPayloadUploadDate, payload: payload, location: result.location ) ) await registrationUpdates.send(.updated(channelID: channelID)) return .success } else if response.statusCode == 409 { AirshipLogger.trace("Channel conflict, recreating") self.clearChannelData() self.register(forcefully: true) return .success } else { if response.isServerError || response.statusCode == 429 { return .failure } else { return .success } } } private func createChannel( payload: ChannelRegistrationPayload ) async throws -> AirshipWorkResult { let method = try await channelCreateMethod?() ?? .automatic guard case .restore(let channelID) = method, method.isValid, let result = try await tryRestoreChannel(channelID, payload: payload) else { return try await regularCreateChannel(payload: payload) } return result } private func tryRestoreChannel( _ channelId: String, payload: ChannelRegistrationPayload ) async throws -> AirshipWorkResult? { let response: AirshipHTTPResponse<ChannelAPIResponse> = .init( result: .init( channelID: channelId, location: try channelAPIClient.makeChannelLocation(channelID: channelId)), statusCode: 200, headers: [:] ) guard await onNewChannelID(payload: payload, response: response) == .success, let nextPayload = try await self.makeNextUpdatePayload( channelID: channelId, forcefully: true, payload: payload, lastRegistrationInfo: lastRegistrationInfo) else { return nil } return try await updateChannel(channelId, payload: payload, minimizedPayload: nextPayload) } private func regularCreateChannel( payload: ChannelRegistrationPayload ) async throws -> AirshipWorkResult { let response = try await self.channelAPIClient.createChannel( payload: payload ) AirshipLogger.debug("Channel create request finished with response: \(response)") return await onNewChannelID(payload: payload, response: response) } private func onNewChannelID( payload: ChannelRegistrationPayload, response: AirshipHTTPResponse<ChannelAPIResponse> ) async -> AirshipWorkResult { guard response.isSuccess, let result = response.result else { if response.isServerError || response.statusCode == 429 { return .failure } else { return .success } } self.channelID = result.channelID await registrationUpdates.send( .created( channelID: result.channelID, isExisting: response.statusCode == 200 ) ) await self.registrationSuccess( channelID: result.channelID, registrationInfo: LastRegistrationInfo( date: self.date.now, lastFullPayloadSent: self.date.now, payload: payload, location: result.location ) ) return .success } private func clearChannelData() { self.channelID = nil self.lastRegistrationInfo = nil } private func registrationSuccess( channelID: String, registrationInfo: LastRegistrationInfo ) async { self.lastRegistrationInfo = registrationInfo let nextUploadPayload = try? await self.makeNextUpdatePayload( channelID: channelID, forcefully: false, payload: await makePayload(), lastRegistrationInfo: registrationInfo ) if (nextUploadPayload != nil) { self.register(forcefully: false) } } @MainActor private func makePayload() async throws -> ChannelRegistrationPayload { guard let payloadCreateBlock, let payload = await payloadCreateBlock() else { throw AirshipErrors.error("Failed to make a payload") } return payload } private func makeNextUpdatePayload( channelID: String, forcefully: Bool, payload: ChannelRegistrationPayload, lastRegistrationInfo: LastRegistrationInfo? ) async throws -> ChannelRegistrationPayload? { let currentLocation = try self.channelAPIClient.makeChannelLocation( channelID: channelID ) // If no channel registrations are enabled - skip the cadence check guard privacyManager.isAnyFeatureEnabled() else { return if lastRegistrationInfo?.location != currentLocation || lastRegistrationInfo?.payload != payload { payload.minimizePayload(previous: lastRegistrationInfo?.payload) } else { nil } } guard let lastRegistrationInfo = lastRegistrationInfo, currentLocation == lastRegistrationInfo.location, let lastFullPayloadSent = lastRegistrationInfo.lastFullPayloadSent, self.date.now.timeIntervalSince(lastFullPayloadSent) <= Self.payloadCadence else { return payload } let timeSinceLastUpdate = self.date.now.timeIntervalSince( lastRegistrationInfo.date ) let isActive = await self.appStateTracker.state == .active let shouldUpdateForActive = isActive && timeSinceLastUpdate >= Self.payloadCadence guard forcefully || shouldUpdateForActive || payload != lastRegistrationInfo.payload else { return nil } return payload.minimizePayload(previous: lastRegistrationInfo.payload) } fileprivate struct LastRegistrationInfo: Codable { let date: Date let lastFullPayloadSent: Date? let payload: ChannelRegistrationPayload let location: URL } } fileprivate extension ChannelGenerationMethod { var isValid: Bool { switch self { case .automatic: return true case .restore(let id): return UUID(uuidString: id) != nil } } } ================================================ FILE: Airship/AirshipCore/Source/ChannelRegistrationPayload.swift ================================================ import Foundation /// NOTE: For internal use only. :nodoc: public struct ChannelRegistrationPayload: Codable, Equatable, Sendable { public var channel: ChannelInfo public var identityHints: IdentityHints? enum CodingKeys: String, CodingKey { case channel = "channel" case identityHints = "identity_hints" } public init() { self.channel = ChannelInfo() } public func minimizePayload( previous: ChannelRegistrationPayload? ) -> ChannelRegistrationPayload { guard let previous = previous else { return self } var minPayload = self minPayload.channel = self.channel.minimize( previous: previous.channel ) minPayload.identityHints = nil return minPayload } /// NOTE: For internal use only. :nodoc: public struct ChannelInfo: Codable, Equatable, Sendable { var deviceType: String = "ios" /// This flag indicates that the user is able to receive push notifications. public var isOptedIn: Bool = false /// This flag indicates that the user is able to receive background notifications. public var isBackgroundEnabled: Bool = false /// The address to push notifications to. This should be the device token. public var pushAddress: String? /// The flag indicates tags in this request should be handled. public var setTags: Bool = false /// The tags for this device. public var tags: [String]? /// Tag changes. public var tagChanges: TagChanges? /// The locale language for this device. public var language: String? /// The locale country for this device. public var country: String? /// The time zone for this device. public var timeZone: String? /// The flag indicating if the user is active. public var isActive: Bool = false /// The app version. public var appVersion: String? /// The sdk version. public var sdkVersion: String? /// The device model. public var deviceModel: String? /// The device OS. public var deviceOS: String? public var contactID: String? public var iOSChannelSettings: iOSChannelSettings? public var permissions: [String: String]? enum CodingKeys: String, CodingKey { case deviceType = "device_type" case isOptedIn = "opt_in" case isBackgroundEnabled = "background" case pushAddress = "push_address" case setTags = "set_tags" case tags = "tags" case tagChanges = "tag_changes" case language = "locale_language" case country = "locale_country" case timeZone = "timezone" case appVersion = "app_version" case sdkVersion = "sdk_version" case deviceModel = "device_model" case deviceOS = "device_os" case contactID = "contact_id" case iOSChannelSettings = "ios" case isActive = "is_activity" case permissions = "permissions" } fileprivate func minimize(previous: ChannelInfo?) -> ChannelInfo { guard let previous = previous else { return self } var channel = self if channel.setTags && previous.setTags { if channel.tags == previous.tags { channel.tags = nil channel.setTags = false } else { let channelTags = channel.tags ?? [] let previousTags = previous.tags ?? [] let adds = channelTags.filter { !previousTags.contains($0) } let removes = previousTags.filter { !channelTags.contains($0) } channel.tagChanges = TagChanges(adds: adds, removes: removes) } } if channel.contactID == previous.contactID { if channel.language == previous.language { channel.language = nil } if channel.country == previous.country { channel.country = nil } if channel.timeZone == previous.timeZone { channel.timeZone = nil } if channel.appVersion == previous.appVersion { channel.appVersion = nil } if channel.sdkVersion == previous.sdkVersion { channel.sdkVersion = nil } if channel.deviceModel == previous.deviceModel { channel.deviceModel = nil } if channel.deviceOS == previous.deviceOS { channel.deviceOS = nil } } if previous.permissions == channel.permissions { channel.permissions = nil } channel.iOSChannelSettings = channel.iOSChannelSettings?.minimize( previous: previous.iOSChannelSettings ) return channel } } /// NOTE: For internal use only. :nodoc: public struct TagChanges: Codable, Equatable, Sendable { let adds: [String]? let removes: [String]? enum CodingKeys: String, CodingKey { case adds = "add" case removes = "remove" } init?(adds: [String], removes: [String]) { guard !adds.isEmpty || removes.isEmpty else { return nil } self.adds = adds.isEmpty ? nil : adds self.removes = removes.isEmpty ? nil : removes } } /// NOTE: For internal use only. :nodoc: public struct iOSChannelSettings: Codable, Equatable, Sendable { /// Quiet time settings for this device. public var quietTime: QuietTime? /// Quiet time time zone. public var quietTimeTimeZone: String? /// The badge for this device. public var badge: Int? public var isScheduledSummary: Bool? public var isTimeSensitive: Bool? enum CodingKeys: String, CodingKey { case quietTime = "quiettime" case quietTimeTimeZone = "tz" case badge = "badge" case isScheduledSummary = "scheduled_summary" case isTimeSensitive = "time_sensitive" } func minimize(previous: iOSChannelSettings?) -> iOSChannelSettings { guard let previous = previous else { return self } var minimized = self if minimized.isScheduledSummary == previous.isScheduledSummary { minimized.isScheduledSummary = nil } if minimized.isTimeSensitive == previous.isTimeSensitive { minimized.isTimeSensitive = nil } return minimized } } /// NOTE: For internal use only. :nodoc: public struct IdentityHints: Codable, Equatable, Sendable { /// The user ID. public var userID: String? public init(userID: String? = nil) { self.userID = userID } enum CodingKeys: String, CodingKey { case userID = "user_id" } } /// NOTE: For internal use only. :nodoc: public struct QuietTime: Codable, Equatable, Sendable { public var start: String public var end: String enum CodingKeys: String, CodingKey { case start = "start" case end = "end" } } } ================================================ FILE: Airship/AirshipCore/Source/ChannelScope.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Channel scope. public enum ChannelScope: String, Codable, Sendable, Equatable { /** * App channels - amazon, android, iOS */ case app /** * Web channels */ case web /** * Email channels */ case email /** * SMS channels */ case sms } ================================================ FILE: Airship/AirshipCore/Source/ChannelSubscriptionListProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine /** * Subscription list provider protocol for receiving contact updates. * @note For internal use only. :nodoc: */ protocol ChannelSubscriptionListProviderProtocol: Sendable { func fetch(channelID: String) async throws -> [String] } final class ChannelSubscriptionListProvider: ChannelSubscriptionListProviderProtocol { private let actor: BaseCachingRemoteDataProvider<ChannelSubscriptionListResult, ChannelAudienceOverrides> private let overridesApplier: OverridesApplier = OverridesApplier() init( audienceOverrides: any AudienceOverridesProvider, apiClient: any SubscriptionListAPIClientProtocol, date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = .shared, maxChannelListCacheAgeSeconds: TimeInterval = 600 ) { self.actor = BaseCachingRemoteDataProvider( remoteFetcher: { channelID in return try await apiClient .get(channelID: channelID) .map(onMap: { response in guard let result = response.result else { return nil } return .success(result) }) }, overridesProvider: { channelID in return AsyncStream { continuation in Task { let override = await audienceOverrides.channelOverrides(channelID: channelID) continuation.yield(override) continuation.finish() } } }, overridesApplier: { [overridesApplier] result, overrides in guard case .success(let list) = result else { return result } return .success(overridesApplier.applySubscriptionListUpdates(list, updates: overrides.subscriptionLists)) }, isEnabled: { true }, date: date, taskSleeper: taskSleeper, cacheTtl: maxChannelListCacheAgeSeconds ) } func fetch(channelID: String) async throws -> [String] { var stream = actor.updates(identifierUpdates: AsyncStream { continuation in continuation.yield(channelID) continuation.finish() }) .makeAsyncIterator() guard let result = await stream.next() else { throw AirshipErrors.error("Failed to get subscription list") } switch result { case .fail(let error): throw error case .success(let list): return list } } } enum ChannelSubscriptionListResult: Equatable, Sendable, Hashable, CachingRemoteDataProviderResult { static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult { return ChannelSubscriptionListResult.fail(error) } case success([String]) case fail(CachingRemoteDataError) public var subscriptionList: [String] { get throws { switch(self) { case .fail(let error): throw error case .success(let list): return list } } } public var isSuccess: Bool { switch(self) { case .fail(_): return false case .success(_): return true } } } private struct OverridesApplier { func applySubscriptionListUpdates( _ ids: [String], updates: [SubscriptionListUpdate] ) -> [String] { guard !updates.isEmpty else { return ids } var result = ids updates.forEach { update in switch update.type { case .subscribe: if !result.contains(update.listId) { result.append(update.listId) } case .unsubscribe: result.removeAll(where: { $0 == update.listId }) } } return result } } ================================================ FILE: Airship/AirshipCore/Source/ChannelType.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Channel type public enum ChannelType: String, Codable, Sendable, Equatable { /** * Email channel */ case email /** * SMS channel */ case sms /** * Open channel */ case open } ================================================ FILE: Airship/AirshipCore/Source/Checkbox.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI @MainActor struct Checkbox: View { private let info: ThomasViewInfo.Checkbox private let constraints: ViewConstraints @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var checkboxState: CheckboxState @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver init(info: ThomasViewInfo.Checkbox, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .checkbox, thomasState: thomasState ) } private var isOnBinding: Binding<Bool> { self.checkboxState.makeBinding( identifier: nil, reportingValue: info.properties.reportingValue ) } private var isEnabled: Bool { let isSelected = self.checkboxState.isSelected( reportingValue: info.properties.reportingValue ) return isSelected || !self.checkboxState.isMaxSelectionReached } var body: some View { Toggle(isOn: self.isOnBinding.animation()) {} .thomasToggleStyle( self.info.properties.style, constraints: self.constraints ) .constraints(constraints) .thomasCommon(self.info) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() .disabled(!self.isEnabled) .accessibilityRemoveTraits(.isSelected) } } ================================================ FILE: Airship/AirshipCore/Source/CheckboxController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct CheckboxController: View { private let info: ThomasViewInfo.CheckboxController private let constraints: ViewConstraints @EnvironmentObject private var environment: ThomasEnvironment init(info: ThomasViewInfo.CheckboxController, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var body: some View { Content( info: self.info, constraints: constraints, environment: environment ) .id(info.properties.identifier) .accessibilityElement(children: .contain) } @MainActor struct Content: View { private let info: ThomasViewInfo.CheckboxController private let constraints: ViewConstraints @Environment(\.pageIdentifier) private var pageID @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var thomasState: ThomasState @ObservedObject private var checkboxState: CheckboxState @EnvironmentObject private var validatableHelper: ValidatableHelper @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .checkboxController, thomasState: thomasState ) } init( info: ThomasViewInfo.CheckboxController, constraints: ViewConstraints, environment: ThomasEnvironment ) { self.info = info self.constraints = constraints // Use the environment to create or retrieve the state in case the view // stack changes and we lose our state. let checkboxState = environment.retrieveState(identifier: info.properties.identifier) { CheckboxState( minSelection: info.properties.minSelection, maxSelection: info.properties.maxSelection ) } self._checkboxState = ObservedObject(wrappedValue: checkboxState) } var body: some View { ViewFactory.createView(self.info.properties.view, constraints: constraints) .constraints(constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() .environmentObject(checkboxState) .airshipOnChangeOf(self.checkboxState.selected) { incoming in updateFormState(selected: incoming) } .onAppear { updateFormState(selected: self.checkboxState.selected) if self.formState.validationMode == .onDemand { validatableHelper.subscribe( forIdentifier: info.properties.identifier, formState: formState, initialValue: checkboxState.selected, valueUpdates: checkboxState.$selected, validatables: info.validation ) { [weak thomasState, weak checkboxState] actions in guard let thomasState, let checkboxState else { return } thomasState.processStateActions( actions, formFieldValue: .multipleCheckbox( Set(checkboxState.selected.map { $0.reportingValue }) ) ) } } } } private func checkValid(_ value: Set<AirshipJSON>) -> Bool { let min = info.properties.minSelection ?? 0 let max = info.properties.maxSelection ?? Int.max guard value.count >= min, value.count <= max else { return false } guard !value.isEmpty else { return info.validation.isRequired != true } return true } private func updateFormState(selected: Set<CheckboxState.Selected>) { let value: Set<AirshipJSON> = Set(selected.map { $0.reportingValue }) let formValue: ThomasFormField.Value = .multipleCheckbox(value) let field: ThomasFormField = if checkValid(value) { ThomasFormField.validField( identifier: self.info.properties.identifier, input: formValue, result: .init( value: formValue ) ) } else { ThomasFormField.invalidField( identifier: self.info.properties.identifier, input: formValue ) } self.formDataCollector.updateField(field, pageID: pageID) } } } ================================================ FILE: Airship/AirshipCore/Source/CheckboxState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine @MainActor class CheckboxState: ObservableObject { let minSelection: Int let maxSelection: Int @Published var selected: Set<Selected> = Set() init(minSelection: Int?, maxSelection: Int?) { self.minSelection = minSelection ?? 0 self.maxSelection = maxSelection ?? Int.max } var isMaxSelectionReached: Bool { selected.count >= maxSelection } func isSelected(identifier: String) -> Bool { return selected.contains { item in item.identifier == identifier } } func isSelected(reportingValue: AirshipJSON) -> Bool { return selected.contains { item in item.reportingValue == reportingValue } } struct Selected: ThomasSerializable, Hashable { var identifier: String? var reportingValue: AirshipJSON } } extension CheckboxState { func makeBinding( identifier: String?, reportingValue: AirshipJSON ) -> Binding<Bool> { return Binding<Bool>( get: { if let identifier { self.isSelected( identifier: identifier ) } else { self.isSelected( reportingValue: reportingValue ) } }, set: { let selected = Selected( identifier: identifier, reportingValue: reportingValue ) if $0 { self.selected.insert(selected) } else { self.selected.remove(selected) } } ) } } // MARK: - ThomasStateProvider extension CheckboxState: ThomasStateProvider { typealias SnapshotType = Set<Selected> var updates: AnyPublisher<any Codable, Never> { return $selected .removeDuplicates() .map(\.self) .eraseToAnyPublisher() } func persistentStateSnapshot() -> SnapshotType { return selected } func restorePersistentState(_ state: SnapshotType) { self.selected = state } } ================================================ FILE: Airship/AirshipCore/Source/CheckboxToggleLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI @MainActor struct CheckboxToggleLayout: View { @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var checkboxState: CheckboxState @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .checkboxToggleLayout, thomasState: thomasState ) } private let info: ThomasViewInfo.CheckboxToggleLayout private let constraints: ViewConstraints init(info: ThomasViewInfo.CheckboxToggleLayout, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var isOnBinding: Binding<Bool> { self.checkboxState.makeBinding( identifier: info.properties.identifier, reportingValue: info.properties.reportingValue ) } private var isEnabled: Bool { let isSelected = self.checkboxState.isSelected( identifier: info.properties.identifier ) return isSelected || !self.checkboxState.isMaxSelectionReached } var body: some View { ToggleLayout( isOn: self.isOnBinding, onToggleOn: self.info.properties.onToggleOn, onToggleOff: self.info.properties.onToggleOff ) { ViewFactory.createView( self.info.properties.view, constraints: constraints ) } .constraints(self.constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() } } ================================================ FILE: Airship/AirshipCore/Source/CircularRegion.swift ================================================ /* Copyright Airship and Contributors */ /// A circular region defines a radius, and latitude and longitude from its center. public class CircularRegion { let radius: Double let latitude: Double let longitude: Double /** * Default constructor. * * - Parameter radius: The radius of the circular region in meters. * - Parameter latitude: The latitude of the circular region's center point in degrees. * - Parameter longitude: The longitude of the circular region's center point in degrees. * * - Returns: Circular region object or `nil` if error occurs */ public init?(radius: Double, latitude: Double, longitude: Double) { guard CircularRegion.isValid(radius: radius) else { return nil } guard EventUtils.isValid(latitude: latitude) else { return nil } guard EventUtils.isValid(longitude: longitude) else { return nil } self.radius = radius self.latitude = latitude self.longitude = longitude } /** * Factory method for creating a circular region. * * - Parameter radius: The radius of the circular region in meters. * - Parameter latitude: The latitude of the circular region's center point in degrees. * - Parameter longitude: The longitude of the circular region's center point in degrees. * * - Returns: Circular region object or `nil` if error occurs */ public class func circularRegion( radius: Double, latitude: Double, longitude: Double ) -> CircularRegion? { return CircularRegion( radius: radius, latitude: latitude, longitude: longitude ) } class func isValid(radius: Double) -> Bool { guard radius >= 0.1 && radius <= 100000 else { AirshipLogger.error( "Invalid radius \(radius). Must be between .1 and 100000" ) return false } return true } } ================================================ FILE: Airship/AirshipCore/Source/CloudSite.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Represents the possible sites. public enum CloudSite: String, Sendable, Decodable { /// Represents the US cloud site. This is the default value. /// Projects available at go.airship.com must use this value. case us /// Represents the EU cloud site. /// Projects available at go.airship.eu must use this value. case eu public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() do { let stringValue = try container.decode(String.self) switch(stringValue.lowercased()) { case "us": self = .us case "eu": self = .eu default: self = .us } } catch { guard let intValue = try? container.decode(Int.self) else { throw error } switch(intValue) { case 0: self = .us case 1: self = .eu default: throw error } } } } ================================================ FILE: Airship/AirshipCore/Source/CompoundDeviceAudienceSelector.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Compound audience selector public indirect enum CompoundDeviceAudienceSelector: Sendable, Codable, Equatable { /// Atomic selector. Defines an actual audience selector. case atomic(DeviceAudienceSelector) /// NOT selector. Negates the results. case not(CompoundDeviceAudienceSelector) /// AND selector. All selectors have to evaluate true to match. If empty, evaluates to true. case and([CompoundDeviceAudienceSelector]) /// OR selector. At least once selector has to evaluate true to match. If empty, evaluates to false. case or([CompoundDeviceAudienceSelector]) enum CodingKeys: String, CodingKey { case type case audience case selector case selectors } private enum SelectorType: String, RawRepresentable, Codable { case atomic case not case and case or } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(SelectorType.self, forKey: .type) switch type { case .atomic: self = .atomic(try container.decode(DeviceAudienceSelector.self, forKey: .audience)) case .not: self = .not(try container.decode(CompoundDeviceAudienceSelector.self, forKey: .selector)) case .and: self = .and(try container.decode([CompoundDeviceAudienceSelector].self, forKey: .selectors)) case .or: self = .or(try container.decode([CompoundDeviceAudienceSelector].self, forKey: .selectors)) } } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .atomic(let content): try container.encode(SelectorType.atomic, forKey: .type) try container.encode(content, forKey: .audience) case .not(let content): try container.encode(SelectorType.not, forKey: .type) try container.encode(content, forKey: .selector) case .and(let content): try container.encode(SelectorType.and, forKey: .type) try container.encode(content, forKey: .selectors) case .or(let content): try container.encode(SelectorType.or, forKey: .type) try container.encode(content, forKey: .selectors) } } } public extension CompoundDeviceAudienceSelector { /// Combines old and new selector into a CompoundDeviceAudienceSelector /// - Parameters: /// - compoundSelector: An optional `CompoundDeviceAudienceSelector`. /// - deviceSelector: An optional `DeviceAudienceSelector`. /// - Returns: A `CompoundDeviceAudienceSelector` if either provided selector /// is non nill, otherwise nil. static func combine( compoundSelector: CompoundDeviceAudienceSelector?, deviceSelector: DeviceAudienceSelector? ) -> CompoundDeviceAudienceSelector? { if let compoundSelector, let deviceSelector { return .and([.atomic(deviceSelector), compoundSelector]) } else if let compoundSelector { return compoundSelector } else if let deviceSelector { return .atomic(deviceSelector) } return nil } } ================================================ FILE: Airship/AirshipCore/Source/ContactAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: protocol ContactsAPIClientProtocol: Sendable { func resolve( channelID: String, contactID: String?, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> func identify( channelID: String, namedUserID: String, contactID: String?, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> func reset( channelID: String, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> func update( contactID: String, tagGroupUpdates: [TagGroupUpdate]?, attributeUpdates: [AttributeUpdate]?, subscriptionListUpdates: [ScopedSubscriptionListUpdate]? ) async throws -> AirshipHTTPResponse<Void> func associateChannel( contactID: String, channelID: String, channelType: ChannelType ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> func registerEmail( contactID: String, address: String, options: EmailRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> func registerSMS( contactID: String, msisdn: String, options: SMSRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> func registerOpen( contactID: String, address: String, options: OpenRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> func disassociateChannel( contactID: String, disassociateOptions: DisassociateOptions ) async throws -> AirshipHTTPResponse<ContactDisassociateChannelResult> func resend( resendOptions: ResendOptions ) async throws -> AirshipHTTPResponse<Bool> } /// NOTE: For internal use only. :nodoc: final class ContactAPIClient: ContactsAPIClientProtocol { private let config: RuntimeConfig private let session: any AirshipRequestSession private var 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 var 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: (any AirshipRequestSession)? = nil) { self.config = config self.session = session ?? config.requestSession } func resolve( channelID: String, contactID: String?, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> { return try await self.performIdentify( channelID: channelID, identifyRequest: .resolve(contactID: contactID, possiblyOrphanedContactID: possiblyOrphanedContactID) ) } func reset( channelID: String, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> { return try await self.performIdentify( channelID: channelID, identifyRequest: .reset(possiblyOrphanedContactID: possiblyOrphanedContactID) ) } func identify( channelID: String, namedUserID: String, contactID: String?, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> { return try await self.performIdentify( channelID: channelID, identifyRequest: .identify(namedUserID: namedUserID, contactID: contactID, possiblyOrphanedContactID: possiblyOrphanedContactID) ) } func update( contactID: String, tagGroupUpdates: [TagGroupUpdate]?, attributeUpdates: [AttributeUpdate]?, subscriptionListUpdates: [ScopedSubscriptionListUpdate]? ) async throws -> AirshipHTTPResponse<Void> { let requestBody = ContactUpdateRequestBody( attributes: try attributeUpdates?.toRequestBody(), tags: tagGroupUpdates?.toRequestBody(), subscriptionLists: subscriptionListUpdates?.toRequestBody(), associate: nil ) return try await performUpdate(contactID: contactID, requestBody: requestBody) } func associateChannel( contactID: String, channelID: String, channelType: ChannelType ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { let requestBody = ContactUpdateRequestBody( attributes: nil, tags: nil, subscriptionLists: nil, associate: [ ContactUpdateRequestBody.AssociateChannelOperation( deviceType: channelType, channelID: channelID ) ] ) return try await performUpdate( contactID: contactID, requestBody: requestBody ).map { response in if (response.isSuccess) { return ContactAssociateChannelResult(channelType: channelType, channelID: channelID) } else { return nil } } } func registerEmail( contactID: String, address: String, options: EmailRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { return try await performChannelRegistration( contactID: contactID, requestBody: EmailChannelRegistrationBody( address: address, options: options, locale: locale, timezone: TimeZone.current.identifier ), channelType: .email ) } func registerSMS( contactID: String, msisdn: String, options: SMSRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { return try await performChannelRegistration( contactID: contactID, requestBody: SMSRegistrationBody( msisdn: msisdn, options: options, locale: locale, timezone: TimeZone.current.identifier ), channelType: .sms ) } func registerOpen( contactID: String, address: String, options: OpenRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { return try await performChannelRegistration( contactID: contactID, requestBody: OpenChannelRegistrationBody( address: address, options: options, locale: locale, timezone: TimeZone.current.identifier ), channelType: .open ) } func disassociateChannel( contactID: String, disassociateOptions: DisassociateOptions ) async throws -> AirshipHTTPResponse<ContactDisassociateChannelResult> { return try await performDisassociate( contactID: contactID, requestBody: disassociateOptions ) } func resend(resendOptions: ResendOptions) async throws -> AirshipHTTPResponse<Bool> { 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.") } let urlString = "\(deviceAPIURL)\(path)" guard let url = URL(string: "\(deviceAPIURL)\(path)") else { throw AirshipErrors.error("Invalid ContactAPIClient URL: \(String(describing: urlString))") } return url } private func makeChannelCreateURL(channelType: ChannelType) throws -> URL { switch(channelType) { case .email: return try self.makeURL(path: "/api/channels/restricted/email") case .open: return try self.makeURL(path: "/api/channels/restricted/open") case .sms: return try self.makeURL(path: "/api/channels/restricted/sms") } } private func performChannelRegistration<T: Encodable>( contactID: String, requestBody: T, channelType: ChannelType ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { let request = AirshipRequest( url: try self.makeChannelCreateURL(channelType: channelType), 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 let createResponse: AirshipHTTPResponse<ChannelCreateResult> = try await self.session.performHTTPRequest( request ) { (data, response) in AirshipLogger.debug("Channel \(channelType) created with response: \(response)") guard let data = data, response.statusCode == 200 || response.statusCode == 201 else { return nil } return try decoder.decode(ChannelCreateResult.self, from: data) } guard createResponse.isSuccess, let channelID = createResponse.result?.channelID else { return try createResponse.map { _ in return nil } } return try await associateChannel( contactID: contactID, channelID: channelID, channelType: channelType ) } private func performIdentify( channelID: String, identifyRequest: ContactIdentifyRequestBody ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> { AirshipLogger.debug("Identifying contact for channel ID \(channelID) request \(identifyRequest)") let request = AirshipRequest( url: try makeURL(path: "/api/contacts/identify/v2"), headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json", ], method: "POST", auth: .generatedChannelToken(identifier: channelID), body: try self.encoder.encode(identifyRequest) ) 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 { return nil } return try decoder.decode(ContactIdentifyResult.self, from: data) } } private func performDisassociate( contactID: String, requestBody: DisassociateOptions ) async throws -> AirshipHTTPResponse<ContactDisassociateChannelResult> { 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<Bool> { 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 ) async throws -> AirshipHTTPResponse<Void> { AirshipLogger.debug("Updating contact \(contactID) with \(requestBody)") let request = AirshipRequest( url: try self.makeURL(path: "/api/contacts/\(contactID)"), headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json", "X-UA-Appkey": self.config.appCredentials.appKey ], method: "POST", auth: .contactAuthToken(identifier: contactID), body: try self.encoder.encode(requestBody) ) return try await session.performHTTPRequest(request) { (data, response) in AirshipLogger.debug("Update finished with response: \(response)") return nil } } } struct ContactIdentifyResult: Decodable, Equatable { let contact: ContactInfo let token: String let tokenExpiresInMilliseconds: UInt enum CodingKeys: String, CodingKey { case tokenExpiresInMilliseconds = "token_expires_in" case token = "token" case contact = "contact" } struct ContactInfo: Decodable, Equatable { let channelAssociatedDate: Date let contactID: String let isAnonymous: Bool enum CodingKeys: String, CodingKey { case channelAssociatedDate = "channel_association_timestamp" case contactID = "contact_id" case isAnonymous = "is_anonymous" } } } 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)) } 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: ChannelType 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: any 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: any 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, 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: ChannelType 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? let subscriptionLists: [SubscriptionListOperation]? let associate: [AssociateChannelOperation]? enum CodingKeys: String, CodingKey { case attributes = "attributes" case tags = "tags" case subscriptionLists = "subscription_lists" case associate = "associate" } enum AttributeOperationAction: String, Encodable { case set case remove } struct AttributeOperation: Encodable { let action: AttributeOperationAction let key: String let value: AirshipJSON? let timestamp: Date } struct TagUpdates: Encodable { let adds: [String: [String]]? let removes: [String: [String]]? let sets: [String: [String]]? enum CodingKeys: String, CodingKey { case adds = "add" case removes = "remove" case sets = "set" } } enum SubscriptionListOperationAction: String, Encodable { case subscribe case unsubscribe } struct SubscriptionListOperation: Encodable { let action: SubscriptionListOperationAction let scope: ChannelScope let timestamp: Date let listID: String enum CodingKeys: String, CodingKey { case action case scope case timestamp case listID = "list_id" } } struct AssociateChannelOperation: Encodable { let deviceType: ChannelType let channelID: String enum CodingKeys: String, CodingKey { case deviceType = "device_type" case channelID = "channel_id" } } } fileprivate struct ContactIdentifyRequestBody: Encodable { private let deviceInfo: DeviceInfo = DeviceInfo() private let action: RequestAction internal init(action: RequestAction) { self.action = action } static func identify(namedUserID: String, contactID: String?, possiblyOrphanedContactID: String?) -> ContactIdentifyRequestBody { return ContactIdentifyRequestBody( action: RequestAction(type: "identify", namedUserID: namedUserID, contactID: contactID, possiblyOrphanedContactID: possiblyOrphanedContactID) ) } static func reset(possiblyOrphanedContactID: String?) -> ContactIdentifyRequestBody { return ContactIdentifyRequestBody( action: RequestAction(type: "reset", namedUserID: nil, contactID: nil, possiblyOrphanedContactID: possiblyOrphanedContactID) ) } static func resolve(contactID: String?, possiblyOrphanedContactID: String?) -> ContactIdentifyRequestBody { return ContactIdentifyRequestBody( action: RequestAction(type: "resolve", namedUserID: nil, contactID: contactID, possiblyOrphanedContactID: possiblyOrphanedContactID) ) } enum CodingKeys: String, CodingKey { case deviceInfo = "device_info" case action = "action" } internal struct DeviceInfo: Codable { let deviceType: String = "ios" enum CodingKeys: String, CodingKey { case deviceType = "device_type" } } internal struct RequestAction: Codable { let type: String let namedUserID: String? let contactID: String? let possiblyOrphanedContactID: String? enum CodingKeys: String, CodingKey { case type = "type" case namedUserID = "named_user_id" case contactID = "contact_id" case possiblyOrphanedContactID = "possibly_orphaned_contact_id" } } } fileprivate struct OpenChannelRegistrationBody: Encodable { let channel: ChannelPayload init( address: String, options: OpenRegistrationOptions, locale: Locale, timezone: String ) { self.channel = ChannelPayload( address: address, timezone: timezone, localeCountry: locale.getRegionCode(), localeLanguage: locale.getLanguageCode(), openInfo: OpenPayload( platformName: options.platformName, identifiers: options.identifiers ) ) } internal struct ChannelPayload: Encodable { let type: String = "open" let optIn: Bool = true let address: String let timezone: String let localeCountry: String? let localeLanguage: String? let openInfo: OpenPayload enum CodingKeys: String, CodingKey { case type case optIn = "opt_in" case address case timezone case localeCountry = "locale_country" case localeLanguage = "locale_language" case openInfo = "open" } } internal struct OpenPayload: Encodable { let platformName: String let identifiers: [String: String]? enum CodingKeys: String, CodingKey { case platformName = "open_platform_name" case identifiers } } } 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? let optInMode: OptInMode init( address: String, options: EmailRegistrationOptions, locale: Locale, timezone: String ) { self.channel = ChannelPayload( address: address, timezone: timezone, localeCountry: locale.getRegionCode(), localeLanguage: locale.getLanguageCode(), commercialOptedIn: options.commercialOptedIn, transactionalOptedIn: options.transactionalOptedIn ) self.optInMode = options.doubleOptIn ? .double : .classic self.properties = options.properties } enum CodingKeys: String, CodingKey { case channel case properties case optInMode = "opt_in_mode" } internal enum OptInMode: String, Encodable { case classic case double } internal struct ChannelPayload: Encodable { let type: String = "email" let address: String let timezone: String let localeCountry: String? let localeLanguage: String? let commercialOptedIn: Date? let transactionalOptedIn: Date? enum CodingKeys: String, CodingKey { case type case address case timezone case localeCountry = "locale_country" case localeLanguage = "locale_language" case commercialOptedIn = "commercial_opted_in" case transactionalOptedIn = "transactional_opted_in" } } internal struct OpenPayload: Encodable { let platformName: String let identifiers: [String: String]? enum CodingKeys: String, CodingKey { case platformName = "open_platform_name" case identifiers } } } fileprivate struct SMSRegistrationBody: Encodable { let msisdn: String let sender: String let timezone: String let localeCountry: String? let localeLanguage: String? init( msisdn: String, options: SMSRegistrationOptions, locale: Locale, timezone: String ) { self.msisdn = msisdn self.sender = options.senderID self.timezone = timezone self.localeCountry = locale.getRegionCode() self.localeLanguage = locale.getLanguageCode() } enum CodingKeys: String, CodingKey { case msisdn case sender case timezone case localeCountry = "locale_country" case localeLanguage = "locale_language" } } fileprivate struct ChannelCreateResult: Decodable { let channelID: String enum CodingKeys: String, CodingKey { case channelID = "channel_id" } } fileprivate extension Array where Element == TagGroupUpdate { func toRequestBody() -> ContactUpdateRequestBody.TagUpdates? { var adds: [String: [String]] = [:] var removes: [String: [String]] = [:] var sets: [String: [String]] = [:] self.forEach { update in switch update.type { case .add: adds[update.group] = update.tags case .remove: removes[update.group] = update.tags case .set: sets[update.group] = update.tags } } guard !adds.isEmpty || !removes.isEmpty || !sets.isEmpty else { return nil } return ContactUpdateRequestBody.TagUpdates( adds: adds.isEmpty ? nil : adds, removes: removes.isEmpty ? nil : removes, sets: sets.isEmpty ? nil : sets ) } } fileprivate extension Array where Element == ScopedSubscriptionListUpdate { func toRequestBody() -> [ContactUpdateRequestBody.SubscriptionListOperation]? { let mapped = self.map { update in switch(update.type) { case .subscribe: return ContactUpdateRequestBody.SubscriptionListOperation( action: .subscribe, scope: update.scope, timestamp: update.date, listID: update.listId ) case .unsubscribe: return ContactUpdateRequestBody.SubscriptionListOperation( action: .unsubscribe, scope: update.scope, timestamp: update.date, listID: update.listId ) } } guard !mapped.isEmpty else { return nil } return mapped } } fileprivate extension Array where Element == AttributeUpdate { func toRequestBody() throws -> [ContactUpdateRequestBody.AttributeOperation]? { let mapped = self.map { update in switch(update.type) { case .set: return ContactUpdateRequestBody.AttributeOperation( action: .set, key: update.attribute, value: update.jsonValue, timestamp: update.date ) case .remove: return ContactUpdateRequestBody.AttributeOperation( action: .remove, key: update.attribute, value: nil, timestamp: update.date ) } } guard !mapped.isEmpty else { return nil } return mapped } } ================================================ FILE: Airship/AirshipCore/Source/ContactChannel.swift ================================================ /* Copyright Airship and Contributors */ public 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 } } ================================================ FILE: Airship/AirshipCore/Source/ContactChannelsAPIClient.swift ================================================ /* 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: any AirshipRequestSession private var 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: any 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: any 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 } } } ================================================ FILE: Airship/AirshipCore/Source/ContactChannelsProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine /** * Contact channels provider protocol for receiving contact updates. * @note For internal use only. :nodoc: */ protocol ContactChannelsProviderProtocol: Sendable { func contactChannels(stableContactIDUpdates: AsyncStream<String>) -> AsyncStream<ContactChannelsResult> func refresh() async func refreshAsync() } final class ContactChannelsProvider: ContactChannelsProviderProtocol { private let actor: BaseCachingRemoteDataProvider<ContactChannelsResult, ContactAudienceOverrides> private let overridesApplier: OverridesApplier = OverridesApplier() init( audienceOverrides: any AudienceOverridesProvider, apiClient: any ContactChannelsAPIClientProtocol, date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = .shared, maxChannelListCacheAgeSeconds: TimeInterval = 600, privacyManager: any AirshipPrivacyManager ) { self.actor = BaseCachingRemoteDataProvider( remoteFetcher: { contactID in return try await apiClient .fetchAssociatedChannelsList(contactID: contactID) .map { response in guard let result = response.result else { return nil } return .success(result) } }, overridesProvider: { identifier in return await audienceOverrides.contactOverrideUpdates(contactID: identifier) }, overridesApplier: { [overridesApplier] result, overrides in return await overridesApplier.applyUpdates(result: result, overrides: overrides) }, isEnabled: { privacyManager.isEnabled(.contacts) }, date: date, taskSleeper: taskSleeper, cacheTtl: maxChannelListCacheAgeSeconds ) } /// Returns the latest contact channel result stream from the latest stable contact ID func contactChannels(stableContactIDUpdates: AsyncStream<String>) -> AsyncStream<ContactChannelsResult> { return actor.updates(identifierUpdates: stableContactIDUpdates) } func refresh() async { await actor.refresh() } func refreshAsync() { Task { await refresh() } } } public enum ContactChannelErrors: Error, Equatable, Sendable, Hashable { case contactsDisabled case failedToFetchContacts } fileprivate extension CachingRemoteDataError { func toChannelError() -> ContactChannelErrors { switch (self) { case .disabled: return .contactsDisabled case .failedToFetch: return .failedToFetchContacts } } } public enum ContactChannelsResult: Equatable, Sendable, Hashable, CachingRemoteDataProviderResult { static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult { return ContactChannelsResult.error(error.toChannelError()) } 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 } } } } ================================================ FILE: Airship/AirshipCore/Source/ContactConflictEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Contact data. public struct ContactConflictEvent: Sendable, Equatable { /** * The named user ID if the conflict was caused by an identify operation with an existing named user through the SDK. */ public let conflictingNamedUserID: String? /** * Subscription lists. */ public let subscriptionLists: [String: [ChannelScope]] /** * Tag groups. */ public let tags: [String: [String]] /** * Attributes. */ public let attributes: [String: AirshipJSON] /** * Associated channels. */ public let associatedChannels: [ChannelInfo] /** * Default constructor. * - Parameters: * - tags: The tags. * - attributes: The attributes. * - subscriptionLists: The subscription lists. * - channels: The associated channels. * - conflictingNamedUserID: The conflicting named user ID. */ init( tags: [String: [String]], attributes: [String: AirshipJSON], associatedChannels: [ChannelInfo], subscriptionLists: [String: [ChannelScope]], conflictingNamedUserID: String? ) { self.tags = tags self.associatedChannels = associatedChannels self.subscriptionLists = subscriptionLists self.conflictingNamedUserID = conflictingNamedUserID self.attributes = attributes } public struct ChannelInfo: Sendable, Equatable { /** * Channel type */ public let channelType: ChannelType /** * channel ID */ public let channelID: String } } ================================================ FILE: Airship/AirshipCore/Source/ContactManager.swift ================================================ import Foundation actor ContactManager: ContactManagerProtocol { private static let operationsKey: String = "Contact.operationEntries" private static let legacyOperationsKey: String = "Contact.operations" // operations without the date private static let contactInfoKey: String = "Contact.contactInfo" private static let anonContactDataKey: String = "Contact.anonContactData" static let identityRateLimitID: String = "Contact.identityRateLimitID" static let updateRateLimitID: String = "Contact.updateRateLimitID" static let updateTaskID: String = "Contact.update" private let cachedAuthToken: CachedValue<AuthToken> = CachedValue() private var dataStore: PreferenceDataStore private let identifySerialQueue: AirshipSerialQueue = AirshipSerialQueue() private let channel: any AirshipChannel private let apiClient: any ContactsAPIClientProtocol private let workManager: any AirshipWorkManagerProtocol private let date: any AirshipDateProtocol private let internalIdentifyRateLimit: TimeInterval private let localeManager: any AirshipLocaleManager private var onAudienceUpdatedCallback: ((ContactAudienceUpdate) async -> Void)? private var lastContactIDUpdate: ContactIDInfo? private var lastNamedUserUpdate: String? let contactUpdates: AsyncStream<ContactUpdate> private let contactUpdatesContinuation: AsyncStream<ContactUpdate>.Continuation private var isEnabled: Bool = false private var lastIdentifyOperationDate: Date = Date.distantPast private var lastSuccessfulIdentifyDate: Date = Date.distantPast private var operationEntries: [ContactOperationEntry] { get { if (dataStore.keyExists(ContactManager.operationsKey)) { return dataStore.safeCodable(forKey: ContactManager.operationsKey) ?? [] } else if (dataStore.keyExists(ContactManager.legacyOperationsKey)) { let operations: [ContactOperation] = dataStore.safeCodable(forKey: ContactManager.operationsKey) ?? [] let now = date.now let entries = operations.map { operation in return ContactOperationEntry(date: now, operation: operation, identifier: UUID().uuidString) } dataStore.setSafeCodable(entries, forKey: ContactManager.operationsKey) dataStore.removeObject(forKey: ContactManager.legacyOperationsKey) return entries } return [] } set { dataStore.setSafeCodable(newValue, forKey: ContactManager.operationsKey) } } private var anonData: AnonContactData? { get { return dataStore.safeCodable(forKey: ContactManager.anonContactDataKey) } set { dataStore.setSafeCodable(newValue, forKey: ContactManager.anonContactDataKey) } } private var lastContactInfo: InternalContactInfo? { get { return dataStore.safeCodable(forKey: ContactManager.contactInfoKey) } set { dataStore.setSafeCodable(newValue, forKey: ContactManager.contactInfoKey) } } private var possiblyOrphanedContactID: String? { guard let lastContactInfo = self.lastContactInfo, lastContactInfo.isAnonymous, (anonData?.channels.isEmpty ?? true) else { return nil } return lastContactInfo.contactID } init( dataStore: PreferenceDataStore, channel: any AirshipChannel, localeManager: any AirshipLocaleManager, apiClient: any ContactsAPIClientProtocol, date: any AirshipDateProtocol = AirshipDate.shared, workManager: any AirshipWorkManagerProtocol = AirshipWorkManager.shared, internalIdentifyRateLimit: TimeInterval = 5.0 ) { self.dataStore = dataStore self.apiClient = apiClient self.channel = channel self.date = date self.workManager = workManager self.localeManager = localeManager self.internalIdentifyRateLimit = internalIdentifyRateLimit ( self.contactUpdates, self.contactUpdatesContinuation ) = AsyncStream<ContactUpdate>.airshipMakeStreamWithContinuation() self.workManager.registerWorker( ContactManager.updateTaskID ) { [weak self] _ in if (try await self?.performNextOperation() != false) { return .success } return .failure } workManager.setRateLimit( ContactManager.identityRateLimitID, rate: 1, timeInterval: 5.0 ) workManager.setRateLimit( ContactManager.updateRateLimitID, rate: 1, timeInterval: 0.5 ) Task { [weak channel] in await self.yieldContactUpdates() let startingChannelID = channel?.identifier let updates = channel?.identifierUpdates.filter { $0 != startingChannelID } if let updates { for await _ in updates { try Task.checkCancellation() await enqueueTask() } } } } func onAudienceUpdated(onAudienceUpdatedCallback: (@Sendable (ContactAudienceUpdate) async -> Void)?) { self.onAudienceUpdatedCallback = onAudienceUpdatedCallback } func resetIfNeeded() { guard self.operationEntries.isEmpty == false || lastContactInfo?.isAnonymous == false || self.hasAnonData() || self.lastContactInfo == nil else { return } addOperation(.reset) } func addOperation(_ operation: ContactOperation) { self.operationEntries.append( ContactOperationEntry(date: self.date.now, operation: operation, identifier: UUID().uuidString) ) self.yieldContactUpdates() self.enqueueTask() } func generateDefaultContactIDIfNotSet() -> Void { guard self.lastContactInfo == nil else { return } self.lastContactInfo = InternalContactInfo( contactID: UUID().uuidString.lowercased(), isAnonymous: true, namedUserID: nil, channelAssociatedDate: self.date.now, resolveDate: self.date.now ) self.yieldContactUpdates() } func currentNamedUserID() -> String? { let entries = self.operationEntries.reversed().first { entry in entry.operation.type == .identify || entry.operation.type == .reset } if let entry = entries { switch(entry.operation) { case .reset: return nil case .identify(let identifier): return identifier default: break } } return self.lastContactInfo?.namedUserID } func resolveAuth(identifier: String) async throws -> String { if lastContactInfo?.contactID == identifier, let token = tokenIfValid() { return token } _ = try await performOperation(.resolve) self.yieldContactUpdates() guard lastContactInfo?.contactID == identifier else { throw AirshipErrors.error("Mismatch contact ID") } if let token = tokenIfValid() { return token } throw AirshipErrors.error("Failed to refresh token") } @inline(never) func authTokenExpired(token: String) async { self.cachedAuthToken.expireIf { auth in return auth.token == token } } func setEnabled(enabled: Bool) { guard self.isEnabled != enabled else { return } self.isEnabled = enabled if enabled { enqueueTask() } } @inline(never) func currentContactIDInfo() -> ContactIDInfo? { guard let lastContactInfo = self.lastContactInfo else { return nil } return ContactIDInfo( contactID: lastContactInfo.contactID, isStable: self.isContactIDStable(), namedUserID: lastContactInfo.namedUserID, resolveDate: lastContactInfo.resolveDate ?? .distantPast ) } // Worker -> one at a time @inline(never) private func performNextOperation() async throws -> Bool { guard self.isEnabled else { AirshipLogger.trace("Contact manager is not enabled, unable to perform operation") return true } guard !self.operationEntries.isEmpty else { AirshipLogger.trace("Operations are empty") return true } defer { self.yieldContactUpdates() } // 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 { let resolveResult = try await performOperation(.resolve) self.yieldContactUpdates() if (!resolveResult) { return false } } self.clearSkippableOperations() yieldContactUpdates() guard let operationGroup = prepareNextOperationGroup() else { AirshipLogger.trace("Next operation group is nil") return true } let result = try await performOperation(operationGroup.mergedOperation) if (result) { let identifiers = operationGroup.operations.map { $0.identifier } self.operationEntries.removeAll { entry in identifiers.contains(entry.identifier) } if (!self.operationEntries.isEmpty) { self.enqueueTask() } } return result } private func tokenIfValid() -> String? { if let token = self.cachedAuthToken.value, token.identifier == self.lastContactInfo?.contactID, self.cachedAuthToken.timeRemaining >= 30 { return token.token } return nil } private func enqueueTask() { guard self.isEnabled else { AirshipLogger.trace("Contact manager is not enabled, unable to enqueue task") return } guard self.channel.identifier != nil else { AirshipLogger.trace("Channel not created, unable to enqueue task") return } var rateLimitIDs = [ContactManager.updateRateLimitID] let next = self.operationEntries.first { !self.isSkippable(operation: $0.operation) }?.operation if (next?.type == .reset || next?.type == .identify || tokenIfValid() == nil) { rateLimitIDs += [ContactManager.identityRateLimitID] } self.workManager.dispatchWorkRequest( AirshipWorkRequest( workID: ContactManager.updateTaskID, requiresNetwork: true, rateLimitIDs: Set(rateLimitIDs) ) ) } private func clearSkippableOperations() { var operations = self.operationEntries while let next = operations.first { if (isSkippable(operation: next.operation)) { operations.removeFirst() } else { break } } self.operationEntries = operations } private func performOperation(_ operation: ContactOperation) async throws -> Bool { AirshipLogger.trace("Performing operation \(operation.type)") guard !self.isSkippable(operation: operation) else { AirshipLogger.trace("Operation skippable, finished operation \(operation.type)") return true } switch operation { case .update(let tagUpdates, let attributeUpdates, let subListUpdates): return try await performUpdateOperation( tagGroupUpdates: tagUpdates, attributeUpdates: attributeUpdates, subscriptionListsUpdates: subListUpdates ) case .identify(let identifier): return try await doIdentify { return try await self.performIdentifyOperation( identifier: identifier ) } case .reset: return try await doIdentify { return try await self.performResetOperation() } case .resolve: return try await doIdentify { return try await self.performResolveOperation() } case .verify(_, _): return try await doIdentify { return try await self.performResolveOperation() } case .registerEmail(let address, let options): return try await performRegisterEmailOperation( address: address, options: options ) case .registerSMS(let msisdn, let options): return try await performRegisterSMSOperation( msisdn: msisdn, options: options ) case .registerOpen(let address, let options): return try await performRegisterOpenChannelOperation( address: address, options: options ) case .associateChannel(let channelID, let type): return try await performAssociateChannelOperation( 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) } } private func performResolveOperation() async throws -> Bool { let response = try await self.apiClient.resolve( channelID: try requireChannelID(), contactID: self.lastContactInfo?.contactID, possiblyOrphanedContactID: possiblyOrphanedContactID ) if let result = response.result, response.isSuccess { await updateContactInfo(result: result, operationType: .resolve) } return response.isOperationComplete } private func performResetOperation() async throws -> Bool { let response = try await self.apiClient.reset( channelID: try requireChannelID(), possiblyOrphanedContactID: possiblyOrphanedContactID ) if let result = response.result, response.isSuccess { await updateContactInfo(result: result, operationType: .reset) } return response.isOperationComplete } private func performIdentifyOperation(identifier: String) async throws -> Bool { let response = try await self.apiClient.identify( channelID: try requireChannelID(), namedUserID: identifier, contactID: self.lastContactInfo?.contactID, possiblyOrphanedContactID: possiblyOrphanedContactID ) if let result = response.result, response.isSuccess { await updateContactInfo( result: result, namedUserID: identifier, operationType: .identify ) } return response.isOperationComplete } private func performRegisterEmailOperation( address: String, options: EmailRegistrationOptions ) async throws -> Bool { let contactID = try requireContactID() let response = try await self.apiClient.registerEmail( contactID: contactID, address: address, options: options, locale: self.localeManager.currentLocale ) 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 contactID = try requireContactID() let response = try await self.apiClient.registerSMS( contactID: contactID, msisdn: msisdn, options: options, locale: self.localeManager.currentLocale ) 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 contactID = try requireContactID() let response = try await self.apiClient.registerOpen( contactID: contactID, address: address, options: options, locale: self.localeManager.currentLocale ) 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 contactID = try requireContactID() let response = try await self.apiClient.associateChannel( contactID: contactID, channelID: channelID, channelType: type ) 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: true ) 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: true ) 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( tagGroupUpdates: [TagGroupUpdate]?, attributeUpdates: [AttributeUpdate]?, subscriptionListsUpdates: [ScopedSubscriptionListUpdate]? ) async throws -> Bool { guard let contactInfo = self.lastContactInfo else { AirshipLogger.error("Failed to update contact, missing contact ID.") return false } let response = try await self.apiClient.update( contactID: contactInfo.contactID, tagGroupUpdates: tagGroupUpdates, attributeUpdates: attributeUpdates, subscriptionListUpdates: subscriptionListsUpdates ) if response.isSuccess { await self.contactUpdated( contactID: contactInfo.contactID, tagGroupUpdates: tagGroupUpdates, attributeUpdates: attributeUpdates, subscriptionListsUpdates: subscriptionListsUpdates ) } return response.isOperationComplete } func pendingAudienceOverrides(contactID: String) -> ContactAudienceOverrides { // If we have a contact ID but its stale, return an empty overrides guard contactID == self.lastContactInfo?.contactID else { return ContactAudienceOverrides() } var tags: [TagGroupUpdate] = [] var attributes: [AttributeUpdate] = [] var subscriptionLists: [ScopedSubscriptionListUpdate] = [] let operations = operationEntries.map { $0.operation } var channels: [ContactChannelUpdate] = [] var lastOperationNamedUser: String? = nil for operation in operations { // A reset will generate a new contact ID if case .reset = operation { break } if case let .identify(namedUserID) = operation { // If we come across an identify operation that does not match the current contact info, // then any further operations are for a different contact if self.lastContactInfo?.isAnonymous == false, namedUserID != self.lastContactInfo?.namedUserID { break } // If we have a lastOperationNamedUser and it does not match the operation if lastOperationNamedUser != nil, lastOperationNamedUser != namedUserID { break } lastOperationNamedUser = namedUserID continue } if case let .update(tagUpdates, attributesUpdates, subscriptionListsUpdates) = operation { if let tagUpdates = tagUpdates { tags += tagUpdates } if let attributesUpdates = attributesUpdates { attributes += attributesUpdates } if let subscriptionListsUpdates = subscriptionListsUpdates { subscriptionLists += subscriptionListsUpdates } 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, channels: channels ) } private func hasAnonData() -> Bool { guard self.lastContactInfo?.isAnonymous == true, let anonData = self.anonData else { return false } return !anonData.isEmpty } private func requireContactID() throws -> String { guard let contactID = lastContactInfo?.contactID else { throw AirshipErrors.error("Missing contact ID") } return contactID } private func requireChannelID() throws -> String { guard let channelID = self.channel.identifier else { throw AirshipErrors.error("Missing channel ID") } return channelID } private func prepareNextOperationGroup() -> ContactOperationGroup? { var operations = self.operationEntries guard let next = operations.first else { return nil } operations.removeFirst() switch (next.operation) { case .update(let nextTags, let nextAttributes, let nextSubList): // Group updates into a single update var group = [next] var mergedTags = nextTags ?? [] var mergedAttributes = nextAttributes ?? [] var mergedSubLists = nextSubList ?? [] for nextNext in operations { if case let .update(otherTags, otherAttributes, otherSubLists) = nextNext.operation { mergedTags += (otherTags ?? []) mergedAttributes += (otherAttributes ?? []) mergedSubLists += (otherSubLists ?? []) group.append(nextNext) } else { break } } let mergedUpdate: ContactOperation = .update( tagUpdates: AudienceUtils.collapse(mergedTags), attributeUpdates: AudienceUtils.collapse(mergedAttributes), subscriptionListsUpdates: AudienceUtils.collapse(mergedSubLists) ) return ContactOperationGroup( 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 // can be performed if we do not have any anon data. guard !hasAnonData() else { return ContactOperationGroup( operations: [next], mergedOperation: next.operation ) } var group = [next] var last = next for nextNext in operations { if (nextNext.operation.type == .identify || nextNext.operation.type == .reset) { group.append(nextNext) last = nextNext } else { break } } return ContactOperationGroup( operations: group, mergedOperation: last.operation ) default: return ContactOperationGroup( operations: [next], mergedOperation: next.operation ) } } private func isContactIDStable() -> Bool { guard let lastContactInfo = lastContactInfo else { return false } return !self.operationEntries.contains { entry in switch entry.operation { case .reset: return true case .verify(_, let required): return required == true case .identify(let identifier): if lastContactInfo.namedUserID != identifier { return true } else { return false } default: return false } } } private func isSkippable(operation: ContactOperation) -> Bool { switch operation { case .update(let tagUpdates, let attributeUpdates, let subListUpdates): // Skip if its an empty update return tagUpdates?.isEmpty ?? true && attributeUpdates?.isEmpty ?? true && subListUpdates?.isEmpty ?? true case .identify(let identifier): // Skip if we are already this user and have a valid token return self.lastContactInfo?.namedUserID == identifier && tokenIfValid() != nil case .reset: // Skip if we are already anonymous, have no data, and a valid token. return lastContactInfo?.isAnonymous == true && self.anonData?.isEmpty != false && tokenIfValid() != nil case .resolve: // Skip if we have a valid token for the current contact info return tokenIfValid() != nil case .verify(let date, _): // Skip if we have a valid token and the resolveDate is newer than the verify date let resolveDate = self.lastContactInfo?.resolveDate ?? .distantPast return tokenIfValid() != nil && date <= resolveDate default: return false } } private func updateContactInfo( result: ContactIdentifyResult, namedUserID: String? = nil, operationType: ContactOperation.OperationType, resolveDate: Date? = nil ) async { let expiration = self.date.now.advanced(by: Double(result.tokenExpiresInMilliseconds)/1000.0 ) // Update token self.cachedAuthToken.set( value: AuthToken( identifier: result.contact.contactID, token: result.token, expiration: expiration ), expiration: expiration ) // Doing a lowercased check so if backend normalizes the contact ID we wont lose data. let isNewContactID = lastContactInfo?.contactID.lowercased() != result.contact.contactID.lowercased() var resolvedNamedUser = namedUserID if !isNewContactID, resolvedNamedUser == nil { resolvedNamedUser = lastContactInfo?.namedUserID } let newContactInfo = InternalContactInfo( contactID: result.contact.contactID, isAnonymous: result.contact.isAnonymous, namedUserID: resolvedNamedUser, channelAssociatedDate: result.contact.channelAssociatedDate, resolveDate: self.date.now ) // Conflict events if isNewContactID, self.lastContactInfo?.isAnonymous == true, let anonData = self.anonData, !anonData.isEmpty { self.contactUpdatesContinuation.yield( .conflict( ContactConflictEvent( tags: anonData.tags, attributes: anonData.attributes, associatedChannels: anonData.channels.map { .init(channelType: $0.channelType, channelID: $0.channelID) }, subscriptionLists: anonData.subscriptionLists, conflictingNamedUserID: namedUserID ) ) ) self.anonData = nil } // Reset anon data if !newContactInfo.isAnonymous { self.anonData = nil } // If we have a resolve that returns a new contactID then it means // it was changed server side. Clear any pending operations that are // older than the resolve date. if self.lastContactInfo != nil, isNewContactID, operationType == .resolve { self.operationEntries = self.operationEntries.filter { entry in if (result.contact.channelAssociatedDate < entry.date) { return true } else { AirshipLogger.trace("Dropping operation \(entry.operation.type) due to channel association date") return false } } } self.lastContactInfo = newContactInfo } private func updateLastIdentifyOperationDate() { self.lastIdentifyOperationDate = self.date.now } private func yieldContactUpdates() { let currentContactIDInfo = self.currentContactIDInfo() if let currentContactIDInfo = currentContactIDInfo, currentContactIDInfo != self.lastContactIDUpdate { self.contactUpdatesContinuation.yield(.contactIDUpdate(currentContactIDInfo)) self.lastContactIDUpdate = currentContactIDInfo } let currentNamedUserID = self.currentNamedUserID() if currentNamedUserID != self.lastNamedUserUpdate { self.contactUpdatesContinuation.yield(.namedUserUpdate(currentNamedUserID)) self.lastNamedUserUpdate = currentNamedUserID } } private func doIdentify<T: Sendable>(work: @escaping @Sendable () async throws -> T) async throws -> T { try await self.identifySerialQueue.run { // We handle identify rate limit internally since both work and auth token might cause identify to be called let remainingTime = self.internalIdentifyRateLimit - self.date.now.timeIntervalSince( await self.lastIdentifyOperationDate ) if (remainingTime > 0) { try await Task.sleep( nanoseconds: UInt64(remainingTime * 1_000_000_000) ) } let result: T = try await work() await self.updateLastIdentifyOperationDate() return result } } private func contactUpdated( contactID: String, tagGroupUpdates: [TagGroupUpdate]? = nil, attributeUpdates: [AttributeUpdate]? = nil, subscriptionListsUpdates: [ScopedSubscriptionListUpdate]? = 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 || channelUpdates?.isEmpty == false { await self.onAudienceUpdatedCallback?( ContactAudienceUpdate( contactID: contactID, tags: tagGroupUpdates, attributes: attributeUpdates, subscriptionLists: subscriptionListsUpdates, contactChannels: channelUpdates ) ) } if contactInfo.isAnonymous { let data = self.anonData var tags: [String: [String]] = data?.tags ?? [:] var attributes: [String: AirshipJSON] = data?.attributes ?? [:] var channels: Set<AnonContactData.Channel> = Set(data?.channels ?? []) var subscriptionLists: [String: [ChannelScope]] = data?.subscriptionLists ?? [:] tags = AudienceUtils.applyTagUpdates( data?.tags, updates: tagGroupUpdates ) attributes = AudienceUtils.applyAttributeUpdates( data?.attributes, updates: attributeUpdates ) subscriptionLists = AudienceUtils.applySubscriptionListsUpdates( data?.subscriptionLists, updates: subscriptionListsUpdates ) 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: Array(channels), subscriptionLists: subscriptionLists ) } } } fileprivate struct InternalContactInfo: Codable, Equatable { let contactID: String let isAnonymous: Bool let namedUserID: String? let channelAssociatedDate: Date? let resolveDate: Date? } fileprivate struct ContactOperationGroup { let operations: [ContactOperationEntry] let mergedOperation: ContactOperation } fileprivate struct ContactOperationEntry: Codable, Sendable { let date: Date let operation: ContactOperation let identifier: String } fileprivate extension AirshipHTTPResponse { var isOperationComplete: Bool { // Consider the operation complete if we have a success // response or a client error. The client error is to avoid // blocking the queue on either an invalid operation or // if the app is not configured to properly. return self.isSuccess || self.isClientError } } ================================================ FILE: Airship/AirshipCore/Source/ContactManagerProtocol.swift ================================================ import Foundation protocol ContactManagerProtocol: Actor, AuthTokenProvider { var contactUpdates: AsyncStream<ContactUpdate> { get } func onAudienceUpdated(onAudienceUpdatedCallback: (@Sendable (ContactAudienceUpdate) async -> Void)?) func addOperation(_ operation: ContactOperation) func generateDefaultContactIDIfNotSet() -> Void func currentNamedUserID() -> String? func setEnabled(enabled: Bool) func currentContactIDInfo() -> ContactIDInfo? func resetIfNeeded() func pendingAudienceOverrides(contactID: String) -> ContactAudienceOverrides } struct ContactAudienceUpdate: Equatable, Sendable { let contactID: String 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, namedUserID: String?, resolveDate: Date = Date.distantPast) { self.contactID = contactID self.isStable = isStable self.resolveDate = resolveDate self.namedUserID = namedUserID } } enum ContactUpdate: Equatable, Sendable { case contactIDUpdate(ContactIDInfo) case namedUserUpdate(String?) 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 } } ================================================ FILE: Airship/AirshipCore/Source/ContactOperation.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: enum ContactOperation: Codable, Equatable, Sendable { var type: OperationType { switch (self) { case .update(_, _, _): return .update case .identify(_): return .identify case .resolve: return .resolve case .verify(_, _): return .verify case .reset: return .reset case .registerEmail(_, _): return .registerEmail case .registerSMS(_, _): return .registerSMS case .registerOpen(_, _): return .registerOpen case .associateChannel(_, _): return .associateChannel case .disassociateChannel(_): return .disassociateChannel case .resend(_): return .resend } } enum OperationType: String, Codable { case update case identify case resolve case reset case verify case registerEmail case registerSMS case registerOpen case associateChannel case disassociateChannel case resend } case update( tagUpdates: [TagGroupUpdate]? = nil, attributeUpdates: [AttributeUpdate]? = nil, subscriptionListsUpdates: [ScopedSubscriptionListUpdate]? = nil ) case identify(String) case resolve case reset case verify(Date, required: Bool? = nil) case registerEmail( address: String, options: EmailRegistrationOptions ) case registerSMS( msisdn: String, options: SMSRegistrationOptions ) case registerOpen( address: String, options: OpenRegistrationOptions ) case associateChannel( channelID: String, channelType: ChannelType ) case disassociateChannel( channel: ContactChannel ) case resend( channel: ContactChannel ) enum CodingKeys: String, CodingKey { case payload case type } enum PayloadCodingKeys: String, CodingKey { case tagUpdates case attrubuteUpdates case attributeUpdates case subscriptionListsUpdates case address case options case msisdn case identifier case channelID case channelType case date case required case channelOptions case dissociateChannelInfo case resendInfo } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .update(let tagUpdates, let attributeUpdates, let subscriptionListsUpdates): var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) try payloadContainer.encodeIfPresent(tagUpdates, forKey: .tagUpdates) try payloadContainer.encodeIfPresent(attributeUpdates, forKey: .attributeUpdates) try payloadContainer.encodeIfPresent(subscriptionListsUpdates, forKey: .subscriptionListsUpdates) try container.encode(OperationType.update, forKey: .type) case .identify(let identifier): var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) try payloadContainer.encode(identifier, forKey: .identifier) try container.encode(OperationType.identify, forKey: .type) case .resolve: try container.encodeNil(forKey: .payload) try container.encode(OperationType.resolve, forKey: .type) case .reset: try container.encodeNil(forKey: .payload) try container.encode(OperationType.reset, forKey: .type) case .verify(let date, let required): var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) try payloadContainer.encode(date, forKey: .date) try payloadContainer.encodeIfPresent(required, forKey: .required) try container.encode(OperationType.verify, forKey: .type) case .registerEmail(let address, let options): var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) try payloadContainer.encode(address, forKey: .address) try payloadContainer.encode(options, forKey: .options) try container.encode(OperationType.registerEmail, forKey: .type) case .registerSMS(let msisdn, let options): var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) try payloadContainer.encode(msisdn, forKey: .msisdn) try payloadContainer.encode(options, forKey: .options) try container.encode(OperationType.registerSMS, forKey: .type) case .registerOpen(let address, let options): var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) try payloadContainer.encode(address, forKey: .address) try payloadContainer.encode(options, forKey: .options) try container.encode(OperationType.registerOpen, forKey: .type) case .associateChannel(let channelID, let channelType): var payloadContainer = container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) 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: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(OperationType.self, forKey: .type) switch type { case .update: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .update( tagUpdates: try payloadContainer.decodeIfPresent( [TagGroupUpdate].self, forKey: .tagUpdates ), attributeUpdates: try payloadContainer.decodeIfPresent( [AttributeUpdate].self, forKey: .attributeUpdates ) ?? payloadContainer.decodeIfPresent( [AttributeUpdate].self, forKey: .attrubuteUpdates ), subscriptionListsUpdates: try payloadContainer.decodeIfPresent( [ScopedSubscriptionListUpdate].self, forKey: .subscriptionListsUpdates ) ) case .identify: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .identify( try payloadContainer.decode( String.self, forKey: .identifier ) ) case .registerEmail: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .registerEmail( address: try payloadContainer.decode( String.self, forKey: .address ), options: try payloadContainer.decode( EmailRegistrationOptions.self, forKey: .options ) ) case .registerSMS: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .registerSMS( msisdn: try payloadContainer.decode( String.self, forKey: .msisdn ), options: try payloadContainer.decode( SMSRegistrationOptions.self, forKey: .options ) ) case .registerOpen: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .registerOpen( address: try payloadContainer.decode( String.self, forKey: .address ), options: try payloadContainer.decode( OpenRegistrationOptions.self, forKey: .options ) ) case .associateChannel: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .associateChannel( channelID: try payloadContainer.decode( String.self, forKey: .channelID ), channelType: try payloadContainer.decode( ChannelType.self, 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 case .reset: self = .reset case .verify: let payloadContainer = try container.nestedContainer(keyedBy: PayloadCodingKeys.self, forKey: .payload) self = .verify( try payloadContainer.decode( Date.self, forKey: .date ), required: try payloadContainer.decodeIfPresent( Bool.self, forKey: .required ) ) } } } ================================================ FILE: Airship/AirshipCore/Source/ContactRemoteDataProviderDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ContactRemoteDataProviderDelegate: RemoteDataProviderDelegate { let source: RemoteDataSource = .contact let storeName: String private let config: RuntimeConfig private let apiClient: any RemoteDataAPIClientProtocol private let contact: any InternalAirshipContact init(config: RuntimeConfig, apiClient: any RemoteDataAPIClientProtocol, contact: any InternalAirshipContact) { self.storeName = "RemoteData-Contact-\(config.appCredentials.appKey).sqlite" self.config = config self.apiClient = apiClient self.contact = contact } private func makeURL(contactID: String, locale: Locale, randomValue: Int) throws -> URL { return try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data-contact/ios/\(contactID)", locale: locale, randomValue: randomValue ) } func isRemoteDataInfoUpToDate( _ remoteDataInfo: RemoteDataInfo, locale: Locale, randomValue: Int ) async -> Bool { let contactInfo = await contact.contactIDInfo guard let contactInfo = contactInfo, contactInfo.isStable else { return false } let url = try? self.makeURL(contactID: contactInfo.contactID, locale: locale, randomValue: randomValue) return remoteDataInfo.url == url && remoteDataInfo.contactID == contactInfo.contactID } func fetchRemoteData( locale: Locale, randomValue: Int, lastRemoteDataInfo: RemoteDataInfo? ) async throws -> AirshipHTTPResponse<RemoteDataResult> { let stableContactID = await contact.getStableContactID() let url = try self.makeURL(contactID: stableContactID, locale: locale, randomValue: randomValue) var lastModified: String? = nil if (lastRemoteDataInfo?.url == url) { lastModified = lastRemoteDataInfo?.lastModifiedTime } return try await self.apiClient.fetchRemoteData( url: url, auth: .contactAuthToken(identifier: stableContactID), lastModified: lastModified ) { newLastModified in return RemoteDataInfo( url: url, lastModifiedTime: newLastModified, source: .contact, contactID: stableContactID ) } } } ================================================ FILE: Airship/AirshipCore/Source/ContactSubscriptionListClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: protocol ContactSubscriptionListAPIClientProtocol: Sendable { func fetchSubscriptionLists( contactID: String ) async throws -> AirshipHTTPResponse<[String: [ChannelScope]]> } /// NOTE: For internal use only. :nodoc: final class ContactSubscriptionListAPIClient: ContactSubscriptionListAPIClientProtocol { private let config: RuntimeConfig private let session: any AirshipRequestSession init(config: RuntimeConfig, session: any AirshipRequestSession) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init(config: config, session: config.requestSession) } func fetchSubscriptionLists( contactID: String ) async throws -> AirshipHTTPResponse<[String: [ChannelScope]]> { AirshipLogger.debug("Retrieving subscription lists associated with a contact") let request = AirshipRequest( url: try self.makeURL(path: "/api/subscription_lists/contacts/\(contactID)"), headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "X-UA-Appkey": self.config.appCredentials.appKey ], method: "GET", auth: .contactAuthToken(identifier: contactID) ) 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 } let parsedBody = try JSONDecoder().decode( SubscriptionResponseBody.self, from: data ) return parsedBody.toScopedSubscriptionLists() } } 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 } } struct SubscriptionResponseBody: Decodable { let subscriptionLists: [Entry] enum CodingKeys: String, CodingKey { case subscriptionLists = "subscription_lists" } struct Entry: Decodable, Equatable { let lists: [String] let scope: ChannelScope enum CodingKeys: String, CodingKey { case lists = "list_ids" case scope = "scope" } } func toScopedSubscriptionLists() -> [String: [ChannelScope]] { var parsed: [String: [ChannelScope]] = [:] self.subscriptionLists.forEach { entry in let scope = entry.scope entry.lists.forEach { listID in var scopes = parsed[listID] ?? [] if !scopes.contains(scope) { scopes.append(scope) parsed[listID] = scopes } } } return parsed } } ================================================ FILE: Airship/AirshipCore/Source/Container.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Container view. struct Container: View { /// Container model. private let info: ThomasViewInfo.Container /// View constraints. private let constraints: ViewConstraints init(info: ThomasViewInfo.Container, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var body: some View { NewContainer(info: self.info, constraints: self.constraints) } } fileprivate struct NewContainer: View { @Environment(\.layoutDirection) private var layoutDirection /// Container model. private let info: ThomasViewInfo.Container /// View constraints. private let constraints: ViewConstraints init(info: ThomasViewInfo.Container, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var body: some View { ContainerLayout( constraints: self.constraints, layoutDirection: layoutDirection ) { ForEach(0..<info.properties.items.count, id: \.self) { idx in childItem(idx, item: info.properties.items[idx]) } } .accessibilityElement(children: .contain) .airshipGeometryGroupCompat() .constraints(constraints) .clipped() .thomasCommon(self.info) } @ViewBuilder @MainActor private func childItem(_ index: Int, item: ThomasViewInfo.Container.Item) -> some View { let consumeSafeAreaInsets = item.ignoreSafeArea != true let borderPadding = self.info.commonProperties.border?.strokeWidth ?? 0 let childConstraints = self.constraints.childConstraints( item.size, margin: item.margin, padding: borderPadding, safeAreaInsetsMode: consumeSafeAreaInsets ? .consumeMargin : .ignore ) ViewFactory.createView( item.view, constraints: childConstraints ) .margin(item.margin) .airshipApplyIf(consumeSafeAreaInsets) { $0.padding(self.constraints.safeAreaInsets) } .frame( alignment: item.position.alignment ) .layoutValue(key: ContainerLayout.ContainerItemPositionKey.self, value: item.position) } } fileprivate struct ContainerLayout: Layout { struct ContainerItemPositionKey: LayoutValueKey { static let defaultValue = ThomasPosition(horizontal: .center, vertical: .center) } struct Cache { var childSizes: [CGSize] } let constraints: ViewConstraints let layoutDirection: LayoutDirection func makeCache(subviews: Subviews) -> Cache { Cache( childSizes: Array(repeating: .zero, count: subviews.count) ) } func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache ) -> CGSize { var maxWidth: CGFloat = (constraints.width == nil) ? 0 : proposal.width ?? 0 var maxHeight: CGFloat = (constraints.height == nil) ? 0 : proposal.height ?? 0 for (index, subview) in subviews.enumerated() { let size = subview.dimensions(in: proposal) let childSize = CGSize( width: size.width.safeValue ?? 0, height: size.height.safeValue ?? 0 ) cache.childSizes[index] = childSize maxWidth = max(maxWidth, childSize.width) maxHeight = max(maxHeight, childSize.height) } return CGSize(width: maxWidth, height: maxHeight) } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache ) { for (subviewIndex, subview) in subviews.enumerated() { let position = subview[ContainerItemPositionKey.self] let childSize = cache.childSizes[subviewIndex] let x: CGFloat = switch position.horizontal { case .start: layoutDirection == .leftToRight ? bounds.minX : bounds.maxX - childSize.width case .end: layoutDirection == .leftToRight ? bounds.maxX - childSize.width : bounds.minX case .center: bounds.midX - (childSize.width / 2) } let y: CGFloat = switch position.vertical { case .top: bounds.minY case .bottom: bounds.maxY - childSize.height case .center: bounds.midY - (childSize.height / 2) } subview.place( at: CGPoint( x: x.safeValue ?? bounds.minX, y: y.safeValue ?? bounds.minY ), proposal: ProposedViewSize( width: childSize.width, height: childSize.height ) ) } } } ================================================ FILE: Airship/AirshipCore/Source/CustomEvent.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// CustomEvent captures information regarding a custom event for /// Analytics. public struct CustomEvent: Sendable { /// Max properties size in bytes public static let maxPropertiesSize: Int = 65536 static let eventNameKey: String = "event_name" static let eventValueKey: String = "event_value" static let eventPropertiesKey: String = "properties" static let eventTransactionIDKey: String = "transaction_id" static let eventInteractionIDKey: String = "interaction_id" static let eventInteractionTypeKey: String = "interaction_type" static let eventInAppKey: String = "in_app" static let eventConversionMetadataKey: String = "conversion_metadata" static let eventConversionSendIDKey: String = "conversion_send_id" static let eventTemplateTypeKey: String = "template_type" static let eventType: String = "enhanced_custom_event" static let interactionMCRAP: String = "ua_mcrap" /// Internal conversion send ID var conversionSendID: String? /// Internal conversion push metadata var conversionPushMetadata: String? /// Template type var templateType: String? /// The in-app message context for custom event attribution var inApp: AirshipJSON? /// Default encoder. Uses `iso8601` date encoding strategy. public static func defaultEncoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return encoder } /// The event's value. The value must be between -2^31 and /// 2^31 - 1 or it will invalidate the event. public var eventValue: Decimal /// The event's name. The name's length must not exceed 255 characters or it will or it will /// invalidate the event. public var eventName: String /// The event's transaction ID. The ID's length must not exceed 255 characters or it will /// invalidate the event. public var transactionID: String? /// The event's interaction type. The type's length must not exceed 255 characters or it will /// invalidate the event. public var interactionType: String? /// The event's interaction ID. The ID's length must not exceed 255 characters or it will /// invalidate the event. public var interactionID: String? /// The event's properties. public private(set) var properties: [String: AirshipJSON] = [:] /// Sets a property string value. /// - Parameters: /// - string: The string value to set. /// - forKey: The properties key public mutating func setProperty( string: String, forKey key: String ) { properties[key] = .string(string) } /// Removes a property. /// - Parameters: /// - forKey: The properties key public mutating func removeProperty( forKey key: String ) { properties[key] = nil } /// Sets a property double value. /// - Parameters: /// - double: The double value to set. /// - forKey: The properties key public mutating func setProperty( double: Double, forKey key: String ) { properties[key] = .number(double) } /// Sets a property bool value. /// - Parameters: /// - bool: The bool value to set. /// - forKey: The properties key public mutating func setProperty( bool: Bool, forKey key: String ) { properties[key] = .bool(bool) } /// Sets a property value. /// - Parameters: /// - value: The value to set. /// - forKey: The properties key /// - encoder: JSONEncoder to use.' public mutating func setProperty( value: Any, forKey key: String, encoder: @autoclosure () -> JSONEncoder = Self.defaultEncoder() ) throws { properties[key] = try AirshipJSON.wrap(value, encoder: encoder()) } /// Sets a property value. /// - Parameters: /// - value: The values to set. The value must result in a JSON object or an error will be thrown. /// - encoder: JSONEncoder to use. public mutating func setProperties( _ object: Any, encoder: @autoclosure () -> JSONEncoder = Self.defaultEncoder() ) throws { let json = try AirshipJSON.wrap(object, encoder: encoder()) guard json.isObject, let properties = json.object else { throw AirshipErrors.error("Properties must be an object") } self.properties = properties } /// Default constructor. /// - Parameter name: The name of the event. The event's name must not exceed /// 255 characters or it will invalidate the event. /// - Parameter value: The event value. The value must be between -2^31 and /// 2^31 - 1 or it will invalidate the event. Defaults to 1. public init(name: String, value: Double = 1.0) { self.eventName = name if value.isFinite { self.eventValue = Decimal(value) } else { self.eventValue = Decimal(1.0) } } /// Default constructor. /// - Parameter name: The name of the event. The event's name must not exceed /// 255 characters or it will invalidate the event. /// - Parameter value: The event value. The value must be between -2^31 and /// 2^31 - 1 or it will invalidate the event. Defaults to 1. public init(name: String, decimalValue: Decimal) { self.eventName = name self.eventValue = decimalValue } } extension CustomEvent { /// Validates the event. /// - Returns: `true` if the event is valid, otherwise `false`. public func isValid() -> Bool { let areFieldsValid = validateFields() let isValueValid = validateValue() let areProperitiesValid = validateProperties() return areFieldsValid && isValueValid && areProperitiesValid } mutating func setInteractionFromMessageCenterMessage(_ messageID: String) { self.interactionID = messageID self.interactionType = CustomEvent.interactionMCRAP } /** * - Note: For internal use only. :nodoc: */ func eventBody(sendID: String?, metadata: String?, formatValue: Bool) -> AirshipJSON { return AirshipJSON.makeObject { object in object.set(string: eventName, key: CustomEvent.eventNameKey) object.set(string: conversionSendID ?? sendID, key: CustomEvent.eventConversionSendIDKey) object.set(string: conversionPushMetadata ?? metadata, key: CustomEvent.eventConversionMetadataKey) object.set(string: interactionID, key: CustomEvent.eventInteractionIDKey) object.set(string: interactionType, key: CustomEvent.eventInteractionTypeKey) object.set(string: transactionID, key: CustomEvent.eventTransactionIDKey) object.set(string: templateType, key: CustomEvent.eventTemplateTypeKey) object.set(json: .object(properties), key: CustomEvent.eventPropertiesKey) object.set(json: inApp, key: CustomEvent.eventInAppKey) if formatValue { let number = (self.eventValue as NSDecimalNumber).multiplying(byPowerOf10: 6) object.set(double: number.doubleValue.rounded(.down), key: CustomEvent.eventValueKey) } else { object.set(double: (self.eventValue as NSDecimalNumber).doubleValue, key: CustomEvent.eventValueKey) } } } /// Adds the event to analytics. public func track() { Airship.analytics.recordCustomEvent(self) } private func validateValue() -> Bool { if !eventValue.isFinite { AirshipLogger.error("Event value \(eventValue) is not finite.") return false } if eventValue > Decimal(Int32.max) { AirshipLogger.error( "Event value \(eventValue) is larger than 2^31-1." ) return false } if eventValue < Decimal(Int32.min) { AirshipLogger.error( "Event value \(eventValue) is smaller than -2^31." ) return false } return true } private func validateProperties() -> Bool { do { let encodedProperties = try AirshipJSON.object(properties).toData() if encodedProperties.count > CustomEvent.maxPropertiesSize { AirshipLogger.error( "Event properties (%lu bytes) are larger than the maximum size of \(CustomEvent.maxPropertiesSize) bytes." ) return false } } catch { AirshipLogger.error("Event properties serialization error \(error)") return false } return true } private func validateFields() -> Bool { let fields: [(name: String, value: String?, required: Bool)] = [ (name: "eventName", value: self.eventName, required: true), (name: "interactionType", value: self.interactionType, required: false), (name: "interactionID", value: self.interactionID, required: false), (name: "transactionID", value: self.transactionID, required: false), (name: "templateType", value: self.templateType, required: false), (name: "transactionID", value: self.templateType, required: false) ] let mapped = fields.map { field in if field.required, (field.value?.count ?? 0) == 0 { AirshipLogger.error("Missing required field \(field.name)") return false } if let value = field.value { if value.count > 255 { AirshipLogger.error( "Field \(field.name) must be between 0 and 255 characters." ) return false } } return true } return !mapped.contains(false) } } ================================================ FILE: Airship/AirshipCore/Source/CustomView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine /** * Internal only * :nodoc: */ struct CustomView: View { let info: ThomasViewInfo.CustomView let constraints: ViewConstraints @EnvironmentObject var thomasEnvironment: ThomasEnvironment @EnvironmentObject var pagerState: PagerState @Environment(\.layoutState) var layoutState var body: some View { let args = AirshipCustomViewArguments( name: self.info.properties.name, properties: self.info.properties.properties, sizeInfo: AirshipCustomViewArguments.SizeInfo( isAutoHeight: constraints.height == nil, isAutoWidth: constraints.width == nil ) ) AirshipCustomViewManager.shared.makeView(args: args) .constraints(constraints) .clipped() /// Clip to view frame to ensure we don't overflow when the view has an intrinsic size it's trying to enforce .thomasCommon(self.info) .environmentObject( AirshipSceneController(pagerState: pagerState, environment: thomasEnvironment) ) } } ================================================ FILE: Airship/AirshipCore/Source/DeepLinkAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Opens a deep link URL. /// /// Expected argument values: A valid URL String. /// /// Valid situations: All but `backgroundPush` and `backgroundInteractiveButton` public final class DeepLinkAction: AirshipAction { /// Default names - "deep_link_action", "^d" public static let defaultNames: [String] = ["deep_link_action", "^d"] /// Default predicate - Rejects `Airship.foregroundPush` public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.situation != .foregroundPush } private let urlOpener: any URLOpenerProtocol init(urlOpener: any URLOpenerProtocol) { self.urlOpener = urlOpener } public convenience init() { self.init(urlOpener: DefaultURLOpener()) } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .backgroundPush: return false case .backgroundInteractiveButton: return false default: return true } } @MainActor public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let url = try parseURL(arguments.value) let result = await Airship.processDeepLink(url) if !result { try await self.openURL(url) } return nil } @MainActor private func openURL(_ url: URL) async throws { guard Airship.urlAllowList.isAllowed(url, scope: .openURL) else { throw AirshipErrors.error("URL \(url) not allowed") } guard await urlOpener.openURL(url) else { throw AirshipErrors.error("Unable to open URL \(url).") } } private func parseURL(_ value: AirshipJSON) throws -> URL { if let value = value.unWrap() as? String { if let url = AirshipUtils.parseURL(value) { return url } } throw AirshipErrors.error("Invalid URL: \(value)") } } ================================================ FILE: Airship/AirshipCore/Source/DeepLinkDelegate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Protocol to be implemented by deep link handlers. public protocol DeepLinkDelegate: AnyObject, Sendable { /// Called when a deep link has been triggered from Airship. If implemented, the delegate is responsible for processing the provided url. /// - Parameters: /// - deepLink: The deep link. @MainActor func receivedDeepLink(_ deepLink: URL) async } ================================================ FILE: Airship/AirshipCore/Source/DefaultAirshipAnalytics.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(WatchKit) import WatchKit #endif /// The Analytics object provides an interface to the Airship Analytics API. final class DefaultAirshipAnalytics: AirshipAnalytics, @unchecked Sendable { private static let associatedIdentifiers: String = "UAAssociatedIdentifiers" static let missingSendID: String = "MISSING_SEND_ID" static let pushMetadata: String = "com.urbanairship.metadata" static let pushSendID: String = "_" private let config: RuntimeConfig private let dataStore: PreferenceDataStore private let channel: any AirshipChannel private let privacyManager: any AirshipPrivacyManager private let notificationCenter: AirshipNotificationCenter private let date: any AirshipDateProtocol private let eventManager: any EventManagerProtocol private let localeManager: any AirshipLocaleManager private let permissionsManager: any AirshipPermissionsManager private let sessionTracker: any SessionTrackerProtocol private let serialQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() private let sdkExtensions: AirshipAtomicValue<[String]> = AirshipAtomicValue([]) // Screen tracking state private let screenState: AirshipMainActorValue<ScreenState> = AirshipMainActorValue(ScreenState()) private let restoreScreenOnForeground: AirshipMainActorValue<String?> = AirshipMainActorValue(nil) private let regions: AirshipMainActorValue<Set<String>> = AirshipMainActorValue(Set()) private var isAirshipReady: Bool = false /// The conversion send ID. :nodoc: public var conversionSendID: String? { return self.sessionTracker.sessionState.conversionSendID } /// The conversion push metadata. :nodoc: public var conversionPushMetadata: String? { return self.sessionTracker.sessionState.conversionMetadata } /// The current session ID. public var sessionID: String { return self.sessionTracker.sessionState.sessionID } private let eventSubject: PassthroughSubject<AirshipEventData, Never> = PassthroughSubject<AirshipEventData, Never>() /// Airship event publisher public var eventPublisher: AnyPublisher<AirshipEventData, Never> { eventSubject.eraseToAnyPublisher() } public let eventFeed: AirshipAnalyticsFeed private var isAnalyticsEnabled: Bool { return self.privacyManager.isEnabled(.analytics) && self.config.airshipConfig.isAnalyticsEnabled } @MainActor convenience init( config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any AirshipChannel, localeManager: any AirshipLocaleManager, privacyManager: any AirshipPrivacyManager, permissionsManager: any AirshipPermissionsManager ) { self.init( config: config, dataStore: dataStore, channel: channel, localeManager: localeManager, privacyManager: privacyManager, permissionsManager: permissionsManager, eventManager: EventManager( config: config, dataStore: dataStore, channel: channel ) ) } @MainActor @inline(never) init( config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any AirshipChannel, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, date: any AirshipDateProtocol = AirshipDate.shared, localeManager: any AirshipLocaleManager, privacyManager: any AirshipPrivacyManager, permissionsManager: any AirshipPermissionsManager, eventManager: any EventManagerProtocol, sessionTracker: (any SessionTrackerProtocol)? = nil, sessionEventFactory: any SessionEventFactoryProtocol = SessionEventFactory() ) { self.config = config self.dataStore = dataStore self.channel = channel self.notificationCenter = notificationCenter self.date = date self.localeManager = localeManager self.privacyManager = privacyManager self.permissionsManager = permissionsManager self.eventManager = eventManager self.sessionTracker = sessionTracker ?? SessionTracker() self.eventFeed = AirshipAnalyticsFeed( privacyManager: privacyManager, isAnalyticsEnabled: config.airshipConfig.isAnalyticsEnabled ) self.eventManager.addHeaderProvider { await self.makeHeaders() } self.notificationCenter.addObserver( self, selector: #selector(applicationWillEnterForeground), name: AppStateTracker.willEnterForegroundNotification, object: nil ) self.notificationCenter.addObserver( self, selector: #selector(applicationDidEnterBackground), name: AppStateTracker.didEnterBackgroundNotification, object: nil ) self.notificationCenter.addObserver( self, selector: #selector(applicationWillTerminate), name: AppStateTracker.willTerminateNotification, object: nil ) self.notificationCenter.addObserver( self, selector: #selector(updateEnablement), name: AirshipNotifications.PrivacyManagerUpdated.name, object: nil ) self.notificationCenter.addObserver( self, selector: #selector(updateEnablement), name: AirshipNotifications.ChannelCreated.name, object: nil ) Task { @MainActor [weak self, tracker = self.sessionTracker] in for await event in tracker.events { guard let self else { return } self.recordEvent( sessionEventFactory.make(event: event), date: event.date, sessionID: event.sessionState.sessionID ) } } } @objc @MainActor private func applicationWillEnterForeground() { // Start tracking previous screen before backgrounding began if let previousScreen = self.restoreScreenOnForeground.value, self.screenState.value.current == nil { trackScreen(previousScreen) } self.restoreScreenOnForeground.set(nil) } @objc @MainActor private func applicationDidEnterBackground() { self.restoreScreenOnForeground.set(self.screenState.value.current) self.trackScreen(nil) } @objc @MainActor private func applicationWillTerminate() { self.trackScreen(nil) } // MARK: - // MARK: Analytics Headers /// :nodoc: @MainActor public func addHeaderProvider( _ headerProvider: @Sendable @escaping () async -> [String: String] ) { self.eventManager.addHeaderProvider(headerProvider) } private func makeHeaders() async -> [String: String] { var headers: [String: String] = [:] headers["X-UA-Device-Family"] = await AirshipDevice.deviceFamily headers["X-UA-OS-Version"] = await AirshipDevice.osVersion headers["X-UA-Device-Model"] = AirshipDevice.modelIdentifier // App info if let infoDictionary = Bundle.main.infoDictionary { headers["X-UA-Package-Name"] = infoDictionary[kCFBundleIdentifierKey as String] as? String } headers["X-UA-Package-Version"] = AirshipUtils.bundleShortVersionString() ?? "" // Time zone let currentLocale = self.localeManager.currentLocale headers["X-UA-Timezone"] = NSTimeZone.default.identifier headers["X-UA-Locale-Language"] = currentLocale.getLanguageCode() headers["X-UA-Locale-Country"] = currentLocale.getRegionCode() headers["X-UA-Locale-Variant"] = currentLocale.getVariantCode() // Airship identifiers headers["X-UA-Channel-ID"] = self.channel.identifier headers["X-UA-App-Key"] = self.config.appCredentials.appKey // SDK Version headers["X-UA-Lib-Version"] = AirshipVersion.version // SDK Extensions let extensions = self.sdkExtensions.value if extensions.count > 0 { headers["X-UA-Frameworks"] = extensions.joined( separator: ", " ) } // Permissions for permission in self.permissionsManager.configuredPermissions { let status = await self.permissionsManager.checkPermissionStatus(permission) headers["X-UA-Permission-\(permission.rawValue)"] = status.rawValue } return headers } public func recordCustomEvent(_ event: CustomEvent) { guard self.isAnalyticsEnabled else { AirshipLogger.info( "Analytics disabled, ignoring custom event \(event)" ) return } guard event.isValid() else { AirshipLogger.info( "Custom event is invalid, ignoring custom event \(event)" ) return } recordEvent( 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 as NSDecimalNumber).doubleValue ), date: self.date.now, sessionID: self.sessionTracker.sessionState.sessionID ) } public func recordRegionEvent(_ event: RegionEvent) { let shouldInsert: Bool = event.boundaryEvent == .enter let regionID = event.regionID Task { @MainActor in self.regions.update { regions in if (shouldInsert) { regions.insert(regionID) } else { regions.remove(regionID) } } } guard self.isAnalyticsEnabled else { AirshipLogger.info( "Analytics disabled, ignoring region event \(event)" ) return } /// Upload do { let eventType: EventType = switch(event.boundaryEvent) { case .enter: .regionEnter case .exit: .regionExit } recordEvent( AirshipEvent( eventType: eventType, eventData: try event.eventBody(stringifyFields: true) ) ) } catch { AirshipLogger.error("Failed to generate event body \(error)") } } public func trackInstallAttribution( appPurchaseDate: Date?, iAdImpressionDate: Date? ) { recordEvent( AirshipEvents.installAttirbutionEvent( appPurchaseDate: appPurchaseDate, iAdImpressionDate: iAdImpressionDate ) ) } public func recordEvent(_ event: AirshipEvent) { 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, feedEvent: AirshipAnalyticsFeed.Event, date: Date, sessionID: String ) { self.serialQueue.enqueue { guard self.isAnalyticsEnabled else { AirshipLogger.trace( "Analytics disabled, ignoring event: \(event.eventType)" ) return } await self.eventFeed.notifyEvent(feedEvent) let eventData = AirshipEventData( body: event.eventData, id: NSUUID().uuidString, date: date, sessionID: sessionID, type: event.eventType ) do { AirshipLogger.debug("Adding event with type \(eventData.type)") AirshipLogger.trace("Adding event \(eventData)") try await self.eventManager.addEvent(eventData) await Task { @MainActor in self.eventSubject.send(eventData) }.value await self.eventManager.scheduleUpload( eventPriority: event.priority ) } catch { AirshipLogger.error("Failed to save event \(error)") return } } } /// Associates identifiers with the device. This call will add a special event /// that will be batched and sent up with our other analytics events. Previous /// associated identifiers will be replaced. /// /// For internal use only. :nodoc: /// /// - Parameter associatedIdentifiers: The associated identifiers. public func associateDeviceIdentifiers( _ associatedIdentifiers: AssociatedIdentifiers ) { guard self.isAnalyticsEnabled else { AirshipLogger.warn( "Unable to associate identifiers \(associatedIdentifiers.allIDs) when analytics is disabled" ) return } if let previous = self.dataStore.object( forKey: DefaultAirshipAnalytics.associatedIdentifiers ) as? [String: String] { if previous == associatedIdentifiers.allIDs { AirshipLogger.info( "Skipping analytics event addition for duplicate associated identifiers." ) return } } do { let event = try AirshipEvents.associatedIdentifiersEvent( identifiers: associatedIdentifiers ) self.recordEvent(event) self.dataStore.setObject( associatedIdentifiers.allIDs, forKey: DefaultAirshipAnalytics.associatedIdentifiers ) } catch { AirshipLogger.error("Failed to add associated idenfiers event \(error)") } } /// The device's current associated identifiers. /// - Returns: The device's current associated identifiers. public func currentAssociatedDeviceIdentifiers() -> AssociatedIdentifiers { let storedIDs = self.dataStore.object(forKey: DefaultAirshipAnalytics.associatedIdentifiers) as? [String: String] return AssociatedIdentifiers( identifiers: storedIDs ?? [:] ) } /// Initiates screen tracking for a specific app screen, must be called once per tracked screen. /// - Parameter screen: The screen's identifier. @MainActor public func trackScreen(_ screen: String?) { let date = self.date.now // Prevent duplicate calls to track same screen guard screen != self.screenState.value.current else { return } Task { await self.eventFeed.notifyEvent(.screen(screen: screen)) } let currentScreen = self.screenState.value.current let screenStartDate = self.screenState.value.startDate let previousScreen = self.screenState.value.previous self.screenState.update { state in state.current = screen state.startDate = date state.previous = currentScreen } // If there's a screen currently being tracked set it's stop time and add it to analytics if let currentScreen = currentScreen, let screenStartDate = screenStartDate { do { let ste = try AirshipEvents.screenTrackingEvent( screen: currentScreen, previousScreen: previousScreen, startDate: screenStartDate, duration: date.timeIntervalSince(screenStartDate) ) // Add screen tracking event to next analytics batch self.recordEvent(ste) } catch { AirshipLogger.error( "Unable to create screen tracking event \(error)" ) } } } /// Registers an SDK extension with the analytics module. /// For internal use only. :nodoc: /// /// - Parameters: /// - ext: The SDK extension. /// - version: The version. public func registerSDKExtension( _ ext: AirshipSDKExtension, version: String ) { let sanitizedVersion = version.replacingOccurrences(of: ",", with: "") self.sdkExtensions.value.append("\(ext.name):\(sanitizedVersion)") } @objc private func updateEnablement() { guard self.isAnalyticsEnabled else { self.eventManager.uploadsEnabled = false Task { do { try await self.eventManager.deleteEvents() } catch { AirshipLogger.error("Failed to delete events \(error)") } } return } let uploadsEnabled = self.isAirshipReady && self.channel.identifier != nil if (self.eventManager.uploadsEnabled != uploadsEnabled) { self.eventManager.uploadsEnabled = uploadsEnabled if (uploadsEnabled) { Task { await self.eventManager.scheduleUpload( eventPriority: .normal ) } } } } } extension DefaultAirshipAnalytics: AirshipComponent, InternalAirshipAnalytics { @MainActor public func airshipReady() { self.isAirshipReady = true self.updateEnablement() self.sessionTracker.airshipReady() } @MainActor public var currentScreen: String? { return self.screenState.value.current } @MainActor public var regionUpdates: AsyncStream<Set<String>> { return self.regions.updates } @MainActor public var currentRegions: Set<String> { return self.regions.value } @MainActor public var screenUpdates: AsyncStream<String?> { return AsyncStream { [screenState] continuation in let updates = screenState.updates let task = Task { for await value in updates { continuation.yield(value.current) } } continuation.onTermination = { _ in task.cancel() } } } /// Called to notify analytics the app was launched from a push notification. /// For internal use only. :nodoc: /// - Parameter notification: The push notification. @MainActor public func launched(fromNotification notification: [AnyHashable: Any]) { if AirshipUtils.isAlertingPush(notification) { let sendID = notification[DefaultAirshipAnalytics.pushSendID] as? String let metadata = notification[DefaultAirshipAnalytics.pushMetadata] as? String self.sessionTracker.launchedFromPush( sendID: sendID ?? DefaultAirshipAnalytics.missingSendID, metadata: metadata ) } } @available(tvOS, unavailable) @MainActor public func onNotificationResponse( response: UNNotificationResponse, action: UNNotificationAction? ) { let userInfo = response.notification.request.content.userInfo if response.actionIdentifier == UNNotificationDefaultActionIdentifier { self.launched(fromNotification: userInfo) } else if let action = action { let categoryID = response.notification.request.content .categoryIdentifier let responseText = (response as? UNTextInputNotificationResponse)? .userText if action.options.contains(.foreground) == true { self.launched(fromNotification: userInfo) } #if !os(tvOS) recordEvent( AirshipEvents.interactiveNotificationEvent( action: action, category: categoryID, notification: userInfo, responseText: responseText ) ) #endif } } } fileprivate struct ScreenState { var current: String? var previous: String? var startDate: Date? } ================================================ FILE: Airship/AirshipCore/Source/DefaultAirshipChannel.swift ================================================ /* Copyright Airship and Contributors */ @preconcurrency import Combine public import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif /// This singleton provides an interface to the channel functionality. final class DefaultAirshipChannel: AirshipChannel, Sendable { private static let tagsDataStoreKey: String = "com.urbanairship.channel.tags" private static let legacyTagsSettingsKey: String = "UAPushTags" private let dataStore: PreferenceDataStore private let config: RuntimeConfig private let privacyManager: any AirshipPrivacyManager private let permissionsManager: any AirshipPermissionsManager private let localeManager: any AirshipLocaleManager private let audienceManager: any ChannelAudienceManagerProtocol private let channelRegistrar: any ChannelRegistrarProtocol private let notificationCenter: AirshipNotificationCenter private let appStateTracker: any AppStateTrackerProtocol private let tagsLock: AirshipLock = AirshipLock() private let subscription: AirshipUnsafeSendableWrapper<AnyCancellable?> = AirshipUnsafeSendableWrapper(nil) private let liveActivityQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() @MainActor private var extenders: [@Sendable (inout ChannelRegistrationPayload) async -> Void] = [] #if canImport(ActivityKit) private let liveActivityRegistry: LiveActivityRegistry #endif private let isChannelCreationEnabled: AirshipAtomicValue<Bool> public var identifier: String? { return self.channelRegistrar.channelID } public var identifierUpdates: AsyncStream<String> { return AsyncStream<String> { [weak self] continuation in let task = Task { [weak self] in guard let stream = await self?.channelRegistrar.registrationUpdates.makeStream() else { return } var current = self?.channelRegistrar.channelID if let current { continuation.yield(current) } for await update in stream { let channelID = switch update { case .created(let channelID, _): channelID case .updated(channelID: let channelID): channelID } if current != channelID { current = channelID continuation.yield(channelID) } } } continuation.onTermination = { _ in task.cancel() } } } /// The channel tags. public var tags: [String] { get { guard self.privacyManager.isEnabled(.tagsAndAttributes) else { return [] } var result: [String]? tagsLock.sync { result = self.dataStore.array(forKey: DefaultAirshipChannel.tagsDataStoreKey) as? [String] } return result ?? [] } set { guard self.privacyManager.isEnabled(.tagsAndAttributes) else { AirshipLogger.warn( "Unable to modify channel tags \(tags) when data collection is disabled." ) return } tagsLock.sync { let normalized = AudienceUtils.normalizeTags(newValue) self.dataStore.setObject( normalized, forKey: DefaultAirshipChannel.tagsDataStoreKey ) } self.updateRegistration() } } private let isChannelTagRegistrationEnabledContainer: AirshipAtomicValue<Bool> = AirshipAtomicValue(true) public var isChannelTagRegistrationEnabled: Bool { get { return isChannelTagRegistrationEnabledContainer.value } set { isChannelTagRegistrationEnabledContainer.value = newValue } } @MainActor @inline(never) init( dataStore: PreferenceDataStore, config: RuntimeConfig, privacyManager: any AirshipPrivacyManager, permissionsManager: any AirshipPermissionsManager, localeManager: any AirshipLocaleManager, audienceManager: any ChannelAudienceManagerProtocol, channelRegistrar: any ChannelRegistrarProtocol, notificationCenter: AirshipNotificationCenter, appStateTracker: any AppStateTrackerProtocol ) { self.dataStore = dataStore self.config = config self.privacyManager = privacyManager self.permissionsManager = permissionsManager self.localeManager = localeManager self.audienceManager = audienceManager self.channelRegistrar = channelRegistrar self.notificationCenter = notificationCenter self.appStateTracker = appStateTracker #if canImport(ActivityKit) self.liveActivityRegistry = LiveActivityRegistry( dataStore: dataStore ) #endif // Check config to see if user wants to delay channel creation // If channel ID exists or channel creation delay is disabled then channelCreationEnabled if self.channelRegistrar.channelID != nil || !config.airshipConfig.isChannelCreationDelayEnabled { self.isChannelCreationEnabled = .init(true) } else { AirshipLogger.debug("Channel creation disabled.") self.isChannelCreationEnabled = .init(false) } self.migrateTags() Task { @MainActor [weak self, weak channelRegistrar] in guard let stream = await channelRegistrar?.registrationUpdates.makeStream() else { return } for await update in stream { self?.processChannelUpdate(update) } } self.channelRegistrar.payloadCreateBlock = { [weak self] in return await self?.makePayload() } self.audienceManager.channelID = self.channelRegistrar.channelID self.audienceManager.enabled = true if let identifier = self.identifier { AirshipLogger.importantInfo("Channel ID \(identifier)") } self.observeNotificationCenterEvents() self.updateRegistration() #if canImport(ActivityKit) Task { for await update in self.liveActivityRegistry.updates { guard privacyManager.isEnabled(.push) || update.action == .remove else { AirshipLogger.error("Unable tot track set operation, push is disabled \(update)") return } self.audienceManager.addLiveActivityUpdate(update) } } Task { for await updates in self.audienceManager.liveActivityUpdates { await self.liveActivityRegistry.updatesProcessed(updates: updates) } } #endif } @MainActor convenience init( dataStore: PreferenceDataStore, config: RuntimeConfig, privacyManager: any AirshipPrivacyManager, permissionsManager: any AirshipPermissionsManager, localeManager: any AirshipLocaleManager, audienceOverridesProvider: any AudienceOverridesProvider ) { self.init( dataStore: dataStore, config: config, privacyManager: privacyManager, permissionsManager: permissionsManager, localeManager: localeManager, audienceManager: ChannelAudienceManager( dataStore: dataStore, config: config, privacyManager: privacyManager, audienceOverridesProvider: audienceOverridesProvider ), channelRegistrar: ChannelRegistrar( config: config, dataStore: dataStore, privacyManager: privacyManager ), notificationCenter: AirshipNotificationCenter.shared, appStateTracker: AppStateTracker.shared ) } private func migrateTags() { guard self.dataStore.keyExists(DefaultAirshipChannel.legacyTagsSettingsKey) else { // Nothing to migrate return } // Normalize tags for older SDK versions, and migrate to UAChannel as necessary if let existingPushTags = self.dataStore.object( forKey: DefaultAirshipChannel.legacyTagsSettingsKey ) as? [String] { let existingChannelTags = self.tags if existingChannelTags.count > 0 { let combinedTagsSet: Set<String> = Set(existingPushTags) .union( Set(existingChannelTags) ) self.tags = Array(combinedTagsSet) } else { self.tags = existingPushTags } } self.dataStore.removeObject(forKey: DefaultAirshipChannel.legacyTagsSettingsKey) } private func observeNotificationCenterEvents() { notificationCenter.addObserver( self, selector: #selector(applicationDidTransitionToForeground), name: AppStateTracker.didTransitionToForeground ) notificationCenter.addObserver( self, selector: #selector(remoteConfigUpdated), name: RuntimeConfig.configUpdatedEvent ) notificationCenter.addObserver( self, selector: #selector(onEnableFeaturesChanged), name: AirshipNotifications.PrivacyManagerUpdated.name ) notificationCenter.addObserver( self, selector: #selector(localeUpdates), name: AirshipNotifications.LocaleUpdated.name ) } @objc private func localeUpdates() { self.updateRegistration() } @objc private func remoteConfigUpdated() { guard self.isRegistrationAllowed else { return } self.updateRegistration(forcefully: true) } @objc private func onEnableFeaturesChanged() { if !self.privacyManager.isEnabled(.tagsAndAttributes) { self.dataStore.removeObject(forKey: DefaultAirshipChannel.tagsDataStoreKey) } self.updateRegistration() } @objc private func applicationDidTransitionToForeground() { if self.privacyManager.isAnyFeatureEnabled() { AirshipLogger.trace( "Application did become active. Updating registration." ) self.updateRegistration() } } public func editTags() -> TagEditor { return TagEditor { tagApplicator in self.tagsLock.sync { self.tags = tagApplicator(self.tags) } } } public func editTags(_ editorBlock: (TagEditor) -> Void) { let editor = editTags() editorBlock(editor) editor.apply() } public func editTagGroups() -> TagGroupsEditor { let allowDeviceTags = !self.isChannelTagRegistrationEnabled return self.audienceManager.editTagGroups( allowDeviceGroup: allowDeviceTags ) } public func editTagGroups(_ editorBlock: (TagGroupsEditor) -> Void) { let editor = editTagGroups() editorBlock(editor) editor.apply() } public func editSubscriptionLists() -> SubscriptionListEditor { return self.audienceManager.editSubscriptionLists() } public func editSubscriptionLists( _ editorBlock: (SubscriptionListEditor) -> Void ) { let editor = editSubscriptionLists() editorBlock(editor) editor.apply() } public func fetchSubscriptionLists() async throws -> [String] { return try await self.audienceManager.fetchSubscriptionLists() } public var subscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { audienceManager.subscriptionListEdits } public func editAttributes() -> AttributesEditor { return self.audienceManager.editAttributes() } public func editAttributes(_ editorBlock: (AttributesEditor) -> Void) { let editor = editAttributes() editorBlock(editor) editor.apply() } public func enableChannelCreation() { if !self.isChannelCreationEnabled.value { self.isChannelCreationEnabled.value = true self.updateRegistration() } } public func updateRegistration() { updateRegistration(forcefully: false) } private var isRegistrationAllowed: Bool { guard self.isChannelCreationEnabled.value else { AirshipLogger.debug( "Channel creation is currently disabled, unable to update" ) return false } guard self.identifier != nil || self.privacyManager.isAnyFeatureEnabled() else { AirshipLogger.trace( "Skipping channel create. All features are disabled." ) return false } return true } public func updateRegistration(forcefully: Bool) { guard self.isRegistrationAllowed else { return } self.channelRegistrar.register(forcefully: forcefully) } } /// - Note: for internal use only. :nodoc: extension DefaultAirshipChannel: AirshipPushableComponent { func receivedRemoteNotification(_ notification: AirshipJSON) async -> UABackgroundFetchResult { if self.identifier == nil { updateRegistration() } return .noData } #if !os(tvOS) func receivedNotificationResponse(_ response: UNNotificationResponse) async { // no-op } #endif private func processChannelUpdate(_ update: ChannelRegistrationUpdate) { switch(update) { case .created(let channelID, let isExisting): AirshipLogger.importantInfo("Channel ID: \(channelID)") self.audienceManager.channelID = channelID self.notificationCenter.post( name: AirshipNotifications.ChannelCreated.name, object: nil, userInfo: [ AirshipNotifications.ChannelCreated.channelIDKey: channelID, AirshipNotifications.ChannelCreated.isExistingChannelKey: isExisting, ] ) case .updated(_): AirshipLogger.info("Channel updated.") } } private func makePayload() async -> ChannelRegistrationPayload { var payload = ChannelRegistrationPayload() guard privacyManager.isAnyFeatureEnabled() else { payload.channel.tags = [] payload.channel.setTags = true payload.channel.isOptedIn = false payload.channel.isBackgroundEnabled = false return payload } for extender in await self.extenders { await extender(&payload) } if await self.appStateTracker.state == .active { payload.channel.isActive = true } if self.isChannelTagRegistrationEnabled { payload.channel.tags = self.tags payload.channel.setTags = true } else { payload.channel.setTags = false } if self.privacyManager.isEnabled(.analytics) { payload.channel.deviceModel = AirshipDevice.modelIdentifier payload.channel.appVersion = AirshipUtils.bundleShortVersionString() payload.channel.deviceOS = await AirshipDevice.osVersion } if self.privacyManager.isAnyFeatureEnabled() { let currentLocale = self.localeManager.currentLocale payload.channel.language = currentLocale.getLanguageCode() payload.channel.country = currentLocale.getRegionCode() payload.channel.timeZone = TimeZone.current.identifier payload.channel.sdkVersion = AirshipVersion.version } if self.privacyManager.isEnabled(.tagsAndAttributes) { var permissions: [String: String] = [:] for permission in self.permissionsManager.configuredPermissions { let status = await self.permissionsManager.checkPermissionStatus( permission ) if status != .notDetermined { permissions[permission.rawValue] = status.rawValue } } payload.channel.permissions = permissions } return payload } } extension DefaultAirshipChannel: InternalAirshipChannel { @MainActor public func addRegistrationExtender( _ extender: @Sendable @escaping (inout ChannelRegistrationPayload) async -> Void ) { self.extenders.append(extender) } public func clearSubscriptionListsCache() { self.audienceManager.clearSubscriptionListCache() } } #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) && !os(macOS) import ActivityKit @available(iOS 16.1, *) extension DefaultAirshipChannel { /// Gets an AsyncSequence of `LiveActivityRegistrationStatus` updates for a given live acitvity name. /// - Parameters: /// - name: The live activity name /// - Returns A `LiveActivityRegistrationStatusUpdates` @available(iOS 16.1, *) public func liveActivityRegistrationStatusUpdates( name: String ) -> LiveActivityRegistrationStatusUpdates { self.liveActivityRegistry.registrationUpdates(name: name, id: nil) } /// Gets an AsyncSequence of `LiveActivityRegistrationStatus` updates for a given live acitvity ID. /// - Parameters: /// - activity: The live activity /// - Returns A `LiveActivityRegistrationStatusUpdates` @available(iOS 16.1, *) public func liveActivityRegistrationStatusUpdates<T: ActivityAttributes>( activity: Activity<T> ) -> LiveActivityRegistrationStatusUpdates { self.liveActivityRegistry.registrationUpdates(name: nil, id: activity.id) } /// Tracks a live activity with Airship for the given name. /// Airship will monitor the push token and status and automatically /// add and remove it from the channel for the App. If an activity is already /// tracked with the given name it will be replaced with the new activity. /// /// The name will be used to send updates through Airship. It can be unique /// for the device or shared across many devices. /// /// - Parameters: /// - activity: The live activity /// - name: The name of the activity public func trackLiveActivity<T: ActivityAttributes>( _ activity: Activity<T>, name: String ) { guard privacyManager.isEnabled(.push) else { AirshipLogger.error("Push is not enabled, unable to track live activity.") return } let liveActivity = LiveActivity(activity: activity) liveActivityQueue.enqueue { [liveActivityRegistry] in // This nested function is a workaround for a Swift compiler Sendable warning. // It avoids the Task's @Sendable closure from directly capturing the generic type. func run() async { await liveActivityRegistry.addLiveActivity(liveActivity, name: name) } await run() } } /// Called to restore live activity tracking. This method needs to be called exactly once /// during `application(_:didFinishLaunchingWithOptions:)` right /// after takeOff. Any activities not restored will stop being tracked by Airship. /// - Parameters: /// - callback: Callback with the restorer. public func restoreLiveActivityTracking( callback: @escaping @Sendable (any LiveActivityRestorer) async -> Void ) { liveActivityQueue.enqueue { [liveActivityRegistry] in let restorer = AirshipLiveActivityRestorer() await callback(restorer) await restorer.apply(registry: liveActivityRegistry) } } } #endif extension DefaultAirshipChannel: AirshipComponent {} public extension AirshipNotifications { /// NSNotification info when the channel is created. final class ChannelCreated { /// NSNotification name. public static let name: NSNotification.Name = NSNotification.Name( "com.urbanairship.channel.channel_created" ) /// NSNotification userInfo key to get the channel ID. public static let channelIDKey: String = "channel_identifier" /// NSNotification userInfo key to get a boolean if the channel is existing or not. public static let isExistingChannelKey: String = "channel_existing" } } ================================================ FILE: Airship/AirshipCore/Source/DefaultAirshipContact.swift ================================================ /* Copyright Airship and Contributors */ @preconcurrency public import Combine import Foundation #if canImport(UIKit) import UIKit #endif public import UserNotifications /// Airship contact. A contact is distinct from a channel and represents a "user" /// within Airship. Contacts may be named and have channels associated with it. public final class DefaultAirshipContact: AirshipContact, @unchecked Sendable { static let refreshContactPushPayloadKey: String = "com.urbanairship.contact.update" public var contactChannelUpdates: AsyncStream<ContactChannelsResult> { get { return self.contactChannelsProvider.contactChannels( stableContactIDUpdates: self.stableContactIDUpdates ) } } public var contactChannelPublisher: AnyPublisher<ContactChannelsResult, Never> { get { let updates = self.contactChannelUpdates let subject = CurrentValueSubject<ContactChannelsResult?, Never>(nil) Task { @Sendable [weak subject] in for await update in updates { subject?.send(update) } } return subject.compactMap { $0 }.eraseToAnyPublisher() } } private var stableContactIDUpdates: AsyncStream<String> { 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: String = "Contact.resolveDate" static let legacyPendingTagGroupsKey: String = "com.urbanairship.tag_groups.pending_channel_tag_groups_mutations" static let legacyPendingAttributesKey: String = "com.urbanairship.named_user_attributes.registrar_persistent_queue_key" static let legacyNamedUserKey: String = "UANamedUserID" // Interval for how often we emit a resolve operation on foreground static let defaultForegroundResolveInterval: TimeInterval = 3600.0 // 1 hour // Max age of a contact ID update that we consider verified for CRA static let defaultVerifiedContactIDAge: TimeInterval = 600.0 // 10 mins // Subscription list cache age private static let maxSubscriptionListCacheAge: TimeInterval = 600.0 // 10 mins public static let maxNamedUserIDLength: Int = 128 private let dataStore: PreferenceDataStore private let config: RuntimeConfig private let privacyManager: any AirshipPrivacyManager private let contactChannelsProvider: any ContactChannelsProviderProtocol private let subscriptionListProvider: any SubscriptionListProviderProtocol private let date: any AirshipDateProtocol private let audienceOverridesProvider: any AudienceOverridesProvider private let contactManager: any ContactManagerProtocol private let cachedSubscriptionLists: CachedValue<(String, [String: [ChannelScope]])> private var setupTask: Task<Void, Never>? = nil private var subscriptions: Set<AnyCancellable> = Set() private let serialQueue: AirshipAsyncSerialQueue private var lastResolveDate: Date { get { let date = self.dataStore.object(forKey: DefaultAirshipContact.resolveDateKey) as? Date return date ?? Date.distantPast } set { self.dataStore.setObject(newValue, forKey: DefaultAirshipContact.resolveDateKey) } } private let subscriptionListEditsSubject: PassthroughSubject<ScopedSubscriptionListEdit, Never> = PassthroughSubject<ScopedSubscriptionListEdit, Never>() /// Publishes all edits made to the subscription lists through the SDK public var subscriptionListEdits: AnyPublisher<ScopedSubscriptionListEdit, Never> { subscriptionListEditsSubject.eraseToAnyPublisher() } private let conflictEventSubject: PassthroughSubject<ContactConflictEvent, Never> = PassthroughSubject<ContactConflictEvent, Never>() public var conflictEventPublisher: AnyPublisher<ContactConflictEvent, Never> { conflictEventSubject.eraseToAnyPublisher() } private let contactIDUpdatesSubject: CurrentValueSubject<ContactIDInfo?, Never> = CurrentValueSubject<ContactIDInfo?, Never>(nil) var contactIDUpdates: AnyPublisher<ContactIDInfo, Never> { return self.contactIDUpdatesSubject .compactMap { $0 } .removeDuplicates() .eraseToAnyPublisher() } private let namedUserUpdateSubject: CurrentValueSubject<NamedUserIDEvent?, Never> = CurrentValueSubject<NamedUserIDEvent?, Never>(nil) public var namedUserIDPublisher: AnyPublisher<String?, Never> { namedUserUpdateSubject .compactMap { $0 } .map { $0.identifier } .removeDuplicates() .eraseToAnyPublisher() } public var namedUserID: String? { get async { return await self.contactManager.currentNamedUserID() } } private var foregroundInterval: TimeInterval { let interval = self.config.remoteConfig.contactConfig?.foregroundInterval return interval ?? Self.defaultForegroundResolveInterval } private var verifiedContactIDMaxAge: TimeInterval { let age = self.config.remoteConfig.contactConfig?.channelRegistrationMaxResolveAge return age ?? Self.defaultVerifiedContactIDAge } /** * Internal only * :nodoc: */ @MainActor init( dataStore: PreferenceDataStore, config: RuntimeConfig, channel: any InternalAirshipChannel, privacyManager: any AirshipPrivacyManager, contactChannelsProvider: any ContactChannelsProviderProtocol, subscriptionListProvider: any SubscriptionListProviderProtocol, date: any AirshipDateProtocol = AirshipDate.shared, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, audienceOverridesProvider: any AudienceOverridesProvider, contactManager: any ContactManagerProtocol, serialQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue(priority: .high) ) { self.dataStore = dataStore self.config = config self.privacyManager = privacyManager self.contactChannelsProvider = contactChannelsProvider self.audienceOverridesProvider = audienceOverridesProvider self.date = date self.contactManager = contactManager self.serialQueue = serialQueue self.subscriptionListProvider = subscriptionListProvider self.cachedSubscriptionLists = CachedValue(date: date) self.setupTask = Task { 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) } await audienceOverridesProvider.setStableContactIDProvider { return await self.getStableContactID() } await contactManager.onAudienceUpdated { update in await audienceOverridesProvider.contactUpdated( contactID: update.contactID, tags: update.tags, attributes: update.attributes, subscriptionLists: update.subscriptionLists, channels: update.contactChannels ) } await self.contactManager.setEnabled(enabled: true) } self.serialQueue.enqueue { await self.setupTask?.value } // Whenever the contact ID changes, ignoring stableness, notify the channel self.contactIDUpdatesSubject .receive(on: RunLoop.main) .map { $0?.contactID } .removeDuplicates() .sink { _ in channel.clearSubscriptionListsCache() channel.updateRegistration() }.store(in: &self.subscriptions) // For obj-c compatibility self.conflictEventPublisher .receive(on: RunLoop.main) .sink { event in notificationCenter.post( name: AirshipNotifications.ContactConflict.name, object: nil, userInfo: [ AirshipNotifications.ContactConflict.eventKey: event ] ) }.store(in: &self.subscriptions) channel.addRegistrationExtender { [weak self] payload in await self?.setupTask?.value if await self?.contactID == nil { await self?.contactManager.generateDefaultContactIDIfNotSet() } if (channel.identifier != nil) { payload.channel.contactID = await self?.getStableVerifiedContactID() } else { payload.channel.contactID = await self?.contactID } } notificationCenter.addObserver( self, selector: #selector(didBecomeActive), name: AppStateTracker.didBecomeActiveNotification, object: nil ) notificationCenter.addObserver( self, selector: #selector(channelCreated), name: AirshipNotifications.ChannelCreated.name, object: nil ) notificationCenter.addObserver( self, selector: #selector(checkPrivacyManager), name: AirshipNotifications.PrivacyManagerUpdated.name, object: nil ) self.checkPrivacyManager() } public func airshipReady() { Task { @MainActor [weak self] in if let self = self { let contactInfo = await self.contactManager.currentContactIDInfo() self.contactIDUpdatesSubject.send(contactInfo) let namedUserID = await self.contactManager.currentNamedUserID() self.namedUserUpdateSubject.send(NamedUserIDEvent(identifier: namedUserID)) } guard let updates = await self?.contactManager.contactUpdates else { return } for await update in updates { guard let self else { return } switch (update) { case .conflict(let event): self.conflictEventSubject.send(event) case .contactIDUpdate(let update): self.contactIDUpdatesSubject.send(update) case .namedUserUpdate(let namedUserID): self.namedUserUpdateSubject.send(NamedUserIDEvent(identifier: namedUserID)) } } } } /** * Internal only * :nodoc: */ @MainActor convenience init( dataStore: PreferenceDataStore, config: RuntimeConfig, channel: any InternalAirshipChannel, privacyManager: any AirshipPrivacyManager, audienceOverridesProvider: any AudienceOverridesProvider, localeManager: any AirshipLocaleManager ) { self.init( dataStore: dataStore, config: config, channel: channel, privacyManager: privacyManager, contactChannelsProvider: ContactChannelsProvider( audienceOverrides: audienceOverridesProvider, apiClient: ContactChannelsAPIClient(config: config), privacyManager: privacyManager ), subscriptionListProvider: SubscriptionListProvider( audienceOverrides: audienceOverridesProvider, apiClient: ContactSubscriptionListAPIClient(config: config), privacyManager: privacyManager), audienceOverridesProvider: audienceOverridesProvider, contactManager: ContactManager( dataStore: dataStore, channel: channel, localeManager: localeManager, apiClient: ContactAPIClient(config: config) ) ) } /// Identifies the contact. /// - Parameter namedUserID: The named user ID. @inline(never) public func identify(_ namedUserID: String) { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.warn("Contacts disabled. Enable to identify user.") return } do { self.addOperation( .identify( try namedUserID.normalizedNamedUserID ) ) } catch { AirshipLogger.error("Unable to set named user \(error)") } } /// Resets the contact. @inline(never) public func reset() { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.trace("Contacts are disabled, ignoring reset request") return } 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. @inline(never) public func notifyRemoteLogin() { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.trace("Contacts are disabled, ignoring notifyRemoteLogin request") return } self.addOperation(.verify(self.date.now, required: true)) } /// Begins a tag groups editing session. /// - Returns: A TagGroupsEditor public func editTagGroups() -> TagGroupsEditor { return TagGroupsEditor { updates in guard !updates.isEmpty else { AirshipLogger.trace("Empty tag group updates, ignoring") return } guard self.privacyManager.isEnabled([.contacts, .tagsAndAttributes]) else { AirshipLogger.warn( "Contacts or tags are disabled. Enable to apply tag edits." ) return } self.addOperation(.update(tagUpdates: updates)) self.notifyOverridesChanged() } } private func notifyOverridesChanged() { Task { [weak audienceOverridesProvider] in await audienceOverridesProvider?.notifyPendingChanged() } } /// Begins a tag groups editing session. /// - Parameter editorBlock: A tag groups editor block. /// - Returns: A TagGroupsEditor public func editTagGroups(_ editorBlock: (TagGroupsEditor) -> Void) { let editor = editTagGroups() editorBlock(editor) editor.apply() } /// Begins an attribute editing session. /// - Returns: An AttributesEditor public func editAttributes() -> AttributesEditor { return AttributesEditor { updates in guard !updates.isEmpty else { AirshipLogger.trace("Empty attribute updates, ignoring") return } guard self.privacyManager.isEnabled([.contacts, .tagsAndAttributes]) else { AirshipLogger.warn( "Contacts or tags are disabled. Enable to apply attribute edits." ) return } self.addOperation( .update(attributeUpdates: updates) ) self.notifyOverridesChanged() } } /// Begins an attribute editing session. /// - Parameter editorBlock: An attributes editor block. /// - Returns: An AttributesEditor public func editAttributes(_ editorBlock: (AttributesEditor) -> Void) { let editor = editAttributes() editorBlock(editor) editor.apply() } /** * Associates an Email channel to the contact. * - Parameters: * - address: The email address. * - options: The email channel registration options. */ public func registerEmail( _ address: String, options: EmailRegistrationOptions ) { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.warn( "Contacts disabled. Enable to associate Email channel." ) return } self.addOperation(.registerEmail(address: address, options: options)) self.notifyOverridesChanged() } /** * Associates a SMS channel to the contact. * - Parameters: * - msisdn: The SMS Mobile Station International Subscriber Directory Number.. * - options: The SMS channel registration options. */ public func registerSMS(_ msisdn: String, options: SMSRegistrationOptions) { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.warn( "Contacts disabled. Enable to associate SMS channel." ) return } self.addOperation(.registerSMS(msisdn: msisdn, options: options)) self.notifyOverridesChanged() } /// Associates an open channel to the contact. /// - Parameter address: The open channel address. /// - Parameter options: The open channel registration options. public func registerOpen( _ address: String, options: OpenRegistrationOptions ) { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.warn( "Contacts disabled. Enable to associate Open channel." ) return } self.addOperation(.registerOpen(address: address, options: options)) } /** * Associates a channel to the contact. * - Parameters: * - channelID: The channel ID. * - type: The channel type. * - options: The SMS/email channel options */ public func associateChannel( _ channelID: String, type: ChannelType ) { guard self.privacyManager.isEnabled(.contacts) else { AirshipLogger.warn( "Contacts disabled. Enable to associate channel." ) 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)) Task { [weak audienceOverridesProvider] in await audienceOverridesProvider?.notifyPendingChanged() } } /// Begins a subscription list editing session /// - Returns: A Scoped subscription list editor public func editSubscriptionLists() -> ScopedSubscriptionListEditor { return ScopedSubscriptionListEditor(date: self.date) { updates in guard !updates.isEmpty else { return } guard self.privacyManager.isEnabled([.contacts, .tagsAndAttributes]) else { AirshipLogger.warn( "Contacts or tags are disabled. Enable to apply subscription lists edits." ) return } Task { @MainActor in updates.forEach { switch $0.type { case .subscribe: self.subscriptionListEditsSubject.send( .subscribe($0.listId, $0.scope) ) case .unsubscribe: self.subscriptionListEditsSubject.send( .unsubscribe($0.listId, $0.scope) ) } } } self.addOperation(.update(subscriptionListsUpdates: updates)) } } /// Begins a subscription list editing session /// - Parameter editorBlock: A scoped subscription list editor block. /// - Returns: A ScopedSubscriptionListEditor public func editSubscriptionLists( _ editorBlock: (ScopedSubscriptionListEditor) -> Void ) { let editor = editSubscriptionLists() editorBlock(editor) editor.apply() } private func waitForContactIDInfo(filter: @Sendable @escaping (ContactIDInfo) -> Bool) async -> ContactIDInfo { // Stableness is determined by a reset or identify operation. 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() var subscription: AnyCancellable? let result: ContactIDInfo = await withCheckedContinuation { continuation in subscription = self.contactIDUpdates .first { update in filter(update) } .sink { update in continuation.resume(returning: update) } } subscription?.cancel() return result } public func getStableContactID() async -> String { return await waitForContactIDInfo { update in 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 let stableIDInfo = await waitForContactIDInfo { update in update.isStable } let secondsSinceLastResolve = now.timeIntervalSince(stableIDInfo.resolveDate) guard secondsSinceLastResolve >= self.verifiedContactIDMaxAge else { return stableIDInfo.contactID } addOperation(.verify(now)) return await waitForContactIDInfo { update in update.isStable && update.resolveDate >= now }.contactID } public func fetchSubscriptionLists() async throws -> [String: [ChannelScope]] { let contactID = await getStableContactID() return try await subscriptionListProvider.fetch(contactID: contactID) } @objc private func checkPrivacyManager() { self.serialQueue.enqueue { if self.privacyManager.isAnyFeatureEnabled() { await self.contactManager.generateDefaultContactIDIfNotSet() } guard self.privacyManager.isEnabled(.contacts) else { await self.contactManager.resetIfNeeded() return } } } @objc private func didBecomeActive() { guard self.privacyManager.isEnabled(.contacts) else { return } let lastActive = self.date.now.timeIntervalSince(self.lastResolveDate) if (lastActive >= self.foregroundInterval) { self.lastResolveDate = self.date.now self.addOperation(.resolve) } self.contactChannelsProvider.refreshAsync() } @objc private func channelCreated(notification: NSNotification) { guard self.privacyManager.isEnabled(.contacts) else { return } let existing = notification.userInfo?[AirshipNotifications.ChannelCreated.isExistingChannelKey] as? Bool if existing == true && self.config.airshipConfig.clearNamedUserOnAppRestore { self.addOperation(.reset) } else { self.addOperation(.resolve) } } private func addOperation(_ operation: ContactOperation) { self.serialQueue.enqueue { AirshipLogger.trace("Adding contact operation \(operation.type)") await self.contactManager.addOperation(operation) } } private func migrateNamedUser() async { defer { self.dataStore.removeObject(forKey: DefaultAirshipContact.legacyNamedUserKey) self.dataStore.removeObject( forKey: DefaultAirshipContact.legacyPendingTagGroupsKey ) self.dataStore.removeObject( forKey: DefaultAirshipContact.legacyPendingAttributesKey ) } guard self.privacyManager.isEnabled(.contacts) else { return } guard let legacyNamedUserID = try? self.dataStore.string( forKey: DefaultAirshipContact.legacyNamedUserKey )?.normalizedNamedUserID else { await self.contactManager.generateDefaultContactIDIfNotSet() return } if await self.contactManager.currentContactIDInfo() == nil { // Need to call through to contact manager directly to ensure operation order await self.contactManager.addOperation(.identify(legacyNamedUserID)) } if self.privacyManager.isEnabled(.tagsAndAttributes) { var pendingTagUpdates: [TagGroupUpdate]? var pendingAttributeUpdates: [AttributeUpdate]? if let pendingTagGroupsData = self.dataStore.data( forKey: DefaultAirshipContact.legacyPendingTagGroupsKey ) { let classes = [NSArray.self, TagGroupsMutation.self] let pendingTagGroups = try? NSKeyedUnarchiver.unarchivedObject( ofClasses: classes, from: pendingTagGroupsData ) if let pendingTagGroups = pendingTagGroups as? [TagGroupsMutation] { pendingTagUpdates = pendingTagGroups.map { $0.tagGroupUpdates } .reduce([], +) } } if let pendingAttributesData = self.dataStore.data( forKey: DefaultAirshipContact.legacyPendingAttributesKey ) { let classes = [NSArray.self, AttributePendingMutations.self] let pendingAttributes = try? NSKeyedUnarchiver.unarchivedObject( ofClasses: classes, from: pendingAttributesData ) if let pendingAttributes = pendingAttributes as? [AttributePendingMutations] { pendingAttributeUpdates = pendingAttributes.map { $0.attributeUpdates } .reduce([], +) } } if !(pendingTagUpdates?.isEmpty ?? true && pendingAttributeUpdates?.isEmpty ?? true) { // Need to call through to contact manager directly to ensure operation order await self.contactManager.addOperation( .update( tagUpdates: pendingTagUpdates, attributeUpdates: pendingAttributeUpdates ) ) } } } } extension String { var normalizedNamedUserID: String { get throws { let trimmedID = self.trimmingCharacters( in: .whitespacesAndNewlines ) guard trimmedID.count > 0, trimmedID.count <= DefaultAirshipContact.maxNamedUserIDLength else { throw AirshipErrors.error("Invalid named user ID \(trimmedID). IDs must be between 1 and \(DefaultAirshipContact.maxNamedUserIDLength) characters.") } return trimmedID } } } extension DefaultAirshipContact : InternalAirshipContact { var contactIDInfo: ContactIDInfo? { get async { return await self.contactManager.currentContactIDInfo() } } var authTokenProvider: any AuthTokenProvider { return self.contactManager } var contactID: String? { get async { return await self.contactManager.currentContactIDInfo()?.contactID } } } extension DefaultAirshipContact: AirshipPushableComponent { public func receivedRemoteNotification(_ notification: AirshipJSON) async -> UABackgroundFetchResult { guard let userInfo = notification.unWrap() as? [AnyHashable: Any], userInfo[Self.refreshContactPushPayloadKey] != nil else { return .noData } self.contactChannelsProvider.refreshAsync() return .newData } #if !os(tvOS) public func receivedNotificationResponse(_ response: UNNotificationResponse) async { // no-op } #endif } extension DefaultAirshipContact: AirshipComponent {} public extension AirshipNotifications { /// NSNotification info when a conflict event is emitted. final class ContactConflict { /// NSNotification name. public static let name: NSNotification.Name = NSNotification.Name( "com.urbanairship.contact_conflict" ) /// NSNotification userInfo key to get the `ContactConflictEvent`. public static let eventKey: String = "event" } } fileprivate struct NamedUserIDEvent { let identifier: String? } ================================================ FILE: Airship/AirshipCore/Source/DefaultAirshipPush.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation @preconcurrency public import UserNotifications #if canImport(WatchKit) import WatchKit #endif #if canImport(UIKit) import UIKit #endif /// This singleton provides an interface to the functionality provided by the Airship iOS Push API. final class DefaultAirshipPush: AirshipPush, @unchecked Sendable { private let pushTokenChannel: AirshipAsyncChannel<String> = AirshipAsyncChannel<String>() private let notificationStatusChannel: AirshipAsyncChannel<AirshipNotificationStatus> = AirshipAsyncChannel<AirshipNotificationStatus>() @MainActor public var notificationStatusPublisher: AnyPublisher<AirshipNotificationStatus, Never> { return notificationStatusUpdates .airshipPublisher .compactMap { $0 } .eraseToAnyPublisher() } var notificationStatusUpdates: AsyncStream<AirshipNotificationStatus> { return self.notificationStatusChannel.makeNonIsolatedDedupingStream( initialValue: { [weak self] in await self?.notificationStatus } ) } private static let pushNotificationsOptionsKey: String = "UAUserPushNotificationsOptions" private static let userPushNotificationsEnabledKey: String = "UAUserPushNotificationsEnabled" private static let backgroundPushNotificationsEnabledKey: String = "UABackgroundPushNotificationsEnabled" private static let requestExplicitPermissionWhenEphemeralKey: String = "UAExtendedPushNotificationPermissionEnabled" private static let badgeSettingsKey: String = "UAPushBadge" private static let deviceTokenKey: String = "UADeviceToken" private static let quietTimeSettingsKey: String = "UAPushQuietTime" private static let quietTimeEnabledSettingsKey: String = "UAPushQuietTimeEnabled" private static let timeZoneSettingsKey: String = "UAPushTimeZone" private static let typesAuthorizedKey: String = "UAPushTypesAuthorized" private static let authorizationStatusKey: String = "UAPushAuthorizationStatus" private static let userPromptedForNotificationsKey: String = "UAPushUserPromptedForNotifications" // Old push enabled key private static let oldPushEnabledKey: String = "UAPushEnabled" // The default device tag group. private static let defaultDeviceTagGroup: String = "device" // The foreground presentation options that can be defined from API or dashboard private static let presentationOptionBadge: String = "badge" private static let presentationOptionAlert: String = "alert" private static let presentationOptionSound: String = "sound" private static let presentationOptionList: String = "list" private static let presentationOptionBanner: String = "banner" // Foreground presentation keys private static let ForegroundPresentationLegacykey: String = "foreground_presentation" private static let ForegroundPresentationkey: String = "com.urbanairship.foreground_presentation" private static let deviceTokenRegistrationWaitTime: TimeInterval = 10 private let config: RuntimeConfig private let dataStore: PreferenceDataStore private let channel: any InternalAirshipChannel private let privacyManager: any AirshipPrivacyManager private let permissionsManager: any AirshipPermissionsManager private let notificationCenter: AirshipNotificationCenter private let notificationRegistrar: any NotificationRegistrar private let apnsRegistrar: any APNSRegistrar private let badger: any BadgerProtocol @MainActor private var waitForDeviceToken: Bool = false @MainActor private var pushEnabled: Bool = false private let serialQueue: AirshipAsyncSerialQueue @MainActor public var onAPNSRegistrationFinished: (@MainActor @Sendable (APNSRegistrationResult) -> Void)? @MainActor public var onNotificationRegistrationFinished: (@MainActor @Sendable (NotificationRegistrationResult) -> Void)? @MainActor public var onNotificationAuthorizedSettingsDidChange: (@MainActor @Sendable (AirshipAuthorizedNotificationSettings) -> Void)? // Notification callbacks @MainActor public var onForegroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> Void)? #if os(watchOS) @MainActor public var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> WKBackgroundFetchResult)? #elseif os(macOS) @MainActor public var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> Void)? #else @MainActor public var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> UIBackgroundFetchResult)? #endif #if !os(tvOS) @MainActor public var onNotificationResponseReceived: (@MainActor @Sendable (UNNotificationResponse) async -> Void)? #endif @MainActor public var onExtendPresentationOptions: (@MainActor @Sendable (UNNotificationPresentationOptions, UNNotification) async -> UNNotificationPresentationOptions)? @MainActor private var isRegisteredForRemoteNotifications: Bool { return self.apnsRegistrar.isRegisteredForRemoteNotifications } @MainActor private var isBackgroundRefreshStatusAvailable: Bool { return self.apnsRegistrar.isBackgroundRefreshStatusAvailable } private var subscriptions: Set<AnyCancellable> = Set() @MainActor @inline(never) init( config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any InternalAirshipChannel, analytics: any InternalAirshipAnalytics, privacyManager: any AirshipPrivacyManager, permissionsManager: any AirshipPermissionsManager, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, notificationRegistrar: any NotificationRegistrar = UNNotificationRegistrar(), apnsRegistrar: any APNSRegistrar, badger: any BadgerProtocol, serialQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() ) { self.config = config self.dataStore = dataStore self.channel = channel self.privacyManager = privacyManager self.permissionsManager = permissionsManager self.notificationCenter = notificationCenter self.notificationRegistrar = notificationRegistrar self.apnsRegistrar = apnsRegistrar self.badger = badger self.serialQueue = serialQueue let permissionDelegate = NotificationPermissionDelegate( registrar: self.notificationRegistrar ) { let options = self.notificationOptions let skipIfEphemeral = !self .requestExplicitPermissionWhenEphemeral return NotificationPermissionDelegate.Config( options: options, skipIfEphemeral: skipIfEphemeral ) } self.permissionsManager.setDelegate( permissionDelegate, permission: .displayNotifications ) self.permissionsManager.addRequestExtender( permission: .displayNotifications ) { status in await self.notificationRegistrationFinished() } self.permissionsManager.addAirshipEnabler( permission: .displayNotifications ) { self.dataStore.setBool( true, forKey: DefaultAirshipPush.userPushNotificationsEnabledKey ) self.privacyManager.enableFeatures(.push) self.channel.updateRegistration() self.updateNotificationStatus() } self.waitForDeviceToken = self.channel.identifier == nil self.observeNotificationCenterEvents() let checkAppRestoreTask = Task { if await self.dataStore.isAppRestore { self.resetDeviceToken() } } self.channel.addRegistrationExtender { payload in await checkAppRestoreTask.value return await self.extendChannelRegistrationPayload(&payload) } analytics.addHeaderProvider { await self.analyticsHeaders() } self.updatePushEnablement() if !self.apnsRegistrar.isRemoteNotificationBackgroundModeEnabled { AirshipLogger.impError( "Application is not configured for background notifications. Please enable remote notifications in the application's background modes.", skipLogLevelCheck: false ) } } @MainActor private func observeNotificationCenterEvents() { #if !os(watchOS) && !os(macOS) self.notificationCenter.addObserver( self, selector: #selector(applicationBackgroundRefreshStatusChanged), name: UIApplication .backgroundRefreshStatusDidChangeNotification, object: nil ) #endif self.notificationCenter.addObserver( self, selector: #selector(applicationDidBecomeActive), name: AppStateTracker.didBecomeActiveNotification, object: nil ) self.notificationCenter.addObserver( self, selector: #selector(applicationDidEnterBackground), name: AppStateTracker.didEnterBackgroundNotification, object: nil ) self.notificationCenter.addObserver( self, selector: #selector(onEnabledFeaturesChanged), name: AirshipNotifications.PrivacyManagerUpdated.name, object: nil ) } /// Enables/disables background remote notifications on this device through Airship. /// Defaults to `true`. public var backgroundPushNotificationsEnabled: Bool { set { let previous = self.backgroundPushNotificationsEnabled self.dataStore.setBool( newValue, forKey: DefaultAirshipPush.backgroundPushNotificationsEnabledKey ) if !previous == newValue { self.channel.updateRegistration() } } get { return self.dataStore.bool( forKey: DefaultAirshipPush.backgroundPushNotificationsEnabledKey, defaultValue: true ) } } /// Enables/disables user notifications on this device through Airship. /// Defaults to `false`. Once set to `true`, the user will be prompted for remote notifications. public var userPushNotificationsEnabled: Bool { set { let previous = self.userPushNotificationsEnabled self.dataStore.setBool( newValue, forKey: DefaultAirshipPush.userPushNotificationsEnabledKey ) if previous != newValue { self.dispatchUpdateNotifications() } self.updateNotificationStatus() } get { return self.dataStore.bool( forKey: DefaultAirshipPush.userPushNotificationsEnabledKey ) } } /// When enabled, if the user has ephemeral notification authorization the SDK will prompt the user for /// notifications. Defaults to `false`. public var requestExplicitPermissionWhenEphemeral: Bool { set { let previous = self.requestExplicitPermissionWhenEphemeral if previous != newValue { self.dataStore.setBool( newValue, forKey: DefaultAirshipPush.requestExplicitPermissionWhenEphemeralKey ) self.dispatchUpdateNotifications() } } get { return self.dataStore.bool( forKey: DefaultAirshipPush.requestExplicitPermissionWhenEphemeralKey ) } } /// The device token for this device, as a hex string. @MainActor public private(set) var deviceToken: String? { set { guard let deviceToken = newValue else { self.dataStore.removeObject(forKey: DefaultAirshipPush.deviceTokenKey) self.updateNotificationStatus() return } do { let regex = try NSRegularExpression( pattern: "[^0-9a-fA-F]", options: .caseInsensitive ) if regex.numberOfMatches( in: deviceToken, options: [], range: NSRange(location: 0, length: deviceToken.count) ) != 0 { AirshipLogger.error( "Device token \(deviceToken) contains invalid characters. Only hex characters are allowed" ) return } if deviceToken.count < 64 || deviceToken.count > 200 { AirshipLogger.warn( "Device token \(deviceToken) should be 64 to 200 hex characters (32 to 100 bytes) long." ) } self.dataStore.setObject( deviceToken, forKey: DefaultAirshipPush.deviceTokenKey ) AirshipLogger.importantInfo("Device token: \(deviceToken)") Task { await self.pushTokenChannel.send(deviceToken) } } catch { AirshipLogger.error("Unable to set device token") } self.updateNotificationStatus() } get { return self.dataStore.string(forKey: DefaultAirshipPush.deviceTokenKey) } } /// User Notification options this app will request from APNS. Changes to this value /// will not take effect until the next time the app registers with /// updateRegistration. /// /// Defaults to alert, sound and badge. public var notificationOptions: UNAuthorizationOptions { set { let previous = self.notificationOptions self.dataStore.setObject( newValue.rawValue, forKey: DefaultAirshipPush.pushNotificationsOptionsKey ) if previous != newValue { self.dispatchUpdateNotifications() } } get { guard let value = self.dataStore.object( forKey: DefaultAirshipPush.pushNotificationsOptionsKey ) as? UInt else { #if os(tvOS) return .badge #else guard self.authorizationStatus == .provisional else { return [.badge, .sound, .alert] } return [.badge, .sound, .alert, .provisional] #endif } return UNAuthorizationOptions(rawValue: value) } } #if !os(tvOS) /// Custom notification categories. Airship default notification /// categories will be unaffected by this field. @MainActor public var customCategories: Set<UNNotificationCategory> = Set() { didSet { self.updateCategories() } } /// The combined set of notification categories from `customCategories` set by the app /// and the Airship provided categories. @MainActor public var combinedCategories: Set<UNNotificationCategory> { let defaultCategories = NotificationCategories.defaultCategories( withRequireAuth: requireAuthorizationForDefaultCategories ) return defaultCategories.union(self.customCategories) } #endif /// Sets authorization required for the default Airship categories. Only applies /// to background user notification actions. /// /// Changes to this value will not take effect until the next time the app registers /// with updateRegistration. @MainActor public var requireAuthorizationForDefaultCategories: Bool = true { didSet { self.updateCategories() } } @MainActor public weak var pushNotificationDelegate: (any PushNotificationDelegate)? @MainActor public weak var registrationDelegate: (any RegistrationDelegate)? #if !os(tvOS) /// Notification response that launched the application. public private(set) var launchNotificationResponse: UNNotificationResponse? #endif public private(set) var authorizedNotificationSettings: AirshipAuthorizedNotificationSettings { set { self.dataStore.setInteger( Int(newValue.rawValue), forKey: DefaultAirshipPush.typesAuthorizedKey ) } get { guard let value = self.dataStore.object( forKey: DefaultAirshipPush.typesAuthorizedKey ) as? Int else { return [] } return AirshipAuthorizedNotificationSettings(rawValue: UInt(value)) } } public private(set) var authorizationStatus: UNAuthorizationStatus { set { self.dataStore.setInteger( newValue.rawValue, forKey: DefaultAirshipPush.authorizationStatusKey ) } get { guard let value = self.dataStore.object( forKey: DefaultAirshipPush.authorizationStatusKey ) as? Int else { return .notDetermined } return UNAuthorizationStatus(rawValue: Int(value)) ?? .notDetermined } } public private(set) var userPromptedForNotifications: Bool { set { self.dataStore.setBool( newValue, forKey: DefaultAirshipPush.userPromptedForNotificationsKey ) } get { return self.dataStore.bool( forKey: DefaultAirshipPush.userPromptedForNotificationsKey ) } } public var defaultPresentationOptions: UNNotificationPresentationOptions = [] @MainActor private func updateAuthorizedNotificationTypes( alwaysUpdateChannel: Bool = false ) async -> (UNAuthorizationStatus, AirshipAuthorizedNotificationSettings) { AirshipLogger.trace("Updating authorized types.") let (status, settings) = await self.notificationRegistrar.checkStatus() var settingsChanged = false if self.privacyManager.isEnabled(.push) { if !self.userPromptedForNotifications { #if os(tvOS) || os(watchOS) self.userPromptedForNotifications = status != .notDetermined #elseif os(macOS) if status != .notDetermined{ self.userPromptedForNotifications = true } #else if status != .notDetermined && status != .ephemeral { self.userPromptedForNotifications = true } #endif } if status != self.authorizationStatus { self.authorizationStatus = status settingsChanged = true } if self.authorizedNotificationSettings != settings { self.authorizedNotificationSettings = settings if let onNotificationAuthorizedSettingsDidChange { onNotificationAuthorizedSettingsDidChange(settings) } else { self.registrationDelegate?.notificationAuthorizedSettingsDidChange( settings ) } settingsChanged = true } } updateNotificationStatus() if (settingsChanged || alwaysUpdateChannel) { self.channel.updateRegistration() } return(status, settings) } public func enableUserPushNotifications() async -> Bool { return await enableUserPushNotifications(fallback: .none) } public func enableUserPushNotifications( fallback: PromptPermissionFallback ) async -> Bool { guard self.config.airshipConfig.requestAuthorizationToUseNotifications else { self.userPushNotificationsEnabled = true if !fallback.isNone { AirshipLogger.error( "Airship.push.enableUserPushNotifications(fallback:) called but the AirshipConfig.requestAuthorizationToUseNotifications is disabled. Unable to request permissions. Use Airship.permissionsManager.requestPermission(.displayNotifications, enableAirshipUsageOnGrant: true, fallback: fallback) instead." ) } return await self.permissionsManager.checkPermissionStatus(.displayNotifications) == .granted } self.dataStore.setBool( true, forKey: DefaultAirshipPush.userPushNotificationsEnabledKey ) let result = await self.permissionsManager.requestPermission( .displayNotifications, enableAirshipUsageOnGrant: false, fallback: fallback ) return result.endStatus == .granted } @MainActor private func waitForDeviceTokenRegistration() async { guard self.waitForDeviceToken, self.privacyManager.isEnabled(.push), self.apnsRegistrar.isRegisteredForRemoteNotifications else { return } self.waitForDeviceToken = false let updates = await pushTokenChannel.makeStream() guard self.deviceToken == nil else { return } let waitTask = Task { for await _ in updates { return } } let cancelTask = Task { @MainActor in try await Task.sleep( nanoseconds: UInt64(DefaultAirshipPush.deviceTokenRegistrationWaitTime * 1_000_000_000) ) try Task.checkCancellation() waitTask.cancel() } await waitTask.value cancelTask.cancel() } @MainActor public var isPushNotificationsOptedIn: Bool { var optedIn = true if self.deviceToken == nil { AirshipLogger.trace("Opted out: missing device token") optedIn = false } if !self.userPushNotificationsEnabled { AirshipLogger.trace("Opted out: user push notifications disabled") optedIn = false } if self.authorizedNotificationSettings == [] { AirshipLogger.trace( "Opted out: no authorized notification settings" ) optedIn = false } if !self.isRegisteredForRemoteNotifications { AirshipLogger.trace( "Opted out: not registered for remote notifications" ) optedIn = false } if !self.privacyManager.isEnabled(.push) { AirshipLogger.trace("Opted out: push is disabled") optedIn = false } return optedIn } public var notificationStatus: AirshipNotificationStatus { get async { let (status, settings) = await self.notificationRegistrar.checkStatus() let isRegisteredForRemoteNotifications = await self.apnsRegistrar.isRegisteredForRemoteNotifications let displayNotificationStatus = await self.permissionsManager.checkPermissionStatus(.displayNotifications) return await AirshipNotificationStatus( isUserNotificationsEnabled: self.userPushNotificationsEnabled, areNotificationsAllowed: status != .denied && status != .notDetermined && settings != [], isPushPrivacyFeatureEnabled: self.privacyManager.isEnabled(.push), isPushTokenRegistered: self.deviceToken != nil && isRegisteredForRemoteNotifications, displayNotificationStatus: displayNotificationStatus ) } } @MainActor private func backgroundPushNotificationsAllowed() -> Bool { guard self.deviceToken != nil, self.backgroundPushNotificationsEnabled, self.apnsRegistrar.isRemoteNotificationBackgroundModeEnabled, self.privacyManager.isEnabled(.push) else { return false } return self.isRegisteredForRemoteNotifications && self.isBackgroundRefreshStatusAvailable } @MainActor private func updatePushEnablement() { if self.privacyManager.isEnabled(.push) { if (!self.pushEnabled) { self.pushEnabled = true self.apnsRegistrar.registerForRemoteNotifications() self.dispatchUpdateNotifications() self.updateCategories() } } else { self.pushEnabled = false } updateNotificationStatus() } private func updateNotificationStatus() { Task { await self.notificationStatusChannel.send(await self.notificationStatus) } } @MainActor private func notificationRegistrationFinished() async { guard self.privacyManager.isEnabled(.push) else { return } if self.deviceToken == nil { self.apnsRegistrar.registerForRemoteNotifications() } let (status, settings) = await self.updateAuthorizedNotificationTypes( alwaysUpdateChannel: true ) #if !os(tvOS) if let onNotificationRegistrationFinished { onNotificationRegistrationFinished( NotificationRegistrationResult( authorizedSettings: settings, status: status, categories: self.combinedCategories ) ) } else { self.registrationDelegate? .notificationRegistrationFinished( withAuthorizedSettings: settings, categories: self.combinedCategories, status: status ) } #else if let onNotificationRegistrationFinished { onNotificationRegistrationFinished( NotificationRegistrationResult( authorizedSettings: settings, status: status ) ) } else { self.registrationDelegate? .notificationRegistrationFinished( withAuthorizedSettings: settings, status: status ) } #endif } #if !os(watchOS) public func setBadgeNumber(_ newBadgeNumber: Int) async throws { try await self.badger.setBadgeNumber(newBadgeNumber) if self.autobadgeEnabled, privacyManager.isEnabled(.push) { self.channel.updateRegistration(forcefully: true) } } /// deprecation warning @MainActor public var badgeNumber: Int { get { return self.badger.badgeNumber } } public var autobadgeEnabled: Bool { set { if self.autobadgeEnabled != newValue { self.dataStore.setBool( newValue, forKey: DefaultAirshipPush.badgeSettingsKey ) if privacyManager.isEnabled(.push) { self.channel.updateRegistration(forcefully: true) } } } get { return self.dataStore.bool(forKey: DefaultAirshipPush.badgeSettingsKey) } } @MainActor func resetBadge() async throws { try await self.setBadgeNumber(0) } #endif public var quietTime: QuietTimeSettings? { get { guard let quietTime = self.dataStore.dictionary(forKey: Self.quietTimeSettingsKey) else { return nil } return QuietTimeSettings(from: quietTime) } set { if let newValue { AirshipLogger.debug("Setting quiet time: \(newValue)") self.dataStore.setObject(newValue.dictionary, forKey: Self.quietTimeSettingsKey) } else { AirshipLogger.debug("Clearing quiet time") self.dataStore.removeObject(forKey: Self.quietTimeSettingsKey) } self.channel.updateRegistration() } } /// Time Zone for quiet time. If the time zone is not set, the current /// local time zone is returned. public var timeZone: NSTimeZone? { set { self.dataStore.setObject( newValue?.name ?? nil, forKey: DefaultAirshipPush.timeZoneSettingsKey ) } get { let timeZoneName = self.dataStore.string(forKey: DefaultAirshipPush.timeZoneSettingsKey) ?? "" return NSTimeZone(name: timeZoneName) ?? NSTimeZone.default as NSTimeZone } } /// Enables/Disables quiet time public var quietTimeEnabled: Bool { set { self.dataStore.setBool( newValue, forKey: DefaultAirshipPush.quietTimeEnabledSettingsKey ) } get { return self.dataStore.bool(forKey: DefaultAirshipPush.quietTimeEnabledSettingsKey) } } public func setQuietTimeStartHour( _ startHour: Int, startMinute: Int, endHour: Int, endMinute: Int ) { do { self.quietTime = try QuietTimeSettings( startHour: UInt(startHour), startMinute: UInt(startMinute), endHour: UInt(endHour), endMinute: UInt(endMinute) ) } catch { AirshipLogger.error( "Unable to set quiet time, invalid time: \(error)" ) } } private func updateRegistration() { self.dispatchUpdateNotifications() } @MainActor private func updateCategories() { #if !os(tvOS) guard self.privacyManager.isEnabled(.push), self.config.airshipConfig.requestAuthorizationToUseNotifications else { return } self.notificationRegistrar.setCategories(self.combinedCategories) #endif } private func dispatchUpdateNotifications() { self.serialQueue.enqueue { await self.updateNotifications() } } @MainActor private func updateNotifications() async { guard self.privacyManager.isEnabled(.push) else { return } guard self.config.airshipConfig.requestAuthorizationToUseNotifications else { self.channel.updateRegistration() return } if self.userPushNotificationsEnabled { _ = await self.permissionsManager.requestPermission(.displayNotifications) } else { // If we are going from `ephemeral` to `[]` it will prompt the user to disable notifications... // avoid that by just skipping if we have ephemeral. await self.notificationRegistrar.updateRegistration( options: [], skipIfEphemeral: true ) await self.notificationRegistrationFinished() } } @objc private func onEnabledFeaturesChanged() { self.serialQueue.enqueue { await self.updatePushEnablement() } } @objc private func applicationDidBecomeActive() { if self.privacyManager.isEnabled(.push) { self.dispatchUpdateAuthorizedNotificationTypes() } } @objc private func applicationDidEnterBackground() { #if !os(tvOS) self.launchNotificationResponse = nil #endif if self.privacyManager.isEnabled(.push) { self.dispatchUpdateAuthorizedNotificationTypes() } } #if !os(watchOS) && !os(macOS) @objc @MainActor private func applicationBackgroundRefreshStatusChanged() { if self.privacyManager.isEnabled(.push) { AirshipLogger.trace("Background refresh status changed.") if self.apnsRegistrar.isBackgroundRefreshStatusAvailable { self.apnsRegistrar.registerForRemoteNotifications() } else { self.channel.updateRegistration() } } } #endif @MainActor private func extendChannelRegistrationPayload( _ payload: inout ChannelRegistrationPayload ) async { guard self.privacyManager.isEnabled(.push) else { return } await self.waitForDeviceTokenRegistration() guard self.privacyManager.isEnabled(.push) else { return } payload.channel.pushAddress = self.deviceToken payload.channel.isOptedIn = self.isPushNotificationsOptedIn #if !os(watchOS) payload.channel.isBackgroundEnabled = self.backgroundPushNotificationsAllowed() #endif payload.channel.iOSChannelSettings = payload.channel.iOSChannelSettings ?? ChannelRegistrationPayload.iOSChannelSettings() #if !os(watchOS) if self.autobadgeEnabled { payload.channel.iOSChannelSettings?.badge = self.badgeNumber } #endif if let timeZoneName = self.timeZone?.name, let quietTime, self.quietTimeEnabled { let quietTime = ChannelRegistrationPayload.QuietTime( start: quietTime.startString, end: quietTime.endString ) payload.channel.iOSChannelSettings?.quietTimeTimeZone = timeZoneName payload.channel.iOSChannelSettings?.quietTime = quietTime } payload.channel.iOSChannelSettings?.isScheduledSummary = (self.authorizedNotificationSettings.rawValue & AirshipAuthorizedNotificationSettings.scheduledDelivery .rawValue > 0) payload.channel.iOSChannelSettings?.isTimeSensitive = (self.authorizedNotificationSettings.rawValue & AirshipAuthorizedNotificationSettings.timeSensitive.rawValue > 0) return } @MainActor private func analyticsHeaders() -> [String: String] { guard self.privacyManager.isEnabled(.push) else { return [ "X-UA-Channel-Opted-In": "false", "X-UA-Channel-Background-Enabled": "false", ] } var headers: [String: String] = [:] headers["X-UA-Channel-Opted-In"] = self.isPushNotificationsOptedIn ? "true" : "false" headers["X-UA-Notification-Prompted"] = self.userPromptedForNotifications ? "true" : "false" headers["X-UA-Channel-Background-Enabled"] = self.backgroundPushNotificationsAllowed() ? "true" : "false" headers["X-UA-Push-Address"] = self.deviceToken return headers } } /// - Note: For internal use only. :nodoc: extension DefaultAirshipPush: InternalAirshipPush { public func dispatchUpdateAuthorizedNotificationTypes() { self.serialQueue.enqueue { _ = await self.updateAuthorizedNotificationTypes() } } @MainActor public func didRegisterForRemoteNotifications(_ deviceToken: Data) { guard self.privacyManager.isEnabled(.push) else { return } let tokenString = AirshipUtils.deviceTokenStringFromDeviceToken(deviceToken) AirshipLogger.info("Device token string: \(tokenString)") self.deviceToken = tokenString self.channel.updateRegistration() if let onAPNSRegistrationFinished { onAPNSRegistrationFinished(.success(deviceToken: tokenString)) } else { self.registrationDelegate?.apnsRegistrationSucceeded( withDeviceToken: deviceToken ) } } public func didFailToRegisterForRemoteNotifications(_ error: any Error) { guard self.privacyManager.isEnabled(.push) else { return } if let onAPNSRegistrationFinished { onAPNSRegistrationFinished(.failure(error: error)) } else { self.registrationDelegate?.apnsRegistrationFailedWithError(error) } } public func presentationOptionsForNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions { guard self.privacyManager.isEnabled(.push) else { return [] } var options: UNNotificationPresentationOptions = [] // get foreground presentation options defined from the push API/dashboard if let payloadPresentationOptions = self.foregroundPresentationOptions( notification: notification ) { if payloadPresentationOptions.count > 0 { // build the options bitmask from the array for presentationOption in payloadPresentationOptions { switch presentationOption { case DefaultAirshipPush.presentationOptionBadge: options.insert(.badge) case DefaultAirshipPush.presentationOptionAlert: options.insert(.list) options.insert(.banner) case DefaultAirshipPush.presentationOptionSound: options.insert(.sound) case DefaultAirshipPush.presentationOptionList: options.insert(.list) case DefaultAirshipPush.presentationOptionBanner: options.insert(.banner) default: break } } } else { options = self.defaultPresentationOptions } } else { options = self.defaultPresentationOptions } if let onExtendPresentationOptions = self.onExtendPresentationOptions { options = await onExtendPresentationOptions(options, notification) } else if let delegate = self.pushNotificationDelegate { options = await delegate.extendPresentationOptions(options, notification: notification) } return options } #if !os(tvOS) public func didReceiveNotificationResponse(_ response: UNNotificationResponse) async { guard self.privacyManager.isEnabled(.push) else { return } if response.actionIdentifier == UNNotificationDefaultActionIdentifier { self.launchNotificationResponse = response } self.notificationCenter.post( name: AirshipNotifications.ReceivedNotificationResponse.name, object: self, userInfo: [ AirshipNotifications.ReceivedNotificationResponse.responseKey: response ] ) if let onNotificationResponseReceived = self.onNotificationResponseReceived { await onNotificationResponseReceived(response) } else { await self.pushNotificationDelegate?.receivedNotificationResponse(response) } } #endif @MainActor public func didReceiveRemoteNotification( _ notification: [AnyHashable: Any], isForeground: Bool ) async -> UABackgroundFetchResult { guard self.privacyManager.isEnabled(.push) else { return .noData } let delegate = self.pushNotificationDelegate self.notificationCenter.post( name: AirshipNotifications.RecievedNotification.name, object: self, userInfo: [ AirshipNotifications.RecievedNotification.isForegroundKey: isForeground, AirshipNotifications.RecievedNotification.notificationKey: notification ] ) if isForeground { if let onForegroundNotificationReceived = self.onForegroundNotificationReceived { await onForegroundNotificationReceived(notification) } else { await delegate?.receivedForegroundNotification(notification) } return .noData } else { #if os(watchOS) let result: WKBackgroundFetchResult = if let onBackgroundNotificationReceived = self.onBackgroundNotificationReceived { await onBackgroundNotificationReceived(notification) } else if let result = await delegate?.receivedBackgroundNotification(notification) { result } else { .noData } return UABackgroundFetchResult(from: result) #elseif os(macOS) if let onBackgroundNotificationReceived = self.onBackgroundNotificationReceived { await onBackgroundNotificationReceived(notification) } else { await delegate?.receivedBackgroundNotification(notification) } return .noData #else let result: UIBackgroundFetchResult = if let onBackgroundNotificationReceived = self.onBackgroundNotificationReceived { await onBackgroundNotificationReceived(notification) } else if let result = await delegate?.receivedBackgroundNotification(notification) { result } else { .noData } return UABackgroundFetchResult(from: result) #endif } } private func foregroundPresentationOptions(notification: UNNotification) -> [String]? { var presentationOptions: [String]? = nil #if !os(tvOS) // get the presentation options from the the notification presentationOptions = notification.request.content.userInfo[ DefaultAirshipPush.ForegroundPresentationkey ] as? [String] if presentationOptions == nil { presentationOptions = notification.request.content.userInfo[ DefaultAirshipPush.ForegroundPresentationLegacykey ] as? [String] } #endif return presentationOptions } /// - NOTE: For internal use only. :nodoc: @MainActor func resetDeviceToken() { self.deviceToken = nil self.apnsRegistrar.registerForRemoteNotifications() self.waitForDeviceToken = true } } #if !os(tvOS) extension UNNotification { /// Checks if the push was sent from Airship. /// - Returns: true if it's an Airship notification, otherwise false. public func isAirshipPush() -> Bool { return self.request.content.userInfo["com.urbanairship.metadata"] != nil } } #endif extension DefaultAirshipPush: AirshipComponent {} public extension AirshipNotifications { /// NSNotification info when a notification response is received. final class ReceivedNotificationResponse { /// NSNotification name. public static let name: NSNotification.Name = NSNotification.Name( "com.urbanairship.push.received_notification_response" ) /// NSNotification userInfo key to get the response dictionary. public static let responseKey: String = "response" } /// NSNotification info when a notification is received.. final class RecievedNotification { /// NSNotification name. public static let name: NSNotification.Name = NSNotification.Name( "com.urbanairship.push.received_notification" ) /// NSNotification userInfo key to get a boolean if the notification was received in the foreground or not. public static let isForegroundKey: String = "is_foreground" /// NSNotification userInfo key to get the notification user info. public static let notificationKey: String = "notification" } } /// Quiet time settings public struct QuietTimeSettings: Sendable, Equatable { private static let quietTimeStartKey: String = "start" private static let quietTimeEndKey: String = "end" /// Start hour public let startHour: UInt /// Start minute public let startMinute: UInt /// End hour public let endHour: UInt /// End minute public let endMinute: UInt var startString: String { return "\(String(format: "%02d", startHour)):\(String(format: "%02d", startMinute))" } var endString: String { return "\(String(format: "%02d", endHour)):\(String(format: "%02d", endMinute))" } var dictionary: [AnyHashable: Any] { return [ Self.quietTimeStartKey: startString, Self.quietTimeEndKey: endString, ] } /// Default constructor. /// - Parameters: /// - startHour: The starting hour. Must be between 0-23. /// - startMinute: The starting minute. Must be between 0-59. /// - endHour: The ending hour. Must be between 0-23. /// - endMinute: The ending minute. Must be between 0-59. public init(startHour: UInt, startMinute: UInt, endHour: UInt, endMinute: UInt) throws { guard startHour < 24, startMinute < 60 else { throw AirshipErrors.error("Invalid start time") } guard endHour < 24, endMinute < 60 else { throw AirshipErrors.error("Invalid end time") } self.startHour = startHour self.startMinute = startMinute self.endHour = endHour self.endMinute = endMinute } fileprivate init?(from dictionary: [AnyHashable: Any]) { guard let startTime = dictionary[Self.quietTimeStartKey] as? String, let endTime = dictionary[Self.quietTimeEndKey] as? String else { return nil } let startParts = startTime.components(separatedBy:":").compactMap { UInt($0) } let endParts = endTime.components(separatedBy:":").compactMap { UInt($0) } guard startParts.count == 2, endParts.count == 2 else { return nil } self.startHour = startParts[0] self.startMinute = startParts[1] self.endHour = endParts[0] self.endMinute = endParts[1] } } ================================================ FILE: Airship/AirshipCore/Source/DefaultAppIntegrationDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(WatchKit) import WatchKit #endif // NOTE: For internal use only. :nodoc: final class DefaultAppIntegrationDelegate: AppIntegrationDelegate, Sendable { let push: any InternalAirshipPush let analytics: any InternalAirshipAnalytics let pushableComponents: [any AirshipPushableComponent] init( push: any InternalAirshipPush, analytics: any InternalAirshipAnalytics, pushableComponents: [any AirshipPushableComponent] ) { self.push = push self.analytics = analytics self.pushableComponents = pushableComponents } @MainActor public func onBackgroundAppRefresh() { AirshipLogger.info("Application received background app refresh") self.push.dispatchUpdateAuthorizedNotificationTypes() } @MainActor public func didRegisterForRemoteNotifications(deviceToken: Data) { let tokenString = AirshipUtils.deviceTokenStringFromDeviceToken(deviceToken) AirshipLogger.info("Application registered device token: \(tokenString)") self.push.didRegisterForRemoteNotifications(deviceToken) } @MainActor public func didFailToRegisterForRemoteNotifications(error: any Error) { AirshipLogger.error("Application failed to register for remote notifications with error \(error)") self.push.didFailToRegisterForRemoteNotifications(error) } @MainActor public func presentationOptions(for notification: UNNotification, completionHandler: @escaping @Sendable (UNNotificationPresentationOptions) -> Void) { Task { @MainActor in let options = await self.push.presentationOptionsForNotification(notification) completionHandler(options) } } @MainActor public func willPresentNotification(notification: UNNotification, presentationOptions: UNNotificationPresentationOptions, completionHandler: @escaping @Sendable () -> Void) { Task { @MainActor in #if !os(tvOS) && !os(watchOS) _ = await self.processPush( notification.request.content.userInfo, isForeground: true, presentationOptions: presentationOptions ) #endif completionHandler() } } // MARK: - Response Handling (Conditional for tvOS) #if !os(tvOS) @MainActor public func didReceiveNotificationResponse(response: UNNotificationResponse, completionHandler: @escaping @Sendable () -> Void) { AirshipLogger.info("Application received notification response: \(response)") let userInfo = response.notification.request.content.userInfo let categoryID = response.notification.request.content.categoryIdentifier let actionID = response.actionIdentifier let action = self.notificationAction(categoryID: categoryID, actionID: actionID) self.analytics.onNotificationResponse(response: response, action: action) Task { @MainActor in for component in pushableComponents { await component.receivedNotificationResponse(response) } if let actionsPayload = self.actionsPayloadForNotification(userInfo: userInfo, actionID: actionID) { let situation = self.situationFromAction(action) ?? .launchedFromPush let metadata: [String: any Sendable] = [ ActionArguments.userNotificationActionIDMetadataKey: actionID, ActionArguments.pushPayloadJSONMetadataKey: try? AirshipJSON.wrap(userInfo), ActionArguments.responseInfoMetadataKey: (response as? UNTextInputNotificationResponse)?.userText ] await ActionRunner.run( actionsPayload: actionsPayload, situation: situation, metadata: metadata ) } await self.push.didReceiveNotificationResponse(response) completionHandler() } } #endif // MARK: - Remote Notification Handling #if os(watchOS) @MainActor public func didReceiveRemoteNotification(userInfo: [AnyHashable : Any], isForeground: Bool, completionHandler: @escaping @Sendable (WKBackgroundFetchResult) -> Void) { self.processRemoteNotification(userInfo: userInfo, isForeground: isForeground) { result in completionHandler(result.osFetchResult) } } #elseif os(macOS) @MainActor public func didReceiveRemoteNotification( userInfo: [AnyHashable : Any], isForeground: Bool ) { self.processRemoteNotification( userInfo: userInfo, isForeground: isForeground ) { _ in } } #else @MainActor public func didReceiveRemoteNotification(userInfo: [AnyHashable : Any], isForeground: Bool, completionHandler: @escaping @Sendable (UIBackgroundFetchResult) -> Void) { self.processRemoteNotification(userInfo: userInfo, isForeground: isForeground) { result in completionHandler(result.osFetchResult) } } #endif @MainActor private func processRemoteNotification(userInfo: [AnyHashable : Any], isForeground: Bool, completionHandler: @escaping @Sendable (UABackgroundFetchResult) -> Void) { guard !isForeground || AirshipUtils.isSilentPush(userInfo) else { completionHandler(.noData) return } Task { @MainActor in let result = await self.processPush( userInfo, isForeground: isForeground, presentationOptions: nil ) completionHandler(result) } } // MARK: - Private Processing @MainActor private func processPush( _ userInfo: [AnyHashable: Any], isForeground: Bool, presentationOptions: UNNotificationPresentationOptions? ) async -> UABackgroundFetchResult { AirshipLogger.info("Application received remote notification: \(userInfo)") // Start with .noData as the baseline let finalResult = AirshipAtomicValue(UABackgroundFetchResult.noData) let wrappedUserInfo = self.safeWrap(userInfo: userInfo) ?? .null await withTaskGroup(of: UABackgroundFetchResult.self) { taskGroup in for component in pushableComponents { taskGroup.addTask { return await component.receivedRemoteNotification(wrappedUserInfo) } } for await result in taskGroup { finalResult.update { $0.merge(result) } } } // Get and merge the platform-specific fetch result let fetchResult = await getFetchResult(userInfo, isForeground: isForeground) finalResult.update { $0.merge(fetchResult) } if let pushJSON = self.safeWrap(userInfo: userInfo) { let situation: ActionSituation = isForeground ? .foregroundPush : .backgroundPush let isForegroundPresentation = self.isForegroundPresentation(presentationOptions) let metadata: [String: any Sendable] = [ ActionArguments.pushPayloadJSONMetadataKey: pushJSON, ActionArguments.isForegroundPresentationMetadataKey: isForegroundPresentation ] await ActionRunner.run( actionsPayload: pushJSON, situation: situation, metadata: metadata ) } return finalResult.value } @MainActor private func getFetchResult( _ userInfo: [AnyHashable: Any], isForeground: Bool ) async -> UABackgroundFetchResult { return await self.push.didReceiveRemoteNotification( userInfo, isForeground: isForeground ) } // MARK: - Helpers private func isForegroundPresentation(_ presentationOptions: UNNotificationPresentationOptions?) -> Bool { guard var options = presentationOptions else { return false } options.remove(.sound) options.remove(.badge) return options != [] } #if !os(tvOS) private func situationFromAction(_ action: UNNotificationAction?) -> ActionSituation? { guard let options = action?.options else { return nil } return options.contains(.foreground) ? .foregroundInteractiveButton : .backgroundInteractiveButton } private func actionsPayloadForNotification(userInfo: [AnyHashable: Any], actionID: String?) -> AirshipJSON? { guard let actionID = actionID, actionID != UNNotificationDefaultActionIdentifier else { return try? AirshipJSON.wrap(userInfo) } let interactive = userInfo["com.urbanairship.interactive_actions"] as? [AnyHashable: Any] return try? AirshipJSON.wrap(interactive?[actionID]) } @MainActor private func notificationAction(categoryID: String, actionID: String) -> UNNotificationAction? { guard actionID != UNNotificationDefaultActionIdentifier else { return nil } let category = self.push.combinedCategories.first { $0.identifier == categoryID } if category == nil { AirshipLogger.error("Unknown notification category identifier \(categoryID)") return nil } let action = category?.actions.first { $0.identifier == actionID } if action == nil { AirshipLogger.error("Unknown notification action identifier \(actionID)") } return action } #endif private func safeWrap(userInfo: [AnyHashable: Any]?) -> AirshipJSON? { guard let userInfo = userInfo else { return nil } if let json = try? AirshipJSON.wrap(userInfo) { return json } var parsed: [String: AirshipJSON] = [:] userInfo.forEach { (key, value) in if let stringKey = key as? String, let jsonValue = try? AirshipJSON.wrap(value) { parsed[stringKey] = jsonValue } } return try? AirshipJSON.wrap(parsed) } } ================================================ FILE: Airship/AirshipCore/Source/DeferredAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol DeferredAPIClientProtocol: Sendable { func resolve( url: URL, channelID: String, contactID: String?, stateOverrides: AirshipStateOverrides, audienceOverrides: ChannelAudienceOverrides, triggerContext: AirshipTriggerContext? ) async throws -> AirshipHTTPResponse<Data> } final class DeferredAPIClient: DeferredAPIClientProtocol { func resolve( url: URL, channelID: String, contactID: String?, stateOverrides: AirshipStateOverrides, audienceOverrides: ChannelAudienceOverrides, triggerContext: AirshipTriggerContext? ) async throws -> AirshipHTTPResponse<Data> { var tagOverrides: TagGroupOverrides? if (!audienceOverrides.tags.isEmpty) { tagOverrides = TagGroupOverrides.from(updates: audienceOverrides.tags) } var attributeOverrides: [AttributeOperation]? if (!audienceOverrides.attributes.isEmpty) { attributeOverrides = audienceOverrides.attributes.map { $0.operation } } let body = RequestBody( channelID: channelID, contactID: contactID, stateOverrides: stateOverrides, triggerContext: triggerContext, tagOverrides: tagOverrides, attributeOverrides: attributeOverrides ) let request = AirshipRequest( url: url, headers: [ "Accept": "application/vnd.urbanairship+json; version=3;" ], method: "POST", auth: .channelAuthToken(identifier: channelID), body: try JSONEncoder().encode(body) ) AirshipLogger.trace("Resolving deferred with request \(request) body \(body)") return try await session.performHTTPRequest(request) { data, response in AirshipLogger.debug("Resolving deferred response finished with response: \(response)") if (response.statusCode == 200) { return data } return nil } } private let config: RuntimeConfig private let session: any AirshipRequestSession init(config: RuntimeConfig, session: any AirshipRequestSession) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init( config: config, session: config.requestSession ) } fileprivate struct RequestBody: Encodable { let platform: String = "ios" let channelID: String let contactID: String? let stateOverrides: AirshipStateOverrides let triggerContext: AirshipTriggerContext? let tagOverrides: TagGroupOverrides? let attributeOverrides: [AttributeOperation]? enum CodingKeys: String, CodingKey { case platform case channelID = "channel_id" case contactID = "contact_id" case stateOverrides = "state_overrides" case triggerContext = "trigger" case tagOverrides = "tag_overrides" case attributeOverrides = "attribute_overrides" } } } ================================================ FILE: Airship/AirshipCore/Source/DeferredResolver.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public enum AirshipDeferredResult<T : Sendable&Equatable>: Sendable, Equatable { case success(T) case timedOut case outOfDate case notFound case retriableError(retryAfter: TimeInterval? = nil, statusCode: Int? = nil) } /// NOTE: For internal use only. :nodoc: public struct DeferredRequest: Sendable, Equatable { public var url: URL public var channelID: String public var contactID: String? var triggerContext: AirshipTriggerContext? var locale: Locale var notificationOptIn: Bool var appVersion: String var sdkVersion: String public init( url: URL, channelID: String, contactID: String? = nil, triggerContext: AirshipTriggerContext? = nil, locale: Locale, notificationOptIn: Bool, appVersion: String = AirshipUtils.bundleShortVersionString() ?? "", sdkVersion: String = AirshipVersion.version ) { self.url = url self.channelID = channelID self.contactID = contactID self.triggerContext = triggerContext self.locale = locale self.notificationOptIn = notificationOptIn self.appVersion = appVersion self.sdkVersion = sdkVersion } } /// NOTE: For internal use only. :nodoc: public protocol AirshipDeferredResolverProtocol : Sendable { func resolve<T: Sendable>( request: DeferredRequest, resultParser: @escaping @Sendable (Data) async throws -> T ) async -> AirshipDeferredResult<T> } actor AirshipDeferredResolver : AirshipDeferredResolverProtocol { private final let audienceOverridesProvider: any AudienceOverridesProvider private final let client: any DeferredAPIClientProtocol private var locationMap: [URL: URL] = [:] private var outdatedURLs: Set<URL> = Set() init( config: RuntimeConfig, audienceOverrides: any AudienceOverridesProvider ) { self.init( client: DeferredAPIClient(config: config), audienceOverrides: audienceOverrides ) } init( client: any DeferredAPIClientProtocol, audienceOverrides: any AudienceOverridesProvider ) { self.client = client self.audienceOverridesProvider = audienceOverrides } func resolve<T: Sendable>( request: DeferredRequest, resultParser: @escaping @Sendable (Data) async throws -> T ) async -> AirshipDeferredResult<T> { let audienceOverrides = await audienceOverridesProvider.channelOverrides( channelID: request.channelID, contactID: request.contactID ) let stateOverrides = AirshipStateOverrides( appVersion: request.appVersion, sdkVersion: request.sdkVersion, notificationOptIn: request.notificationOptIn, localeLangauge: request.locale.getLanguageCode(), localeCountry: request.locale.getRegionCode() ) return await resolve( request: request, stateOverrides: stateOverrides, audienceOverrides: audienceOverrides, resultParser: resultParser, allowRetry: true ) } private func resolve<T: Sendable>( request: DeferredRequest, stateOverrides: AirshipStateOverrides, audienceOverrides: ChannelAudienceOverrides, resultParser: @escaping @Sendable (Data) async throws -> T, allowRetry: Bool ) async -> AirshipDeferredResult<T> { let resolvedURL = self.locationMap[request.url] ?? request.url AirshipLogger.trace("Resolving deferred \(resolvedURL)") guard !outdatedURLs.contains(resolvedURL) else { AirshipLogger.trace("Deferred out of date \(resolvedURL)") return .outOfDate } var result: AirshipHTTPResponse<Data>? do { result = try await client.resolve( url: self.locationMap[request.url] ?? request.url, channelID: request.channelID, contactID: request.contactID, stateOverrides: stateOverrides, audienceOverrides: audienceOverrides, triggerContext: request.triggerContext ) } catch { AirshipLogger.trace("Failed to resolve deferred: \(resolvedURL) error: \(error)") } guard let result = result else { AirshipLogger.trace("Resolving deferred timed out \(resolvedURL)") return .timedOut } AirshipLogger.trace("Resolving deferred result: \(result)") switch (result.statusCode) { case 200: do { guard let body = result.result else { return .retriableError(statusCode: result.statusCode) } let parsed = try await resultParser(body) AirshipLogger.trace("Deferred result body: \(parsed)") return .success(parsed) } catch { AirshipLogger.error("Failed to parse deferred body \(error) with status code: \(result.statusCode)") return .retriableError(statusCode: result.statusCode) } case 404: return .notFound case 409: outdatedURLs.insert(resolvedURL) return .outOfDate case 429: if let location = result.locationHeader { locationMap[request.url] = location } return .retriableError(retryAfter: result.retryAfter, statusCode: result.statusCode) case 307: if let location = result.locationHeader { locationMap[request.url] = location if let retry = result.retryAfter, retry > 0 { return .retriableError(retryAfter: retry, statusCode: result.statusCode) } if (allowRetry) { return await resolve( request: request, stateOverrides: stateOverrides, audienceOverrides: audienceOverrides, resultParser: resultParser, allowRetry: false ) } } return .retriableError(statusCode: result.statusCode) default: return .retriableError(statusCode: result.statusCode) } } } extension AirshipHTTPResponse { var locationHeader: URL? { guard let location = self.headers["Location"] else { return nil } return URL(string: location) } var retryAfter: TimeInterval? { guard let retryAfter = self.headers["Retry-After"] else { return nil } if let seconds = Double(retryAfter) { return seconds } return AirshipDateFormatter.date(fromISOString: retryAfter)?.timeIntervalSince1970 } } ================================================ FILE: Airship/AirshipCore/Source/DeviceAudienceChecker.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public protocol DeviceAudienceChecker: Sendable { func evaluate( audienceSelector: CompoundDeviceAudienceSelector?, newUserEvaluationDate: Date, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> AirshipDeviceAudienceResult } /// NOTE: For internal use only. :nodoc: struct DefaultDeviceAudienceChecker: DeviceAudienceChecker { private let hashChecker: HashChecker public init(cache: any AirshipCache) { self.hashChecker = HashChecker(cache: cache) } public func evaluate( audienceSelector: CompoundDeviceAudienceSelector?, newUserEvaluationDate: Date, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> AirshipDeviceAudienceResult { guard let audienceSelector else { return .match } return try await audienceSelector.evaluate( newUserEvaluationDate: newUserEvaluationDate, deviceInfoProvider: deviceInfoProvider, hashChecker: hashChecker ) } } extension Array where Element == AirshipDeviceAudienceResult { func reducedResult(reducer: (Bool, Bool) -> Bool) -> AirshipDeviceAudienceResult { var isMatch: Bool? var reportingMetadata: [AirshipJSON]? = nil self.forEach { isMatch = if let isMatch { reducer(isMatch, $0.isMatch) } else { $0.isMatch } if let reporting = $0.reportingMetadata { if (reportingMetadata == nil) { reportingMetadata = [] } reportingMetadata?.append(contentsOf: reporting) } } return AirshipDeviceAudienceResult( isMatch: isMatch ?? true, reportingMetadata: reportingMetadata ) } } extension CompoundDeviceAudienceSelector { func evaluate( newUserEvaluationDate: Date = Date.distantPast, deviceInfoProvider: any AudienceDeviceInfoProvider = DefaultAudienceDeviceInfoProvider(), hashChecker: HashChecker ) async throws -> AirshipDeviceAudienceResult { switch self { case .atomic(let audience): return try await audience.evaluate( newUserEvaluationDate: newUserEvaluationDate, deviceInfoProvider: deviceInfoProvider, hashChecker: hashChecker ) case .not(let selector): var result = try await selector.evaluate( newUserEvaluationDate: newUserEvaluationDate, deviceInfoProvider: deviceInfoProvider, hashChecker: hashChecker ) result.negate() return result case .and(let selectors): guard !selectors.isEmpty else { return AirshipDeviceAudienceResult.match } var results: [AirshipDeviceAudienceResult] = [] for selector in selectors { let selectorResult = try await selector.evaluate( newUserEvaluationDate: newUserEvaluationDate, deviceInfoProvider: deviceInfoProvider, hashChecker: hashChecker ) results.append(selectorResult) if !selectorResult.isMatch { break } } return results.reducedResult { first, second in first && second } case .or(let selectors): guard !selectors.isEmpty else { return AirshipDeviceAudienceResult.miss } var results: [AirshipDeviceAudienceResult] = [] for selector in selectors { let selectorResult = try await selector.evaluate( newUserEvaluationDate: newUserEvaluationDate, deviceInfoProvider: deviceInfoProvider, hashChecker: hashChecker ) results.append(selectorResult) if selectorResult.isMatch { break } } return results.reducedResult { first, second in first || second } } } } /// NOTE: For internal use only. :nodoc: extension DeviceAudienceSelector { func evaluate( newUserEvaluationDate: Date = Date.distantPast, deviceInfoProvider: any AudienceDeviceInfoProvider = DefaultAudienceDeviceInfoProvider(), hashChecker: HashChecker ) async throws -> AirshipDeviceAudienceResult { AirshipLogger.trace("Evaluating audience conditions \(self)") guard deviceInfoProvider.isAirshipReady else { throw AirshipErrors.error("Airship not ready, unable to check audience") } guard checkNewUser(deviceInfoProvider: deviceInfoProvider, newUserEvaluationDate: newUserEvaluationDate) else { AirshipLogger.trace("Locale condition not met for audience: \(self)") return .miss } guard checkDeviceTypes() else { AirshipLogger.trace("Device type condition not met for audience: \(self)") return .miss } guard checkLocale(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Locale condition not met for audience: \(self)") return .miss } guard await checkTags(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Tags condition not met for audience: \(self)") return .miss } guard try await checkTestDevices(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Test device condition not met for audience: \(self)") return .miss } guard try checkVersion(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("App version condition not met for audience: \(self)") return .miss } guard checkAnalytics(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Analytics condition not met for audience: \(self)") return .miss } guard await checkNotificationOptIn(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Notification opt-in condition not met for audience: \(self)") return .miss } guard try await checkPermissions(deviceInfoProvider: deviceInfoProvider) else { AirshipLogger.trace("Permission condition not met for audience: \(self)") return .miss } let hashCheckerResult = try await hashChecker.evaluate( hashSelector: self.hashSelector, deviceInfoProvider: deviceInfoProvider ) if !hashCheckerResult.isMatch { AirshipLogger.trace("Hash condition not met for audience: \(self)") } return hashCheckerResult } private func checkNewUser(deviceInfoProvider: any AudienceDeviceInfoProvider, newUserEvaluationDate: Date) -> Bool { guard let newUser = self.newUser else { return true } return newUser == (deviceInfoProvider.installDate >= newUserEvaluationDate) } private func checkDeviceTypes() -> Bool { return deviceTypes?.contains("ios") ?? true } private func checkPermissions(deviceInfoProvider: any AudienceDeviceInfoProvider) async throws -> Bool { guard self.permissionPredicate != nil || self.locationOptIn != nil else { return true } let permissions = await deviceInfoProvider.permissions if let permissionPredicate = self.permissionPredicate { var map: [String: AirshipJSON] = [:] for entry in permissions { map[entry.key.rawValue] = .string(entry.value.rawValue) } guard permissionPredicate.evaluate(json: .object(map)) else { return false } } if let locationOptIn = self.locationOptIn { let isLocationPermissionGranted = permissions[.location] == .granted return locationOptIn == isLocationPermissionGranted } return true } private func checkAnalytics(deviceInfoProvider: any AudienceDeviceInfoProvider) -> Bool { guard let requiresAnalytics = self.requiresAnalytics else { return true } return requiresAnalytics == false || deviceInfoProvider.analyticsEnabled } private func checkVersion(deviceInfoProvider: any AudienceDeviceInfoProvider) throws -> Bool { guard let versionPredicate = self.versionPredicate else { return true } guard let appVersion = deviceInfoProvider.appVersion else { AirshipLogger.trace("Unable to query app version for audience: \(self)") return false } let versionObject: AirshipJSON = [ "ios": [ "version": .string(appVersion) ] ] return versionPredicate.evaluate(json: versionObject) } private func checkTags(deviceInfoProvider: any AudienceDeviceInfoProvider) async -> Bool { guard let tagSelector = self.tagSelector else { return true } return tagSelector.evaluate(tags: deviceInfoProvider.tags) } private func checkNotificationOptIn(deviceInfoProvider: any AudienceDeviceInfoProvider) async -> Bool { guard let notificationOptIn = self.notificationOptIn else { return true } return await deviceInfoProvider.isUserOptedInPushNotifications == notificationOptIn } private func checkTestDevices(deviceInfoProvider: any AudienceDeviceInfoProvider) async throws -> Bool { guard let testDevices = self.testDevices else { return true } guard deviceInfoProvider.isChannelCreated else { return false } 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 } } private func checkLocale(deviceInfoProvider: any AudienceDeviceInfoProvider) -> Bool { guard let languageIDs = self.languageIDs else { return true } let currentLocale = deviceInfoProvider.locale return languageIDs.contains { languageID in let locale = Locale(identifier: languageID) if currentLocale.getLanguageCode() != locale.getLanguageCode() { return false } if (!locale.getRegionCode().isEmpty && locale.getRegionCode() != currentLocale.getRegionCode()) { return false } return true } } } ================================================ FILE: Airship/AirshipCore/Source/DeviceAudienceSelector.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// A collection of properties defining an automation audience public struct DeviceAudienceSelector: Sendable, Codable, Equatable { var newUser: Bool? var notificationOptIn: Bool? var locationOptIn: Bool? var languageIDs: [String]? var tagSelector: DeviceTagSelector? var requiresAnalytics: Bool? var permissionPredicate: JSONPredicate? var versionPredicate: JSONPredicate? var testDevices: [String]? var hashSelector: AudienceHashSelector? var deviceTypes: [String]? enum CodingKeys: String, CodingKey { case newUser = "new_user" case notificationOptIn = "notification_opt_in" case locationOptIn = "location_opt_in" case languageIDs = "locale" case tagSelector = "tags" case requiresAnalytics = "requires_analytics" case permissionPredicate = "permissions" case versionPredicate = "app_version" case testDevices = "test_devices" case hashSelector = "hash" case deviceTypes = "device_types" } /// Audience selector initializer /// - Parameters: /// - newUser: Flag indicating if audience consists of new users /// - notificationOptIn: Flag indicating if audience consists of users opted into notifications /// - locationOptIn: Flag indicating if audience consists of users that have opted into location /// - languageIDs: Array of language IDs representing a given audience /// - tagSelector: Internal-only selector /// - versionPredicate: Version predicate representing a given audience /// - requiresAnalytics: Flag indicating if audience consists of users that require analytics tracking /// - permissionPredicate: Flag indicating if audience consists of users that require certain permissions /// - testDevices: Array of test device identifiers representing a given audience /// - hashSelector: Internal-only selector /// - deviceTypes: Array of device types representing a given audience public init( newUser: Bool? = nil, notificationOptIn: Bool? = nil, locationOptIn: Bool? = nil, languageIDs: [String]? = nil, tagSelector: DeviceTagSelector? = nil, versionPredicate: JSONPredicate? = nil, requiresAnalytics: Bool? = nil, permissionPredicate: JSONPredicate? = nil, testDevices: [String]? = nil, hashSelector: AudienceHashSelector? = nil, deviceTypes: [String]? = nil ) { self.newUser = newUser self.notificationOptIn = notificationOptIn self.locationOptIn = locationOptIn self.languageIDs = languageIDs self.tagSelector = tagSelector self.versionPredicate = versionPredicate self.requiresAnalytics = requiresAnalytics self.permissionPredicate = permissionPredicate self.testDevices = testDevices self.hashSelector = hashSelector self.deviceTypes = deviceTypes } } ================================================ FILE: Airship/AirshipCore/Source/DeviceTagSelector.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /* * <tag_selector> := <tag> | <not> | <and> | <or> * <tag> := { "tag": string } * <not> := { "not": <tag_selector> } * <and> := { "and": [<tag_selector>, <tag_selector>, ...] } * <or> := { "or": [<tag_selector>, <tag_selector>, ...] } */ /// NOTE: For internal use only. :nodoc: public indirect enum DeviceTagSelector: Codable, Sendable, Equatable { case or([DeviceTagSelector]) case not(DeviceTagSelector) case and([DeviceTagSelector]) case tag(String) public func evaluate(tags: Set<String>) -> Bool { switch (self) { case .tag(let tag): return tags.contains(tag) case .or(let selectors): return selectors.contains { selector in selector.evaluate(tags: tags) } case .not(let selector): return !selector.evaluate(tags: tags) case .and(let selectors): return selectors.allSatisfy { selector in selector.evaluate(tags: tags) } } } enum CodingKeys: CodingKey { case or case not case and case tag } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) var allKeys = ArraySlice(container.allKeys) guard let selectorType = allKeys.popFirst(), allKeys.isEmpty else { throw DecodingError.typeMismatch( DeviceTagSelector.self, DecodingError.Context(codingPath: container.codingPath, debugDescription: "Invalid number of keys found, expected one.", underlyingError: nil) ) } switch selectorType { case .or: self = .or(try container.decode([DeviceTagSelector].self, forKey: .or)) case .not: self = .not(try container.decode(DeviceTagSelector.self, forKey: .not)) case .and: self = .and(try container.decode([DeviceTagSelector].self, forKey: .and)) case .tag: self = .tag(try container.decode(String.self, forKey: .tag)) } } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .or(let selectors): try container.encode(selectors, forKey: .or) case .not(let selector): try container.encode(selector, forKey: .not) case .and(let selectors): try container.encode(selectors, forKey: .and) case .tag(let tag): try container.encode(tag, forKey: .tag) } } } ================================================ FILE: Airship/AirshipCore/Source/Dispatcher.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol UADispatcher: AnyObject, Sendable { func doSync(_ block: @Sendable @escaping () -> Void) func dispatchAsyncIfNecessary(_ block: @Sendable @escaping () -> Void) func dispatchAsync(_ block: @Sendable @escaping () -> Void) } final class DefaultDispatcher: UADispatcher, Sendable { static let main: DefaultDispatcher = DefaultDispatcher( queue: DispatchQueue.main ) private static let dispatchKey: DispatchSpecificKey<DefaultDispatcher> = DispatchSpecificKey<DefaultDispatcher>() private let queue: DispatchQueue private init(queue: DispatchQueue) { self.queue = queue queue.setSpecific(key: DefaultDispatcher.dispatchKey, value: self) } class func serial(_ qos: DispatchQoS = .default) -> DefaultDispatcher { let queue = DispatchQueue( label: "com.urbanairship.dispatcher.serial_queue", qos: qos ) return DefaultDispatcher(queue: queue) } func doSync(_ block: @escaping () -> Void) { if isCurrentQueue() { block() } else { queue.sync(execute: block) } } func dispatchAsyncIfNecessary(_ block: @Sendable @escaping () -> Void) { if isCurrentQueue() { block() } else { dispatchAsync(block) } } func dispatchAsync(_ block: @Sendable @escaping () -> Void) { queue.async(execute: block) } private func isCurrentQueue() -> Bool { if DispatchQueue.getSpecific(key: DefaultDispatcher.dispatchKey) === self { return true } else if self === DefaultDispatcher.main && Thread.isMainThread { return true } else { return false } } } ================================================ FILE: Airship/AirshipCore/Source/EmailRegistrationOptions.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Email registration options public struct EmailRegistrationOptions: Codable, Sendable, Equatable, Hashable { /** * Transactional opted-in value */ let transactionalOptedIn: Date? /** * Commercial opted-in value - used to determine the email opt-in state during double opt-in */ let commercialOptedIn: Date? /** * Properties */ let properties: AirshipJSON? /** * Double opt-in value */ let doubleOptIn: Bool private init( transactionalOptedIn: Date?, commercialOptedIn: Date? = nil, properties: [String: Any]?, doubleOptIn: Bool = false ) { self.transactionalOptedIn = transactionalOptedIn self.commercialOptedIn = commercialOptedIn self.properties = try? AirshipJSON.wrap(properties) self.doubleOptIn = doubleOptIn } /// Returns an Email registration options with double opt-in value to false /// - Parameter transactionalOptedIn: The transactional opted-in value /// - Parameter commercialOptedIn: The commercial opted-in value /// - Parameter properties: The properties. They must be JSON serializable. /// - Returns: An Email registration options. public static func commercialOptions( transactionalOptedIn: Date?, commercialOptedIn: Date?, properties: [String: Any]? ) -> EmailRegistrationOptions { return EmailRegistrationOptions( transactionalOptedIn: transactionalOptedIn, commercialOptedIn: commercialOptedIn, properties: properties ) } /// Returns an Email registration options. /// - Parameter transactionalOptedIn: The transactional opted-in date. /// - Parameter properties: The properties. They must be JSON serializable. /// - Parameter doubleOptIn: The double opt-in value /// - Returns: An Email registration options. public static func options( transactionalOptedIn: Date?, properties: [String: Any]?, doubleOptIn: Bool ) -> EmailRegistrationOptions { return EmailRegistrationOptions( transactionalOptedIn: transactionalOptedIn, properties: properties, doubleOptIn: doubleOptIn ) } /// Returns an Email registration options. /// - Parameter properties: The properties. They must be JSON serializable. /// - Parameter doubleOptIn: The double opt-in value /// - Returns: An Email registration options. public static func options( properties: [String: Any]?, doubleOptIn: Bool ) -> EmailRegistrationOptions { return EmailRegistrationOptions( transactionalOptedIn: nil, properties: properties, doubleOptIn: doubleOptIn ) } enum CodingKeys: String, CodingKey { case transactionalOptedIn case commercialOptedIn case properties = "properties" case doubleOptIn } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.transactionalOptedIn = try container.decodeIfPresent(Date.self, forKey: .transactionalOptedIn) self.commercialOptedIn = try container.decodeIfPresent(Date.self, forKey: .commercialOptedIn) self.doubleOptIn = try container.decode(Bool.self, forKey: .doubleOptIn) do { self.properties = try container.decodeIfPresent(AirshipJSON.self, forKey: .properties) } catch { let legacy = try? container.decodeIfPresent(JsonValue.self, forKey: .properties) guard let legacy = legacy else { throw error } if let decoder = decoder as? JSONDecoder { self.properties = try AirshipJSON.from( json: legacy.jsonEncodedValue, decoder: decoder ) } else { self.properties = try AirshipJSON.from( json: legacy.jsonEncodedValue ) } } } // Migration purposes fileprivate struct JsonValue: Decodable { let jsonEncodedValue: String? } } ================================================ FILE: Airship/AirshipCore/Source/EmbeddedView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI @available(iOS 16, tvOS 16, watchOS 9.0, *) struct AdoptLayout: SwiftUI.Layout { let placement: ThomasPresentationInfo.Embedded.Placement @Binding var viewConstraints: ViewConstraints? let embeddedSize: AirshipEmbeddedSize? func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let viewSize = subviews.first?.sizeThatFits(proposal) let height = size( constraint: placement.size.height, parent: self.embeddedSize?.parentHeight, proposal: proposal.height, sizeThataFits: viewSize?.height ) let width = size( constraint: placement.size.width, parent: self.embeddedSize?.parentWidth, proposal: proposal.width, sizeThataFits: viewSize?.width ) /// proposal.replacingUnspecifiedDimensions() uses `10`, so we shall as well let size = CGSize(width: width ?? 10, height: height ?? 10) let constraintWidth: CGFloat? = if placement.size.width.isAuto { nil } else { size.width } let constraintHeight: CGFloat? = if placement.size.height.isAuto { nil } else { size.height } let viewConstraints = ViewConstraints( width: constraintWidth, height: constraintHeight ) DispatchQueue.main.async { if (self.viewConstraints != viewConstraints) { self.viewConstraints = viewConstraints } } return size } private func size(constraint: ThomasSizeConstraint, parent: CGFloat?, proposal: CGFloat?, sizeThataFits: CGFloat? = nil) -> CGFloat? { switch (constraint) { case .auto: return sizeThataFits ?? proposal case .percent(let percent): if let parent = parent { return parent * percent/100.0 } return proposal ?? sizeThataFits case .points(let size): return size } } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let viewProposal = ProposedViewSize( width: size( constraint: placement.size.width, parent: embeddedSize?.parentWidth ?? bounds.width, proposal: proposal.width ), height: size( constraint: placement.size.height, parent: embeddedSize?.parentHeight ?? bounds.height, proposal: proposal.height ) ) let center = CGPoint(x: bounds.midX, y: bounds.midY) subviews.forEach { layout in layout.place(at: center, anchor: .center, proposal: viewProposal) } } } struct EmbeddedView: View { private let presentation: ThomasPresentationInfo.Embedded private let layout: AirshipLayout private let thomasEnvironment: ThomasEnvironment private let embeddedSize: AirshipEmbeddedSize? @State private var viewConstraints: ViewConstraints? @Environment(\.isVoiceOverRunning) private var isVoiceOverRunning init( presentation: ThomasPresentationInfo.Embedded, layout: AirshipLayout, thomasEnvironment: ThomasEnvironment, embeddedSize: AirshipEmbeddedSize? ) { self.presentation = presentation self.layout = layout self.thomasEnvironment = thomasEnvironment self.embeddedSize = embeddedSize } var body: some View { RootView(thomasEnvironment: thomasEnvironment, layout: layout) { orientation, windowSize in let placement = resolvePlacement( orientation: orientation, windowSize: windowSize ) AdoptLayout(placement: placement, viewConstraints: $viewConstraints, embeddedSize: embeddedSize) { if let constraints = viewConstraints { createView(constraints: constraints, placement: placement) } else { Color.clear } } } #if os(macOS) .onAppear { if isVoiceOverRunning { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { NSAccessibility.post( element: (NSApp.mainWindow ?? NSApp) as Any, notification: .layoutChanged ) } } } #elseif !os(watchOS) // iOS, tvOS, visionOS .onAppear { if isVoiceOverRunning { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { UIAccessibility.post(notification: .screenChanged, argument: nil) } } } #endif } @MainActor private func createView( constraints: ViewConstraints, placement: ThomasPresentationInfo.Embedded.Placement ) -> some View { return ViewFactory .createView(layout.view, constraints: constraints) .thomasBackground( color: placement.backgroundColor, border: placement.border ) .margin(placement.margin) .constraints(constraints) } private func resolvePlacement( orientation: ThomasOrientation, windowSize: ThomasWindowSize ) -> ThomasPresentationInfo.Embedded.Placement { var placement = self.presentation.defaultPlacement for placementSelector in self.presentation.placementSelectors ?? [] { if placementSelector.windowSize != nil && placementSelector.windowSize != windowSize { continue } if placementSelector.orientation != nil && placementSelector.orientation != orientation { continue } // its a match! placement = placementSelector.placement } return placement } } ================================================ FILE: Airship/AirshipCore/Source/EmbeddedViewSelector.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @MainActor final class EmbeddedViewSelector { @MainActor static let shared: EmbeddedViewSelector = EmbeddedViewSelector() private var lastDisplayed: [String: String] = [:] func onViewDisplayed(_ info: AirshipEmbeddedInfo) { AirshipLogger.trace("Updating last displayed for \(info.embeddedID): \(info.instanceID)") lastDisplayed[info.embeddedID] = info.instanceID } func selectView(embeddedID: String, views: [AirshipEmbeddedContentView]) -> AirshipEmbeddedContentView? { guard let lastInstanceID = lastDisplayed[embeddedID], let last = views.first(where: { view in view.embeddedInfo.instanceID == lastInstanceID }) else { let view = views.sorted(by: { f, s in f.embeddedInfo.priority < s.embeddedInfo.priority }).first if let view { AirshipLogger.trace("Selecting priority sorted view for \(embeddedID): \(view.embeddedInfo)") } return view } AirshipLogger.trace("Selecting previously displayed view for \(embeddedID): \(last.embeddedInfo.instanceID)") return last } } ================================================ FILE: Airship/AirshipCore/Source/EmptyAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Action that produces an empty result. public final class EmptyAction: AirshipAction { public func accepts(arguments: ActionArguments) async -> Bool { return true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { return nil } } ================================================ FILE: Airship/AirshipCore/Source/EmptyView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Empty View struct AirshipEmptyView: View { let info: ThomasViewInfo.EmptyView let constraints: ViewConstraints var body: some View { Color.clear .constraints(constraints) .thomasCommon(self.info) } } ================================================ FILE: Airship/AirshipCore/Source/EnableBehaviorModifiers.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI internal struct FormSubmissionEnableBehavior: ViewModifier { let onApply: ((Bool, ThomasEnableBehavior) -> Void)? @EnvironmentObject var formState: ThomasFormState @ViewBuilder func body(content: Content) -> some View { if let onApply = onApply { content.onReceive(self.formState.$status) { value in onApply(value != .submitted, .formSubmission) } } else { content.disabled(formState.status == .submitted) } } } internal struct ValidFormButtonEnableBehavior: ViewModifier { let onApply: ((Bool, ThomasEnableBehavior) -> Void)? @EnvironmentObject var formState: ThomasFormState @Environment(\.isVisible) private var isVisible @State var isEnabled: Bool? @ViewBuilder func body(content: Content) -> some View { if isVisible { content.airshipOnChangeOf( self.formState.status, initial: true ) { status in let isEnabled = switch(formState.validationMode) { case .onDemand: switch(status) { case .error, .valid, .pendingValidation: true case .invalid, .validating, .submitted: false } case .immediate: switch(status) { case .error, .valid: true case .pendingValidation, .invalid, .validating, .submitted: false } } if let onApply = onApply { onApply(!isEnabled, .formValidation) } else { DispatchQueue.main.async { self.isEnabled = isEnabled } } } .disabled(isEnabled == false) } else { content } } } internal struct PagerNextButtonEnableBehavior: ViewModifier { let onApply: ((Bool, ThomasEnableBehavior) -> Void)? @EnvironmentObject var pagerState: PagerState @ViewBuilder func body(content: Content) -> some View { if let onApply = onApply { content.airshipOnChangeOf(pagerState.canGoForward, initial: true) { canGoForward in onApply(canGoForward, .pagerNext) } } else { content.disabled(!pagerState.canGoForward) } } } struct PagerPreviousButtonEnableBehavior: ViewModifier { let onApply: ((Bool, ThomasEnableBehavior) -> Void)? @EnvironmentObject var pagerState: PagerState @ViewBuilder func body(content: Content) -> some View { if let onApply = onApply { content.airshipOnChangeOf(pagerState.canGoBack, initial: true) { canGoBack in onApply(canGoBack, .pagerPrevious) } } else { content.disabled(!pagerState.canGoBack) } } } internal struct AggregateEnableBehavior: ViewModifier { let behaviors: [ThomasEnableBehavior] let onApply: ((Bool) -> Void) @State private var enabledBehaviors: [ThomasEnableBehavior: Bool] = [:] @State private var enabled: Bool? @ViewBuilder func body(content: Content) -> some View { content.addBehaviorModifiers(behaviors) { behaviorEnabled, behavior in enabledBehaviors[behavior] = behaviorEnabled let updated = !enabledBehaviors.contains(where: { _, value in !value }) if updated != enabled { enabled = updated onApply(updated) } } } } extension View { @ViewBuilder fileprivate func addBehaviorModifiers( _ behaviors: [ThomasEnableBehavior]?, onApply: ((Bool, ThomasEnableBehavior) -> Void)? = nil ) -> some View { if let behaviors = behaviors { self.viewModifiers { if behaviors.contains(.formValidation) { ValidFormButtonEnableBehavior(onApply: onApply) } if behaviors.contains(.pagerNext) { PagerNextButtonEnableBehavior(onApply: onApply) } if behaviors.contains(.pagerPrevious) { PagerPreviousButtonEnableBehavior(onApply: onApply) } if behaviors.contains(.formSubmission) { FormSubmissionEnableBehavior(onApply: onApply) } } } else { self } } @ViewBuilder func thomasEnableBehaviors( _ behaviors: [ThomasEnableBehavior]?, onApply: @escaping (Bool) -> Void ) -> some View { if let behaviors = behaviors { self.modifier( AggregateEnableBehavior( behaviors: behaviors, onApply: onApply ) ) } else { self } } } ================================================ FILE: Airship/AirshipCore/Source/EnableFeatureAction.swift ================================================ /* Copyright Airship and Contributors */ /// Enables an Airship feature. /// /// Expected argument values: /// - "user_notifications": To enable user notifications. /// - "location": To enable location updates. /// - "background_location": To enable location and allow background updates. /// /// Valid situations: `ActionSituation.launchedFromPush`, /// `ActionSituation.webViewInvocation`, `ActionSituation.manualInvocation`, /// `ActionSituation.foregroundInteractiveButton`, and `ActionSituation.automation` public final class EnableFeatureAction: AirshipAction { /// Default names - "enable_feature", "^ef" public static let defaultNames: [String] = ["enable_feature", "^ef"] /// Default predicate - rejects foreground pushes with visible display options public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } /// Metadata key for a block that takes the permission results`(PermissionStatus, PermissionStatus) -> Void`. /// - Note: For internal use only. :nodoc: public static let resultReceiverMetadataKey: String = PromptPermissionAction .resultReceiverMetadataKey public static let userNotificationsActionValue: String = "user_notifications" public static let locationActionValue: String = "location" public static let backgroundLocationActionValue: String = "background_location" private let permissionPrompter: @Sendable () -> any PermissionPrompter public convenience init() { self.init { return AirshipPermissionPrompter( permissionsManager: Airship.permissionsManager ) } } required init(permissionPrompter: @escaping @Sendable () -> any PermissionPrompter) { self.permissionPrompter = permissionPrompter } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .automation, .manualInvocation, .launchedFromPush, .webViewInvocation, .foregroundPush, .foregroundInteractiveButton: return (try? self.parsePermission(arguments: arguments)) != nil case .backgroundPush: fallthrough case .backgroundInteractiveButton: fallthrough @unknown default: return false } } @MainActor public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let permission = try parsePermission(arguments: arguments) let result = await self.permissionPrompter() .prompt( permission: permission, enableAirshipUsage: true, fallbackSystemSettings: true ) let resultReceiver = arguments.metadata[ EnableFeatureAction.resultReceiverMetadataKey ] as? PermissionResultReceiver await resultReceiver?(permission, result.startStatus, result.endStatus) return nil } private func parsePermission( arguments: ActionArguments ) throws -> AirshipPermission { let unwrapped = arguments.value.unWrap() let value = unwrapped as? String ?? "" switch value { case EnableFeatureAction.userNotificationsActionValue: return .displayNotifications case EnableFeatureAction.locationActionValue: return .location case EnableFeatureAction.backgroundLocationActionValue: return .location default: throw AirshipErrors.error("Invalid argument \(value)") } } } ================================================ FILE: Airship/AirshipCore/Source/EnvironmentValues.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI private struct OrientationKey: EnvironmentKey { static let defaultValue: ThomasOrientation? = nil } private struct WindowSizeKey: EnvironmentKey { static let defaultValue: ThomasWindowSize? = nil } private struct VoiceOverRunningKey: EnvironmentKey { static let defaultValue: Bool = false } private struct VisibleEnvironmentKey: EnvironmentKey { static let defaultValue: Bool = false } private struct ButtonActionsEnabledKey: EnvironmentKey { static let defaultValue: Bool = true } private struct PageIdentifierKey: EnvironmentKey { static let defaultValue: String? = nil } private struct ThomasAssociatedLabelResolverKey: EnvironmentKey { static let defaultValue: ThomasAssociatedLabelResolver? = nil } private struct LayoutStateEnvironmentKey: EnvironmentKey { static let defaultValue: LayoutState = LayoutState.empty } extension EnvironmentValues { var orientation: ThomasOrientation? { get { self[OrientationKey.self] } set { self[OrientationKey.self] = newValue } } var windowSize: ThomasWindowSize? { get { self[WindowSizeKey.self] } set { self[WindowSizeKey.self] = newValue } } var isVoiceOverRunning: Bool { get { self[VoiceOverRunningKey.self] } set { self[VoiceOverRunningKey.self] = newValue } } var isVisible: Bool { get { self[VisibleEnvironmentKey.self] } set { self[VisibleEnvironmentKey.self] = newValue } } var isButtonActionsEnabled: Bool { get { self[ButtonActionsEnabledKey.self] } set { self[ButtonActionsEnabledKey.self] = newValue } } var pageIdentifier: String? { get { self[PageIdentifierKey.self] } set { self[PageIdentifierKey.self] = newValue } } var thomasAssociatedLabelResolver: ThomasAssociatedLabelResolver? { get { self[ThomasAssociatedLabelResolverKey.self] } set { self[ThomasAssociatedLabelResolverKey.self] = newValue } } internal var layoutState: LayoutState { get { self[LayoutStateEnvironmentKey.self] } set { self[LayoutStateEnvironmentKey.self] = newValue } } } extension View { func setVisible(_ visible: Bool) -> some View { environment(\.isVisible, visible) } } ================================================ FILE: Airship/AirshipCore/Source/EventAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol EventAPIClientProtocol: Sendable { func uploadEvents( _ events: [AirshipEventData], channelID: String, headers: [String: String] ) async throws -> AirshipHTTPResponse<EventUploadTuningInfo> } final class EventAPIClient: EventAPIClientProtocol { private let config: RuntimeConfig private let session: any AirshipRequestSession init(config: RuntimeConfig, session: any AirshipRequestSession) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init( config: config, session: config.requestSession ) } func uploadEvents( _ events: [AirshipEventData], channelID: String, headers: [String: String] ) async throws -> AirshipHTTPResponse<EventUploadTuningInfo> { guard let analyticsURL = config.analyticsURL else { throw AirshipErrors.error("The analyticsURL is nil") } var allHeaders = headers allHeaders["X-UA-Sent-At"] = "\(Date().timeIntervalSince1970)" allHeaders["Content-Type"] = "application/json" let request = AirshipRequest( url: URL(string: "\(analyticsURL)/warp9/"), headers: allHeaders, method: "POST", auth: .channelAuthToken(identifier: channelID), body: try self.requestBody(fromEvents: events), contentEncoding: .deflate ) AirshipLogger.trace("Sending to server: \(config.analyticsURL ?? "")") AirshipLogger.trace("Sending analytics headers: \(allHeaders)") AirshipLogger.trace("Sending analytics events: \(events)") // 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" ), maxBatchSizeKB: response.unsignedInt( forHeader: "X-UA-Max-Batch" ), minBatchInterval: response.double( forHeader: "X-UA-Min-Batch-Interval" ) ) } } private func requestBody(fromEvents events: [AirshipEventData]) throws -> Data { let preparedEvents: [[String: Any]] = events.compactMap { eventData in var eventBody: [String: Any] = [:] eventBody["event_id"] = eventData.id eventBody["time"] = String( format: "%f", eventData.date.timeIntervalSince1970 ) eventBody["type"] = eventData.type.reportingName guard var data = eventData.body.unWrap() as? [String: Any] else { AirshipLogger.error("Failed to deserialize event body \(eventData)") return nil } data["session_id"] = eventData.sessionID eventBody["data"] = data return eventBody } return try AirshipJSONUtils.data(preparedEvents, options: []) } } fileprivate extension HTTPURLResponse { func double(forHeader header: String) -> Double? { guard let value = self.allHeaderFields[header] else { return nil } if let value = value as? Double { return value } if let value = value as? String { return Double(value) } if let value = value as? NSNumber { return value.doubleValue } return nil } func unsignedInt(forHeader header: String) -> UInt? { guard let value = self.allHeaderFields[header] else { return nil } if let value = value as? UInt { return value } if let value = value as? String { return UInt(value) } if let value = value as? NSNumber { return value.uintValue } return nil } } ================================================ FILE: Airship/AirshipCore/Source/EventHandlerViewModifier.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI internal struct EventHandlerViewModifier: ViewModifier { @EnvironmentObject var thomasEnvironment: ThomasEnvironment @EnvironmentObject var thomasState: ThomasState @EnvironmentObject var formState: ThomasFormState @EnvironmentObject var pagerState: PagerState @Environment(\.layoutState) private var layoutState let eventHandlers: [ThomasEventHandler] let formInputID: String? @ViewBuilder func body(content: Content) -> some View { let types = eventHandlers.map { $0.type } content.airshipApplyIf(types.contains(.tap)) { view in view.addTapGesture { handleEvent(type: .tap) } } .airshipApplyIf(types.contains(.formInput)) { view in if let formInputID { view.airshipOnChangeOf(self.formState.field(identifier: formInputID)?.input) { input in handleEvent(type: .formInput, formFieldValue: input) } } } } private func handleEvent( type: ThomasEventHandler.EventType, formFieldValue: ThomasFormField.Value? = nil ) { let handlers = eventHandlers.filter { $0.type == type } // Process handlers.forEach { handler in handleStateAction(handler.stateActions, formFieldValue: formFieldValue) } } private func handleStateAction( _ stateActions: [ThomasStateAction], formFieldValue: ThomasFormField.Value? ) { thomasState.processStateActions(stateActions, formFieldValue: formFieldValue) } } ================================================ FILE: Airship/AirshipCore/Source/EventManager.swift ================================================ import Foundation protocol EventManagerProtocol: AnyObject, Sendable { var uploadsEnabled: Bool { get set } func addEvent(_ event: AirshipEventData) async throws func deleteEvents() async throws func scheduleUpload(eventPriority: AirshipEventPriority) async @MainActor func addHeaderProvider( _ headerProvider: @Sendable @escaping () async -> [String: String] ) } final class EventManager: EventManagerProtocol { private let headerBlocks: AirshipMainActorValue<[@Sendable () async -> [String: String]]> = AirshipMainActorValue([]) private let _uploadsEnabled: AirshipAtomicValue<Bool> = AirshipAtomicValue<Bool>(false) var uploadsEnabled: Bool { get { _uploadsEnabled.value } set { _uploadsEnabled.value = newValue } } private let eventStore: EventStore private let eventAPIClient: any EventAPIClientProtocol private let eventScheduler: any EventUploadSchedulerProtocol private let state: EventManagerState private let channel: any AirshipChannel @MainActor convenience init( config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any AirshipChannel ) { self.init( dataStore: dataStore, channel: channel, eventStore: EventStore(appKey: config.appCredentials.appKey), eventAPIClient: EventAPIClient(config: config) ) } @MainActor init( dataStore: PreferenceDataStore, channel: any AirshipChannel, eventStore: EventStore, eventAPIClient: any EventAPIClientProtocol, eventScheduler: (any EventUploadSchedulerProtocol)? = nil ) { self.channel = channel self.eventStore = eventStore self.eventAPIClient = eventAPIClient self.eventScheduler = eventScheduler ?? EventUploadScheduler() self.state = EventManagerState(dataStore: dataStore) Task { await self.eventScheduler.setWorkBlock { [weak self] in try await self?.uploadEvents() ?? .success } } } func addEvent(_ event: AirshipEventData) async throws { try await self.eventStore.save(event: event) } func deleteEvents() async throws { try await self.eventStore.deleteAllEvents() } @MainActor func addHeaderProvider( _ headerProvider: @Sendable @escaping () async -> [String : String] ) { self.headerBlocks.update { $0.append(headerProvider) } } func scheduleUpload(eventPriority: AirshipEventPriority) async { guard self.uploadsEnabled else { return } await self.eventScheduler.scheduleUpload( eventPriority: eventPriority, minBatchInterval: await self.state.minBatchInterval ) } private func uploadEvents() async throws -> AirshipWorkResult { guard self.uploadsEnabled else { return .success } guard let channelID = channel.identifier else { return .success } let events = try await self.prepareEvents() guard !events.isEmpty else { AirshipLogger.trace( "Analytic upload finished, no events to upload." ) return .success } let headers = await self.prepareHeaders() let response = try await self.eventAPIClient.uploadEvents( events, channelID: channelID, headers: headers ) guard response.isSuccess else { AirshipLogger.trace( "Analytics upload request failed with status: \(response.statusCode)" ) return .failure } AirshipLogger.trace("Analytic upload success") try await self.eventStore.deleteEvents( eventIDs: events.map { event in return event.id } ) await self.state.updateTuniningInfo(response.result) if (try? await self.eventStore.hasEvents()) == true { await self.scheduleUpload(eventPriority: .normal) } return .success } private func prepareEvents() async throws -> [AirshipEventData] { do { try await self.eventStore.trimEvents( maxStoreSizeKB: await self.state.maxTotalStoreSizeKB ) } catch { AirshipLogger.warn("Unable to trim database: \(error)") } return try await self.eventStore.fetchEvents( maxBatchSizeKB: await self.state.maxBatchSizeKB ) } @MainActor private func prepareHeaders() async -> [String: String] { let providers = self.headerBlocks.value var allHeaders: [String: String] = [:] for headerBlock in providers { let headers = await headerBlock() allHeaders.merge(headers) { (_, new) in AirshipLogger.warn("Analytic header merge conflict \(new)") return new } } return allHeaders } } fileprivate actor EventManagerState { // Max database size private static let maxTotalDBSizeKB: UInt = 5120 private static let minTotalDBSizeKB: UInt = 10 private static let tuninigInfoDefaultsKey: String = "Analytics.tuningInfo" // Total size in bytes that a given event post is allowed to send. private static let maxBatchSizeKB: UInt = 500 private static let minBatchSizeKB: UInt = 10 // The actual amount of time in seconds that elapse between event-server posts private static let minBatchInterval: TimeInterval = 60 private static let maxBatchInterval: TimeInterval = 604800 // 7 days private var _tuningInfo: EventUploadTuningInfo? private var tuningInfo: EventUploadTuningInfo? { get { if let tuningInfo = self._tuningInfo { return tuningInfo } self._tuningInfo = try? self.dataStore.codable( forKey: EventManagerState.tuninigInfoDefaultsKey ) return self._tuningInfo } set { self._tuningInfo = newValue try? self.dataStore.setCodable( newValue, forKey: EventManagerState.tuninigInfoDefaultsKey ) } } var minBatchInterval: TimeInterval { Self.clamp( self.tuningInfo?.minBatchInterval ?? EventManagerState.minBatchInterval, min: EventManagerState.minBatchInterval, max: EventManagerState.maxBatchInterval ) } var maxTotalStoreSizeKB: UInt { Self.clamp( self.tuningInfo?.maxTotalStoreSizeKB ?? EventManagerState.maxTotalDBSizeKB, min: EventManagerState.minTotalDBSizeKB, max: EventManagerState.maxTotalDBSizeKB ) } var maxBatchSizeKB: UInt { Self.clamp( self.tuningInfo?.maxBatchSizeKB ?? EventManagerState.maxBatchSizeKB, min: EventManagerState.minBatchSizeKB, max: EventManagerState.maxBatchSizeKB ) } let dataStore: PreferenceDataStore init(dataStore: PreferenceDataStore) { self.dataStore = dataStore } func updateTuniningInfo(_ tuningInfo: EventUploadTuningInfo?) { self.tuningInfo = tuningInfo } static func clamp<T>(_ value: T, min: T, max: T) -> T where T: Comparable { if value < min { return min } if value > max { return max } return value } } ================================================ FILE: Airship/AirshipCore/Source/EventStore.swift ================================================ /* Copyright Airship and Contributors */ import CoreData import Foundation actor EventStore { private static let eventDataEntityName: String = "UAEventData" private static let fetchEventLimit: Int = 500 private var coreData: UACoreData private var storeName: String? private nonisolated let inMemory: Bool init(appKey: String, inMemory: Bool = false) { self.inMemory = inMemory let modelURL = AirshipCoreResources.bundle.url( forResource: "UAEvents", withExtension: "momd" ) self.coreData = UACoreData( name: Self.eventDataEntityName, modelURL: modelURL!, inMemory: inMemory, stores: ["Events-\(appKey).sqlite"] ) } func save( event: AirshipEventData ) async throws { try await self.coreData.perform { context in try self.saveEvent(event: event, context: context) } } func fetchEvents( maxBatchSizeKB: UInt ) async throws -> [AirshipEventData] { return try await self.coreData.performWithResult { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: EventStore.eventDataEntityName ) request.fetchLimit = EventStore.fetchEventLimit request.sortDescriptors = [ NSSortDescriptor(key: "storeDate", ascending: true) ] let fetchResult = try context.fetch(request) as? [EventData] ?? [] let batchSizeBytesLimit = maxBatchSizeKB * 1024 var batchSize = 0 var events: [AirshipEventData] = [] for eventData in fetchResult { let bytes = eventData.bytes?.intValue ?? 0 if ((batchSize + bytes) > batchSizeBytesLimit) { break } do { events.append( try self.convert(internalEventData: eventData) ) batchSize += bytes } catch { AirshipLogger.error("Unable to read event, deleting. \(error)") context.delete(eventData) } } return events } } func hasEvents() async throws -> Bool { return try await self.coreData.performWithResult { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: EventStore.eventDataEntityName ) return try context.count(for: request) > 0 } } func deleteEvents(eventIDs: [String]) async throws { try await self.coreData.perform { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: EventStore.eventDataEntityName ) request.predicate = NSPredicate( format: "identifier IN %@", eventIDs ) do { if self.inMemory { request.includesPropertyValues = false let events = try context.fetch(request) as? [NSManagedObject] events?.forEach { event in context.delete(event) } } else { let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) } } catch { AirshipLogger.error("Error deleting analytics events: \(error)") } } } func deleteAllEvents() async throws { try await self.coreData.perform(skipIfStoreNotCreated: true) { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: EventStore.eventDataEntityName ) do { if self.inMemory { request.includesPropertyValues = false let events = try context.fetch(request) as? [NSManagedObject] events?.forEach { event in context.delete(event) } } else { let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) } } catch { AirshipLogger.error("Error deleting analytics events: \(error)") } } } func trimEvents(maxStoreSizeKB: UInt) async throws { let maxBytes = maxStoreSizeKB * 1024 try await self.coreData.perform { context in while self.fetchTotalEventSize(with: context) > maxBytes { guard let sessionID = self.fetchOldestSessionID(with: context), self.deleteSession(sessionID, context: context) else { return } } } } nonisolated private func deleteSession( _ sessionID: String, context: NSManagedObjectContext ) -> Bool { let request = NSFetchRequest<any NSFetchRequestResult>( entityName: EventStore.eventDataEntityName ) request.predicate = NSPredicate(format: "sessionID == %@", sessionID) do { let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) return true } catch { AirshipLogger.error("Error deleting session: \(sessionID)") return false } } nonisolated private func fetchOldestSessionID(with context: NSManagedObjectContext) -> String? { let request = NSFetchRequest<any NSFetchRequestResult>( entityName: EventStore.eventDataEntityName ) request.fetchLimit = 1 request.sortDescriptors = [ NSSortDescriptor(key: "storeDate", ascending: true) ] request.propertiesToFetch = ["sessionID"] do { let result = try context.fetch(request) as? [EventData] ?? [] return result.first?.sessionID } catch { AirshipLogger.error("Error fetching oldest sessionID: \(error)") return nil } } nonisolated private func fetchTotalEventSize(with context: NSManagedObjectContext) -> Int { guard !self.inMemory else { return 0 } let sumDescription = NSExpressionDescription() sumDescription.name = "sum" sumDescription.expression = NSExpression( forFunction: "sum:", arguments: [NSExpression(forKeyPath: "bytes")] ) sumDescription.expressionResultType = .doubleAttributeType let request = NSFetchRequest<any NSFetchRequestResult>( entityName: EventStore.eventDataEntityName ) request.resultType = .dictionaryResultType request.propertiesToFetch = [sumDescription] do { let result = try context.fetch(request) as? [[String: Int]] ?? [] return result.first?["sum"] ?? 0 } catch { AirshipLogger.error("Error trimming analytic event store: \(error)") return 0 } } nonisolated private func saveEvent( event: AirshipEventData, context: NSManagedObjectContext ) throws { if let eventData = NSEntityDescription.insertNewObject( forEntityName: EventStore.eventDataEntityName, into: context ) as? EventData { eventData.sessionID = event.sessionID eventData.type = event.type.reportingName eventData.identifier = event.id eventData.data = try event.body.toData() eventData.storeDate = event.date // Approximate size var count = 0 count += eventData.sessionID?.count ?? 0 count += eventData.type?.count ?? 0 count += eventData.time?.count ?? 0 count += eventData.identifier?.count ?? 0 count += eventData.data?.count ?? 0 eventData.bytes = NSNumber(value: count) AirshipLogger.debug("Event saved: \(event)") } else { AirshipLogger.error("Failed to save event: \(event)") } } nonisolated private func date(internalEventData: EventData) -> Date? { // Stopped using the time field on new events. Will remove // in a future SDK version. if let time = internalEventData.time, let time = Double(time) { return Date(timeIntervalSince1970: time) } return internalEventData.storeDate } nonisolated private func convert( internalEventData: EventData ) throws -> AirshipEventData { 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") } return AirshipEventData( body: try AirshipJSON.from(data: internalEventData.data), id: id, date: date, sessionID: sessionID, type: convertedType ) } } // Internal core data entity @objc(UAEventData) fileprivate class EventData: NSManagedObject { /// The event's session ID. @objc @NSManaged public dynamic var sessionID: String? /// The event's Data. @NSManaged public dynamic var data: Data? /// The event's creation time. @objc @NSManaged public dynamic var time: String? /// The event's number of bytes. @objc @NSManaged public dynamic var bytes: NSNumber? /// The event's type. @objc @NSManaged public dynamic var type: String? /// The event's identifier. @objc @NSManaged public dynamic var identifier: String? /// The event's store date. @objc @NSManaged public dynamic var storeDate: Date? } ================================================ FILE: Airship/AirshipCore/Source/EventUploadScheduler.swift ================================================ import Foundation protocol EventUploadSchedulerProtocol: Sendable { func scheduleUpload( eventPriority: AirshipEventPriority, minBatchInterval: TimeInterval ) async func setWorkBlock( _ workBlock: @Sendable @escaping () async throws -> AirshipWorkResult ) async } actor EventUploadScheduler: EventUploadSchedulerProtocol { private static let foregroundWorkBatchDelay: TimeInterval = 5 private static let backgroundWorkBatchDelay: TimeInterval = 1 private static let uploadScheduleDelay: TimeInterval = 15 private static let workID: String = "EventUploadScheduler.upload" private var lastWorkDate: Date = .distantPast private var nextScheduleDate: Date = .distantFuture private var isScheduled: Bool = false private var workBlock: (() async throws -> AirshipWorkResult)? private let workManager: any AirshipWorkManagerProtocol private let appStateTracker: any AppStateTrackerProtocol private let date: any AirshipDateProtocol private let taskSleeper: any AirshipTaskSleeper @MainActor init( appStateTracker: (any AppStateTrackerProtocol)? = nil, workManager: any AirshipWorkManagerProtocol = AirshipWorkManager.shared, date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = DefaultAirshipTaskSleeper.shared ) { self.appStateTracker = appStateTracker ?? AppStateTracker.shared self.workManager = workManager self.date = date self.taskSleeper = taskSleeper self.workManager.registerWorker( EventUploadScheduler.workID ) { [weak self] _ in guard let self else { return .success } return try await self.performWork() } } private func performWork() async throws -> AirshipWorkResult { self.lastWorkDate = self.date.now self.isScheduled = false var batchDelay = EventUploadScheduler.backgroundWorkBatchDelay if (await self.appStateTracker.state == .active) { batchDelay = EventUploadScheduler.foregroundWorkBatchDelay } try await self.taskSleeper.sleep(timeInterval: batchDelay) guard let workBlock = self.workBlock else { return .success } try Task.checkCancellation() return try await workBlock() } func scheduleUpload( eventPriority: AirshipEventPriority, minBatchInterval: TimeInterval ) async { let delay = await self.calculateNextUploadDelay( eventPriority: eventPriority, minBatchInterval: minBatchInterval ) let proposedScheduleDate = self.date.now.advanced(by: delay) guard !self.isScheduled || self.nextScheduleDate >= proposedScheduleDate else { AirshipLogger.trace( "Upload already scheduled for an earlier time." ) return } self.nextScheduleDate = proposedScheduleDate self.isScheduled = true self.workManager.dispatchWorkRequest( AirshipWorkRequest( workID: EventUploadScheduler.workID, initialDelay: delay, requiresNetwork: true, conflictPolicy: .replace ) ) } private func calculateNextUploadDelay( eventPriority: AirshipEventPriority, minBatchInterval: TimeInterval ) async -> TimeInterval { switch(eventPriority) { case .high: return 0 case .normal: fallthrough default: if await self.appStateTracker.state == .background { return 0 } else { var delay: TimeInterval = 0 let timeSincelastSend = self.date.now.timeIntervalSince(self.lastWorkDate) if timeSincelastSend < minBatchInterval { delay = minBatchInterval - timeSincelastSend } return max(delay, EventUploadScheduler.uploadScheduleDelay) } } } func setWorkBlock( _ workBlock: @Sendable @escaping () async throws -> AirshipWorkResult ) { self.workBlock = workBlock } } ================================================ FILE: Airship/AirshipCore/Source/EventUploadTuningInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct EventUploadTuningInfo: Codable { let maxTotalStoreSizeKB: UInt? let maxBatchSizeKB: UInt? let minBatchInterval: TimeInterval? } ================================================ FILE: Airship/AirshipCore/Source/EventUtils.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import UserNotifications class EventUtils { class func isValid(latitude: Double) -> Bool { guard latitude >= -90 && latitude <= 90 else { AirshipLogger.error( "Invalid latitude \(latitude). Must be between -90 and 90" ) return false } return true } class func isValid(longitude: Double) -> Bool { guard longitude >= -180 && longitude <= 180 else { AirshipLogger.error( "Invalid longitude \(longitude). Must be between -180 and 180" ) return false } return true } class func notificationTypes( authorizedSettings: AirshipAuthorizedNotificationSettings ) -> [String]? { var notificationTypes: [String] = [] if (AirshipAuthorizedNotificationSettings.badge.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("badge") } #if !os(tvOS) if (AirshipAuthorizedNotificationSettings.sound.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("sound") } if (AirshipAuthorizedNotificationSettings.alert.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("alert") } if (AirshipAuthorizedNotificationSettings.carPlay.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("car_play") } if (AirshipAuthorizedNotificationSettings.lockScreen.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("lock_screen") } if (AirshipAuthorizedNotificationSettings.notificationCenter.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("notification_center") } if (AirshipAuthorizedNotificationSettings.criticalAlert.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("critical_alert") } if (AirshipAuthorizedNotificationSettings.scheduledDelivery.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("scheduled_summary") } if (AirshipAuthorizedNotificationSettings.timeSensitive.rawValue & authorizedSettings.rawValue) > 0 { notificationTypes.append("time_sensitive") } #endif return notificationTypes } class func notificationAuthorization( authorizationStatus: UNAuthorizationStatus ) -> String? { switch authorizationStatus { case .notDetermined: return "not_determined" case .denied: return "denied" case .authorized: return "authorized" case .provisional: return "provisional" case .ephemeral: return "ephemeral" default: return "not_determined" } } } ================================================ FILE: Airship/AirshipCore/Source/Experiment.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ExperimentType: String, Codable, Sendable, Equatable { case holdoutGroup = "holdout" } enum ResultionType: String, Codable, Sendable, Equatable { case `static` = "static" } struct ExperimentCompoundAudience: Codable, Sendable, Equatable { var selector: CompoundDeviceAudienceSelector } struct Experiment: Codable, Sendable, Equatable { let id: String let type: ExperimentType let resolutionType: ResultionType let lastUpdated: Date let created: Date let reportingMetadata: AirshipJSON let audienceSelector: DeviceAudienceSelector? let compoundAudience: ExperimentCompoundAudience? let exclusions: [MessageCriteria]? let timeCriteria: AirshipTimeCriteria? enum CodingKeys: String, CodingKey { case id = "experiment_id" case created case lastUpdated = "last_updated" case experimentDefinition = "experiment_definition" } enum ExperimentDefinitionKeys: String, CodingKey { case type = "experiment_type" case resolutionType = "type" case reportingMetadata = "reporting_metadata" case audienceSelector = "audience_selector" case compoundAudience = "compound_audience" case exclusions = "message_exclusions" case timeCriteria = "time_criteria" } init( id: String, type: ExperimentType = .holdoutGroup, resolutionType: ResultionType = ResultionType.static, lastUpdated: Date, created: Date, reportingMetadata: AirshipJSON, audienceSelector: DeviceAudienceSelector? = nil, compoundAudience: ExperimentCompoundAudience? = nil, exclusions: [MessageCriteria]? = nil, timeCriteria: AirshipTimeCriteria? = nil ) { self.id = id self.type = type self.resolutionType = resolutionType self.lastUpdated = lastUpdated self.created = created self.reportingMetadata = reportingMetadata self.audienceSelector = audienceSelector self.compoundAudience = compoundAudience self.exclusions = exclusions self.timeCriteria = timeCriteria } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.created = try container.decode(Date.self, forKey: .created) self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated) let definitionContainer = try container.nestedContainer(keyedBy: ExperimentDefinitionKeys.self, forKey: .experimentDefinition) self.type = try definitionContainer.decode(ExperimentType.self, forKey: .type) self.resolutionType = try definitionContainer.decode(ResultionType.self, forKey: .resolutionType) self.reportingMetadata = try definitionContainer.decode(AirshipJSON.self, forKey: .reportingMetadata) self.audienceSelector = try definitionContainer.decodeIfPresent(DeviceAudienceSelector.self, forKey: .audienceSelector) self.exclusions = try definitionContainer.decodeIfPresent([MessageCriteria].self, forKey: .exclusions) self.timeCriteria = try definitionContainer.decodeIfPresent(AirshipTimeCriteria.self, forKey: .timeCriteria) self.compoundAudience = try definitionContainer.decodeIfPresent(ExperimentCompoundAudience.self, forKey: .compoundAudience) } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.id, forKey: .id) try container.encode(Self.dateFormatter.string(from: self.created), forKey: .created) try container.encode(Self.dateFormatter.string(from: self.lastUpdated), forKey: .lastUpdated) var definition = container.nestedContainer(keyedBy: ExperimentDefinitionKeys.self, forKey: .experimentDefinition) try definition.encode(self.type, forKey: .type) try definition.encode(self.resolutionType, forKey: .resolutionType) try definition.encode(self.reportingMetadata, forKey: .reportingMetadata) try definition.encodeIfPresent(self.audienceSelector, forKey: .audienceSelector) try definition.encodeIfPresent(self.compoundAudience, forKey: .compoundAudience) try definition.encodeIfPresent(self.exclusions, forKey: .exclusions) try definition.encodeIfPresent(self.timeCriteria, forKey: .timeCriteria) } private static let dateFormatter: DateFormatter = { let result = DateFormatter() result.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" return result }() static let decoder: JSONDecoder = { var decoder = JSONDecoder() decoder.dateDecodingStrategy = .formatted(Self.dateFormatter) return decoder }() } ================================================ FILE: Airship/AirshipCore/Source/ExperimentDataProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public protocol ExperimentDataProvider: Sendable { func evaluateExperiments( info: MessageInfo, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> ExperimentResult? } /// NOTE: For internal use only. :nodoc: public struct MessageInfo: Equatable, Hashable { let messageType: String let campaigns: AirshipJSON? public init(messageType: String, campaigns: AirshipJSON? = nil) { self.messageType = messageType self.campaigns = try? AirshipJSON.wrap(campaigns) } } /// NOTE: For internal use only. :nodoc: public struct ExperimentResult: Codable, Sendable, Hashable { public let channelID: String public let contactID: String public let isMatch: Bool public let reportingMetadata: [AirshipJSON] public init(channelID: String, contactID: String, isMatch: Bool, reportingMetadata: [AirshipJSON]) { self.channelID = channelID self.contactID = contactID self.isMatch = isMatch self.reportingMetadata = reportingMetadata } } ================================================ FILE: Airship/AirshipCore/Source/ExperimentManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: final class ExperimentManager: ExperimentDataProvider { private static let payloadType: String = "experiments" private let dataStore: PreferenceDataStore private let remoteData: any RemoteDataProtocol private let audienceChecker: any DeviceAudienceChecker private let date: any AirshipDateProtocol init( dataStore: PreferenceDataStore, remoteData: any RemoteDataProtocol, audienceChecker: any DeviceAudienceChecker, date: any AirshipDateProtocol = AirshipDate.shared ) { self.dataStore = dataStore self.remoteData = remoteData self.audienceChecker = audienceChecker self.date = date } public func evaluateExperiments( info: MessageInfo, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> ExperimentResult? { let experiments = await getExperiments(info: info) guard !experiments.isEmpty else { return nil } let contactID = await deviceInfoProvider.stableContactInfo.contactID let channelID = try await deviceInfoProvider.channelID var evaluatedMetadata: [AirshipJSON] = [] var isMatch: Bool = false for experiment in experiments { isMatch = try await self.audienceChecker.evaluate( audienceSelector: .combine( compoundSelector: experiment.compoundAudience?.selector, deviceSelector: experiment.audienceSelector ), newUserEvaluationDate: experiment.created, deviceInfoProvider: deviceInfoProvider ).isMatch evaluatedMetadata.append(experiment.reportingMetadata) if (isMatch) { break } } return ExperimentResult( channelID: channelID, contactID: contactID, isMatch: isMatch, reportingMetadata: evaluatedMetadata ) } func getExperiments(info: MessageInfo) async -> [Experiment] { return await remoteData .payloads(types: [Self.payloadType]) .compactMap { payload in payload.data.object?[Self.payloadType]?.array } .flatMap { $0 } .compactMap { json in do { let experiment: Experiment = try json.decode(decoder: Experiment.decoder) return experiment } catch { AirshipLogger.error("Failed to parse experiment \(error)") return nil } } .filter { $0.isActive(date: self.date.now) } .filter { !$0.isExcluded(info: info) } } } private extension Experiment { func isExcluded(info: MessageInfo) -> Bool { return self.exclusions?.contains { criteria in let messageType = criteria.messageTypePredicate?.evaluate(json: .string(info.messageType)) ?? false let campaigns = criteria.campaignsPredicate?.evaluate(json: info.campaigns ?? .null) ?? false return messageType || campaigns } ?? false } func isActive(date: Date) -> Bool { return self.timeCriteria?.isActive(date: date) ?? true } } ================================================ FILE: Airship/AirshipCore/Source/ExternalURLProcessor.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(UIKit) import UIKit #endif #if canImport(WatchKit) import WatchKit #endif #if canImport(AppKit) import AppKit #endif /// Protocol for opening URLs and settings across different platforms. public protocol URLOpenerProtocol: Sendable { /// Opens a URL asynchronously. @MainActor @discardableResult func openURL(_ url: URL) async -> Bool /// Opens a URL with a completion handler. @MainActor func openURL(_ url: URL, completionHandler: (@MainActor @Sendable (Bool) -> Void)?) /// Opens the app or system settings. @MainActor @discardableResult func openSettings() async -> Bool } /// Default implementation of the URLOpenerProtocol. public struct DefaultURLOpener: URLOpenerProtocol { @MainActor public static let shared = DefaultURLOpener() @MainActor @discardableResult public func openURL(_ url: URL) async -> Bool { #if os(macOS) return NSWorkspace.shared.open(url) #elseif os(watchOS) WKExtension.shared().openSystemURL(url) return true #else return await UIApplication.shared.open(url, options: [:]) #endif } @MainActor public func openURL(_ url: URL, completionHandler: (@MainActor @Sendable (Bool) -> Void)?) { #if os(macOS) let success = NSWorkspace.shared.open(url) completionHandler?(success) #elseif os(watchOS) WKExtension.shared().openSystemURL(url) completionHandler?(true) #else UIApplication.shared.open(url, options: [:], completionHandler: completionHandler) #endif } @MainActor public func openSettings() async -> Bool { #if os(macOS) // Parity: Opens the app's own Settings window (Command + ,) // macOS users expect this for "App Settings" deep links. NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) return true #elseif os(watchOS) // watchOS does not support opening settings via URL return false #else // iOS, tvOS, visionOS guard let url = URL(string: UIApplication.openSettingsURLString) else { return false } return await self.openURL(url) #endif } } ================================================ FILE: Airship/AirshipCore/Source/FarmHashFingerprint64.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /** * Implementation of FarmHash Fingerprint64, an open-source fingerprinting algorithm for strings. * * Based on https://github.com/google/guava/blob/master/guava/src/com/google/common/hash/FarmHashFingerprint64.java * */ struct FarmHashFingerprint64 { // some fun primes private static let k0: UInt64 = 0xc3a5c85c97cb3127 private static let k1: UInt64 = 0xb492b66fbe98f273 private static let k2: UInt64 = 0x9ae16a3b2f90404f public static func fingerprint(_ value: String) -> UInt64 { let bytes: [UInt8] = Array(value.utf8) return fingerprint(bytes) } public static func fingerprint(_ bytes: [UInt8]) -> UInt64 { if (bytes.count <= 32) { if (bytes.count <= 16) { return hashLength0to16(bytes) } else { return hashLength17to32(bytes) } } else if (bytes.count <= 64) { return hashLength33To64(bytes) } else { return hashLength65Plus(bytes) } } public static func fingerprint(_ bytes: [UInt8], _ length: Int) -> UInt64 { let trimmedBytes : [UInt8] = Array(bytes.prefix(length)) return fingerprint(trimmedBytes) } private static func load64(_ bytes: [UInt8], _ offset: Int) -> UInt64 { var result: UInt64 = 0 for i in 0...7 { let value: UInt64 = UInt64(bytes[offset + i]) << (i * 8) result = result | value } return result } private static func load32(_ bytes: [UInt8], _ offset: Int) -> UInt64 { var result: UInt64 = 0 for i in 0...3 { let value: UInt64 = UInt64(bytes[offset + i]) << (i * 8) result = result | value } return result } private static func rotateRight(_ value: UInt64, _ distance: Int) -> UInt64 { return (value >> UInt64(distance)) | (value << (value.bitWidth - distance)) } private static func hashLength16(_ u: UInt64, _ v: UInt64, _ mul: UInt64) -> UInt64 { var a = (u ^ v) &* mul a ^= (a >> 47) var b = (v ^ a) &* mul b ^= (b >> 47) b = b &* mul return b } private static func shiftMix(_ value: UInt64) -> UInt64 { return value ^ (value >> 47) } private static func hashLength0to16(_ bytes: [UInt8]) -> UInt64 { let length: Int = bytes.count if (length >= 8) { let mul = k2 + UInt64(length) &* 2 let a = load64(bytes, 0) &+ k2 let b = load64(bytes, length - 8) let c = rotateRight(b, 37) &* mul &+ a let d = (rotateRight(a, 25) &+ b) &* mul return hashLength16(c, d, mul) } if (length >= 4) { let mul: UInt64 = k2 + UInt64(length) &* 2 let a: UInt64 = load32(bytes, 0) return hashLength16( UInt64(length) &+ (a << 3), load32(bytes, length - 4), mul ) } if (length > 0) { let a = Int(bytes[0]) let b = Int(bytes[Int(length >> 1)]) let c = bytes[Int(length - 1)] let y = a + (b << 8) let z = length + ((Int(c) << 2)) return shiftMix(UInt64(y) &* k2 ^ UInt64(z) &* k0) &* k2 } return k2 } static func hashLength17to32(_ bytes: [UInt8]) -> UInt64 { let length = bytes.count let mul: UInt64 = k2 + UInt64(length) &* 2 let a: UInt64 = load64(bytes, 0) &* k1 let b: UInt64 = load64(bytes, 8) let c: UInt64 = load64(bytes, length - 8) &* mul let d: UInt64 = load64(bytes, length - 16) &* k2 return hashLength16( rotateRight(a &+ b, 43) &+ rotateRight(c, 30) &+ d, a &+ rotateRight(b &+ k2, 18) &+ c, mul ) } static func hashLength33To64(_ bytes: [UInt8]) -> UInt64 { let length: UInt64 = UInt64(bytes.count) let mul: UInt64 = k2 &+ length &* 2 let a: UInt64 = load64(bytes, 0) &* k2 let b: UInt64 = load64(bytes, 8) let c: UInt64 = load64(bytes, Int(length) - 8) &* mul let d: UInt64 = load64(bytes, Int(length) - 16) &* k2 let y: UInt64 = rotateRight(a &+ b, 43) &+ rotateRight(c, 30) &+ d let z: UInt64 = hashLength16(y, a &+ rotateRight(b &+ k2, 18) &+ c, mul) let e: UInt64 = load64(bytes, 16) &* mul let f: UInt64 = load64(bytes, 24) let g: UInt64 = (y &+ load64(bytes, Int(length) - 32)) &* mul let h: UInt64 = (z &+ load64(bytes, Int(length) - 24)) &* mul return hashLength16( rotateRight(e &+ f, 43) &+ rotateRight(g, 30) &+ h, e &+ rotateRight(f &+ a, 18) &+ g, mul ) } /** * Computes intermediate hash of 32 bytes of byte array from the given offset. */ static func weakHashLength32WithSeeds( _ bytes: [UInt8], _ offset: Int, _ seedA: UInt64, _ seedB: UInt64 ) -> [UInt64] { let part1 = load64(bytes, offset) let part2 = load64(bytes, offset + 8) let part3 = load64(bytes, offset + 16) let part4 = load64(bytes, offset + 24) var mutableSeedA = seedA &+ part1; var mutableSeedB = rotateRight(seedB &+ mutableSeedA &+ part4, 21) let c = mutableSeedA mutableSeedA &+= part2 mutableSeedA &+= part3 mutableSeedB &+= rotateRight(mutableSeedA, 44) return [ mutableSeedA &+ part4, mutableSeedB &+ c ] } private static func hashLength65Plus(_ bytes: [UInt8]) -> UInt64 { let length: Int = bytes.count let seed: UInt64 = 81 // For strings over 64 bytes we loop. Internal state consists of 56 bytes: v, w, x, y, and z. var x: UInt64 = seed var offset = 0 var y: UInt64 = seed &* k1 &+ 113 var z: UInt64 = shiftMix(y &* k2 &+ 113) &* k2 var v: [UInt64] = [0,0] var w: [UInt64] = [0,0] x = x &* k2 &+ load64(bytes, offset) // Set end so that after the loop we have 1 to 64 bytes left to process. let end = offset + ((length - 1) / 64) * 64 let last64offset = end + ((length - 1) & 63) - 63 repeat { x = rotateRight(x &+ y &+ v[0] &+ load64(bytes, offset + 8), 37) &* k1 y = rotateRight(y &+ v[1] &+ load64(bytes, offset + 48), 42) &* k1 x ^= w[1] y &+= v[0] &+ load64(bytes, offset + 40) z = rotateRight(z &+ w[0], 33) &* k1 v = weakHashLength32WithSeeds(bytes, offset, v[1] &* k1, x &+ w[0]) w = weakHashLength32WithSeeds( bytes, offset + 32, z &+ w[1], y &+ load64(bytes, offset + 16) ) let tmp: UInt64 = x x = z z = tmp offset += 64 } while (offset != end) let mul : UInt64 = k1 &+ ((z & 0xFF) << 1) // Operate on the last 64 bytes of input. offset = last64offset w[0] &+= UInt64((length - 1) & 63) v[0] &+= w[0] w[0] &+= v[0] x = rotateRight(x &+ y &+ v[0] &+ load64(bytes, offset + 8), 37) &* mul y = rotateRight(y &+ v[1] &+ load64(bytes, offset + 48), 42) &* mul x ^= w[1] &* 9 y &+= v[0] &* 9 &+ load64(bytes, offset + 40) z = rotateRight(z &+ w[0], 33) &* mul v = weakHashLength32WithSeeds(bytes, offset, v[1] &* mul, x &+ w[0]) w = weakHashLength32WithSeeds( bytes, offset + 32, z &+ w[1], y &+ load64(bytes, offset + 16) ) return hashLength16( hashLength16(v[0], w[0], mul) &+ shiftMix(y) &* k0 &+ x, hashLength16(v[1], w[1], mul) &+ z, mul ) } } extension String { var farmHashFingerprint64: UInt64 { return FarmHashFingerprint64.fingerprint(self) } } ================================================ FILE: Airship/AirshipCore/Source/FetchDeviceInfoAction.swift ================================================ /* Copyright Airship and Contributors */ /// Fetches device info. /// /// Expected argument values: none. /// /// Valid situations: `ActionSituation.launchedFromPush`, /// `ActionSituation.webViewInvocation`, `ActionSituation.manualInvocation`, /// `ActionSituation.foregroundInteractiveButton`, `ActionSituation.backgroundInteractiveButton`, /// and `ActionSituation.automation` /// /// Result value: JSON payload containing the device's channel ID, named user ID, push opt-in status, /// location enabled status, and tags. An example response as JSON: /// { /// "channel_id": "9c36e8c7-5a73-47c0-9716-99fd3d4197d5", /// "push_opt_in": true, /// "location_enabled": true, /// "named_user": "cool_user", /// "tags": ["tag1", "tag2, "tag3"] /// } /// public final class FetchDeviceInfoAction: AirshipAction { /// Default names - "fetch_device_info", "^+fdi" public static let defaultNames: [String] = ["fetch_device_info", "^+fdi"] /// Default predicate - only accepts `ActionSituation.manualInvocation` and `ActionSituation.webViewInvocation` public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.situation == .manualInvocation || args.situation == .webViewInvocation } // Channel ID key public static let channelID: String = "channel_id" // Named user key public static let namedUser: String = "named_user" // Tags key public static let tags: String = "tags" // Push opt-in key public static let pushOptIn: String = "push_opt_in" private let channel: @Sendable () -> any AirshipChannel private let contact: @Sendable () -> any AirshipContact private let push: @Sendable () -> any AirshipPush public convenience init() { self.init( channel: Airship.componentSupplier(), contact: Airship.componentSupplier(), push: Airship.componentSupplier() ) } init( channel: @escaping @Sendable () -> any AirshipChannel, contact: @escaping @Sendable () -> any AirshipContact, push: @escaping @Sendable () -> any AirshipPush ) { self.channel = channel self.contact = contact self.push = push } public func accepts(arguments: ActionArguments) async -> Bool { return true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let info = DeviceInfo( channelID: channel().identifier, pushOptIn: await push().isPushNotificationsOptedIn, namedUser: await contact().namedUserID, tags: channel().tags ) return try AirshipJSON.wrap(info) } } fileprivate struct DeviceInfo: Encodable { let channelID: String? let pushOptIn: Bool let namedUser: String? let tags: [String] init(channelID: String?, pushOptIn: Bool, namedUser: String?, tags: [String]) { self.channelID = channelID self.pushOptIn = pushOptIn self.namedUser = namedUser self.tags = tags } enum CodingKeys: String, CodingKey { case channelID = "channel_id" case pushOptIn = "push_opt_in" case namedUser = "named_user" case tags = "tags" } } ================================================ FILE: Airship/AirshipCore/Source/FontViewModifier.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct TextAppearanceViewModifier: ViewModifier { let textAppearance: ThomasTextAppearance // Needed for dynamic font size @Environment(\.sizeCategory) var sizeCategory @ViewBuilder func body(content: Content) -> some View { let baseFontSize = textAppearance.fontSize let scaledFontSize = AirshipFont.scaledSize(baseFontSize) let scaleFactor = Double(scaledFontSize) / baseFontSize content .font(self.textAppearance.font) .applyLineHeightMultiplier( textAppearance.lineHeightMultiplier, scaledFontSize: scaledFontSize ) .applyKerning( textAppearance.kerning, scaleFactor: scaleFactor ) } } extension Text { private func applyTextStyles(styles: [ThomasTextAppearance.TextStyle]?) -> Text { var text = self if let styles = styles { if styles.contains(.bold) { text = text.bold() } if styles.contains(.italic) { text = text.italic() } if styles.contains(.underlined) { text = text.underline() } } return text } @ViewBuilder @MainActor func textAppearance( _ textAppearance: ThomasTextAppearance?, colorScheme: ColorScheme ) -> some View { if let textAppearance = textAppearance { self.applyTextStyles(styles: textAppearance.styles) .multilineTextAlignment( textAppearance.alignment?.toSwiftTextAlignment() ?? .center ) .modifier( TextAppearanceViewModifier(textAppearance: textAppearance) ) .foreground(textAppearance.color, colorScheme: colorScheme) } else { self } } } extension View { @ViewBuilder @MainActor func applyLineHeightMultiplier( _ multiplier: Double?, scaledFontSize: Double ) -> some View { if let multiplier { if #available(iOS 26.0, macOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) { self.lineHeight(.multiple(factor: multiplier)) } else { // Fallback: approximate using scaled font size as base line height. // // Natural line height ~= scaledFontSize * (font's internal multiplier). // We don't know that exact internal multiplier in SwiftUI, // but using scaledFontSize as the "1.0" baseline is a reasonable approximation. let baseLineHeight = scaledFontSize let effective = baseLineHeight * multiplier let extra = max(effective - baseLineHeight, 0) self.lineSpacing(extra) } } else { self } } @ViewBuilder @MainActor fileprivate func applyKerning( _ kerning: Double?, scaleFactor: Double ) -> some View { if let kerning { self.kerning(kerning * scaleFactor) } else { self } } @ViewBuilder func applyViewAppearance( _ textAppearance: ThomasTextAppearance?, colorScheme: ColorScheme ) -> some View { if let textAppearance = textAppearance { self .multilineTextAlignment( textAppearance.alignment?.toSwiftTextAlignment() ?? .center ) .modifier( TextAppearanceViewModifier(textAppearance: textAppearance) ) .foreground(textAppearance.color, colorScheme: colorScheme) } else { self } } } ================================================ FILE: Airship/AirshipCore/Source/FormController.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI @MainActor struct FormController: View { enum FormInfo { case nps(ThomasViewInfo.NPSController) case form(ThomasViewInfo.FormController) } private let info: FormInfo private let constraints: ViewConstraints @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var environment: ThomasEnvironment @EnvironmentObject private var state: ThomasState init(info: FormInfo, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var body: some View { Content( info: self.info, constraints: constraints, environment: environment, parentFormState: formState, parentFormDataCollector: formDataCollector, parentState: state ) .id(info.identifier) } private struct Content: View { private let info: FormController.FormInfo private let constraints: ViewConstraints @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @Environment(\.layoutState) private var layoutState @ObservedObject private var formState: ThomasFormState @StateObject private var formDataCollector: ThomasFormDataCollector @StateObject private var state: ThomasState init( info: FormController.FormInfo, constraints: ViewConstraints, environment: ThomasEnvironment, parentFormState: ThomasFormState, parentFormDataCollector: ThomasFormDataCollector, parentState: ThomasState ) { self.info = info self.constraints = constraints // Use the environment to create or retrieve the state in case the view // stack changes and we lose our state. let formState = environment.retrieveState(identifier: info.identifier) { ThomasFormState( identifier: info.identifier, formType: info.formType, formResponseType: info.responseType, validationMode: info.validationMode ?? .immediate, parentFormState: info.isParent ? nil : parentFormState ) } if info.isParent { formState.onSubmit = { [weak environment] identifier, result, layoutState in guard let environment else { throw AirshipErrors.error("Missing environment") } environment.submitForm( result: ThomasFormResult( identifier: identifier, formData: try ThomasFormPayloadGenerator.makeFormEventPayload( identifier: identifier, formValue: result.value ) ), channels: result.channels ?? [], attributes: result.attributes ?? [], layoutState: layoutState ) } } else { formState.onSubmit = { [weak parentFormDataCollector] identifier, result, layoutState in guard let parentFormDataCollector else { throw AirshipErrors.error("Missing form collector") } let field = ThomasFormField.validField(identifier: identifier, input: result.value, result: result) parentFormDataCollector.updateField(field, pageID: layoutState.pagerState?.currentPageId) } } self._formState = ObservedObject(wrappedValue: formState) self._formDataCollector = StateObject( wrappedValue: parentFormDataCollector.with(formState: formState) ) self._state = StateObject( wrappedValue: parentState.with(formState: formState) ) } var body: some View { ViewFactory.createView(self.info.view, constraints: constraints) .thomasCommon(self.info.thomasInfo, formInputID: self.info.identifier) .thomasEnableBehaviors(self.info.formEnableBehaviors) { enabled in self.formState.isEnabled = enabled } .environmentObject(formState) .environmentObject(formDataCollector) .environmentObject(state) .airshipOnChangeOf(formState.isVisible) { [weak formState, weak thomasEnvironment] incoming in guard info.isParent, incoming, let formState, let thomasEnvironment else { return } thomasEnvironment.formDisplayed( formState, layoutState: layoutState.override( formState: formState ) ) } } } } struct FormControllerDebug: View { @EnvironmentObject var state: ThomasFormState var body: some View { Text(String(describing: state)) } } fileprivate extension FormController.FormInfo { var responseType: String? { switch(self) { case .nps(let info): info.properties.responseType case .form(let info): info.properties.responseType } } var formType: ThomasFormState.FormType { switch(self) { case .nps(let info): .nps(info.properties.npsIdentifier) case .form: .form } } var formEnableBehaviors: [ThomasEnableBehavior]? { switch(self) { case .nps(let info): info.properties.formEnableBehaviors case .form(let info): info.properties.formEnableBehaviors } } var identifier: String { switch(self) { case .nps(let info): info.properties.identifier case .form(let info): info.properties.identifier } } var isParent: Bool { switch(self) { case .nps(let info): info.properties.submit != nil case .form(let info): info.properties.submit != nil } } var validationMode: ThomasFormValidationMode? { switch(self) { case .nps(let info): info.properties.validationMode case .form(let info): info.properties.validationMode } } var view: ThomasViewInfo { switch(self) { case .nps(let info): info.properties.view case .form(let info): info.properties.view } } var thomasInfo: any ThomasViewInfo.BaseInfo { switch(self) { case .nps(let info): info case .form(let info): info } } } ================================================ FILE: Airship/AirshipCore/Source/FormInputViewModifier.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI struct FormVisibilityViewModifier: ViewModifier { @Environment(\.isVisible) private var isVisible @EnvironmentObject var formState: ThomasFormState @ViewBuilder func body(content: Content) -> some View { content .onAppear { if isVisible { formState.markVisible() } } .airshipOnChangeOf(isVisible) { [weak formState] newValue in if newValue { formState?.markVisible() } } } } struct FormInputEnabledViewModifier: ViewModifier { @EnvironmentObject var formState: ThomasFormState @ViewBuilder func body(content: Content) -> some View { content.disabled( !formState.isFormInputEnabled ) } } extension View { @ViewBuilder @MainActor func formElement() -> some View { self.viewModifiers { FormVisibilityViewModifier() FormInputEnabledViewModifier() } } } ================================================ FILE: Airship/AirshipCore/Source/HashChecker.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct HashChecker { private let queue: AirshipSerialQueue = AirshipSerialQueue() private let cache: any AirshipCache init(cache: any AirshipCache) { self.cache = cache } func evaluate( hashSelector: AudienceHashSelector?, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> AirshipDeviceAudienceResult { guard let hashSelector else { return .match } return try await self.queue.run { let contactID = await deviceInfoProvider.stableContactInfo.contactID let channelID = try await deviceInfoProvider.channelID let result = await self.resolveResult( hashSelector: hashSelector, contactID: contactID, channelID: channelID ) await self.cacheResult( selector: hashSelector, result: result, contactID: contactID, channelID: channelID ) return result } } private func resolveResult( hashSelector: AudienceHashSelector, contactID: String, channelID: String ) async -> AirshipDeviceAudienceResult { guard let cached = await self.getCachedResult( selector: hashSelector, contactID: contactID, channelID: channelID ) else { let isMatch = hashSelector.evaluate( channelID: channelID, contactID: contactID ) let reportingMetadata: [AirshipJSON]? = if let reporting = hashSelector.sticky?.reportingMetadata { [reporting] } else { nil } return AirshipDeviceAudienceResult( isMatch: isMatch, reportingMetadata: reportingMetadata ) } return cached } private func cacheResult( selector: AudienceHashSelector, result: AirshipDeviceAudienceResult, contactID: String, channelID: String ) async { guard let sticky = selector.sticky else { return } let key = Self.makeCacheKey( sticky.id, contactID: contactID, channelID: channelID ) await cache.setCachedValue(result, key: key, ttl: sticky.lastAccessTTL) } private func getCachedResult( selector: AudienceHashSelector, contactID: String, channelID: String ) async -> AirshipDeviceAudienceResult? { guard let sticky = selector.sticky else { return nil } let key = Self.makeCacheKey( sticky.id, contactID: contactID, channelID: channelID ) return await cache.getCachedValue(key: key) } private static func makeCacheKey( _ id: String, contactID: String, channelID: String ) -> String { return "StickyHash:\(contactID):\(channelID):\(id)" } } ================================================ FILE: Airship/AirshipCore/Source/IconView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI // Icon view that can be used to display icons inside a toggle layout struct IconView: View { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var thomasState: ThomasState private let info: ThomasViewInfo.IconView private let constraints: ViewConstraints init(info: ThomasViewInfo.IconView, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var resolvedIcon: ThomasIconInfo { return ThomasPropertyOverride.resolveRequired( state: self.thomasState, overrides: self.info.overrides?.icon, defaultValue: self.info.properties.icon ) } var body: some View { Icons.icon(info: resolvedIcon, colorScheme: colorScheme) .constraints(constraints, fixedSize: true) .background(Color.airshipTappableClear) .thomasCommon(self.info) } } ================================================ FILE: Airship/AirshipCore/Source/Icons.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct Icons { @MainActor static func makeSystemImageIcon( name: String, resizable: Bool, color: Color ) -> some View { Image(systemName: name) .airshipApplyIf(resizable) { view in view.resizable() } .foregroundColor(color) } @MainActor @ViewBuilder private static func makeView( icon: ThomasIconInfo.Icon, resizable: Bool, color: Color ) -> some View { switch icon { case .asterisk: makeSystemImageIcon( name: "asterisk", resizable: resizable, color: color ) case .asteriskCicleFill: makeSystemImageIcon( name: "asterisk.circle.fill", resizable: resizable, color: color ) case .checkmark: makeSystemImageIcon( name: "checkmark", resizable: resizable, color: color ) case .close: makeSystemImageIcon( name: "xmark", resizable: resizable, color: color ) case .backArrow: makeSystemImageIcon( name: "arrow.backward", resizable: resizable, color: color ) case .forwardArrow: makeSystemImageIcon( name: "arrow.forward", resizable: resizable, color: color ) case .chevronForward: makeSystemImageIcon( name: "chevron.forward", resizable: resizable, color: color ) case .chevronBackward: makeSystemImageIcon( name: "chevron.backward", resizable: resizable, color: color ) case .play: makeSystemImageIcon( name: "play.fill", resizable: resizable, color: color ) case .pause: makeSystemImageIcon( name: "pause", resizable: resizable, color: color ) case .mute: makeSystemImageIcon( name: "speaker.slash.fill", resizable: resizable, color: color ) case .unmute: makeSystemImageIcon( name: "speaker.wave.2.fill", resizable: resizable, color: color ) case .exclamationmarkCircleFill: makeSystemImageIcon( name: "exclamationmark.circle.fill", resizable: resizable, color: color ) case .star: makeSystemImageIcon( name: "star", resizable: resizable, color: color ) case .starFill: makeSystemImageIcon( name: "star.fill", resizable: resizable, color: color ) case .heart: makeSystemImageIcon( name: "heart", resizable: resizable, color: color ) case .heartFill: makeSystemImageIcon( name: "heart.fill", resizable: resizable, color: color ) case .progressSpinner: ProgressSpinnerIconView( resizable: resizable, color: color ) } } @ViewBuilder @MainActor static func icon( info: ThomasIconInfo, colorScheme: ColorScheme, resizable: Bool = true ) -> some View { makeView( icon: info.icon, resizable: resizable, color: info.color.toColor(colorScheme) ) .aspectRatio(contentMode: .fit) .airshipApplyIf(info.scale != nil) { view in view.scaleEffect(info.scale ?? 1) } } } @MainActor private struct ProgressSpinnerIconView: View { let resizable: Bool let color: Color // Only used for < 18 @State private var isSpinning: Bool = false var body: some View { if #available(iOS 18.0, visionOS 2.0, *) { Icons.makeSystemImageIcon( name: "progress.indicator", resizable: resizable, color: color ) .symbolEffect(.variableColor.iterative, options: .repeat(.continuous)) } else { Icons.makeSystemImageIcon( name: "rays", resizable: resizable, color: color ) .rotationEffect(.degrees(isSpinning ? 360 : 0)) .onAppear { withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) { isSpinning = true } } } } } ================================================ FILE: Airship/AirshipCore/Source/Image.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(UIKit) public import UIKit public typealias AirshipNativeImage = UIImage #elseif canImport(AppKit) public import AppKit public typealias AirshipNativeImage = NSImage #endif @preconcurrency import ImageIO /// - Note: for internal use only. :nodoc: public final class AirshipImageData: Sendable { // Image frame struct Frame { let image: AirshipNativeImage let duration: TimeInterval } static let minFrameDuration: TimeInterval = 0.01 private let source: CGImageSource private let imageActor: AirshipImageDataFrameActor let isAnimated: Bool let imageFramesCount: Int let loopCount: Int? init(_ source: CGImageSource) throws { self.source = source imageFramesCount = CGImageSourceGetCount(source) if imageFramesCount < 1 { throw AirshipErrors.error("Invalid image, no frames.") } self.loopCount = source.gifLoopCount() self.isAnimated = imageFramesCount > 1 self.imageActor = AirshipImageDataFrameActor(source: source) } public convenience init(data: Data) throws { guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { throw AirshipErrors.error("Invalid image data") } try self.init(source) } func loadFrames() async -> [Frame] { await withCheckedContinuation { continuation in DispatchQueue.global(qos: .userInitiated).async { let frames = Self.frames(from: self.source) DispatchQueue.main.async { continuation.resume(returning: frames) } } } } func getActor() -> AirshipImageDataFrameActor { return self.imageActor } private class func frames(from source: CGImageSource) -> [Frame] { let count = CGImageSourceGetCount(source) guard count > 1 else { guard let image = AirshipImageDataFrameActor.frameImage(0, source: source) else { return [] } return [Frame(image: image, duration: 0.0)] } var frames: [Frame] = [] for i in 0..<count { guard let image = AirshipImageDataFrameActor.frameImage(i, source: source) else { continue } frames.append( Frame( image: image, duration: AirshipImageDataFrameActor.frameDuration(i, source: source) ) ) } return frames } } actor AirshipImageDataFrameActor { private let source: CGImageSource let framesCount: Int init(source: CGImageSource) { self.source = source framesCount = CGImageSourceGetCount(source) } func loadFrame(at index: Int) -> AirshipImageData.Frame? { guard index >= 0, index < framesCount else { return nil } guard let image = Self.frameImage(index, source: source) else { return nil } return AirshipImageData.Frame( image: image, duration: Self.frameDuration(index, source: source) ) } fileprivate static func frameImage( _ index: Int, source: CGImageSource ) -> AirshipNativeImage? { guard let imageRef = CGImageSourceCreateImageAtIndex(source, index, nil) else { return nil } // Use a cross-platform initializer return AirshipNativeImage.make(with: imageRef) } fileprivate static func frameDuration( _ index: Int, source: CGImageSource ) -> TimeInterval { guard let properties = imageProperties(index: index, source: source) else { return AirshipImageData.minFrameDuration } let delayTime = properties[kCGImageAnimationDelayTime as String] as? TimeInterval let gifDelayTime = properties[[kCGImagePropertyGIFUnclampedDelayTime as String]] as? TimeInterval return max(gifDelayTime ?? delayTime ?? 0.0, AirshipImageData.minFrameDuration) } fileprivate static func imageProperties( index: Int, source: CGImageSource ) -> [AnyHashable: Any]? { guard let properties = CGImageSourceCopyPropertiesAtIndex( source, index, nil ) as? [AnyHashable: Any] else { return nil } let gif = properties[ kCGImagePropertyGIFDictionary as String ] as? [AnyHashable: Any] let webp = properties[ kCGImagePropertyWebPDictionary as String ] as? [AnyHashable: Any] return gif ?? webp } } extension CGImageSource { func gifLoopCount() -> Int? { guard let properties = CGImageSourceCopyProperties(self, nil) as NSDictionary?, let gifDictionary = properties[kCGImagePropertyGIFDictionary] as? NSDictionary else { return nil } let loopCount = gifDictionary[kCGImagePropertyGIFLoopCount] as? Int return loopCount } } fileprivate extension AirshipNativeImage { static func make(with cgImage: CGImage) -> AirshipNativeImage { #if os(macOS) return NSImage(cgImage: cgImage, size: .zero) // .zero size uses the pixel dimensions #else return UIImage(cgImage: cgImage) #endif } } public extension Image { /// Bridges UIImage and NSImage into a single SwiftUI Image initializer init(airshipNativeImage: AirshipNativeImage) { #if os(macOS) self.init(nsImage: airshipNativeImage) #else self.init(uiImage: airshipNativeImage) #endif } } public extension AirshipNativeImage { /// Cross-platform initializer for SF Symbols static func airshipSystemImage(name: String) -> AirshipNativeImage? { #if os(macOS) return NSImage(systemSymbolName: name, accessibilityDescription: nil) #else return UIImage(systemName: name) #endif } } ================================================ FILE: Airship/AirshipCore/Source/ImageButton.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine /// Image Button view. struct ImageButton : View { /// Image Button model. private let info: ThomasViewInfo.ImageButton /// View constraints. private let constraints: ViewConstraints @Environment(\.colorScheme) private var colorScheme @Environment(\.layoutState) private var layoutState @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver init(info: ThomasViewInfo.ImageButton, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .imageButton, thomasState: thomasState ) } @ViewBuilder var body: some View { AirshipButton( identifier: self.info.properties.identifier, reportingMetadata: self.info.properties.reportingMetadata, description: self.info.accessible.resolveContentDescription, clickBehaviors: self.info.properties.clickBehaviors, eventHandlers: self.info.commonProperties.eventHandlers, actions: self.info.properties.actions, tapEffect: self.info.properties.tapEffect ) { makeInnerButton() .constraints(constraints, fixedSize: true) .thomasCommon(self.info, scope: [.background]) .accessible( self.info.accessible, associatedLabel: self.associatedLabel, hideIfDescriptionIsMissing: false ) .background(Color.airshipTappableClear) } .thomasCommon(self.info, scope: [.enableBehaviors, .visibility]) .environment( \.layoutState, layoutState.override( buttonState: ButtonState(identifier: self.info.properties.identifier) ) ) .accessibilityHidden(info.accessible.accessibilityHidden ?? false) } @ViewBuilder private func makeInnerButton() -> some View { switch(self.info.properties.image) { case .url(let info): ThomasAsyncImage( url: info.url, imageLoader: thomasEnvironment.imageLoader, image: { image, imageSize in image.fitMedia( mediaFit: info.mediaFit ?? .centerInside, cropPosition: info.cropPosition, constraints: constraints, imageSize: imageSize ) }, placeholder: { AirshipProgressView() } ) case .icon(let info): Icons.icon(info: info, colorScheme: colorScheme) } } } ================================================ FILE: Airship/AirshipCore/Source/JSONMatcher.swift ================================================ // Copyright Airship and Contributors public import Foundation /// A matcher for evaluating a JSON payload against a set of criteria. /// /// `JSONMatcher` allows you to specify conditions for a JSON value, optionally at a specific key or nested path (`scope`), /// and then evaluate if a given JSON object meets those conditions. public final class JSONMatcher: NSObject, Sendable, Codable { /// The key to look for in the JSON object. private let key: String? /// The path to the value within the JSON object. private let scope: [String]? /// The matcher to apply to the found JSON value. private let valueMatcher: JSONValueMatcher /// If `true`, string comparisons will ignore case. private let ignoreCase: Bool? /// Private designated initializer. init( valueMatcher: JSONValueMatcher, key: String?, scope: [String]?, ignoreCase: Bool? ) { self.valueMatcher = valueMatcher self.key = key self.scope = scope self.ignoreCase = ignoreCase super.init() } /// Coding keys for backward compatibility. private enum CodingKeys: String, CodingKey { case key case scope case valueMatcher = "value" case ignoreCase = "ignore_case" } /// Creates a new `JSONMatcher`. /// - Parameter valueMatcher: The `JSONValueMatcher` to apply to the value. /// - Returns: A new `JSONMatcher` instance. public convenience init(valueMatcher: JSONValueMatcher) { self.init( valueMatcher: valueMatcher, key: nil, scope: nil, ignoreCase: nil ) } /// Creates a new `JSONMatcher` with a specified scope. /// - Parameters: /// - valueMatcher: The `JSONValueMatcher` to apply to the value. /// - scope: An array of keys representing the path to the value. /// - Returns: A new `JSONMatcher` instance. public convenience init(valueMatcher: JSONValueMatcher, scope: [String]) { self.init( valueMatcher: valueMatcher, key: nil, scope: scope, ignoreCase: nil ) } /// - Note: For internal use only. :nodoc: public convenience init( valueMatcher: JSONValueMatcher, scope: [String], ignoreCase: Bool ) { self.init( valueMatcher: valueMatcher, key: nil, scope: scope, ignoreCase: ignoreCase ) } /// - Note: For internal use only. :nodoc: public convenience init( valueMatcher: JSONValueMatcher, ignoreCase: Bool ) { self.init( valueMatcher: valueMatcher, key: nil, scope: nil, ignoreCase: ignoreCase ) } /// Evaluates the given `AirshipJSON` value against the matcher's criteria. /// /// This method traverses the JSON object using the `scope` and `key` to find the target value, /// then uses the `valueMatcher` to perform the evaluation. /// /// - Parameter json: The `AirshipJSON` object to evaluate. /// - Returns: `true` if the value matches the criteria; otherwise, `false`. public func evaluate(json: AirshipJSON) -> Bool { var paths: [String] = [] if let scope = scope { paths.append(contentsOf: scope) } if let key = key { paths.append(key) } var object = json for path in paths { guard let obj = object.object else { object = .null break } object = obj[path] ?? .null } return valueMatcher.evaluate(json: object, ignoreCase: self.ignoreCase ?? false) } /// - Note: For internal use only. :nodoc: public override func isEqual(_ other: Any?) -> Bool { guard let matcher = other as? JSONMatcher else { return false } if self === matcher { return true } return isEqual(to: matcher) } /// - Note: For internal use only. :nodoc: public func isEqual(to matcher: JSONMatcher) -> Bool { guard self.valueMatcher == matcher.valueMatcher, self.key == matcher.key, self.scope == matcher.scope, self.ignoreCase ?? false == matcher.ignoreCase ?? false else { return false } return true } /// - Note: For internal use only. :nodoc: public override var hash: Int { var hasher = Hasher() hasher.combine(valueMatcher) hasher.combine(key) hasher.combine(scope) hasher.combine(ignoreCase) return hasher.finalize() } } ================================================ FILE: Airship/AirshipCore/Source/JSONPredicate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Defines a predicate for evaluating a JSON payload. /// /// `JSONPredicate` can be used to build complex logical conditions (`AND`, `OR`, `NOT`) /// composed of multiple `JSONMatcher` objects. public final class JSONPredicate: NSObject, Sendable, Codable { /// Key for the 'AND' logical operator. private static let andTypeKey: String = "and" /// Key for the 'OR' logical operator. private static let orTypeKey: String = "or" /// Key for the 'NOT' logical operator. private static let notTypeKey: String = "not" /// The type of logical operation (e.g., "and", "or", "not"). private let type: String? /// The collection of sub-predicates for logical operations. private let subpredicates: [JSONPredicate]? /// The matcher to apply if this is a leaf predicate. private let jsonMatcher: JSONMatcher? /// Designated initializer. required init( type: String?, jsonMatcher: JSONMatcher?, subpredicates: [JSONPredicate]? ) { self.type = type self.jsonMatcher = jsonMatcher self.subpredicates = subpredicates super.init() } /// Coding keys for serialization. private enum CodingKeys: String, CodingKey, CaseIterable { case keyAnd = "and" case keyOr = "or" case keyNot = "not" } /// Creates a new predicate from a JSON payload. /// /// - Parameter json: The JSON payload representing the predicate. /// - Throws: An error if the JSON is invalid or cannot be decoded. public convenience init(json: Any?) throws { let value: JSONPredicate = try AirshipJSON.wrap(json).decode() self.init(type: value.type, jsonMatcher: value.jsonMatcher, subpredicates: value.subpredicates) } /// - Note: For internal use only. :nodoc: public convenience init(from decoder: any Decoder) throws { // This implementation is for backward compatibility and may be refactored. let container = try decoder.container(keyedBy: CodingKeys.self) if let key = CodingKeys.allCases.first(where: { container.contains($0) }) { let subpredicates: [JSONPredicate] if key == CodingKeys.keyNot { // Handle 'not' which can contain a single predicate or an array with one predicate if let singlePredicate = try? container.decode(JSONPredicate.self, forKey: key) { subpredicates = [singlePredicate] } else { let predicates = try container.decode([JSONPredicate].self, forKey: key) guard predicates.count == 1 else { throw AirshipErrors.error("A `not` predicate must contain a single sub-predicate.") } subpredicates = predicates } } else { subpredicates = try container.decode([JSONPredicate].self, forKey: key) } self.init( type: key.rawValue, jsonMatcher: nil, subpredicates: subpredicates ) } else { let matcher = try decoder.singleValueContainer().decode(JSONMatcher.self) self.init(jsonMatcher: matcher) } } /// - Note: For internal use only. :nodoc: public func encode(to encoder: any Encoder) throws { if let jsonMatcher { var container = encoder.singleValueContainer() try container.encode(jsonMatcher) return } var container = encoder.container(keyedBy: CodingKeys.self) let key: CodingKeys switch(type) { case CodingKeys.keyAnd.rawValue: key = .keyAnd case CodingKeys.keyOr.rawValue: key = .keyOr case CodingKeys.keyNot.rawValue: key = .keyNot default: throw AirshipErrors.error("Invalid predicate type \(type ?? "n/a")") } try container.encodeIfPresent(self.subpredicates, forKey: key) } /// Evaluates the given `AirshipJSON` value against the predicate. /// - Parameter json: The `AirshipJSON` object to evaluate. /// - Returns: `true` if the value matches the predicate; otherwise, `false`. public func evaluate(json: AirshipJSON) -> Bool { switch type { case JSONPredicate.andTypeKey: // All sub-predicates must be true return subpredicates?.allSatisfy { $0.evaluate(json: json) } ?? true case JSONPredicate.orTypeKey: // At least one sub-predicate must be true return subpredicates?.contains { $0.evaluate(json: json) } ?? false case JSONPredicate.notTypeKey: // The single sub-predicate must be false return !(subpredicates?.first?.evaluate(json: json) ?? false) default: // Evaluate using the JSON matcher return jsonMatcher?.evaluate(json: json) ?? false } } /// Creates a predicate from a `JSONMatcher`. /// - Parameter matcher: The `JSONMatcher` to base the predicate on. public convenience init(jsonMatcher matcher: JSONMatcher) { self.init(type: nil, jsonMatcher: matcher, subpredicates: nil) } /// Creates a predicate by AND-ing an array of sub-predicates. /// - Parameter subpredicates: An array of predicates to combine. /// - Returns: A new `JSONPredicate` instance. public class func andPredicate(subpredicates: [JSONPredicate]) -> JSONPredicate { return JSONPredicate( type: JSONPredicate.andTypeKey, jsonMatcher: nil, subpredicates: subpredicates ) } /// Creates a predicate by OR-ing an array of sub-predicates. /// - Parameter subpredicates: An array of predicates to combine. /// - Returns: A new `JSONPredicate` instance. public class func orPredicate(subpredicates: [JSONPredicate]) -> JSONPredicate { return JSONPredicate( type: JSONPredicate.orTypeKey, jsonMatcher: nil, subpredicates: subpredicates ) } /// Creates a predicate by NOT-ing a single sub-predicate. /// - Parameter subpredicate: The predicate to negate. /// - Returns: A new `JSONPredicate` instance. public class func notPredicate(subpredicate: JSONPredicate) -> JSONPredicate { return JSONPredicate( type: JSONPredicate.notTypeKey, jsonMatcher: nil, subpredicates: [subpredicate] ) } /// Creates a predicate from a JSON payload. /// - Parameter json: The JSON payload. /// - Returns: A predicate or `nil` if the JSON is invalid. /// - Throws: An error if the JSON cannot be parsed. class func fromJson(json: Any?) throws -> JSONPredicate { return try JSONPredicate(json: json) } /// - Note: For internal use only. :nodoc: func isEqual(to predicate: JSONPredicate) -> Bool { return type == predicate.type && jsonMatcher == predicate.jsonMatcher && subpredicates == predicate.subpredicates } /// - Note: For internal use only. :nodoc: public override func isEqual(_ object: Any?) -> Bool { guard let predicate = object as? JSONPredicate else { return false } if self === predicate { return true } return isEqual(to: predicate) } /// - Note: For internal use only. :nodoc: public override var hash: Int { var hasher = Hasher() hasher.combine(type) hasher.combine(jsonMatcher) hasher.combine(subpredicates) return hasher.finalize() } } ================================================ FILE: Airship/AirshipCore/Source/JSONValueMatcher.swift ================================================ // Copyright Airship and Contributors public import Foundation /// A `JSONValueMatcher` is used to match a JSON value against a set of constraints. /// /// This class provides a flexible way to define conditions for JSON values, such as checking for equality, /// numerical ranges, presence of a value, version constraints, and conditions on array elements. /// It is `Codable`, allowing it to be easily serialized and deserialized. public final class JSONValueMatcher: NSObject, Sendable, Codable { /// A protocol for defining the specific logic of a JSON value matcher. /// Each predicate implementation checks a JSON value against a specific condition. public protocol Predicate: Codable, Sendable, Hashable, Equatable { /// Evaluates the predicate against a given JSON value. /// - Parameters: /// - json: The `AirshipJSON` value to evaluate. /// - ignoreCase: If `true`, string comparisons will be case-insensitive. /// - Returns: `true` if the JSON value matches the predicate's conditions, otherwise `false`. func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool /// Checks if this predicate is equal to another predicate. /// - Parameter other: The other predicate to compare against. /// - Returns: `true` if the predicates are equal, otherwise `false`. func isEqual(to other: any Predicate) -> Bool } private let predicate: any Predicate public init(from decoder: any Decoder) throws { // Decode the predicate using a helper function to avoid compiler timeouts. guard let predicate = Self.decodePredicate(from: decoder) else { throw AirshipErrors.parseError("Unsupported JSONValueMatcher predicate") } self.predicate = predicate } /// A helper function to decode one of the possible predicate types. private static func decodePredicate(from decoder: any Decoder) -> (any JSONValueMatcher.Predicate)? { // The decoding order matters and is designed to match Android/Backend implementations. if let predicate = try? JSONValueMatcher.EqualsPredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.NumberRangePredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.PresencePredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.VersionPredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.ArrayLengthPredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.ArrayContainsPredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.StringBeginsPredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.StringEndsPredicate(from: decoder) { return predicate } if let predicate = try? JSONValueMatcher.StringContainsPredicate(from: decoder) { return predicate } return nil } init(predicate: any Predicate) { self.predicate = predicate } /// Creates a matcher that requires a number to be at least a minimum value. /// - Parameter atLeast: The minimum acceptable value. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWhereNumberAtLeast( _ atLeast: Double )-> JSONValueMatcher { return .init( predicate: NumberRangePredicate(atLeast: atLeast) ) } /// Creates a matcher that requires a number to be within a specified range. /// - Parameters: /// - atLeast: The minimum acceptable value (inclusive). /// - atMost: The maximum acceptable value (inclusive). /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWhereNumberAtLeast( _ atLeast: Double, atMost: Double ) -> JSONValueMatcher { return .init( predicate: NumberRangePredicate( atLeast: atLeast, atMost: atMost ) ) } /// Creates a matcher that requires a number to be at most a maximum value. /// - Parameter atMost: The maximum acceptable value. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWhereNumberAtMost( _ atMost: Double ) -> JSONValueMatcher { return .init( predicate: NumberRangePredicate( atMost: atMost ) ) } /// Creates a matcher for an exact number value. /// - Parameter number: The exact number to match. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWhereNumberEquals( to number: Double ) -> JSONValueMatcher { return .init( predicate: EqualsPredicate( equals: .number(number) ) ) } /// Creates a matcher for an exact boolean value. /// - Parameter boolean: The exact boolean to match. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWhereBooleanEquals( _ boolean: Bool ) -> JSONValueMatcher { return .init( predicate: EqualsPredicate( equals: .bool(boolean) ) ) } /// Creates a matcher for an exact string value. /// - Parameter string: The exact string to match. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWhereStringEquals( _ string: String ) -> JSONValueMatcher { return .init( predicate: EqualsPredicate( equals: .string(string) ) ) } /// Creates a matcher that checks for the presence or absence of a value. /// - Parameter present: If `true`, the value must exist (not be null). If `false`, it must not. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWhereValueIsPresent( _ present: Bool ) -> JSONValueMatcher { return .init( predicate: PresencePredicate( isPresent: present ) ) } /// Creates a matcher that checks a value against a version constraint. /// The value being checked is expected to be a string representing a version. /// - Parameter versionConstraint: The version constraint string (e.g., "1.0.0+", "[1.0, 2.0)"). /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWithVersionConstraint( _ versionConstraint: String ) -> JSONValueMatcher? { return .init( predicate: VersionPredicate( versionConstraint: versionConstraint ) ) } /// Creates a matcher that checks if an array contains an element that matches a `JSONPredicate`. /// - Parameter predicate: The predicate to apply to elements in the array. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWithArrayContainsPredicate( _ predicate: JSONPredicate ) -> JSONValueMatcher? { return .init( predicate: ArrayContainsPredicate(arrayContains: predicate) ) } /// Creates a matcher that checks if an array element at a specific index matches a `JSONPredicate`. /// - Parameters: /// - predicate: The predicate to apply to the element at the specified index. /// - index: The index of the array element to check. /// - Returns: A `JSONValueMatcher` for the specified condition. public class func matcherWithArrayContainsPredicate( _ predicate: JSONPredicate, at index: Int ) -> JSONValueMatcher? { return .init( predicate: ArrayContainsPredicate( arrayContains: predicate, index: index ) ) } public func encode(to encoder: any Encoder) throws { try self.predicate.encode(to: encoder) } /// Evaluates the given `AirshipJSON` value against the matcher. /// - Parameters: /// - json: The `AirshipJSON` value to evaluate. /// - ignoreCase: If `true`, string comparisons will be case-insensitive. /// - Returns: `true` if the value matches, otherwise `false`. public func evaluate(json: AirshipJSON, ignoreCase: Bool = false) -> Bool { self.predicate.evaluate(json: json, ignoreCase: ignoreCase) } public override func isEqual(_ other: Any?) -> Bool { guard let matcher = other as? JSONValueMatcher else { return false } if self === matcher { return true } return predicate.isEqual(to: matcher.predicate) } public func hash() -> Int { return predicate.hashValue } } extension JSONValueMatcher.Predicate { func isEqual(to other: any JSONValueMatcher.Predicate) -> Bool { // Attempt to cast the `other` existential to our own concrete type (`Self`). guard let otherAsSelf = other as? Self else { // If the types are different (e.g., comparing EqualsPredicate to // NumberRangePredicate), the cast will fail and they are not equal. return false } // If the types are the same, we can now use the concrete `==` // implementation provided by the Equatable conformance. return self == otherAsSelf } } ================================================ FILE: Airship/AirshipCore/Source/JSONValueMatcherPredicates.swift ================================================ // Copyright Airship and Contributors import Foundation extension JSONValueMatcher { struct VersionPredicate: Predicate { let versionConstraint: String init(versionConstraint: String) { self.versionConstraint = versionConstraint } func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { guard let version = json.string else { return false } do { let versionMatcher = try AirshipIvyVersionMatcher(versionConstraint: versionConstraint) return versionMatcher.evaluate(version: version) } catch { AirshipLogger.error("Invalid constraint \(versionConstraint)") } return false } enum CodingKeys: String, CodingKey { case versionMatches = "version_matches" case deprecatedVersionMatches = "version" } func encode(to encoder: any Encoder) throws { var containter = encoder.container(keyedBy: CodingKeys.self) try containter.encode(versionConstraint, forKey: .versionMatches) } init(from decoder: any Decoder) throws { let containter = try decoder.container(keyedBy: CodingKeys.self) if let value = try containter.decodeIfPresent(String.self, forKey: .versionMatches) { self.versionConstraint = value } else { self.versionConstraint = try containter.decode(String.self, forKey: .deprecatedVersionMatches) } } } struct PresencePredicate: Predicate { var isPresent: Bool func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { return json.isNull != isPresent } enum CodingKeys: String, CodingKey { case isPresent = "is_present" } } struct EqualsPredicate: Predicate { var equals: AirshipJSON func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { return if ignoreCase { isEqualIgnoreCase(valueOne: equals, valueTwo: json) } else { equals == json } } func isEqualIgnoreCase(valueOne: AirshipJSON, valueTwo: AirshipJSON) -> Bool { if let string = valueOne.string, let otherString = valueTwo.string { return string.normalizedIgnoreCaseComparison() == otherString.normalizedIgnoreCaseComparison() } if let array = valueOne.array, let otherArray = valueTwo.array { guard array.count == otherArray.count else { return false } for (index, element) in array.enumerated() { guard isEqualIgnoreCase(valueOne: element, valueTwo: otherArray[index]) else { return false } } return true } if let object = valueOne.object, let otherObject = valueTwo.object { guard object.count == otherObject.count else { return false } for (key, value) in object { guard let otherValue = otherObject[key], isEqualIgnoreCase(valueOne: value, valueTwo: otherValue) else { return false } } return true } // Remaining types - bool, number, mismatch types return valueOne == valueTwo } } struct NumberRangePredicate: Predicate { var atLeast: Double? var atMost: Double? init (atLeast: Double? = nil, atMost: Double? = nil) { self.atLeast = atLeast self.atMost = atMost } func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { guard let number = json.number else { return false } if let atLeast, number < atLeast { return false } if let atMost, number > atMost { return false } return true } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.atLeast = try container.decodeIfPresent(Double.self, forKey: .atLeast) self.atMost = try container.decodeIfPresent(Double.self, forKey: .atMost) guard self.atLeast != nil || self.atMost != nil else { throw AirshipErrors.parseError("Invalid number range predicate") } } enum CodingKeys: String, CodingKey { case atLeast = "at_least" case atMost = "at_most" } } struct ArrayContainsPredicate: Predicate { var arrayContains: JSONPredicate var index: Int? enum CodingKeys: String, CodingKey { case arrayContains = "array_contains" case index } func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { guard let array = json.array else { return false } if let index { guard array.count > index else { return false } return arrayContains.evaluate(json: array[index]) } else { return array.contains { value in arrayContains.evaluate(json: value) } } } } struct ArrayLengthPredicate: Predicate { var arrayLength: JSONPredicate func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { guard let length = json.array?.count else { return false } return arrayLength.evaluate(json: .number(Double(length))) } enum CodingKeys: String, CodingKey { case arrayLength = "array_length" } } struct StringBeginsPredicate: Predicate { var stringBegins: String func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { guard let string = json.string else { return false } return if ignoreCase { string.normalizedIgnoreCaseComparison().hasPrefix( stringBegins.normalizedIgnoreCaseComparison() ) } else { string.hasPrefix(stringBegins) } } enum CodingKeys: String, CodingKey { case stringBegins = "string_begins" } } struct StringEndsPredicate: Predicate { var stringEnds: String func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { guard let string = json.string else { return false } return if ignoreCase { string.normalizedIgnoreCaseComparison().hasSuffix( stringEnds.normalizedIgnoreCaseComparison() ) } else { string.hasSuffix(stringEnds) } } enum CodingKeys: String, CodingKey { case stringEnds = "string_ends" } } struct StringContainsPredicate: Predicate { var stringContains: String func evaluate(json: AirshipJSON, ignoreCase: Bool) -> Bool { guard let string = json.string else { return false } return if ignoreCase { string.normalizedIgnoreCaseComparison().contains( stringContains.normalizedIgnoreCaseComparison() ) } else { string.contains(stringContains) } } enum CodingKeys: String, CodingKey { case stringContains = "string_contains" } } } fileprivate extension String { /// Returns a normalized representation of the string for case- and diacritic-insensitive comparisons. /// /// This method "folds" the string into a simplified form by removing case distinctions (e.g., "a" vs. "A") /// and diacritical marks (e.g., "é" vs. "e"). The resulting string is suitable for reliable, /// locale-agnostic comparisons where variations in case or accents should be ignored. /// /// - Returns: A normalized string, ready for comparison. func normalizedIgnoreCaseComparison() -> String { return self.folding( options: [.caseInsensitive, .diacriticInsensitive], locale: nil ) } } ================================================ FILE: Airship/AirshipCore/Source/JSONValueTransformer.swift ================================================ /* Copyright Airship and Contributors */ // Legacy transformers. We still need these around for coredata migrations. // Do not use these anymore, its almost always a better idea to use Codables // instead. public import Foundation /// NOTE: For internal use only. :nodoc: public class JSONValueTransformer: ValueTransformer { public override class func transformedValueClass() -> AnyClass { return NSData.self } public override class func allowsReverseTransformation() -> Bool { return true } public override func transformedValue(_ value: Any?) -> Any? { guard let value = value else { return nil } do { return try AirshipJSONUtils.data( value, options: JSONSerialization.WritingOptions.prettyPrinted ) } catch { AirshipLogger.error( "Failed to transform value: \(value), error: \(error)" ) return nil } } public override func reverseTransformedValue(_ value: Any?) -> Any? { guard let value = value as? Data else { return nil } do { return try JSONSerialization.jsonObject( with: value, options: .mutableContainers ) } catch { AirshipLogger.error( "Failed to reverse transform value: \(value), error: \(error)" ) return nil } } } /// NOTE: For internal use only. :nodoc: public class NSDictionaryValueTransformer: ValueTransformer { public override class func transformedValueClass() -> AnyClass { return NSData.self } public override class func allowsReverseTransformation() -> Bool { return true } public override func transformedValue(_ value: Any?) -> Any? { guard let value = value else { return nil } do { return try NSKeyedArchiver.archivedData( withRootObject: value, requiringSecureCoding: true ) } catch { AirshipLogger.error( "Failed to transform value: \(value), error: \(error)" ) return nil } } public override func reverseTransformedValue(_ value: Any?) -> Any? { guard let value = value as? Data else { return nil } do { let classes = [ NSString.self, NSDictionary.self, NSArray.self, NSSet.self, NSData.self, NSNumber.self, NSDate.self, NSURL.self, NSUUID.self, NSNull.self, ] return try NSKeyedUnarchiver.unarchivedObject( ofClasses: classes, from: value ) } catch { AirshipLogger.error( "Failed to reverse transform value: \(value), error: \(error)" ) return nil } } } // NOTE: For internal use only. :nodoc: public class NSURLValueTransformer: ValueTransformer { public override class func transformedValueClass() -> AnyClass { return NSData.self } public override class func allowsReverseTransformation() -> Bool { return true } public override func transformedValue(_ value: Any?) -> Any? { guard let value = value else { return nil } do { return try NSKeyedArchiver.archivedData( withRootObject: value, requiringSecureCoding: true ) } catch { AirshipLogger.error( "Failed to transform value: \(value), error: \(error)" ) return nil } } public override func reverseTransformedValue(_ value: Any?) -> Any? { guard let value = value as? Data else { return nil } do { return try NSKeyedUnarchiver.unarchivedObject( ofClass: NSURL.self, from: value ) } catch { AirshipLogger.error( "Failed to reverse transform value: \(value), error: \(error)" ) return nil } } } // NOTE: For internal use only. :nodoc: public class NSArrayValueTransformer: ValueTransformer { public override class func transformedValueClass() -> AnyClass { return NSData.self } public override class func allowsReverseTransformation() -> Bool { return true } public override func transformedValue(_ value: Any?) -> Any? { guard let value = value else { return nil } do { return try NSKeyedArchiver.archivedData( withRootObject: value, requiringSecureCoding: true ) } catch { AirshipLogger.error( "Failed to transform value: \(value), error: \(error)" ) return nil } } public override func reverseTransformedValue(_ value: Any?) -> Any? { guard let value = value as? Data else { return nil } do { let classes = [ NSString.self, NSDictionary.self, NSArray.self, NSSet.self, NSData.self, NSNumber.self, NSDate.self, NSURL.self, NSUUID.self, NSNull.self, ] return try NSKeyedUnarchiver.unarchivedObject( ofClasses: classes, from: value ) } catch { AirshipLogger.error( "Failed to reverse transform value: \(value), error: \(error)" ) return nil } } } ================================================ FILE: Airship/AirshipCore/Source/JavaScriptCommand.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) public import Foundation // Model object for holding data associated with JS delegate calls public struct JavaScriptCommand: Sendable, CustomDebugStringConvertible { // A name, derived from the host passed in the delegate call URL. public let name: String? // The argument strings passed in the call. public let arguments: [String] // The query options passed in the call. public let options: [String: [String]] // The original URL that initiated the call. public let url: URL public init(url: URL) { self.url = url let components = URLComponents(url: url, resolvingAgainstBaseURL: false) var encodedUrlPath = components?.percentEncodedPath if let path = encodedUrlPath, path.hasPrefix("/") { encodedUrlPath = String(path.dropFirst()) } // Put the arguments into an array // NOTE: we special case an empty array as componentsSeparatedByString // returns an array with a copy of the input in the first position when passed // a string without any delimiters var args: [String] = [] if let encodedUrlPath = encodedUrlPath, !encodedUrlPath.isEmpty { let encodedArgs = encodedUrlPath.components(separatedBy: "/") args = encodedArgs.compactMap { encoded in encoded.removingPercentEncoding } } self.arguments = args var options: [String: [String]] = [:] components?.queryItems?.forEach { item in if (options[item.name] == nil) { options[item.name] = [] } options[item.name]?.append(item.value ?? "") } self.options = options self.name = url.host } public var debugDescription: String { "JavaScriptCommand{name=\(String(describing: name)), options=\(options)}, arguments=\(arguments), url=\(url)})" } } #endif ================================================ FILE: Airship/AirshipCore/Source/JavaScriptCommandDelegate.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Foundation public import WebKit /// A standard protocol for handling commands from the NativeBridge.. public protocol JavaScriptCommandDelegate: AnyObject, Sendable { /// Delegates must implement this method. Implementations take a model object representing /// the JavaScript command which includes the command name, an array of string arguments, /// and a dictionary of key-value pairs (all strings). /// /// If the passed command name is not one the delegate responds to return `NO`. If the command is handled, return /// `YES` and the command will not be handled by another delegate. /// /// To pass information to the delegate from a webview, insert links with a "uairship" scheme, /// args in the path and key-value option pairs in the query string. The host /// portion of the URL is treated as the command name. /// /// The basic URL format: /// uairship:///command-name/<args>?<key/value options> /// /// For example, to invoke a command named "foo", and pass in three args (arg1, arg2 and arg3) /// and three key-value options {option1:one, option2:two, option3:three}: /// /// uairship:///foo/arg1/arg2/arg3?option1=one&option2=two&option3=three /// /// - Parameter command: The javascript command /// - Parameter webView: The web view /// - Returns: `true` if the command was handled, otherwise `false` @MainActor func performCommand(_ command: JavaScriptCommand, webView: WKWebView) -> Bool } #endif ================================================ FILE: Airship/AirshipCore/Source/JavaScriptEnvironment.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Foundation /// Protocol for building the Airship JavaScript environment injected into web views. public protocol JavaScriptEnvironmentProtocol: Sendable { /// Adds a string getter to the Airship JavaScript environment. /// - Parameter getter: The getter's name. /// - Parameter string: The getter's value. func add(_ getter: String, string: String?) /// Adds a number getter to the Airship JavaScript environment. /// - Parameter getter: The getter's name. /// - Parameter number: The getter's value. func add(_ getter: String, number: Double?) /// Adds a dictionary getter to the Airship JavaScript environment. /// - Parameter getter: The getter's name. /// - Parameter dictionary: The getter's value. func add(_ getter: String, dictionary: [AnyHashable: Any]?) /** * Builds the script that can be injected into a web view. * - Returns: The script. */ func build() async -> String } /// The JavaScript environment builder that is used by the native bridge. public final class JavaScriptEnvironment: JavaScriptEnvironmentProtocol, Sendable { private let extensions: AirshipAtomicValue<[String]> = AirshipAtomicValue([String]()) private let channel: @Sendable () -> any AirshipChannel private let contact: @Sendable () -> any AirshipContact public convenience init() { self.init( channel: Airship.componentSupplier(), contact: Airship.componentSupplier() ) } init( channel: @escaping @Sendable () -> any AirshipChannel, contact: @escaping @Sendable () -> any AirshipContact ) { self.channel = channel self.contact = contact } public func add(_ getter: String, string: String?) { self.addExtension( makeGetter(name: getter, string: string) ) } public func add(_ getter: String, number: Double?) { self.addExtension( makeGetter(name: getter, number: number) ) } public func add(_ getter: String, dictionary: [AnyHashable: Any]?) { self.addExtension( makeGetter(name: getter, dictionary: dictionary) ) } public func build() async -> String { var js = "var _UAirship = {};" var extensions: [String] = await self.makeDefaultExtensions() extensions += self.extensions.value for ext in extensions { js = js.appending(ext) } guard let path = AirshipCoreResources.bundle.path( forResource: "UANativeBridge", ofType: "" ) else { AirshipLogger.impError( "UANativeBridge resource file is missing." ) return js } let bridge: String do { bridge = try String(contentsOfFile: path, encoding: .utf8) } catch { AirshipLogger.impError( "UANativeBridge resource file is missing." ) return js } return js.appending(bridge) } private func makeDefaultExtensions() async -> [String] { return await [ makeGetter( name: "getDeviceModel", string: AirshipDevice.model ), makeGetter( name: "getNamedUser", string: self.contact().namedUserID ), makeGetter( name: "getChannelId", string: self.channel().identifier ), makeGetter( name: "getAppKey", string: Airship.config.appCredentials.appKey ) ] } private func addExtension(_ ext: String) { self.extensions.update { current in var mutable = current mutable.append(ext) return mutable } } private func makeGetter( name: String, string: String? ) -> String { guard let value = string else { return String( format: "_UAirship.%@ = function() {return null;};", name ) } let encodedValue = value.addingPercentEncoding( withAllowedCharacters: CharacterSet.urlHostAllowed ) return String( format: "_UAirship.%@ = function() {return decodeURIComponent(\"%@\");};", name, encodedValue ?? "" ) } private func makeGetter( name: String, number: Double? ) -> String { String( format: "_UAirship.%@ = function() {return %@;};", name, String(number ?? -1) ) } private func makeGetter(name: String, dictionary: [AnyHashable: Any]?) -> String { guard let value = dictionary, JSONSerialization.isValidJSONObject(value), let jsonData: Data = try? JSONSerialization.data( withJSONObject: value, options: [] ), let jsonString = String.init(data: jsonData, encoding: .utf8) else { return String( format: "_UAirship.%@ = function() {return null;};", name ) } return String( format: "_UAirship.%@ = function() {return %@;};", name, jsonString ) } } #endif ================================================ FILE: Airship/AirshipCore/Source/Label.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif /// Text/Label view struct Label: View { private let info: ThomasViewInfo.Label /// View constraints. private let constraints: ViewConstraints @EnvironmentObject private var thomasState: ThomasState @Environment(\.colorScheme) private var colorScheme @Environment(\.sizeCategory) private var sizeCategory static let defaultHighlightColor: Color = Color( red: 1.0, green: 0.84, blue: 0.04, opacity: 0.3 ) init(info: ThomasViewInfo.Label, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var scaledFontSize: Double { AirshipFont.scaledSize(self.info.properties.textAppearance.fontSize) } private var resolvedEndIcon: ThomasViewInfo.Label.LabelIcon? { return ThomasPropertyOverride.resolveOptional( state: thomasState, overrides: self.info.overrides?.iconEnd, defaultValue: self.info.properties.iconEnd ) } private var resolvedStartIcon: ThomasViewInfo.Label.LabelIcon? { return ThomasPropertyOverride.resolveOptional( state: thomasState, overrides: self.info.overrides?.iconStart, defaultValue: self.info.properties.iconStart ) } private var resolvedTextAppearance: ThomasTextAppearance { return ThomasPropertyOverride.resolveRequired( state: thomasState, overrides: self.info.overrides?.textAppearance, defaultValue: self.info.properties.textAppearance ) } private var textView: Text { guard self.info.properties.markdown?.disabled != true else { return Text(verbatim: info.resolveLabelString(thomasState: thomasState)) } do { return try markdownText } catch { let resolved = info.resolveLabelString(thomasState: thomasState) AirshipLogger.error("Failed to parse markdown text \(error) text \(resolved)") return Text(verbatim: resolved) } } var body: some View { HStack(spacing: 0) { if let icon = resolvedStartIcon { let size = scaledFontSize Icons.icon(info: icon.icon, colorScheme: colorScheme) .frame(width: size, height: size) .padding(.trailing, icon.space) .accessibilityHidden(true) } if #available(iOS 26.0, visionOS 26.0, *) { self.textView .textAppearance(resolvedTextAppearance, colorScheme: colorScheme) .truncationMode(.tail) .textRenderer(HighlightRenderer()) } else { self.textView .textAppearance(resolvedTextAppearance, colorScheme: colorScheme) .truncationMode(.tail) } if let icon = resolvedEndIcon { // Add a spacer if we are not auto to push the icon to the edge if constraints.width != nil { Spacer() } let size = scaledFontSize Icons.icon(info: icon.icon, colorScheme: colorScheme) .frame(width: size, height: size) .padding(.leading, icon.space) .accessibilityHidden(true) } } .constraints( constraints, alignment: self.info.properties.textAppearance.alignment? .toFrameAlignment() ?? Alignment.center ) .fixedSize( horizontal: false, vertical: self.constraints.height == nil ) .thomasCommon(self.info) .accessible(self.info.accessible, associatedLabel: nil, hideIfDescriptionIsMissing: true) .accessibilityRole(self.info.properties.accessibilityRole) .onAppear { if self.info.properties.isAccessibilityAlert == true { let message = self.info.resolveLabelString(thomasState: self.thomasState) #if !os(watchOS) && !os(macOS) UIAccessibility.post(notification: .announcement, argument: message) #endif } } } } extension ThomasTextAppearance.TextAlignement { func toFrameAlignment() -> Alignment { switch self { case .start: return Alignment.leading case .end: return Alignment.trailing case .center: return Alignment.center } } func toSwiftTextAlignment() -> SwiftUI.TextAlignment { switch self { case .start: return SwiftUI.TextAlignment.leading case .end: return SwiftUI.TextAlignment.trailing case .center: return SwiftUI.TextAlignment.center } } } extension View { fileprivate func headingLevel(_ int: Int) -> AccessibilityHeadingLevel { switch int { case 1: return .h1 case 2: return .h2 case 3: return .h3 case 4: return .h4 case 5: return .h5 case 6: return .h6 default: return .unspecified } } @ViewBuilder fileprivate func accessibilityRole(_ role: ThomasViewInfo.Label.AccessibilityRole?) -> some View { switch role { case .heading(let level): self.accessibilityAddTraits(.isHeader) .accessibilityHeading(headingLevel(level)) case .none: self } } } extension ThomasViewInfo.Label { @MainActor func resolveLabelString(thomasState: ThomasState) -> String { let resolvedRefs = ThomasPropertyOverride.resolveOptional( state: thomasState, overrides: overrides?.refs, defaultValue: properties.refs ) let resolvedRef = ThomasPropertyOverride.resolveOptional( state: thomasState, overrides: overrides?.ref, defaultValue: properties.ref ) if let refs = resolvedRefs { for ref in refs { if let string = AirshipResources.localizedString(key: ref) { return string } } } else if let ref = resolvedRef { if let string = AirshipResources.localizedString(key: ref) { return string } } return ThomasPropertyOverride.resolveRequired( state: thomasState, overrides: overrides?.text, defaultValue: properties.text ) } } extension Label { private static let scriptFontScale: Double = 0.65 private static let superscriptBaselineScale: Double = 0.4 private static let subscriptBaselineScale: Double = 0.2 private struct Segment { let range: Range<AttributedString.Index> let isHighlight: Bool let isSuperscript: Bool let isSubscript: Bool } private struct DelimitedRange { /// Full range including the delimiter characters themselves. let outer: Range<AttributedString.Index> /// Inner content range, excluding the delimiter characters. let inner: Range<AttributedString.Index> } private func findDelimitedRanges( in chars: AttributedString.CharacterView, open: (Character, Character), close: (Character, Character) ) -> [DelimitedRange] { var results: [DelimitedRange] = [] let end = chars.endIndex var i = chars.startIndex while i < end { let next = chars.index(after: i) guard next < end else { break } if chars[i] == open.0, chars[next] == open.1 { let outerStart = i let openEnd = chars.index(after: next) var j = openEnd var foundClose = false while j < end { let jNext = chars.index(after: j) if jNext < end, chars[j] == close.0, chars[jNext] == close.1 { let innerStart = openEnd let innerEnd = j let outerEnd = chars.index(after: jNext) if innerStart < innerEnd { results.append(DelimitedRange( outer: outerStart..<outerEnd, inner: innerStart..<innerEnd )) } i = outerEnd foundClose = true break } j = chars.index(after: j) } if !foundClose { i = chars.index(after: i) } } else { i = next } } return results } private func formatSegments(in attributed: AttributedString) -> [Segment] { let chars = attributed.characters let start = chars.startIndex let end = chars.endIndex let highlightRanges = findDelimitedRanges(in: chars, open: ("=", "="), close: ("=", "=")) let superscriptRanges = findDelimitedRanges(in: chars, open: ("^", "^"), close: ("^", "^")) let subscriptRanges = findDelimitedRanges(in: chars, open: (",", "{"), close: ("}", ",")) // Build boundaries from both outer and inner edges so delimiter // characters form their own sub-segments and can be skipped. var boundarySet: [AttributedString.Index] = [start, end] for r in highlightRanges + superscriptRanges + subscriptRanges { boundarySet.append(r.outer.lowerBound) boundarySet.append(r.inner.lowerBound) boundarySet.append(r.inner.upperBound) boundarySet.append(r.outer.upperBound) } let boundaries = boundarySet .sorted { $0 < $1 } .reduce(into: [AttributedString.Index]()) { result, idx in if result.last != idx { result.append(idx) } } var segments: [Segment] = [] for idx in 0..<(boundaries.count - 1) { let segStart = boundaries[idx] let segEnd = boundaries[idx + 1] guard segStart < segEnd else { continue } // Use midpoint to determine which ranges contain this segment. let mid = chars.index(segStart, offsetBy: chars.distance(from: segStart, to: segEnd) / 2) // A segment is a "delimiter" if it falls inside an outer range but // outside that range's inner content — skip it so delimiters are invisible. let isDelimiter = highlightRanges.contains { $0.outer.contains(mid) && !$0.inner.contains(mid) } || superscriptRanges.contains { $0.outer.contains(mid) && !$0.inner.contains(mid) } || subscriptRanges.contains { $0.outer.contains(mid) && !$0.inner.contains(mid) } if isDelimiter { continue } let isHighlight = highlightRanges.contains { $0.inner.contains(mid) } let isSuperscript = superscriptRanges.contains { $0.inner.contains(mid) } let isSubscript = subscriptRanges.contains { $0.inner.contains(mid) } segments.append( Segment( range: segStart..<segEnd, isHighlight: isHighlight, isSuperscript: isSuperscript, isSubscript: isSubscript ) ) } return segments } private var markdownText: Text { get throws { let resolved = info.resolveLabelString(thomasState: thomasState) // Parse markdown into attributed let attributed = try AttributedString( markdown: resolved, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) ) let highlightOptions = self.info.properties.markdown?.appearance?.highlight let fontSize = scaledFontSize // Find format segments INSIDE the attributed string let segments = formatSegments(in: attributed) // Build Text by slicing var result: Text? for seg in segments { var slice = attributed[seg.range] let piece: Text if seg.isSuperscript || seg.isSubscript { slice.baselineOffset = seg.isSuperscript ? fontSize * Self.superscriptBaselineScale : -(fontSize * Self.subscriptBaselineScale) slice.font = AirshipFont.resolveFont( size: resolvedTextAppearance.fontSize * Self.scriptFontScale, families: resolvedTextAppearance.fontFamilies, weight: resolvedTextAppearance.fontWeight, isItalic: resolvedTextAppearance.hasStyle(.italic), isBold: resolvedTextAppearance.hasStyle(.bold) ) } if seg.isHighlight { // For custom cornerRadius we have to use a custom attribute and renderer if #available(iOS 18.0, visionOS 2.0, *), let cornerRadius = highlightOptions?.cornerRadius { let highlight = HighlightAttribute( color: highlightOptions?.color?.toColor(colorScheme) ?? Self.defaultHighlightColor, cornerRadius: cornerRadius ) piece = Text(AttributedString(slice)) .customAttribute(highlight) } else { slice.backgroundColor = highlightOptions?.color?.toColor(colorScheme) ?? Self.defaultHighlightColor piece = Text(AttributedString(slice)) } } else { piece = Text(AttributedString(slice)) } result = result.map { $0 + piece } ?? piece } return result ?? Text(attributed) } } } @available(iOS 18.0, *) struct HighlightAttribute: TextAttribute { let color: Color let cornerRadius: CGFloat } @available(iOS 18.0, *) struct HighlightRenderer: TextRenderer { struct Cluster { var rect: CGRect var attr: HighlightAttribute } func draw(layout: Text.Layout, in context: inout GraphicsContext) { for line in layout { var clusters: [Cluster] = [] var currentRect: CGRect? var currentAttr: HighlightAttribute? for run in line { if let highlight = run[HighlightAttribute.self] { let runRect = run.typographicBounds.rect if var rect = currentRect, let attr = currentAttr, attr.color == highlight.color, attr.cornerRadius == highlight.cornerRadius { rect = rect.union(runRect) currentRect = rect currentAttr = highlight } else { // flush previous cluster if let rect = currentRect, let attr = currentAttr { clusters.append(Cluster(rect: rect, attr: attr)) } currentRect = runRect currentAttr = highlight } } else { // end of a cluster if let rect = currentRect, let attr = currentAttr { clusters.append(Cluster(rect: rect, attr: attr)) currentRect = nil currentAttr = nil } } } if let rect = currentRect, let attr = currentAttr { clusters.append(Cluster(rect: rect, attr: attr)) } for cluster in clusters { let path = Path( roundedRect: cluster.rect, cornerRadius: cluster.attr.cornerRadius ) context.fill(path, with: .color(cluster.attr.color)) } for run in line { context.draw(run) } } } } ================================================ FILE: Airship/AirshipCore/Source/LabelButton.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Button view. struct LabelButton : View { private let info: ThomasViewInfo.LabelButton private let constraints: ViewConstraints @Environment(\.layoutState) private var layoutState @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .labelButton, thomasState: thomasState ) } init(info: ThomasViewInfo.LabelButton, constraints: ViewConstraints) { self.info = info self.constraints = constraints } @ViewBuilder private var labelContent: some View { Label( info: self.info.properties.label, constraints: ViewConstraints() ) .airshipApplyIf(self.constraints.height == nil) { view in view.padding([.bottom, .top], 12) } .airshipApplyIf(self.constraints.width == nil) { view in view.padding([.leading, .trailing], 12) } .constraints(constraints) .thomasCommon(info, scope: [.background]) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .background(Color.airshipTappableClear) } var body: some View { AirshipButton( identifier: self.info.properties.identifier, reportingMetadata: self.info.properties.reportingMetadata, description: self.info.accessible.contentDescription ?? self.info.properties.label.properties.text, clickBehaviors: self.info.properties.clickBehaviors, eventHandlers: self.info.commonProperties.eventHandlers, actions: self.info.properties.actions, tapEffect: self.info.properties.tapEffect ) { labelContent } .thomasCommon(info, scope: [.enableBehaviors, .visibility]) .environment( \.layoutState, layoutState.override( buttonState: ButtonState(identifier: self.info.properties.identifier) ) ) .accessibilityHidden(info.accessible.accessibilityHidden ?? false) } } ================================================ FILE: Airship/AirshipCore/Source/LayoutState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct LayoutState: Sendable { static let empty = LayoutState( pagerState: nil, formState: nil, buttonState: nil ) var pagerState: PagerState? var formState: ThomasFormState? var buttonState: ButtonState? func override(pagerState: PagerState?) -> LayoutState { var context = self context.pagerState = pagerState return context } func override(formState: ThomasFormState?) -> LayoutState { var context = self context.formState = formState return context } func override(buttonState: ButtonState?) -> LayoutState { var context = self context.buttonState = buttonState return context } } ================================================ FILE: Airship/AirshipCore/Source/LinearLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Linear Layout - either a VStack or HStack depending on the direction. struct LinearLayout: View { /// LinearLayout model. private let info: ThomasViewInfo.LinearLayout /// View constraints. private let constraints: ViewConstraints @State private var numberGenerator: RepeatableNumberGenerator = RepeatableNumberGenerator() init(info: ThomasViewInfo.LinearLayout, constraints: ViewConstraints) { self.info = info self.constraints = constraints } @ViewBuilder @MainActor private func makeVStack( items: [ThomasViewInfo.LinearLayout.Item], parentConstraints: ViewConstraints ) -> some View { VStack(alignment: .center, spacing: 0) { ForEach(0..<items.count, id: \.self) { index in #if os(tvOS) HStack { childItem(items[index], parentConstraints: parentConstraints) } .frame(maxWidth: .infinity) .focusSection() .airshipApplyIf( items[index].hasPerItemAlignment(stackDirection: .vertical) ) { $0.frame( maxWidth: .infinity, alignment: items[index].position?.alignment ?? .center ) } #else childItem(items[index], parentConstraints: parentConstraints) .airshipApplyIf( items[index].hasPerItemAlignment(stackDirection: .vertical) ) { $0.frame( maxWidth: .infinity, alignment: items[index].position?.alignment ?? .center ) } #endif } } .airshipGeometryGroupCompat() .accessibilityElement(children: .contain) .constraints(self.constraints, alignment: .top) .applyFixedSizeForAlignment(info: self.info, constraints: constraints) } @ViewBuilder @MainActor private func makeHStack( items: [ThomasViewInfo.LinearLayout.Item], parentConstraints: ViewConstraints ) -> some View { HStack(alignment: .center, spacing: 0) { ForEach(0..<items.count, id: \.self) { index in #if os(tvOS) VStack { childItem(items[index], parentConstraints: parentConstraints) } .frame(maxHeight: .infinity) .focusSection() .airshipApplyIf( items[index].hasPerItemAlignment(stackDirection: .horizontal) ) { $0.frame( maxHeight: .infinity, alignment: items[index].position?.alignment ?? .center ) } #else childItem(items[index], parentConstraints: parentConstraints) .airshipApplyIf( items[index].hasPerItemAlignment(stackDirection: .horizontal) ) { $0.frame( maxHeight: .infinity, alignment: items[index].position?.alignment ?? .center ) } #endif } } .constraints(constraints, alignment: .leading) .applyFixedSizeForAlignment(info: self.info, constraints: constraints) } @ViewBuilder @MainActor private func makeStack() -> some View { if self.info.properties.direction == .vertical { makeVStack( items: orderedItems(), parentConstraints: parentConstraints() ) } else { makeHStack( items: orderedItems(), parentConstraints: parentConstraints() ) } } var body: some View { makeStack() .clipped() .thomasCommon(self.info) } @ViewBuilder @MainActor private func childItem( _ item: ThomasViewInfo.LinearLayout.Item, parentConstraints: ViewConstraints ) -> some View { let constraints = parentConstraints.childConstraints( item.size, margin: item.margin, padding: self.info.commonProperties.border?.strokeWidth ?? 0, safeAreaInsetsMode: .consume ) ViewFactory.createView(item.view, constraints: constraints) .margin(item.margin) #if os(tvOS) .focusSection() #endif } private func parentConstraints() -> ViewConstraints { var constraints = self.constraints if self.info.properties.direction == .vertical { constraints.isVerticalFixedSize = false } else { constraints.isHorizontalFixedSize = false } return constraints } private func orderedItems() -> [ThomasViewInfo.LinearLayout.Item] { guard self.info.properties.randomizeChildren == true else { return self.info.properties.items } var generator = self.numberGenerator generator.repeatNumbers() return self.info.properties.items.shuffled(using: &generator) } } class RepeatableNumberGenerator: RandomNumberGenerator { private var numbers: [UInt64] = [] private var index: Int = 0 private var numberGenerator: SystemRandomNumberGenerator = SystemRandomNumberGenerator() func next() -> UInt64 { defer { self.index += 1 } guard index < numbers.count else { let next = numberGenerator.next() numbers.append(next) return next } return numbers[index] } func repeatNumbers() { index = 0 } } fileprivate extension ThomasViewInfo.LinearLayout.Item { func hasPerItemAlignment(stackDirection: ThomasDirection) -> Bool { guard let position = self.position else { return false } switch(stackDirection) { case .horizontal: return position.vertical != .center case .vertical: return position.horizontal != .center } } } fileprivate extension View { @ViewBuilder func applyFixedSizeForAlignment( info: ThomasViewInfo.LinearLayout, constraints: ViewConstraints ) -> some View { if info.properties.direction == .horizontal { let hasPerItemAlignment = info.properties.items.contains { $0.hasPerItemAlignment(stackDirection: .horizontal) } if hasPerItemAlignment, constraints.height == nil { self.fixedSize(horizontal: false, vertical: true) } else { self } } else { let hasPerItemAlignment = info.properties.items.contains { $0.hasPerItemAlignment(stackDirection: .vertical) } if hasPerItemAlignment, constraints.width == nil { self.fixedSize(horizontal: true, vertical: false) } else { self } } } } ================================================ FILE: Airship/AirshipCore/Source/LiveActivity.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) import UIKit #endif /// LiveActivity protocol. Makes everything testable. protocol LiveActivityPushToStartTrackerProtocol: Sendable { var attributeType: String { get } func track(tokenUpdates: @Sendable @escaping (String) async -> Void) async } /// LiveActivity protocol. Makes everything testable. protocol LiveActivityProtocol: Sendable { /// The activity's ID var id: String { get } var isUpdatable: Bool { get } var pushTokenString: String? { get } /// Awaits for the activity to be finished. /// - Parameters: /// - tokenUpdates: A closure that is called whenever the token changes func track(tokenUpdates: @Sendable @escaping (String) async -> Void) async } #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) && !os(macOS) public import ActivityKit @available(iOS 17.2, *) struct LiveActivityPushToStartTracker<T: ActivityAttributes>: LiveActivityPushToStartTrackerProtocol { var attributeType: String { return String(describing: T.self) } func track(tokenUpdates: @escaping @Sendable (String) async -> Void) async { var tokenString: String? = Activity<T>.pushToStartToken?.tokenString if let tokenString = tokenString { await tokenUpdates(tokenString) } for await token in Activity<T>.pushToStartTokenUpdates { if Task.isCancelled { break } let newTokenString = token.tokenString if tokenString != newTokenString { tokenString = newTokenString await tokenUpdates(newTokenString) } } } } @available(iOS 16.1, *) fileprivate struct ActivityProvider<T : ActivityAttributes>: Sendable { public let id: String func getActivity() -> Activity<T>? { Activity<T>.activities.first { activity in activity.id == id } } } @available(iOS 16.1, *) struct LiveActivity<T: ActivityAttributes>: LiveActivityProtocol { public let id: String public var isUpdatable: Bool { return provider.getActivity()?.activityState.isStaleOrActive ?? false } public var pushTokenString: String? { return provider.getActivity()?.pushToken?.tokenString } fileprivate let provider: ActivityProvider<T> init(activity: Activity<T>) { self.id = activity.id self.provider = ActivityProvider(id: activity.id) } /// Awaits for the activity to be finished. /// - Parameters: /// - tokenUpdates: A closure that is called whenever the token changes func track(tokenUpdates: @Sendable @escaping (String) async -> Void) async { guard let activity = provider.getActivity(), activity.activityState.isStaleOrActive else { return } // Use a background task to wait for the first token update let backgroundTask = await AirshipBackgroundTask( name: "live_activity: \(self.id)", expiry: 30.0 ) let task = Task { // This nested function is a workaround for a Swift compiler Sendable warning. // It avoids the Task's @Sendable closure from directly capturing the generic type. func run() async throws { guard let activity = provider.getActivity(), activity.activityState.isStaleOrActive else { return } for await token in activity.pushTokenUpdates { if Task.isCancelled { await backgroundTask.stop() try Task.checkCancellation() } await tokenUpdates(token.tokenString) await backgroundTask.stop() } } try await run() } /// If the push token is already created it does not cause an update above, /// so we will call the tokenUpdate callback directly if we have a token. if let token = activity.pushToken { await tokenUpdates(token.tokenString) await backgroundTask.stop() } for await update in activity.activityStateUpdates { if !update.isStaleOrActive || Task.isCancelled { await backgroundTask.stop() task.cancel() break } } } } @available(iOS 16.1, *) extension ActivityState { public var isStaleOrActive: Bool { if #available(iOS 16.2, *) { return self == .active || self == .stale } else { return self == .active } } } extension Data { fileprivate var tokenString: String { AirshipUtils.deviceTokenStringFromDeviceToken(self) } } @MainActor @available(iOS 16.1, *) fileprivate class AirshipBackgroundTask { private var taskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid private let name: String init(name: String, expiry: TimeInterval) { self.name = name taskID = UIApplication.shared.beginBackgroundTask(withName: name) { Task { @MainActor [weak self] in self?.stop() } } if (taskID != UIBackgroundTaskIdentifier.invalid) { AirshipLogger.trace("Background task started: \(name)") Task { try await Task.sleep( nanoseconds: UInt64(expiry * 1_000_000_000) ) self.stop() } } } func stop() { if (taskID != UIBackgroundTaskIdentifier.invalid) { UIApplication.shared.endBackgroundTask(taskID) AirshipLogger.trace("Background task ended: \(name)") } taskID = UIBackgroundTaskIdentifier.invalid } } @available(iOS 16.1, *) extension Activity where Attributes : ActivityAttributes { fileprivate class func _airshipCheckActivities(activityBlock: @escaping @Sendable (Activity<Attributes>) -> Void) { self.activities.filter { activity in if #available(iOS 16.2, *) { return activity.activityState == .active || activity.activityState == .stale } else { return activity.activityState == .active } }.forEach { activity in activityBlock(activity) } } /// Calls `checkActivity` on every active activity on the first call and on each `pushToStartTokenUpdates` update. /// - Parameters: /// - activityBlock: Block that is called with the activity public class func airshipWatchActivities(activityBlock: @escaping @Sendable (Activity<Attributes>) -> Void) { Task { // This nested function is a workaround for a Swift compiler Sendable warning. // It avoids the Task's @Sendable closure from directly capturing the generic type. func run() async { _airshipCheckActivities(activityBlock: activityBlock) if #available(iOS 17.2, *) { for await _ in Activity<Attributes>.pushToStartTokenUpdates { _airshipCheckActivities(activityBlock: activityBlock) } } } await run() } } } #endif ================================================ FILE: Airship/AirshipCore/Source/LiveActivityRegistrationStatus.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Airship live activity registration status. public enum LiveActivityRegistrationStatus: String, Codable, Sendable { /// The live activity is either waiting for a token to be generated and/or waiting for its registration to be sent /// to Airship. case pending /// The live activity is registered with Airship and is now able to be updated through APNS. case registered /// Airship is not actively tracking the live activity. Usually this means it has ended, been replaced /// with another activity using the same name, or was not tracked with Airship. case notTracked } ================================================ FILE: Airship/AirshipCore/Source/LiveActivityRegistrationStatusUpdates.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// AsyncSequence of `LiveActivityRegistrationStatus` updates. public struct LiveActivityRegistrationStatusUpdates: AsyncSequence { public typealias Element = LiveActivityRegistrationStatus private let statusProducer: @Sendable (LiveActivityRegistrationStatus?) async -> LiveActivityRegistrationStatus? init(statusProducer: @escaping @Sendable (LiveActivityRegistrationStatus?) async -> LiveActivityRegistrationStatus?) { self.statusProducer = statusProducer } public struct AsyncIterator: AsyncIteratorProtocol { private let statusProducer: @Sendable (LiveActivityRegistrationStatus?) async -> LiveActivityRegistrationStatus? private var lastStatus: LiveActivityRegistrationStatus? init(statusProducer: @escaping @Sendable (LiveActivityRegistrationStatus?) async -> LiveActivityRegistrationStatus?) { self.statusProducer = statusProducer } public mutating func next() async -> Element? { let status = await statusProducer(lastStatus) self.lastStatus = status return status } } public func makeAsyncIterator() -> AsyncIterator { return AsyncIterator(statusProducer: statusProducer) } } ================================================ FILE: Airship/AirshipCore/Source/LiveActivityRegistry.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(ActivityKit) import Foundation @preconcurrency import Combine /// Registers and watches live activities actor LiveActivityRegistry { private nonisolated let liveActivityUpdatesSubject = CurrentValueSubject<Void, Never>(()) /// A stream of registry updates let updates: AsyncStream<LiveActivityUpdate> private static let maxActiveTime: TimeInterval = 8 * 60 * 60 private static let staleTokenAge: TimeInterval = 48 * 60 * 60 private let liveActivityKey: String = "LiveaActivityRegister#tracked" private let startTokensKey: String = "LiveaActivityRegister#trackedStartTokens" private var restoreCalled: Bool = false private var liveActivityInfos: [LiveActivityInfo] { get { return self.dataStore.safeCodable(forKey: liveActivityKey) ?? [] } set { self.dataStore.setSafeCodable(newValue, forKey: liveActivityKey) } } private var startTokenInfos: [String: StartTokenInfo] { get { return self.dataStore.safeCodable(forKey: startTokensKey) ?? [:] } set { self.dataStore.setSafeCodable(newValue, forKey: startTokensKey) } } private var liveActivityTaskMap: [String: Task<Void, Never>] = [:] private let updatesContinuation: AsyncStream<LiveActivityUpdate>.Continuation private let dataStore: PreferenceDataStore private let date: any AirshipDateProtocol init( dataStore: PreferenceDataStore, date: any AirshipDateProtocol = AirshipDate.shared ) { self.date = date self.dataStore = dataStore (self.updates, self.updatesContinuation) = AsyncStream<LiveActivityUpdate>.airshipMakeStreamWithContinuation() } /// For tests func stop() { self.updatesContinuation.finish() self.liveActivityTaskMap.values.forEach { task in task.cancel() } } /// Should be called for all live activities right after takeOff. /// - Parameters: /// - activities: An array of activities func restoreTracking( activities: [any LiveActivityProtocol], startTokenTrackers: [any LiveActivityPushToStartTrackerProtocol] ) { guard !restoreCalled else { AirshipLogger.error("Restore called mulitple times. Ignoring.") return } restoreCalled = true /// Track push to start tokens startTokenTrackers.forEach { tracker in Task { await tracker.track { token in await self.updateStartToken(attributeType: tracker.attributeType, token: token) } } } // Watch activities activities.forEach { activity in findLiveActivityInfos(id: activity.id).forEach { info in AirshipLogger.debug( "Live activity restore: \(activity.id) name: \(info.name)" ) watchActivity(activity, name: info.name) } } clearUntrackedActivities( currentActivityIDs: Set(activities.map { $0.id }) ) clearUntrackedStartTokens( currentAttributeTypes: Set(startTokenTrackers.map { $0.attributeType }) ) resendStaleStartTokens() } func updatesProcessed(updates: [LiveActivityUpdate]) { let setIds = updates.compactMap { update in return if case .liveActivity(id: let id, name: _, startTimeMS: _) = update.source { id } else { nil } } guard !setIds.isEmpty else { return } var infos = liveActivityInfos setIds.forEach { updateId in let index = infos.firstIndex(where: { info in info.id == updateId }) if let index = index { infos[index].status = .registered } } self.liveActivityInfos = infos liveActivityUpdatesSubject.send() } @available(iOS 16.1, *) public nonisolated func registrationUpdates( name: String?, id: String? ) -> LiveActivityRegistrationStatusUpdates { return LiveActivityRegistrationStatusUpdates { previous in var async = self.liveActivityUpdatesSubject.values.map { _ in return await self.findLiveActivityInfos(id: id, name: name).last?.status ?? .notTracked }.makeAsyncIterator() while !Task.isCancelled { let status = await async.next() if status != previous { return status } } return nil } } /// Adds a live activity to the registry. The activity will be monitored and /// automatically removed after its finished. func addLiveActivity( _ liveActivity: any LiveActivityProtocol, name: String ) { guard liveActivity.isUpdatable else { return } guard findLiveActivityInfos(id: liveActivity.id, name: name).isEmpty else { return } findLiveActivityInfos(name: name) .forEach { info in self.removeLiveActivity(id: info.id, name: info.name) } let info = LiveActivityInfo( id: liveActivity.id, name: name, token: liveActivity.pushTokenString, startDate: self.date.now, status: .pending ) self.liveActivityInfos.append(info) if info.token != nil { yieldLiveActivityUpdate( info: info, action: .set ) } watchActivity(liveActivity, name: info.name) liveActivityUpdatesSubject.send() } private func watchActivity( _ liveActivity: any LiveActivityProtocol, name: String ) { let task: Task<Void, Never> = Task { /// This will wait until the activity is no longer active await liveActivity.track { token in await self.updateLiveActivityToken(id: liveActivity.id, name: name, token: token) } self.removeLiveActivity(id: liveActivity.id, name: name) } liveActivityTaskMap[makeTaskID(id: liveActivity.id, name: name)] = task } private func clearUntrackedStartTokens(currentAttributeTypes: Set<String>) { let shouldClear = self.startTokenInfos.values.filter { info in !currentAttributeTypes.contains(info.attributeType) } shouldClear.forEach { info in self.updateStartToken(attributeType: info.attributeType, token: nil) } } /// Should be called after all activities have been restored. private func clearUntrackedActivities(currentActivityIDs: Set<String>) { let shouldClear = liveActivityInfos.filter { info in !currentActivityIDs.contains(info.id) } shouldClear.forEach { info in var date = self.date.now let maxActiveDate = info.startDate.advanced(by: Self.maxActiveTime) if date > maxActiveDate { date = maxActiveDate } removeLiveActivity( id: info.id, name: info.name, date: date ) } liveActivityUpdatesSubject.send() } private func resendStaleStartTokens() { self.startTokenInfos.values.filter { info in self.date.now.timeIntervalSince(info.sentDate) > Self.staleTokenAge }.forEach { info in yieldStartTokenUpdate(attributeType: info.attributeType, token: info.token) } } private func updateStartToken(attributeType: String, token: String?) { let existing = self.startTokenInfos[attributeType] guard let token = token else { if (existing != nil) { self.startTokenInfos[attributeType] = nil yieldStartTokenUpdate(attributeType: attributeType, token: nil) } return } guard token != existing?.token else { return } self.startTokenInfos[attributeType] = StartTokenInfo( attributeType: attributeType, token: token, sentDate: self.date.now ) yieldStartTokenUpdate(attributeType: attributeType, token: token) } private func updateLiveActivityToken(id: String, name: String, token: String) { var tracked = self.liveActivityInfos for index in 0..<tracked.count { if tracked[index].id == id && tracked[index].name == name { if tracked[index].token != token { tracked[index].token = token yieldLiveActivityUpdate(info: tracked[index], action: .set) } break } } self.liveActivityInfos = tracked } private func removeLiveActivity( id: String, name: String, date: Date? = nil ) { let taskID = makeTaskID(id: id, name: name) liveActivityTaskMap[taskID]?.cancel() liveActivityTaskMap[taskID] = nil self.liveActivityInfos.removeAll { info in if info.name == name && info.id == id { if info.token != nil { yieldLiveActivityUpdate(info: info, action: .remove, date: date) } return true } return false } liveActivityUpdatesSubject.send() } private func yieldLiveActivityUpdate( info: LiveActivityInfo, action: LiveActivityUpdate.Action, date: Date? = nil ) { let actionDate = date ?? self.date.now let update = LiveActivityUpdate( action: action, source: .liveActivity(id: info.id, name: info.name, startTimeMS: info.startDate.millisecondsSince1970), actionTimeMS: actionDate.millisecondsSince1970, token: action == .set ? info.token : nil) updatesContinuation.yield(update) } private func yieldStartTokenUpdate( attributeType: String, token: String? ) { let update = LiveActivityUpdate( action: token == nil ? .remove : .set, source: .startToken(attributeType: attributeType), actionTimeMS: self.date.now.millisecondsSince1970, token: token ) updatesContinuation.yield(update) } private func findLiveActivityInfos( id: String? = nil, name: String? = nil ) -> [LiveActivityInfo] { return self.liveActivityInfos.filter { info in if id != nil && info.id != id { return false } if name != nil && info.name != name { return false } return true } } private func makeTaskID(id: String, name: String) -> String { return id + name } } private struct LiveActivityInfo: Codable, Sendable { var id: String var name: String var token: String? var startDate: Date var status: LiveActivityRegistrationStatus? } private struct StartTokenInfo: Codable, Sendable, Equatable { var attributeType: String var token: String var sentDate: Date } #endif ================================================ FILE: Airship/AirshipCore/Source/LiveActivityRestorer.swift ================================================ #if canImport(ActivityKit) && !targetEnvironment(macCatalyst) && !os(macOS) public import ActivityKit /// Restores live activity tracking @available(iOS 16.1, *) public protocol LiveActivityRestorer: Actor { /// Should be called for every live activity type that you track with Airship. /// This method will track a push to start token for the activity and check if any previousl /// tracked live activities are still available by comparing the activity's ID. If we previously tracked /// the activity, Airship will resume tracking the status and push token. /// - Parameters: /// - forType: The live activity type func restore<T: ActivityAttributes>(forType: Activity<T>.Type) async } @available(iOS 16.1, *) actor AirshipLiveActivityRestorer: LiveActivityRestorer { var liveActivities: [any LiveActivityProtocol] = [] var pushToStartTokenTrackers: [any LiveActivityPushToStartTrackerProtocol] = [] public func restore<T: ActivityAttributes>( forType: Activity<T>.Type ) { self.liveActivities.append( contentsOf: forType.activities.map { LiveActivity(activity: $0) } ) if #available(iOS 17.2, *) { self.pushToStartTokenTrackers.append( LiveActivityPushToStartTracker<T>() ) } } func apply(registry: LiveActivityRegistry) async { await registry.restoreTracking( activities: liveActivities, startTokenTrackers: pushToStartTokenTrackers ) } } #endif ================================================ FILE: Airship/AirshipCore/Source/LiveActivityUpdate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// An update to a live activity struct LiveActivityUpdate: Codable, Equatable { enum Action: String, Codable { case set case remove } enum Source: Equatable { enum TokenType: String, Codable { case start = "start_token" case update = "update_token" } case liveActivity(id: String, name: String, startTimeMS: Int64) case startToken(attributeType: String) } /// Update action var action: Action /// Update source let source: Source /// The token, should be available on a set var token: String? /// The update start time in milliseconds var actionTimeMS: Int64 enum CodingKeys: String, CodingKey { case action = "action" case id = "id" case name = "name" case token = "token" case actionTimeMS = "action_ts_ms" case startTimeMS = "start_ts_ms" case type = "type" case attributeType = "attributes_type" } init(action: Action, source: Source, actionTimeMS: Int64, token: String? = nil ) { self.action = action self.source = source self.token = token self.actionTimeMS = actionTimeMS } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type: Source.TokenType if container.contains(.type) { type = try container.decode(Source.TokenType.self, forKey: .type) } else { type = .update } switch type { case .start: self.source = .startToken(attributeType: try container.decode(String.self, forKey: .attributeType)) case .update: self.source = .liveActivity( id: try container.decode(String.self, forKey: .id), name: try container.decode(String.self, forKey: .name), startTimeMS: try container.decode(Int64.self, forKey: .startTimeMS)) } self.action = try container.decode(Action.self, forKey: .action) self.token = try container.decodeIfPresent(String.self, forKey: .token) self.actionTimeMS = try container.decode(Int64.self, forKey: .actionTimeMS) } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(action, forKey: .action) try container.encodeIfPresent(token, forKey: .token) try container.encode(actionTimeMS, forKey: .actionTimeMS) switch source { case .liveActivity(let id, let name, let startTimeMS): try container.encode(id, forKey: .id) try container.encode(name, forKey: .name) try container.encode(startTimeMS, forKey: .startTimeMS) try container.encode(Source.TokenType.update, forKey: .type) case .startToken(let attributeType): try container.encode(attributeType, forKey: .attributeType) try container.encode(Source.TokenType.start, forKey: .type) } } } ================================================ FILE: Airship/AirshipCore/Source/LocaleManager.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Airship locale manager. public protocol AirshipLocaleManager: AnyObject, Sendable { /** * Resets the current locale. */ func clearLocale() /** * The current locale used by Airship. Defaults to `autoupdatingCurrent` or the user preferred lanaguage, depending on * `AirshipConfig.useUserPreferredLocale`. */ var currentLocale: Locale { get set } } final class DefaultAirshipLocaleManager: AirshipLocaleManager { fileprivate static let storeKey: String = "com.urbanairship.locale.locale" private let dataStore: PreferenceDataStore private let config: RuntimeConfig private let notificationCenter: AirshipNotificationCenter /** * The current locale used by Airship. Defaults to `autoupdatingCurrent`. */ public var currentLocale: Locale { get { if self.config.airshipConfig.useUserPreferredLocale { let preferredLanguage = Locale.preferredLanguages[0] let preferredLocale = Locale(identifier: preferredLanguage) return dataStore.localeOverride ?? preferredLocale } else { return dataStore.localeOverride ?? Locale.autoupdatingCurrent } } set { dataStore.localeOverride = newValue dispatchUpdate() } } /** * - Note: For internal use only. :nodoc: */ init( dataStore: PreferenceDataStore, config: RuntimeConfig, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared ) { self.dataStore = dataStore self.config = config self.notificationCenter = notificationCenter self.notificationCenter.addObserver( self, selector: #selector(autoLocaleChanged), name: NSLocale.currentLocaleDidChangeNotification ) } /** * Resets the current locale. */ public func clearLocale() { dataStore.localeOverride = nil dispatchUpdate() } @objc private func autoLocaleChanged() { if (dataStore.localeOverride == nil) { dispatchUpdate() } } private func dispatchUpdate() { notificationCenter.postOnMain( name: AirshipNotifications.LocaleUpdated.name, object: [AirshipNotifications.LocaleUpdated.localeKey: self.currentLocale] ) } } fileprivate extension PreferenceDataStore { var localeOverride: Locale? { get { guard let encodedLocale = object(forKey: DefaultAirshipLocaleManager.storeKey) as? Data else { return nil } return try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSLocale.self, from: encodedLocale) as? Locale } set { guard let locale = newValue else { removeObject(forKey: DefaultAirshipLocaleManager.storeKey) return } guard let encodedLocale: Data = try? NSKeyedArchiver.archivedData( withRootObject: locale, requiringSecureCoding: true ) else { AirshipLogger.error("Failed to encode locale!") return } setValue( encodedLocale, forKey: DefaultAirshipLocaleManager.storeKey ) } } } public extension AirshipNotifications { /// NSNotification info when the locale is updated. final class LocaleUpdated: NSObject { /// NSNotification name. public static let name: NSNotification.Name = NSNotification.Name( "com.urbanairship.locale.locale_updated" ) /// NSNotification userInfo key to get the locale. public static let localeKey: String = "locale" } } ================================================ FILE: Airship/AirshipCore/Source/Media.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import AVFoundation /// Media view. struct Media: View { @EnvironmentObject private var thomasEnvironment: ThomasEnvironment private let info: ThomasViewInfo.Media private let constraints: ViewConstraints @State private var mediaID: UUID = UUID() private let defaultAspectRatio: Double = 16.0 / 9.0 @EnvironmentObject private var pagerState: PagerState @Environment(\.pageIdentifier) private var pageIdentifier init(info: ThomasViewInfo.Media, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var videoAspectRatio: CGFloat { CGFloat(self.info.properties.video?.aspectRatio ?? defaultAspectRatio) } var body: some View { switch self.info.properties.mediaType { case .image: ThomasAsyncImage( url: self.info.properties.url, imageLoader: thomasEnvironment.imageLoader ) { image, imageSize in image.fitMedia( mediaFit: self.info.properties.mediaFit, cropPosition: self.info.properties.cropPosition, constraints: constraints, imageSize: imageSize ).allowsHitTesting(false) } placeholder: { AirshipProgressView() } .constraints(constraints) .thomasCommon(self.info) .accessible( self.info.accessible, associatedLabel: nil, hideIfDescriptionIsMissing: true ) case .video: #if !os(watchOS) && !os(macOS) VideoMediaNativeView( info: self.info, videoIdentifier: self.info.properties.identifier ?? mediaID.uuidString, constraints: constraints, videoAspectRatio: videoAspectRatio, onMediaReady: { pagerState.setMediaReady( pageId: pageIdentifier ?? "", id: mediaID, isReady: true ) } ) .onAppear { pagerState.registerMedia(pageId: pageIdentifier ?? "", id: mediaID) } .thomasCommon(self.info) #endif case .youtube, .vimeo: #if !os(tvOS) && !os(watchOS) VideoMediaWebView( info: self.info, videoIdentifier: self.info.properties.identifier ?? mediaID.uuidString ) { pagerState.setMediaReady( pageId: pageIdentifier ?? "", id: mediaID, isReady: true ) } .airshipApplyIf(self.constraints.width == nil || self.constraints.height == nil) { $0.aspectRatio(videoAspectRatio, contentMode: ContentMode.fit) } .constraints(constraints) .onAppear { pagerState.registerMedia(pageId: pageIdentifier ?? "", id: mediaID) } .thomasCommon(self.info) #endif } } } extension Image { @ViewBuilder @MainActor func fitMedia( mediaFit: ThomasMediaFit, cropPosition: ThomasPosition?, constraints: ViewConstraints, imageSize: CGSize ) -> some View { switch mediaFit { case .center: cropAligned(constraints: constraints, imageSize: imageSize) case .fitCrop: cropAligned(constraints: constraints, imageSize: imageSize, alignment: cropPosition?.alignment ?? .center) case .centerCrop: cropAligned(constraints: constraints, imageSize: imageSize) case .centerInside: centerInside(constraints: constraints) } } private func shouldCenterInside(constraints: ViewConstraints, imageSize: CGSize) -> Bool { guard constraints.height == nil || constraints.width == nil else { return false } let aspectRatio = imageSize.height > 0 ? imageSize.width/imageSize.height : 1.0 if let height = constraints.height, let maxWidth = constraints.maxWidth { let fitWidth = height * aspectRatio return fitWidth <= maxWidth } if let width = constraints.width, let maxHeight = constraints.maxHeight { let fitHeight = width / aspectRatio return fitHeight <= maxHeight } return false } @ViewBuilder @MainActor private func cropAligned(constraints: ViewConstraints, imageSize: CGSize, alignment: Alignment = .center) -> some View { // If we have an auto bound constraint and we can fit the image then centerInside if shouldCenterInside(constraints: constraints, imageSize: imageSize) { centerInside(constraints: constraints) } else { self.resizable() .scaledToFill() .constraints(constraints, alignment: alignment) .frame(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight) .clipped() } } @MainActor private func centerInside(constraints: ViewConstraints) -> some View { self.resizable() .scaledToFit() .constraints(constraints) .ignoresSafeArea() .clipped() } } // Basically mirror the Image.fitMedia functionality extension View { @ViewBuilder @MainActor func fitVideo( mediaFit: ThomasMediaFit, cropPosition: ThomasPosition?, constraints: ViewConstraints, videoAspectRatio: CGFloat ) -> some View { switch mediaFit { case .center: cropAlignedVideo(constraints: constraints, videoAspectRatio: videoAspectRatio) case .fitCrop: cropAlignedVideo(constraints: constraints, videoAspectRatio: videoAspectRatio, alignment: cropPosition?.alignment ?? .center) case .centerCrop: cropAlignedVideo(constraints: constraints, videoAspectRatio: videoAspectRatio) case .centerInside: centerInsideVideo(constraints: constraints, videoAspectRatio: videoAspectRatio) } } private func shouldCenterInsideVideo(constraints: ViewConstraints, videoAspectRatio: CGFloat) -> Bool { guard constraints.height == nil || constraints.width == nil else { return false } if let height = constraints.height, let maxWidth = constraints.maxWidth { let fitWidth = height * videoAspectRatio return fitWidth <= maxWidth } if let width = constraints.width, let maxHeight = constraints.maxHeight { let fitHeight = width / videoAspectRatio return fitHeight <= maxHeight } return false } @ViewBuilder @MainActor private func cropAlignedVideo(constraints: ViewConstraints, videoAspectRatio: CGFloat, alignment: Alignment = .center) -> some View { if shouldCenterInsideVideo(constraints: constraints, videoAspectRatio: videoAspectRatio) { centerInsideVideo(constraints: constraints, videoAspectRatio: videoAspectRatio) } else { self.aspectRatio(videoAspectRatio, contentMode: .fill) .constraints(constraints, alignment: alignment) .frame(maxWidth: constraints.maxWidth, maxHeight: constraints.maxHeight) .clipped() } } @MainActor private func centerInsideVideo(constraints: ViewConstraints, videoAspectRatio: CGFloat ) -> some View { self.aspectRatio(videoAspectRatio, contentMode: .fit) .constraints(constraints) } } ================================================ FILE: Airship/AirshipCore/Source/MediaEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public extension CustomEvent { /// Media event types enum MediaTemplate: Sendable { /// Browsed media case browsed /// Consumed media case consumed /// Shared media /// - Parameters: /// - source: Optional source. /// - medium: Optional medium. case shared(source: String? = nil, medium: String? = nil) /// Starred media case starred fileprivate static let templateName: String = "media" fileprivate var eventName: String { return switch self { case .browsed: "browsed_content" case .consumed: "consumed_content" case .shared: "shared_content" case .starred: "starred_content" } } } /// Additional media template properties struct MediaProperties: Encodable, Sendable { /// The event's ID. public var id: String? /// The event's category. public var category: String? /// The event's type. public var type: String? /// The event's description. public var eventDescription: String? /// The event's author. public var author: String? /// The event's published date. public var publishedDate: Date? /// If the event is a feature public var isFeature: Bool? /// If the value is a lifetime value or not. public var isLTV: Bool var source: String? = nil var medium: String? = nil public init( id: String? = nil, category: String? = nil, type: String? = nil, eventDescription: String? = nil, isLTV: Bool = false, author: String? = nil, publishedDate: Date? = nil, isFeature: Bool? = nil ) { self.id = id self.category = category self.type = type self.eventDescription = eventDescription self.isLTV = isLTV self.author = author self.publishedDate = publishedDate self.isFeature = isFeature } enum CodingKeys: String, CodingKey { case isLTV = "ltv" case isFeature = "feature" case id case category case type case source case medium case eventDescription = "description" case author case publishedDate = "published_date" } } /// Constructs a custom event using the media template. /// - Parameters: /// - mediaTemplate: The media template. /// - properties: Media properties. /// - encoder: Encoder used to encode the additional properties. Defaults to `CustomEvent.defaultEncoder`. init( mediaTemplate: MediaTemplate, properties: MediaProperties = MediaProperties(), encoder: @autoclosure () -> JSONEncoder = CustomEvent.defaultEncoder() ) { self = .init(name: mediaTemplate.eventName) self.templateType = MediaTemplate.templateName var mutableProperties = properties switch (mediaTemplate) { case .browsed: break case .starred: break case .consumed: break case .shared(source: let source, medium: let medium): mutableProperties.source = source mutableProperties.medium = medium } do { try self.setProperties(mutableProperties, encoder: encoder()) } catch { /// Should never happen so we are just catching the exception and logging AirshipLogger.error("Failed to generate event \(error)") } } } ================================================ FILE: Airship/AirshipCore/Source/MessageCriteria.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct MessageCriteria: Codable, Sendable, Equatable { let messageTypePredicate: JSONPredicate? let campaignsPredicate: JSONPredicate? enum CodingKeys: String, CodingKey { case messageTypePredicate = "message_type" case campaignsPredicate = "campaigns" } } ================================================ FILE: Airship/AirshipCore/Source/MessageDisplayHistory.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public struct MessageDisplayHistory: Codable, Equatable, Sendable { public var lastImpression: LastImpression? public var lastDisplay: LastDisplay? public init(lastImpression: LastImpression? = nil, lastDisplay: LastDisplay? = nil) { self.lastImpression = lastImpression self.lastDisplay = lastDisplay } public struct LastImpression: Codable, Equatable, Sendable { public var date: Date public var triggerSessionID: String public init(date: Date, triggerSessionID: String) { self.date = date self.triggerSessionID = triggerSessionID } } public struct LastDisplay: Codable, Equatable, Sendable { public var triggerSessionID: String public init(triggerSessionID: String) { self.triggerSessionID = triggerSessionID } } } /// NOTE: For internal use only. :nodoc: public protocol MessageDisplayHistoryStoreProtocol: Sendable { func set( _ history: MessageDisplayHistory, scheduleID: String ) func get( scheduleID: String ) async -> MessageDisplayHistory } /// NOTE: For internal use only. :nodoc: public final class MessageDisplayHistoryStore: MessageDisplayHistoryStoreProtocol { private let storageGetter: @Sendable (String) async throws -> Data? private let storageSetter: @Sendable (String, MessageDisplayHistory) async throws -> Void private let queue: AirshipAsyncSerialQueue public init( storageGetter: @escaping @Sendable (String) async throws -> Data?, storageSetter: @escaping @Sendable (String, MessageDisplayHistory) async throws -> Void, queue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() ) { self.storageGetter = storageGetter self.storageSetter = storageSetter self.queue = queue } public func set(_ history: MessageDisplayHistory, scheduleID: String) { queue.enqueue { [weak self] in do { try await self?.storageSetter(scheduleID, history) } catch { AirshipLogger.error("Failed to save message history \(error)") } } } public func get(scheduleID: String) async -> MessageDisplayHistory { return await withCheckedContinuation { continuation in queue.enqueue { [weak self] in do { guard let data = try await self?.storageGetter(scheduleID) else { continuation.resume(returning: MessageDisplayHistory()) return } let history = try JSONDecoder().decode(MessageDisplayHistory.self, from: data) continuation.resume(returning: history) } catch { AirshipLogger.error("Failed to save message history \(error)") continuation.resume(returning: MessageDisplayHistory()) } } } } } ================================================ FILE: Airship/AirshipCore/Source/MeteredUsageAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol MeteredUsageAPIClientProtocol: Sendable { func uploadEvents( _ events: [AirshipMeteredUsageEvent], channelID: String? ) async throws -> AirshipHTTPResponse<Void> } final class MeteredUsageAPIClient : MeteredUsageAPIClientProtocol { private let config: RuntimeConfig private let session: any AirshipRequestSession private var 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: any AirshipRequestSession) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init( config: config, session: config.requestSession ) } func uploadEvents( _ events: [AirshipMeteredUsageEvent], channelID: String? ) async throws -> AirshipHTTPResponse<Void> { guard let meteredUsageURL = config.meteredUsageURL else { throw AirshipErrors.error("Missing metered usage URL") } var headers: [String: String] = [ "X-UA-Lib-Version": AirshipVersion.version, "X-UA-Device-Family": "ios", "Content-Type": "application/json", "Accept": "application/vnd.urbanairship+json; version=3;" ] if let channelID = channelID { headers["X-UA-Channel-ID"] = channelID } let body = try encoder.encode(RequestBody(usage: events)) let request = AirshipRequest( url: URL(string: "\(meteredUsageURL)/api/metered-usage"), headers: headers, method: "POST", auth: .generatedAppToken, body: body ) AirshipLogger.trace("Sending usage: \(events), request: \(request)") // Perform the upload let result = try await self.session.performHTTPRequest(request) AirshipLogger.debug("Usage result: \(result)") return result } fileprivate struct RequestBody: Encodable { let usage: [AirshipMeteredUsageEvent] } } ================================================ FILE: Airship/AirshipCore/Source/MeteredUsageStore.swift ================================================ import Foundation import CoreData final class MeteredUsageStore: Sendable { private static let fileFormat: String = "MeteredUsage-%@.sqlite" private static let eventDataEntityName: String = "UAMeteredUsageEventData" private static let fetchEventLimit: Int = 500 private let coreData: UACoreData private let inMemory: Bool init(appKey: String, inMemory: Bool = false ) { self.inMemory = inMemory let storeName = String( format: MeteredUsageStore.fileFormat, appKey ) let modelURL = AirshipCoreResources.bundle.url( forResource: "UAMeteredUsage", withExtension: "momd" ) self.coreData = UACoreData( name: Self.eventDataEntityName, modelURL: modelURL!, inMemory: inMemory, stores: [storeName] ) } func saveEvent(_ event: AirshipMeteredUsageEvent) async throws { try await self.coreData.perform { context in let eventData = NSEntityDescription.insertNewObject( forEntityName: MeteredUsageStore.eventDataEntityName, into: context ) as? MeteredUsageEventData guard let eventData = eventData else { throw AirshipErrors.error("Failed to MeteredUsageEventData") } eventData.identifier = event.eventID eventData.data = try JSONEncoder().encode(event) } } func deleteAll() async throws { try await self.coreData.perform(skipIfStoreNotCreated: true) { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: MeteredUsageStore.eventDataEntityName ) if self.inMemory { request.includesPropertyValues = false let events = try context.fetch(request) as? [NSManagedObject] events?.forEach { event in context.delete(event) } } else { let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) } } } func deleteEvents(_ events: [AirshipMeteredUsageEvent]) async throws { let eventIDs = events.map { $0.eventID } try await self.coreData.perform { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: MeteredUsageStore.eventDataEntityName ) request.predicate = NSPredicate( format: "identifier IN %@", eventIDs ) if self.inMemory { request.includesPropertyValues = false let events = try context.fetch(request) as? [NSManagedObject] events?.forEach { event in context.delete(event) } } else { let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) } } } func getEvents() async throws -> [AirshipMeteredUsageEvent] { return try await self.coreData.performWithResult { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: MeteredUsageStore.eventDataEntityName ) request.fetchLimit = MeteredUsageStore.fetchEventLimit let fetchResult = try context.fetch(request) as? [MeteredUsageEventData] ?? [] let events: [AirshipMeteredUsageEvent] = fetchResult.compactMap { eventData in guard let data = eventData.data else { AirshipLogger.error("Unable to read event, deleting. \(eventData)") context.delete(eventData) return nil } do { return try JSONDecoder().decode(AirshipMeteredUsageEvent.self, from: data) } catch { AirshipLogger.error("Unable to read event, deleting. \(error)") context.delete(eventData) return nil } } return events } } } // Internal core data entity @objc(UAMeteredUsageEventData) fileprivate class MeteredUsageEventData: NSManagedObject { /// The event's Data. @NSManaged public dynamic var data: Data? /// The event's identifier. @objc @NSManaged public dynamic var identifier: String? } ================================================ FILE: Airship/AirshipCore/Source/ModalView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI struct ModalView: View { @Environment(\.colorScheme) var colorScheme let presentation: ThomasPresentationInfo.Modal let layout: AirshipLayout @ObservedObject var thomasEnvironment: ThomasEnvironment #if !os(watchOS) let viewControllerOptions: ThomasViewControllerOptions #endif @State private var contentSize: CGSize? = nil var body: some View { GeometryReader { metrics in RootView( thomasEnvironment: thomasEnvironment, layout: layout ) { orientation, windowSize in let placement = resolvePlacement( orientation: orientation, windowSize: windowSize ) createModal(placement: placement, metrics: metrics) } } .ignoresSafeArea(ignoreKeyboardSafeArea ? [.keyboard] : []) } private var ignoreKeyboardSafeArea: Bool { presentation.ios?.keyboardAvoidance == .overTheTop } private func createModal( placement: ThomasPresentationInfo.Modal.Placement, metrics: GeometryProxy ) -> some View { let ignoreSafeArea = placement.ignoreSafeArea == true let safeAreaInsets = ignoreSafeArea ? metrics.safeAreaInsets : ViewConstraints.emptyEdgeSet let alignment = Alignment( horizontal: placement.position?.horizontal.alignment ?? .center, vertical: placement.position?.vertical.alignment ?? .center ) let windowConstraints = ViewConstraints( size: metrics.size, safeAreaInsets: safeAreaInsets ) let contentConstraints = windowConstraints.contentConstraints( placement.size, contentSize: self.contentSize, margin: placement.margin ) let safeAreasToIgnore: SafeAreaRegions = if ignoreSafeArea { [.container, .keyboard] } else { [] } return VStack { ViewFactory.createView( self.layout.view, constraints: contentConstraints ) .background( GeometryReader { contentMetrics -> Color in DispatchQueue.main.async { self.contentSize = contentMetrics.size } return Color.clear } ) .thomasBackground( color: placement.backgroundColor, border: placement.border, shadow: placement.shadow ) .margin(placement.margin) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) .background( modalBackground(placement) .frame(maxWidth: .infinity, maxHeight: .infinity) ) .ignoresSafeArea(safeAreasToIgnore) .opacity(self.contentSize == nil ? 0 : 1) .animation(nil, value: self.contentSize) .accessibilityElement(children: .contain) } @ViewBuilder private func modalBackground(_ placement: ThomasPresentationInfo.Modal.Placement) -> some View { GeometryReader { reader in VStack(spacing: 0) { if placement.isFullscreen, placement.ignoreSafeArea != true { statusBarShimColor() .frame(height: reader.safeAreaInsets.top) } Rectangle() .foreground(placement.shade, colorScheme: colorScheme) .ignoresSafeArea(.all) .airshipApplyIf(self.presentation.dismissOnTouchOutside == true) { view in // Add tap gesture outside of view to dismiss view.addTapGesture { self.thomasEnvironment.dismiss() } } if placement.isFullscreen, placement.ignoreSafeArea != true { statusBarShimColor() .frame(height: reader.safeAreaInsets.bottom) } } .ignoresSafeArea(.all) } } private func resolvePlacement( orientation: ThomasOrientation, windowSize: ThomasWindowSize ) -> ThomasPresentationInfo.Modal.Placement { var placement = self.presentation.defaultPlacement #if !os(watchOS) let resolvedOrientation = viewControllerOptions.orientation ?? orientation #else let resolvedOrientation = orientation #endif for placementSelector in self.presentation.placementSelectors ?? [] { if placementSelector.windowSize != nil && placementSelector.windowSize != windowSize { continue } if placementSelector.orientation != nil && placementSelector.orientation != resolvedOrientation { continue } // its a match! placement = placementSelector.placement break } #if !os(watchOS) self.viewControllerOptions.orientation = placement.device?.orientationLock #endif return placement } private func statusBarShimColor() -> Color { #if os(tvOS) || os(watchOS) || os(macOS) return Color.clear #else var statusBarStyle = UIStatusBarStyle.default if let scene = try? AirshipSceneManager.shared.lastActiveScene, let sceneStyle = scene.statusBarManager?.statusBarStyle { statusBarStyle = sceneStyle } switch statusBarStyle { case .darkContent: return Color.white case .lightContent: return Color.black case .default: return self.colorScheme == .dark ? Color.black : Color.white @unknown default: return Color.black } #endif } } extension ThomasPresentationInfo.Modal.Placement { fileprivate var isFullscreen: Bool { if let horiztonalMargins = self.margin?.horiztonalMargins, horiztonalMargins > 0 { return false } if let verticalMargins = self.margin?.verticalMargins, verticalMargins > 0 { return false } if case let .percent(height) = self.size.height, height >= 100.0, case let .percent(width) = self.size.width, width >= 100.0 { return true } return false } } ================================================ FILE: Airship/AirshipCore/Source/ModifyAttributesAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Modifies attributes. /// /// An example JSON payload: /// /// { /// "channel": { /// set: {"key": value, ... }, /// remove: ["attribute", ....] /// }, /// "named_user": { /// set: {"key": value, ... }, /// remove: ["attribute", ....] /// } /// } /// /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush` /// `ActionSituation.webViewInvocation`, `ActionSituation.foregroundInteractiveButton`, /// `ActionSituation.backgroundInteractiveButton`, `ActionSituation.manualInvocation`, and /// `ActionSituation.automation` public final class ModifyAttributesAction: AirshipAction { /// Default names - "modify_attributes_action", "^a" public static let defaultNames: [String] = ["modify_attributes_action", "set_attributes_action", "^a"] /// Default predicate - rejects foreground pushes with visible display options public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } private static let namedUserKey: String = "named_user" private static let channelsKey: String = "channel" private static let setActionKey: String = "set" private static let removeActionKey: String = "remove" private let channel: @Sendable () -> any AirshipChannel private let contact: @Sendable () -> any AirshipContact init( channel: @escaping @Sendable () -> any AirshipChannel, contact: @escaping @Sendable () -> any AirshipContact ) { self.channel = channel self.contact = contact } public convenience init() { self.init( channel: Airship.componentSupplier(), contact: Airship.componentSupplier() ) } public func accepts(arguments: ActionArguments) async -> Bool { guard arguments.situation != .backgroundPush else { return false } do { let changes = try parse(value: arguments.value) return !changes.isEmpty } catch { return false } } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let actions = try parse(value: arguments.value) let channelEditor = channel().editAttributes() let contactEditor = contact().editAttributes() for modification in actions { let editor = switch(modification.editor) { case .channel: channelEditor case .contact: contactEditor } switch(modification) { case .set(_, let name, let value): switch value { case .string(let value): editor.set(string: value, attribute: name) case .number(let value): editor.set(number: value, attribute: name) case .date(let value): editor.set(date: value, attribute: name) case .json(let value): try editor.set( json: value.value, attribute: value.name, instanceID: value.instanceId, expiration: value.expiration ) break } case .remove(_, let name): editor.remove(name) } } let editors: Set<AttributeActionArgs.TargetEditor> = Set(actions.compactMap { $0.editor }) if editors.contains(.channel) { channelEditor.apply() } if editors.contains(.contact) { contactEditor.apply() } return nil } private func parse(value: AirshipJSON) throws -> [AttributeActionArgs] { if let unwrapped: [AttributeActionArgs] = try? value.decode() { return unwrapped } guard let unwrapped = value.unWrap(), let dict = unwrapped as? [String: [String: Any]] else { throw AirshipErrors.error("invalid arguments") } let convertEdits: (AttributeActionArgs.TargetEditor, [String: Any]) throws -> [AttributeActionArgs] = { editor, input in let sets: [AttributeActionArgs] = input[ModifyAttributesAction.setActionKey] .map { items in guard let items = items as? [String: Any] else { return [] } return items .compactMapValues { try? AttributeActionArgs.Value(value: $0) } .map { AttributeActionArgs.set(editor, $0.key, $0.value) } } ?? [] if input.keys.contains(ModifyAttributesAction.setActionKey) && sets.isEmpty { throw AirshipErrors.error("failed to parse set arguments") } let removes: [AttributeActionArgs] = input[ModifyAttributesAction.removeActionKey] .map { items in guard let items = items as? [String] else { return [] } return items.map { AttributeActionArgs.remove(editor, $0) } } ?? [] if input.keys.contains(ModifyAttributesAction.removeActionKey) && removes.isEmpty { throw AirshipErrors.error("failed to parse remove arguments") } return sets + removes } return (try convertEdits(.contact, dict[ModifyAttributesAction.namedUserKey] ?? [:])) + (try convertEdits(.channel, dict[ModifyAttributesAction.channelsKey] ?? [:])) } private enum AttributeActionArgs: Codable, Hashable, Sendable { case set(TargetEditor, String, Value) case remove(TargetEditor, String) enum CodingKeys: String, CodingKey { case actionType = "action" case target = "type" case name case value } var editor: TargetEditor { switch self { case .set(let editor, _, _): return editor case .remove(let editor, _): return editor } } var name: String { switch self { case .set(_, let name, _): return name case .remove(_, let name): return name } } var value: Value? { switch self { case .set(_, _, let value): return value case .remove: return nil } } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(ActionType.self, forKey: .actionType) let editor = try container.decode(TargetEditor.self, forKey: .target) let name = try container.decode(String.self, forKey: .name) switch(type) { case .set: self = .set(editor, name, try container.decode(Value.self, forKey: .value)) case .remove: self = .remove(editor, name) } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(editor, forKey: .target) try container.encode(name, forKey: .name) switch(self) { case .set(_, _, let value): try container.encode(ActionType.set, forKey: .actionType) try container.encode(value, forKey: .value) case .remove(_, _): try container.encode(ActionType.remove, forKey: .actionType) } } enum ActionType: String, Codable, Sendable { case set case remove } enum TargetEditor: String, Codable, Sendable { case channel case contact } enum Value: Codable, Sendable, Hashable { case string(String) case number(Double) case date(Date) case json(JsonValue) init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let string = try? container.decode(String.self) { self = .string(string) } else if let double = try? container.decode(Double.self) { self = .number(double) } else if let date = try? container.decode(Date.self) { self = .date(date) } else if let json = try? JsonValue(from: decoder) { self = .json(json) } else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "unsupported type") } } init(value: Any) throws { if let string = value as? String { self = .string(string) } else if let number = value as? NSNumber { self = .number(number.doubleValue) } else if let date = value as? Date { self = .date(date) } else { throw AirshipErrors.error("Unsupported value type for attribute modification") } } func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .string(let string): try container.encode(string) case .number(let number): try container.encode(number) case .date(let date): try container.encode(date) case .json(let json): try container.encode(json) } } } struct JsonValue: Codable, Sendable, Hashable { private static let keyExpiration: String = "exp" let name: String let instanceId: String let expiration: Date? let value: [String: AirshipJSON] init(from decoder: any Decoder) throws { let json = try AirshipJSON(from: decoder) guard case .object(let dict) = json, dict.count == 1, let keyInstanceId = dict.first?.key, keyInstanceId.contains("#"), let contentJson = dict.first?.value, case .object(var content) = contentJson else { throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected JSON object but found \(json)")) } let components = keyInstanceId.split(separator: "#") guard components.count == 2 else { throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Invalid instance ID format: \(keyInstanceId)")) } self.name = String(components[0]) self.instanceId = String(components[1]) self.expiration = Self.convertToDate(content.removeValue(forKey: Self.keyExpiration)) self.value = content } func encode(to encoder: any Encoder) throws { var content = value if let expiration { content[Self.keyExpiration] = .number(expiration.timeIntervalSince1970) } let source = [ "\(name)#\(instanceId)": content ] try AirshipJSON.wrap(source).encode(to: encoder) } private static func convertToDate(_ value: AirshipJSON?) -> Date? { guard let value = value else { return nil } switch value { case .number(let interval): return Date(timeIntervalSince1970: interval) default: return nil } } } } } ================================================ FILE: Airship/AirshipCore/Source/ModifyTagsAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Modify channel or contact tags. /// /// Expected argument values: an array of mutations. /// An example add channel tags JSON payload: /// [ /// { /// "action": "add", /// "tags": [ /// "channel_tag_1", /// "channel_tag_2" /// ], /// "type": "channel" /// }, /// { /// "action": "remove", /// "group": "tag_group" /// "tags": [ /// "contact_tag_1", /// "contact_tag_2" /// ], /// "type": "contact" /// } /// ] /// /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush` /// `ActionSituation.webViewInvocation`, `ActionSituation.foregroundInteractiveButton`, /// `ActionSituation.backgroundInteractiveButton`, `ActionSituation.manualInvocation`, and /// `ActionSituation.automation` public final class ModifyTagsAction: AirshipAction { /// Default names - "tag_action", "^t" public static let defaultNames: [String] = ["tag_action", "^t"] /// Default predicate - rejects foreground pushes with visible display options public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } private let channel: @Sendable () -> any AirshipChannel private let contact: @Sendable () -> any AirshipContact init( channel: @escaping @Sendable () -> any AirshipChannel, contact: @escaping @Sendable () -> any AirshipContact ) { self.channel = channel self.contact = contact } public convenience init() { self.init( channel: Airship.componentSupplier(), contact: Airship.componentSupplier() ) } public func accepts(arguments: ActionArguments) async -> Bool { guard arguments.situation != .backgroundPush else { return false } return true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let data: [Arguments] = try arguments.value.decode() let channelEditor = self.channel().editTags() let channelGroupEditor = self.channel().editTagGroups() let contactGroupEditor = self.contact().editTagGroups() var onDoneCallbacks: [EditorType: () -> Void] = [:] for data in data { performAction( data: data, channelEditor: { if !onDoneCallbacks.keys.contains(.channel) { onDoneCallbacks[.channel] = channelEditor.apply } return channelEditor }) { target in let key: EditorType let editor: TagGroupsEditor switch target { case .channel: key = .channelGroup editor = channelGroupEditor case .contact: key = .contactGroup editor = contactGroupEditor } if !onDoneCallbacks.keys.contains(key) { onDoneCallbacks[key] = editor.apply } return editor } } onDoneCallbacks.values.forEach { $0() } return nil } private func performAction( data: Arguments, channelEditor: () -> TagEditor, groupEditor: (Arguments.Target) -> TagGroupsEditor ) { switch data { case .channel(let args): if let group = args.group { let editor = groupEditor(.channel) switch args.action { case .add: editor.add(args.tags, group: group) case .remove: editor.remove(args.tags, group: group) } } else { let editor = channelEditor() switch args.action { case .add: editor.add(args.tags) case .remove: editor.remove(args.tags) } } case .contact(let args): let editor = groupEditor(.contact) switch args.action { case .add: editor.add(args.tags, group: args.group) case .remove: editor.remove(args.tags, group: args.group) } } } private enum EditorType: Hashable { case channel, channelGroup, contactGroup } private enum Arguments: Codable, Sendable { case channel(ChannelTags) case contact(ContactTags) private enum CodingKeys : String, CodingKey, Sendable { case type } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) switch try container.decode(Target.self, forKey: .type) { case .channel: self = try .channel(.init(from: decoder)) case .contact: self = try .contact(.init(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .channel(let value): try value.encode(to: encoder) case .contact(let value): try value.encode(to: encoder) } } enum Target: String, Codable, Sendable { case channel = "channel" case contact = "contact" } enum ActionType: String, Codable, Sendable { case add = "add" case remove = "remove" } struct ChannelTags: Codable, Sendable { let type: Target = .channel let group: String? let action: ActionType let tags: [String] enum CodingKeys : String, CodingKey, Sendable { case group case action case tags case type } } struct ContactTags: Codable, Sendable { let type: Target = .contact let group: String let action: ActionType let tags: [String] enum CodingKeys : String, CodingKey, Sendable { case group case action case tags case type } } } } ================================================ FILE: Airship/AirshipCore/Source/ModuleLoader.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct AirshiopModuleLoaderArgs { public let config: RuntimeConfig public let dataStore: PreferenceDataStore public let channel: any InternalAirshipChannel public let contact: any AirshipContact public let push: any AirshipPush public let remoteData: any RemoteDataProtocol public let analytics: any InternalAirshipAnalytics public let privacyManager: any AirshipPrivacyManager public let permissionsManager: any AirshipPermissionsManager public let experimentsManager: any ExperimentDataProvider public let meteredUsage: any AirshipMeteredUsage public let deferredResolver: any AirshipDeferredResolverProtocol public let cache: any AirshipCache public let audienceChecker: any DeviceAudienceChecker public let workManager: any AirshipWorkManagerProtocol public let inputValidator: any AirshipInputValidation.Validator } /// NOTE: For internal use only. :nodoc: enum SDKModuleNames: String, CaseIterable { case messageCenter = "UAMessageCenterSDKModule" case preferenceCenter = "UAPreferenceCenterSDKModule" case debug = "UADebugSDKModule" case featureFlags = "UAFeatureFlagsSDKModule" case automation = "UAAutomationSDKModule" } /// NOTE: For internal use only. :nodoc: class ModuleLoader { public let components: [any AirshipComponent] public let actionManifests: [any ActionsManifest] @MainActor init( config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any InternalAirshipChannel, contact: any AirshipContact, push: any AirshipPush, remoteData: any RemoteDataProtocol, analytics: any InternalAirshipAnalytics, privacyManager: any AirshipPrivacyManager, permissionsManager: any AirshipPermissionsManager, audienceOverrides: any AudienceOverridesProvider, experimentsManager: any ExperimentDataProvider, meteredUsage: any AirshipMeteredUsage, deferredResolver: any AirshipDeferredResolverProtocol, cache: any AirshipCache, audienceChecker: any DeviceAudienceChecker, inputValidator: any AirshipInputValidation.Validator ) { let args = AirshiopModuleLoaderArgs( config: config, dataStore: dataStore, channel: channel, contact: contact, push: push, remoteData: remoteData, analytics: analytics, privacyManager: privacyManager, permissionsManager: permissionsManager, experimentsManager: experimentsManager, meteredUsage: meteredUsage, deferredResolver: deferredResolver, cache: cache, audienceChecker: audienceChecker, workManager: AirshipWorkManager.shared, inputValidator: inputValidator ) let modules = ModuleLoader.loadModules(args) self.components = modules.compactMap { $0.components }.reduce([], +) self.actionManifests = modules.compactMap { $0.actionsManifest } } @MainActor private class func loadModules(_ args: AirshiopModuleLoaderArgs) -> [any AirshipSDKModule] { let sdkModules: [any AirshipSDKModule] = SDKModuleNames.allCases.compactMap { guard let moduleClass = NSClassFromString($0.rawValue) as? any AirshipSDKModule.Type else { return nil } AirshipLogger.debug("Loading module \($0)") return moduleClass.load(args) } return sdkModules } } ================================================ FILE: Airship/AirshipCore/Source/NativeBridge.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Foundation @preconcurrency public import WebKit #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif typealias WebViewForwardHandler = (WKWebView, WKNavigationAction, @Sendable @escaping @MainActor (WKNavigationActionPolicy) -> Void) -> Void /// The native bridge will automatically load the Airship JavaScript environment into whitlelisted sites. The native /// bridge must be assigned as the navigation delegate on a `WKWebView` in order to function. public final class NativeBridge: NSObject, WKNavigationDelegate { static let airshipScheme: String = "uairship" private static let closeCommand: String = "close" private static let dismissCommand: String = "dismiss" private static let setNamedUserCommand: String = "named_user" private static let multiCommand: String = "multi" @MainActor private var jsRequests: [JSBridgeLoadRequest] = [] private static let forwardSchemes: [String] = [ "itms-apps", "maps", "sms", "tel", "mailto", ] private static let forwardHosts: [String] = [ "maps.google.com", "www.youtube.com", "phobos.apple.com", "itunes.apple.com", ] /// Delegate to support additional native bridge features such as `close`. public weak var nativeBridgeDelegate: (any NativeBridgeDelegate)? /// Optional delegate to forward any WKNavigationDelegate calls. public weak var forwardNavigationDelegate: (any AirshipWKNavigationDelegate)? /// Optional delegate to support custom JavaScript commands. public weak var javaScriptCommandDelegate: (any JavaScriptCommandDelegate)? /// Optional delegate to extend the native bridge. public weak var nativeBridgeExtensionDelegate: (any NativeBridgeExtensionDelegate)? private let actionHandler: any NativeBridgeActionHandlerProtocol private let javaScriptEnvironmentFactoryBlock: () -> any JavaScriptEnvironmentProtocol private let challengeResolver: ChallengeResolver /// NativeBridge initializer. /// - Note: For internal use only. :nodoc: /// - Parameter actionHandler: An action handler. /// - Parameter javaScriptEnvironmentFactoryBlock: A factory block producing a JavaScript environment. init( actionHandler: any NativeBridgeActionHandlerProtocol, javaScriptEnvironmentFactoryBlock: @escaping () -> any JavaScriptEnvironmentProtocol, resolver: ChallengeResolver = .shared ) { self.actionHandler = actionHandler self.javaScriptEnvironmentFactoryBlock = javaScriptEnvironmentFactoryBlock self.challengeResolver = resolver super.init() } /// NativeBridge initializer. public convenience override init() { self.init( actionHandler: NativeBridgeActionHandler(), javaScriptEnvironmentFactoryBlock: { return JavaScriptEnvironment() } ) } /// NativeBridge initializer. /// - Parameter actionRunner: An action runner to run actions triggered from the web view public convenience init(actionRunner: any NativeBridgeActionRunner) { self.init( actionHandler: NativeBridgeActionHandler(actionRunner: actionRunner), javaScriptEnvironmentFactoryBlock: { return JavaScriptEnvironment() } ) } /** * Decide whether to allow or cancel a navigation. :nodoc: * * If a uairship:// URL, process it ourselves */ public func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void ) { let navigationType = navigationAction.navigationType let originatingURL = webView.url let requestURL = navigationAction.request.url let isAirshipJSAllowed = originatingURL?.isAllowed(scope: .javaScriptInterface) ?? false // Airship commands if let requestURL = requestURL, isAirshipJSAllowed, requestURL.isAirshipCommand { if navigationType == .linkActivated || navigationType == .other { let command = JavaScriptCommand(url: requestURL) Task { @MainActor in await self.handleAirshipCommand( command: command, webView: webView ) } } decisionHandler(.cancel) return } let forward = self.forwardNavigationDelegate?.webView as WebViewForwardHandler? // Forward if let forward = forward { forward(webView, navigationAction) { policyForThisURL in if policyForThisURL == WKNavigationActionPolicy.allow && navigationType == WKNavigationType.linkActivated { let decisionHandlerWrapper = AirshipUnsafeSendableWrapper(decisionHandler) Task { @MainActor in // Try to override any special link handling self.handle(requestURL) { success in decisionHandlerWrapper.value(success ? .cancel : .allow) } } } else { decisionHandler(policyForThisURL) } } return } // Default guard let requestURL = requestURL else { decisionHandler(.allow) return } // Default let handleLink: () -> Void = { /// If target frame is a new window navigation, have OS handle it if navigationAction.targetFrame == nil { DefaultURLOpener.shared.openURL(requestURL) { success in decisionHandler(success ? .cancel : .allow) } } else { decisionHandler(.allow) } } if navigationType == WKNavigationType.linkActivated { let decisionHandlerWrapper = AirshipUnsafeSendableWrapper(decisionHandler) let handleLinkWrapper = AirshipUnsafeSendableWrapper(handleLink) Task { @MainActor in self.handle(requestURL) { success in if success { decisionHandlerWrapper.value(.cancel) } else { handleLinkWrapper.value() } } } } else { handleLink() } } /** * Decide whether to allow or cancel a navigation after its response is known. :nodoc: */ public func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @Sendable @escaping @MainActor (WKNavigationResponsePolicy) -> Void ) { guard let forward = self.forwardNavigationDelegate?.webView as ( ( WKWebView, WKNavigationResponse, @Sendable @MainActor @escaping (WKNavigationResponsePolicy) -> Void ) -> Void )? else { decisionHandler(.allow) return } forward(webView, navigationResponse, decisionHandler) } /** * Called when the navigation is complete. :nodoc: */ @MainActor public func webView( _ webView: WKWebView, didFinish navigation: WKNavigation! ) { AirshipLogger.trace( "Webview finished navigation: \(String(describing: webView.url))" ) cancelJSRequest(webView: webView) if let url = webView.url, url.isAllowed(scope: .javaScriptInterface) { AirshipLogger.trace("Loading Airship JS bridge: \(url)") let request = JSBridgeLoadRequest(webView: webView) { [weak self] in return await self?.makeJSEnvironment(webView: webView) } self.jsRequests.append(request) request.start() } self.forwardNavigationDelegate?.webView?( webView, didFinish: navigation ) } /** * Called when the web view begins to receive web content. :nodoc: */ public func webView( _ webView: WKWebView, didCommit navigation: WKNavigation! ) { self.forwardNavigationDelegate?.webView?( webView, didCommit: navigation ) } /** * Called when the web view’s web content process is terminated. :nodoc: */ public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { self.forwardNavigationDelegate? .webViewWebContentProcessDidTerminate?( webView ) } /** * Called when web content begins to load in a web view. :nodoc: */ @MainActor public func webView( _ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation! ) { self.cancelJSRequest(webView: webView) self.forwardNavigationDelegate?.webView?( webView, didStartProvisionalNavigation: navigation ) } /** * Called when a web view receives a server redirect. :nodoc: */ public func webView( _ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation! ) { self.forwardNavigationDelegate?.webView?( webView, didReceiveServerRedirectForProvisionalNavigation: navigation ) } /** * Called when an error occurs during navigation. :nodoc: */ public func webView( _ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error ) { self.forwardNavigationDelegate?.webView?( webView, didFail: navigation, withError: error ) } /** * Called when an error occurs while the web view is loading content. :nodoc: */ public func webView( _ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error ) { self.forwardNavigationDelegate?.webView?( webView, didFailProvisionalNavigation: navigation, withError: error ) } /** * Called when the web view needs to respond to an authentication challenge. :nodoc: */ public func webView( _ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @Sendable @escaping @MainActor ( URLSession.AuthChallengeDisposition, URLCredential? ) -> Void ) { guard let forward = self.forwardNavigationDelegate?.webView as ( ( WKWebView, URLAuthenticationChallenge, @Sendable @MainActor @escaping ( URLSession.AuthChallengeDisposition, URLCredential? ) -> Void ) -> Void )? else { Task { let (disposition, creds) = await challengeResolver.resolve(challenge) completionHandler(disposition, creds) } return } forward(webView, challenge, completionHandler) } @MainActor private func makeJSEnvironment(webView: WKWebView) async -> String { let jsEnvironment: any JavaScriptEnvironmentProtocol = self.javaScriptEnvironmentFactoryBlock() await self.nativeBridgeExtensionDelegate?.extendJavaScriptEnvironment( jsEnvironment, webView: webView ) return await jsEnvironment.build() } @MainActor private func handleAirshipCommand( command: JavaScriptCommand, webView: WKWebView ) async { switch command.name { case NativeBridge.closeCommand: self.nativeBridgeDelegate?.close() case NativeBridge.dismissCommand: self.nativeBridgeDelegate?.close() case NativeBridge.setNamedUserCommand: let idArgs = command.options["id"] let argument = idArgs?.first let contact: any AirshipContact = Airship.contact if let identifier = argument, !identifier.isEmpty { contact.identify(identifier) } else { contact.reset() } case NativeBridge.multiCommand: let commands = command.url.query?.components(separatedBy: "&") .compactMap { URL(string: $0.removingPercentEncoding ?? "") } .filter { $0.isAirshipCommand }.compactMap { url in JavaScriptCommand(url: url) } ?? [] for command in commands { await self.handleAirshipCommand( command: command, webView: webView ) } default: if NativeBridgeActionHandler.isActionCommand(command: command) { let metadata = self.nativeBridgeExtensionDelegate? .actionsMetadata( for: command, webView: webView ) let script = await self.actionHandler.runActionsForCommand( command: command, metadata: metadata, webView: webView ) do { if let script = script { try await webView.evaluateJavaScriptAsync(script) } } catch { AirshipLogger.error("JavaScript error: \(error) command: \(command)") } } else if !forwardAirshipCommand(command, webView: webView) { AirshipLogger.debug("Unhandled JavaScript command: \(command)") } } } @MainActor private func forwardAirshipCommand( _ command: JavaScriptCommand, webView: WKWebView ) -> Bool { /// Local JavaScript command delegate if self.javaScriptCommandDelegate? .performCommand(command, webView: webView) == true { return true } if Airship.javaScriptCommandDelegate? .performCommand( command, webView: webView ) == true { return true } return false } @MainActor private func cancelJSRequest(webView: WKWebView) { jsRequests.removeAll { request in if request.webView == nil || request.webView == webView { request.cancel() return true } return false } } /** * Handles a link click. * * - Parameters: * - url The link's URL. * - completionHandler The completion handler to execute when openURL processing is complete. * - */ @available(iOSApplicationExtension, unavailable) @MainActor private func handle( _ url: URL?, _ completionHandler: @Sendable @escaping @MainActor (Bool) -> Void ) { guard let url = url, shouldForwardURL(url) else { completionHandler(false) return } DefaultURLOpener.shared.openURL(url, completionHandler: completionHandler) } private func shouldForwardURL(_ url: URL) -> Bool { let scheme = url.scheme?.lowercased() ?? "" let host = url.host?.lowercased() ?? "" return NativeBridge.forwardSchemes.contains(scheme) || NativeBridge.forwardHosts.contains(host) } private func closeWindow(_ animated: Bool) { self.forwardNavigationDelegate?.closeWindow?(animated) } } @objc public protocol AirshipWKNavigationDelegate: WKNavigationDelegate { @objc optional func closeWindow(_ animated: Bool) } extension URL { @MainActor fileprivate var isAirshipCommand: Bool { return self.scheme == NativeBridge.airshipScheme } @MainActor fileprivate func isAllowed(scope: URLAllowListScope) -> Bool { return Airship.urlAllowList.isAllowed(self, scope: scope) } } @MainActor fileprivate class JSBridgeLoadRequest: Sendable { private(set) weak var webView: WKWebView? private let jsFactoryBlock: () async throws -> String? private var task: Task<Void, Never>? init(webView: WKWebView? = nil, jsFactoryBlock: @escaping () async throws -> String?) { self.webView = webView self.jsFactoryBlock = jsFactoryBlock } func start() { task?.cancel() self.task = Task { @MainActor in do { try Task.checkCancellation() let js = try await jsFactoryBlock() try Task.checkCancellation() if let webView = webView, let js = js { try await webView.evaluateJavaScript(js) AirshipLogger.trace("Native bridge injected") } } catch { } } } func cancel() { self.task?.cancel() } } fileprivate extension WKWebView { //The async/await version of `evaluateJavaScript` function exposed by apple is crashing when the JavaScript is a void method. We created this func to avoid the crash and we can update once the crash is fixed. @discardableResult func evaluateJavaScriptAsync(_ str: String) async throws -> Any? { return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Any?, Error>) in DispatchQueue.main.async { self.evaluateJavaScript(str) { data, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: AnySendable(value: data)) } } } } } } // WORKAROUND: WKWebView's evaluateJavaScript async API requires Sendable closures, // but returns non-Sendable Any? values. This wrapper uses @unchecked Sendable to // bridge the JavaScript evaluation result safely across async boundaries. fileprivate struct AnySendable: @unchecked Sendable { let value: Any? } #endif ================================================ FILE: Airship/AirshipCore/Source/NativeBridgeActionHandler.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Foundation import WebKit protocol NativeBridgeActionHandlerProtocol: Sendable { /** * Runs actions for a command. * - Parameters: * - command The action command. * - metadata The action metadata. * - Returns: Returns the optional script to evaluate in the web view.. */ @MainActor func runActionsForCommand( command: JavaScriptCommand, metadata: [String: any Sendable]?, webView: WKWebView ) async -> String? } private struct DefaultNativeBridgeActionRunner: NativeBridgeActionRunner { func runAction(actionName: String, arguments: ActionArguments, webView: WKWebView) async -> ActionResult { return await ActionRunner.run(actionName: actionName, arguments: arguments) } } final class NativeBridgeActionHandler: NativeBridgeActionHandlerProtocol { private let actionRunner: any NativeBridgeActionRunner init(actionRunner: any NativeBridgeActionRunner = DefaultNativeBridgeActionRunner()) { self.actionRunner = actionRunner } /** * Runs actions for a command. * - Parameters: * - command The action command. * - metadata The action metadata. * - webView The web view */ @MainActor public func runActionsForCommand( command: JavaScriptCommand, metadata: [String: any Sendable]?, webView: WKWebView ) async -> String? { AirshipLogger.debug("Running actions for command: \(command)") /* * run-action-cb performs a single action and calls the completion handler with * the result of the action. The action's value is JSON encoded. * * Expected format: * run-action-cb/<actionName>/<actionValue>/<callbackID> */ if command.name == "run-action-cb" { if command.arguments.count != 3 { AirshipLogger.debug( String( format: "Unable to run-action-cb, wrong number of arguments. %@", command.arguments ) ) AirshipLogger.error("Unable to run-action-cb, wrong number of arguments") } let actionName = command.arguments[0] let actionValue = NativeBridgeActionHandler.parse( command.arguments[1] ) let callbackID = command.arguments[2] /// Run the action return await self.run( actionName, actionValue, metadata ?? [:], callbackID, webView: webView ) } /* * run-actions performs several actions with the values JSON encoded. * * Expected format: * run-actions?<actionName>=<actionValue>&<anotherActionName>=<anotherActionValue>... */ if command.name == "run-actions" { await self.run( self.decodeActionValues(command, false), metadata: metadata, webView: webView ) return nil } /* * run-basic-actions performs several actions with basic encoded action values. * * Expected format: * run-basic-actions?<actionName>=<actionValue>&<anotherActionName>=<anotherActionValue>... */ if command.name == "run-basic-actions" { await self.run( self.decodeActionValues(command, true), metadata: metadata, webView: webView ) return nil } return nil } /** * Runs a dictionary of action names to an array of action values. * * - Parameters: * - actionValues A map of action name to an array of action values. * - metadata Optional metadata to pass to the action arguments. */ @MainActor private func run( _ actionValues: [String: [AirshipJSON]], metadata: [String: any Sendable]?, webView: WKWebView ) async { for (actionName, values) in actionValues { for value in values { _ = await self.actionRunner.runAction( actionName: actionName, arguments: ActionArguments( value: value, situation: .webViewInvocation, metadata: metadata ?? [:] ), webView: webView ) } } } /** * Runs an action with a given value. * * - Parameters: * - actionName The name of the action to perform * - actionValue The action argument's value * - metadata Optional metadata to pass to the action arguments. * - callbackID A callback identifier generated in the JS layer. This can be `nil`. */ @MainActor private func run( _ actionName: String, _ actionValue: AirshipJSON, _ metadata: [String: any Sendable], _ callbackID: String, webView: WKWebView ) async -> String? { let callbackID = try? AirshipJSONUtils.string( callbackID, options: .fragmentsAllowed ) let result = await self.actionRunner.runAction( actionName: actionName, arguments: ActionArguments( value: actionValue, situation: .webViewInvocation, metadata: metadata ), webView: webView ) guard let callbackID = callbackID else { return nil } switch result { case .completed(let value): return "UAirship.finishAction(null, \((try? value.toString()) ?? "null"), \(callbackID));" case .actionNotFound: return errorResponse( errorMessage: "No action found with name \(actionName), skipping action.", callbackID: callbackID ) case .error(let error): return errorResponse( errorMessage: error.localizedDescription, callbackID: callbackID ) case .argumentsRejected: return errorResponse( errorMessage: "Action \(actionName) rejected arguments.", callbackID: callbackID ) } } private class func parse(_ json: String) -> AirshipJSON { do { return try AirshipJSON.from(json: json) } catch { AirshipLogger.warn("Unable to json decode action args \(error), \(json)") return AirshipJSON.null } } private func errorResponse(errorMessage: String, callbackID: String) -> String { let json = (try? AirshipJSONUtils.string(errorMessage, options: .fragmentsAllowed)) ?? "" return "var error = new Error(); error.message = \(json); UAirship.finishAction(error, null, \(callbackID));" } /** * Checks if a command defines an action. * - Parameters: * - command The command. * - Returns: `YES` if the command is either `run-actions`, `run-action`, or `run-action-cb`, otherwise `NO`. */ public class func isActionCommand(command: JavaScriptCommand) -> Bool { let name = command.name return (name == "run-actions" || name == "run-basic-actions" || name == "run-action-cb") } /** * Decodes options with basic URL or URL+json encoding * * - Parameters: * - command The JavaScript command. * - basicEncoding Boolean to select for basic encoding * - Returns: A dictionary of action name to an array of action values. */ private func decodeActionValues( _ command: JavaScriptCommand, _ basicEncoding: Bool ) -> [String: [AirshipJSON]] { var actionValues: [String: [AirshipJSON]] = [:] do { try command.options.forEach { (actionName, optionValues) in actionValues[actionName] = try optionValues.compactMap { actionArg in if (actionArg.isEmpty) { return AirshipJSON.null } if basicEncoding{ return AirshipJSON.string(actionArg) } return try AirshipJSON.from(json: actionArg) } } } catch { AirshipLogger.warn("Unable to json decode action args \(error) for command \(command)") return [:] } return actionValues } } #endif ================================================ FILE: Airship/AirshipCore/Source/NativeBridgeActionRunner.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) public import WebKit /// Action runner used in the `NativeBridge`. public protocol NativeBridgeActionRunner: Sendable { /// Called to run an action when triggered from the web view. /// - Parameters: /// - actionName: The action name. /// - arguments: The action arguments. /// - webView: The web view. /// - Returns: The action result. @MainActor func runAction(actionName: String, arguments: ActionArguments, webView: WKWebView) async -> ActionResult } /// Action runner used in the `NativeBridge` that calls through to a block. public struct BlockNativeBridgeActionRunner: NativeBridgeActionRunner { private let onRun: @MainActor (String, ActionArguments, WKWebView) async -> ActionResult /// Default initialiizer. /// - Parameters: /// - onRun: The action block. public init(onRun: @escaping @MainActor (String, ActionArguments, WKWebView) async -> ActionResult) { self.onRun = onRun } @MainActor public func runAction(actionName: String, arguments: ActionArguments, webView: WKWebView) async -> ActionResult { return await self.onRun(actionName, arguments, webView) } } #endif ================================================ FILE: Airship/AirshipCore/Source/NativeBridgeDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if !os(tvOS) && !os(watchOS) /// Delegate for native bridge events from web views. public protocol NativeBridgeDelegate: AnyObject { /// Called when `UAirship.close()` is triggered from the JavaScript environment. func close() } #endif ================================================ FILE: Airship/AirshipCore/Source/NativeBridgeExtensionDelegate.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Foundation public import WebKit /// Delegate to extend the native bridge. public protocol NativeBridgeExtensionDelegate: AnyObject { /// Called when an action is triggered from the JavaScript Environment. This method should return the metadata used in the `ActionArgument`. /// - Parameter command The JavaScript command. /// - Parameter webView The webview. /// @return The action metadata. @MainActor func actionsMetadata( for command: JavaScriptCommand, webView: WKWebView ) -> [String: String] /// Called before the JavaScript environment is being injected into the web view. /// - Parameter js The JavaScript environment. /// - Parameter webView The web view. /// - Parameter completionHandler The completion handler when finished. @MainActor func extendJavaScriptEnvironment( _ js: any JavaScriptEnvironmentProtocol, webView: WKWebView ) async } #endif ================================================ FILE: Airship/AirshipCore/Source/NativeVideoPlayer.swift ================================================ /* Copyright Airship and Contributors */ #if !os(watchOS) && !os(macOS) import Foundation import SwiftUI import AVKit import AVFoundation import UIKit @MainActor struct NativeVideoPlayer: UIViewRepresentable { typealias UIViewType = UIView let info: ThomasViewInfo.Media let videoIdentifier: String? let onMediaReady: @MainActor () -> Void @Binding var hasError: Bool @Binding var player: AVPlayer? @Environment(\.isVisible) private var isVisible @Environment(\.layoutDirection) private var layoutDirection @State private var isLoaded: Bool = false @EnvironmentObject var pagerState: PagerState @EnvironmentObject var videoState: VideoState private var video: ThomasViewInfo.Media.Video? { self.info.properties.video } private var url: String { self.info.properties.url } @MainActor func makeUIView(context: Context) -> UIView { let playerContainer = VideoPlayerContainer(frame: .zero) playerContainer.isAccessibilityElement = true playerContainer.accessibilityLabel = self.info.accessible.contentDescription playerContainer.info = self.info playerContainer.isRTL = layoutDirection == .rightToLeft guard let videoURL = URL(string: url) else { Task { @MainActor in self.hasError = true } return playerContainer } let playerItem = AVPlayerItem(url: videoURL) let playerInstance = AVPlayer(playerItem: playerItem) playerContainer.player = playerInstance playerContainer.videoURL = videoURL playerContainer.shouldLoop = video?.loop ?? false playerContainer.isMuted = video?.muted ?? false playerContainer.configurePlayerView() Task { @MainActor [weak coordinator = context.coordinator] in self.player = playerInstance coordinator?.configure( playerContainer: playerContainer, onMediaReady: onMediaReady ) } return playerContainer } static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { coordinator.teardown() } @MainActor func updateUIView(_ uiView: UIView, context: Context) { let isVisible = isVisible let isLoaded = isLoaded let inProgress = pagerState.inProgress Task { @MainActor [weak coordinator = context.coordinator] in coordinator?.update( isVisible: isVisible, isLoaded: isLoaded, inProgress: inProgress ) } } func makeCoordinator() -> Coordinator { Coordinator( isLoaded: $isLoaded, hasError: $hasError, videoState: videoState, videoIdentifier: videoIdentifier, isAutoplay: video?.autoplay ?? false, showControls: video?.showControls ?? true, autoResetPosition: video?.autoResetPosition ?? ((video?.autoplay ?? false) && !(video?.showControls ?? true)) ) } // MARK: - PlayerObservers private final class PlayerObservers: @unchecked Sendable { var endTimeObserver: (any NSObjectProtocol)? var statusObserver: NSKeyValueObservation? var rateObserver: NSKeyValueObservation? var muteObserver: NSKeyValueObservation? weak var player: AVPlayer? func cleanup() { if let observer = endTimeObserver { NotificationCenter.default.removeObserver(observer) endTimeObserver = nil } statusObserver?.invalidate() statusObserver = nil rateObserver?.invalidate() rateObserver = nil muteObserver?.invalidate() muteObserver = nil } } // MARK: - Coordinator @MainActor class Coordinator: NSObject { private var isLoaded: Binding<Bool> private var hasError: Binding<Bool> private var videoState: VideoState private var videoIdentifier: String? private var isAutoplay: Bool private var showControls: Bool private var autoResetPosition: Bool private weak var playerContainer: VideoPlayerContainer? private var onMediaReady: (@MainActor () -> Void)? private let observers = PlayerObservers() private var lastIsVisible: Bool = false private var lastIsLoaded: Bool = false private var lastInProgress: Bool = true /// Tracks whether the system (visibility change, pager, backgrounding) initiated a pause. /// When true, incoming rate changes from AVPlayer won't clear `localIsPlaying`. private var isSystemPausing: Bool = false /// Tracks playing intent from AVPlayer rate changes. `nil` = initial (autoplay should trigger), /// `true` = playing/was playing, `false` = user explicitly paused. /// Guarded by `isSystemPausing` so system pauses don't clear user intent. private var localIsPlaying: Bool? = nil private var appStateTask: Task<Void, Never>? init( isLoaded: Binding<Bool>, hasError: Binding<Bool>, videoState: VideoState, videoIdentifier: String?, isAutoplay: Bool, showControls: Bool, autoResetPosition: Bool ) { self.isLoaded = isLoaded self.hasError = hasError self.videoState = videoState self.videoIdentifier = videoIdentifier self.isAutoplay = isAutoplay self.showControls = showControls self.autoResetPosition = autoResetPosition super.init() AirshipLogger.trace("NativeVideoPlayer Coordinator init") appStateTask = Task { @MainActor [weak self] in for await state in AppStateTracker.shared.stateUpdates { guard !Task.isCancelled else { return } if state == .active { self?.handleForeground() } else { self?.systemPause() } } } } deinit { appStateTask?.cancel() AirshipLogger.trace("NativeVideoPlayer Coordinator deinit") } @MainActor func teardown() { appStateTask?.cancel() appStateTask = nil observers.player?.pause() observers.cleanup() if let videoIdentifier { videoState.unregister(videoIdentifier: videoIdentifier) } playerContainer = nil } // MARK: - Configuration @MainActor func configure( playerContainer: VideoPlayerContainer, onMediaReady: @MainActor @escaping () -> Void ) { cleanupObservers() self.playerContainer = playerContainer self.onMediaReady = onMediaReady setupObservers() registerWithVideoState() } // MARK: - State Management @MainActor func update(isVisible: Bool, isLoaded: Bool, inProgress: Bool) { let didChange = lastIsVisible != isVisible || lastIsLoaded != isLoaded || lastInProgress != inProgress lastIsVisible = isVisible lastIsLoaded = isLoaded lastInProgress = inProgress playerContainer?.alpha = hasError.wrappedValue ? 0 : 1 guard didChange else { return } if inProgress, isVisible, isLoaded { handleResume() } else { if !isVisible { self.resetToBeginning() } systemPause() } } @MainActor private func handleResume() { let shouldPlay: Bool if videoState.shouldControl(videoIdentifier: videoIdentifier) { shouldPlay = videoState.isPlaying } else if isAutoplay { shouldPlay = localIsPlaying != false } else { shouldPlay = localIsPlaying == true } isSystemPausing = false if shouldPlay { localIsPlaying = true playerContainer?.player?.play() } } @MainActor private func systemPause() { isSystemPausing = true playerContainer?.player?.pause() } @MainActor private func resetToBeginning() { guard autoResetPosition else { return } playerContainer?.player?.seek(to: .zero) } @MainActor private func handleForeground() { guard lastIsVisible, lastIsLoaded, lastInProgress else { return } handleResume() } // MARK: - Video State Registration @MainActor private func registerWithVideoState() { guard let videoId = videoIdentifier, videoState.shouldControl(videoIdentifier: videoId), let player = playerContainer?.player else { return } videoState.register( videoIdentifier: videoId, play: { [weak player] in player?.play() }, pause: { [weak player] in player?.pause() }, mute: { [weak player] in player?.isMuted = true }, unmute: { [weak player] in player?.isMuted = false } ) videoState.muteGroup.initializeMuted(player.isMuted) videoState.playGroup.initializePlaying(isAutoplay || player.rate > 0) player.isMuted = videoState.isMuted } // MARK: - Observers @MainActor private func setupObservers() { guard let playerContainer = playerContainer, let player = playerContainer.player else { return } let shouldLoop = playerContainer.shouldLoop observers.player = player observers.endTimeObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main ) { [weak player] _ in Task { @MainActor in if shouldLoop { player?.seek(to: .zero) player?.play() } } } if let playerItem = player.currentItem { let isLoadedBinding = isLoaded let hasErrorBinding = hasError let onReady = onMediaReady observers.statusObserver = playerItem.observe(\.status, options: [.new]) { item, _ in Task { @MainActor in switch item.status { case .readyToPlay: isLoadedBinding.wrappedValue = true hasErrorBinding.wrappedValue = false onReady?() case .failed: hasErrorBinding.wrappedValue = true case .unknown: break @unknown default: break } } } } observers.muteObserver = player.observe(\.isMuted, options: [.new]) { [weak self] player, _ in Task { @MainActor in guard let self else { return } let canControlVideo = self.lastIsVisible && self.videoState.shouldControl(videoIdentifier: self.videoIdentifier) && self.showControls if canControlVideo { self.videoState.updateMutedState(player.isMuted) } } } observers.rateObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in Task { @MainActor in guard let self else { return } let isPlaying = player.timeControlStatus == .playing let isPaused = player.timeControlStatus == .paused let canControlVideo = self.lastIsVisible && self.videoState.shouldControl(videoIdentifier: self.videoIdentifier) if canControlVideo { if isPlaying { self.videoState.updatePlayingState(true) } else if isPaused && !self.isSystemPausing { self.videoState.updatePlayingState(false) } } else { if isPlaying { self.localIsPlaying = true } else if isPaused && !self.isSystemPausing { self.localIsPlaying = false } } } } } @MainActor private func cleanupObservers() { observers.cleanup() } } // MARK: - VideoPlayerContainer class VideoPlayerContainer: UIView { var player: AVPlayer? var videoURL: URL? var shouldLoop: Bool = false var isMuted: Bool = false var info: ThomasViewInfo.Media? var isRTL: Bool = false override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configurePlayerView() { layer.sublayers?.forEach { if $0 is AVPlayerLayer { $0.removeFromSuperlayer() } } setupPlayer() player?.isMuted = isMuted } func setupPlayer() { guard let player = self.player else { return } let playerLayer = AVPlayerLayer(player: player) playerLayer.frame = bounds if let mediaInfo = self.info { playerLayer.videoGravity = videoGravityForMediaFit(mediaInfo.properties.mediaFit) } else { playerLayer.videoGravity = videoGravityForMediaFit(.centerInside) } layer.addSublayer(playerLayer) } func videoGravityForMediaFit(_ mediaFit: ThomasMediaFit) -> AVLayerVideoGravity { switch mediaFit { case .centerInside: return .resizeAspect case .center: return .resize case .fitCrop, .centerCrop: return .resizeAspectFill } } override func layoutSubviews() { super.layoutSubviews() if let playerLayer = layer.sublayers?.first(where: { $0 is AVPlayerLayer }) as? AVPlayerLayer { playerLayer.frame = bounds } } } } #endif ================================================ FILE: Airship/AirshipCore/Source/NotificationCategories.swift ================================================ // Copyright Airship and Contributors import Foundation #if !os(tvOS) public import UserNotifications /// Utility methods to create categories from plist files or dictionaries. public final class NotificationCategories { // MARK: - Notification Categories Factories /** * Factory method to create the default set of user notification categories. * Background user notification actions will default to requiring authorization. * - Returns: A set of user notification categories */ public class func defaultCategories() -> Set<UNNotificationCategory> { return self.defaultCategories(withRequireAuth: true) } /** * Factory method to create the default set of user notification categories. * * - Parameter requireAuth: If background actions should default to requiring authorization or not. * - Returns: A set of user notification categories. */ public class func defaultCategories(withRequireAuth requireAuth: Bool) -> Set<UNNotificationCategory> { guard let path = AirshipCoreResources.bundle.path( forResource: "UANotificationCategories", ofType: "plist" ) else { return [] } return self.createCategories( fromFile: path, requireAuth: requireAuth ) } /** * Creates a set of categories from the specified `.plist` file. * * Categories are defined in a plist dictionary with the category ID * followed by an array of user notification action definitions. The * action definitions use the same keys as the properties on the action, * with the exception of "foreground" mapping to either UIUserNotificationActivationModeForeground * or UIUserNotificationActivationModeBackground. The required action definition * title can be defined with either the "title" or "title_resource" key, where * the latter takes precedence. If "title_resource" does not exist, the action * definition title will fall back to the value of "title". If the required action * definition title is not defined, the category will not be created. * * Example: * * { * "category_id" : [ * { * "identifier" : "action ID", * "title_resource" : "action title resource", * "title" : "action title", * "foreground" : true, * "authenticationRequired" : false, * "destructive" : false * }] * } * * - Parameter path: The path of the `plist` file * - Returns: A set of categories */ public class func createCategories(fromFile path: String) -> Set< UNNotificationCategory > { return self.createCategories( fromFile: path, actionDefinitionModBlock: { _ in } ) } /** * Creates a user notification category with the specified ID and action definitions. * * - Parameter categoryId: The category identifier * - Parameter actionDefinitions: An array of user notification action dictionaries used to construct UNNotificationAction for the category. * - Returns: The user notification category created, or `nil` if an error occurred. */ public class func createCategory( _ categoryId: String, actions actionDefinitions: [[AnyHashable: Any]] ) -> UNNotificationCategory? { guard let actions = self.getActionsFromActionDefinitions( actionDefinitions ) else { return nil } return UNNotificationCategory( identifier: categoryId, actions: actions, intentIdentifiers: [], options: [] ) } /** * Creates a user notification category with the specified ID, action definitions, and * hiddenPreviewsBodyPlaceholder. * * - Parameter categoryId: The category identifier * - Parameter actionDefinitions: An array of user notification action dictionaries used to construct UNNotificationAction for the category. * - Parameter hiddenPreviewsBodyPlaceholder: A placeholder string to display when the user has disabled notification previews for the app. * - Returns: The user notification category created or `nil` if an error occurred. */ public class func createCategory( _ categoryId: String, actions actionDefinitions: [[AnyHashable: Any]], hiddenPreviewsBodyPlaceholder: String ) -> UNNotificationCategory? { guard let actions = self.getActionsFromActionDefinitions( actionDefinitions ) else { return nil } #if !os(watchOS) return UNNotificationCategory( identifier: categoryId, actions: actions, intentIdentifiers: [], hiddenPreviewsBodyPlaceholder: hiddenPreviewsBodyPlaceholder, options: [] ) #else return UNNotificationCategory( identifier: categoryId, actions: actions, intentIdentifiers: [], options: [] ) #endif } private class func createCategories( fromFile path: String, requireAuth: Bool ) -> Set<UNNotificationCategory> { return self.createCategories( fromFile: path, actionDefinitionModBlock: { actionDefinition in if actionDefinition["foreground"] as? Bool == false { actionDefinition["authenticationRequired"] = requireAuth } } ) } private class func createCategories( fromFile path: String, actionDefinitionModBlock: @escaping (inout [AnyHashable: Any]) -> Void ) -> Set<UNNotificationCategory> { let categoriesDictionary = NSDictionary(contentsOfFile: path) as? [AnyHashable: Any] ?? [:] var categories: Set<UNNotificationCategory> = [] for key in categoriesDictionary.keys { guard let categoryId = key as? String else { continue } guard var actions = categoriesDictionary[categoryId] as? [[AnyHashable: Any]] else { continue } if actions.count == 0 { continue } var mutableActions: [[AnyHashable: Any]] = [] for actionDef in actions { var mutableActionDef: [AnyHashable: Any] = actionDef as [AnyHashable: Any] actionDefinitionModBlock(&mutableActionDef) mutableActions.append(mutableActionDef) } actions = mutableActions if let category = self.createCategory( categoryId, actions: actions ) { categories.insert(category) } } return categories } private class func getTitle(_ actionDefinition: [AnyHashable: Any]) -> String? { guard let title = actionDefinition["title"] as? String else { return nil } if let titleResource = actionDefinition["title_resource"] as? String { return AirshipLocalizationUtils.localizedString( titleResource, withTable: "UrbanAirship", moduleBundle: AirshipCoreResources.bundle, ) ?? title } return title } private class func getActionsFromActionDefinitions( _ actionDefinitions: [[AnyHashable: Any]] ) -> [UNNotificationAction]? { var actions: [UNNotificationAction] = [] for actionDefinition in actionDefinitions { guard let actionId = actionDefinition["identifier"] as? String else { AirshipLogger.error( "Error creating action from definition: \(actionDefinition) due to missing identifier." ) return nil } guard let title = getTitle(actionDefinition) else { AirshipLogger.error( "Error creating action: \(actionId) due to missing title." ) return nil } var options: UNNotificationActionOptions = [] if actionDefinition["destructive"] as? Bool == true { options.insert(.destructive) } if actionDefinition["foreground"] as? Bool == true { options.insert(.foreground) } if actionDefinition["authenticationRequired"] as? Bool == true { options.insert(.authenticationRequired) } if actionDefinition["action_type"] as? String == "text_input" { guard let textInputButtonTitle = actionDefinition["text_input_button_title"] as? String else { AirshipLogger.error( "Error creating action: \(actionId) due to missing text input button title." ) return nil } guard let textInputPlaceholder = actionDefinition["text_input_placeholder"] as? String else { AirshipLogger.error( "Error creating action: \(actionId) due to missing text input placeholder." ) return nil } actions.append( UNTextInputNotificationAction( identifier: actionId, title: title, options: options, textInputButtonTitle: textInputButtonTitle, textInputPlaceholder: textInputPlaceholder ) ) } else { actions.append( UNNotificationAction( identifier: actionId, title: title, options: options ) ) } } return actions } } #endif ================================================ FILE: Airship/AirshipCore/Source/NotificationPermissionDelegate.swift ================================================ // Copyright Airship and Contributors import Foundation import UserNotifications final class NotificationPermissionDelegate: AirshipPermissionDelegate { struct Config: Sendable { let options: UNAuthorizationOptions let skipIfEphemeral: Bool } let registrar: any NotificationRegistrar let config: @Sendable () -> Config init(registrar: any NotificationRegistrar, config: @Sendable @escaping () -> Config) { self.registrar = registrar self.config = config } func checkPermissionStatus() async -> AirshipPermissionStatus { return await registrar.checkStatus().0.permissionStatus } func requestPermission() async -> AirshipPermissionStatus { let config = self.config() await self.registrar.updateRegistration( options: config.options, skipIfEphemeral: config.skipIfEphemeral ) return await self.checkPermissionStatus() } } extension UNAuthorizationStatus { var permissionStatus: AirshipPermissionStatus { switch self { case .authorized: return .granted case .provisional: return .granted case .ephemeral: return .granted case .notDetermined: return .notDetermined case .denied: return .denied @unknown default: return .notDetermined } } } ================================================ FILE: Airship/AirshipCore/Source/NotificationRegistrar.swift ================================================ // Copyright Airship and Contributors import Foundation import UserNotifications protocol NotificationRegistrar: Sendable { #if !os(tvOS) @MainActor func setCategories(_ categories: Set<UNNotificationCategory>) #endif @MainActor func checkStatus() async -> (UNAuthorizationStatus, AirshipAuthorizedNotificationSettings) @MainActor func updateRegistration( options: UNAuthorizationOptions, skipIfEphemeral: Bool ) async -> Void } ================================================ FILE: Airship/AirshipCore/Source/NotificationRegistrationResult.swift ================================================ // Copyright Airship and Contributors import Foundation @preconcurrency public import UserNotifications /// The result of the initial notification registration prompt. public struct NotificationRegistrationResult: Sendable { /// The settings that were authorized at the time of registration. public let authorizedSettings: AirshipAuthorizedNotificationSettings /// The authorization status. public let status: UNAuthorizationStatus #if !os(tvOS) /// Set of the categories that were most recently registered. private let _categories: AirshipUnsafeSendableWrapper<Set<UNNotificationCategory>> public var categories: Set<UNNotificationCategory> { return _categories.value } init(authorizedSettings: AirshipAuthorizedNotificationSettings, status: UNAuthorizationStatus, categories: Set<UNNotificationCategory>) { self.authorizedSettings = authorizedSettings self.status = status self._categories = .init(categories) } #endif } ================================================ FILE: Airship/AirshipCore/Source/OpenExternalURLAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Opens a URL, either in safari or using custom URL schemes. /// /// Expected argument values: A valid URL String. /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush` /// `ActionSituation.webViewInvocation`, `ActionSituation.foregroundInteractiveButton`, /// `ActionSituation.manualInvocation`, and `ActionSituation.automation` /// /// Result value: The input value. public final class OpenExternalURLAction: AirshipAction { /// Default names - "open_external_url_action", "^u", "^w", "wallet_action" public static let defaultNames: [String] = ["open_external_url_action", "^u", "^w", "wallet_action"] /// Default predicate - rejects `ActionSituation.foregroundPush` public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.situation != .foregroundPush } private let urlOpener: any URLOpenerProtocol init(urlOpener: any URLOpenerProtocol) { self.urlOpener = urlOpener } public convenience init() { self.init(urlOpener: DefaultURLOpener()) } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .backgroundPush: return false case .backgroundInteractiveButton: return false default: return true } } @MainActor public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let url = try parseURL(arguments.value) guard Airship.urlAllowList.isAllowed(url, scope: .openURL) else { throw AirshipErrors.error("URL \(url) not allowed") } guard await urlOpener.openURL(url) else { throw AirshipErrors.error("Unable to open url \(arguments.value).") } return arguments.value } private func parseURL(_ value: AirshipJSON) throws -> URL { if let value = value.unWrap() as? String { if let url = AirshipUtils.parseURL(value) { return url } } throw AirshipErrors.error("Invalid URL: \(value)") } } ================================================ FILE: Airship/AirshipCore/Source/OpenRegistrationOptions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Open registration options public struct OpenRegistrationOptions: Codable, Sendable, Equatable, Hashable { /** * Platform name */ let platformName: String /** * Identifiers */ let identifiers: [String: String]? private init(platformName: String, identifiers: [String: String]?) { self.platformName = platformName self.identifiers = identifiers } /// Returns an open registration options with opt-in status /// - Parameter platformName: The platform name /// - Parameter identifiers: The identifiers /// - Returns: An open registration options. public static func optIn( platformName: String, identifiers: [String: String]? ) -> OpenRegistrationOptions { return OpenRegistrationOptions( platformName: platformName, identifiers: identifiers ) } } ================================================ FILE: Airship/AirshipCore/Source/Pager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif @MainActor struct Pager: View { private enum PagerEvent { case gesture(identifier: String, reportingMetadata: AirshipJSON?) case automated(identifier: String, reportingMetadata: AirshipJSON?) case accessibilityAction(ThomasAccessibilityAction) case defaultSwipe(PagerState.NavigationResult) } // For debugging, set to true to force legacy pager behavior on iOS 17+ private static let forceLegacyPager: Bool = false private static let timerTransition: CGFloat = 0.01 private static let minDragDistance: CGFloat = 60.0 static let animationSpeed: TimeInterval = 0.75 @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var pagerState: PagerState @EnvironmentObject private var thomasState: ThomasState @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @Environment(\.isVisible) private var isVisible @Environment(\.layoutState) private var layoutState @Environment(\.layoutDirection) private var layoutDirection @Environment(\.isVoiceOverRunning) private var isVoiceOverRunning private let info: ThomasViewInfo.Pager private let constraints: ViewConstraints @State private var lastReportedPageID: String? @State private var hasReportedCompleted: Bool = false @GestureState private var translation: CGFloat = 0 @State private var size: CGSize? @State private var scrollPosition: String? private let timer: Publishers.Autoconnect<Timer.TimerPublisher> private var isLegacyPageSwipeEnabled: Bool { if #available(iOS 17.0, *) { return if Self.forceLegacyPager { self.info.isDefaultSwipeEnabled } else { false } } return self.info.isDefaultSwipeEnabled } private var shouldAddSwipeGesture: Bool { if isLegacyPageSwipeEnabled { return true } if self.info.containsGestures([.swipe]) { return true } return false } private var shouldAddA11ySwipeActions: Bool { if isVoiceOverRunning { return false } if self.info.isDefaultSwipeEnabled { return true } if self.info.containsGestures([.swipe]) { return true } return false } init( info: ThomasViewInfo.Pager, constraints: ViewConstraints ) { self.info = info self.constraints = constraints self.timer = Timer.publish( every: Pager.timerTransition, on: .main, in: .default ) .autoconnect() } @ViewBuilder func makePager() -> some View { if (pagerState.pageItems.count == 1) { self.makeSinglePagePager() } else { GeometryReader { metrics in let childConstraints = ViewConstraints( width: metrics.size.width.safeValue, height: metrics.size.height.safeValue, isHorizontalFixedSize: self.constraints.isHorizontalFixedSize, isVerticalFixedSize: self.constraints.isVerticalFixedSize, safeAreaInsets: self.constraints.safeAreaInsets ) if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { if (Self.forceLegacyPager) { makeLegacyPager(childConstraints: childConstraints, metrics: metrics) } else { makeScrollViewPager(childConstraints: childConstraints, metrics: metrics) } } else { makeLegacyPager(childConstraints: childConstraints, metrics: metrics) } } } } @ViewBuilder func makeSinglePagePager() -> some View { ViewFactory.createView( pagerState.pageItems[0].view, constraints: constraints ) .environment(\.isVisible, self.isVisible) .environment( \.pageIdentifier, pagerState.pageItems[0].identifier ) .constraints(constraints) .airshipMeasureView(self.$size) } @ViewBuilder func makeLegacyPager(childConstraints: ViewConstraints, metrics: GeometryProxy) -> some View { VStack { HStack(spacing: 0) { makePageViews( childConstraints: childConstraints, metrics: metrics, isLegacyPager: true ) } .offset(x: -((metrics.size.width.safeValue ?? 0) * CGFloat(pagerState.pageIndex))) .offset(x: calcDragOffset(index: pagerState.pageIndex)) .animation(.interactiveSpring(duration: Pager.animationSpeed), value: pagerState.pageIndex) } .frame( width: metrics.size.width.safeValue, height: metrics.size.height.safeValue, alignment: .leading ) .clipped() .onAppear { size = metrics.size } .airshipOnChangeOf(metrics.size) { newSize in size = newSize } } @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) @ViewBuilder func makeScrollViewPager(childConstraints: ViewConstraints, metrics: GeometryProxy) -> some View { ScrollView(.horizontal) { LazyHStack(spacing: 0) { makePageViews( childConstraints: childConstraints, metrics: metrics, isLegacyPager: false ) } .scrollTargetLayout() } .scrollDisabled(self.info.properties.disableSwipe == true || self.pagerState.isScrollingDisabled) .allowsHitTesting(!pagerState.isNavigationInProgress) .scrollTargetBehavior(.paging) .scrollPosition(id: $scrollPosition) .scrollIndicators(.never) .accessibilityElement(children: .contain) .airshipOnChangeOf(scrollPosition ?? "", initial: false) { value in guard !value.isEmpty, value != self.pagerState.currentPageId else { return } let result = self.pagerState.navigateToPage(id: value) if let result { handleEvents(.defaultSwipe(result)) } } .frame( width: metrics.size.width.safeValue, height: metrics.size.height.safeValue, alignment: .leading ) .onAppear { size = metrics.size } .airshipOnChangeOf(metrics.size) { newSize in size = newSize } } @ViewBuilder private func makePageView( for index: Int, childConstraints: ViewConstraints, metrics: GeometryProxy, isLegacyPager: Bool ) -> some View { let pageItem = pagerState.pageItems[index] let isCurrentPage = self.isVisible && pageItem.identifier == pagerState.currentPageId VStack { ViewFactory.createView( pageItem.view, constraints: childConstraints ) .airshipApplyIf(isLegacyPager) { view in view.allowsHitTesting(isCurrentPage) } .environment(\.isVisible, isCurrentPage) .environment(\.pageIdentifier, pageItem.identifier) .accessibilityActions { makeAccessibilityActions(pageItem: pageItem) } .accessibilityHidden(!self.isVisible) } .frame( width: metrics.size.width.safeValue, height: metrics.size.height.safeValue ) .environment( \.isButtonActionsEnabled, (!self.isLegacyPageSwipeEnabled || self.translation == 0) ) .accessibilityElement(children: .contain) .id(pageItem.identifier) } @ViewBuilder private func makePageViews( childConstraints: ViewConstraints, metrics: GeometryProxy, isLegacyPager: Bool ) -> some View { ForEach(0..<pagerState.pageItems.count, id: \.self) { index in makePageView( for: index, childConstraints: childConstraints, metrics: metrics, isLegacyPager: isLegacyPager ) } } @ViewBuilder private func makeAccessibilityActions(pageItem: ThomasViewInfo.Pager.Item) -> some View { if let actions = pageItem.accessibilityActions { ForEach(0..<actions.count, id: \.self) { i in let action = actions[i] Button { handleEvents(.accessibilityAction(action)) self.process( behaviors: action.properties.behaviors, actions: action.properties.actions ) } label: { Text( action.accessible.resolveContentDescription ?? "unknown" ) } .accessibilityRemoveTraits(.isButton) } } } @ViewBuilder var body: some View { makePager() .onAppear(perform: attachToPagerState) .airshipOnChangeOf(pagerState.completed) { completed in guard completed else { return } reportCompleted() } .airshipOnChangeOf(pagerState.currentPageId, initial: true) { pageID in guard let pageID else { return } reportPage(pageID: pageID) guard pageID != scrollPosition else { return } if scrollPosition != nil { pagerState.disableTouchDuringNavigation() } withAnimation { scrollPosition = pageID } } .airshipOnChangeOf(isVisible) { visible in if visible, let pageID = pagerState.currentPageId { reportPage(pageID: pageID) } if visible, pagerState.completed { reportCompleted() } } .onReceive(self.timer) { _ in onTimer() } #if !os(tvOS) .airshipApplyIf(self.shouldAddSwipeGesture) { view in view.simultaneousGesture( makeSwipeGesture() ) } .airshipApplyIf(self.shouldAddA11ySwipeActions) { view in view.accessibilityScrollAction { edge in let swipeDirection = PagerSwipeDirection.from( edge: edge, layoutDirection: self.layoutDirection ) handleSwipe(direction: swipeDirection, isAccessibilityScrollAction: true) } } .airshipApplyIf(self.info.containsGestures([.hold, .tap])) { view in view.addPagerTapGesture( onTouch: { isPressed in handleTouch(isPressed: isPressed) }, onTap: { location in handleTap(tapLocation: location) } ) } #endif .constraints(constraints) .thomasCommon(self.info) .airshipGeometryGroupCompat() .accessibilityElement(children: .contain) } // MARK: Handle Gesture #if !os(tvOS) private func makeSwipeGesture() -> some Gesture { return DragGesture(minimumDistance: Self.minDragDistance) .updating(self.$translation) { value, state, _ in guard self.isLegacyPageSwipeEnabled else { return } if (abs(value.translation.width) > Self.minDragDistance) { state = if (value.translation.width > 0) { value.translation.width - Self.minDragDistance } else { value.translation.width + Self.minDragDistance } } else { state = 0 } } .onEnded { value in guard let size = self.size, let swipeDirection = PagerSwipeDirection.from( dragValue: value, size: size, layoutDirection: layoutDirection ) else { return } handleSwipe(direction: swipeDirection) } } private func handleTap(tapLocation: CGPoint) { guard let size = size else { return } let pagerGestureExplorer = PagerGestureMapExplorer( CGRect( x: 0, y: 0, width: size.width, height: size.height ) ) let locations = pagerGestureExplorer.location( layoutDirection: layoutDirection, forPoint: tapLocation ) locations.forEach { location in self.info.retrieveGestures(type: ThomasViewInfo.Pager.Gesture.Tap.self) .filter { $0.location == location } .forEach { gesture in handleEvents( .gesture( identifier: gesture.identifier, reportingMetadata: gesture.reportingMetadata ) ) self.process( behaviors: gesture.behavior.behaviors, actions: gesture.behavior.actions ) } } } #endif // MARK: Utils methods private func attachToPagerState() { pagerState.setPagesAndListenForUpdates( pages: info.properties.items, thomasState: thomasState, swipeDisableSelectors: info.properties.disableSwipePredicate ) } private func handleSwipe( direction: PagerSwipeDirection, isAccessibilityScrollAction: Bool = false ) { switch(direction) { case .up: fallthrough case .down: self.info.retrieveGestures(type: ThomasViewInfo.Pager.Gesture.Swipe.self) .filter { if ($0.direction == .up && direction == .up) { return true } if ($0.direction == .down && direction == .down) { return true } return false } .forEach { gesture in handleEvents( .gesture( identifier: gesture.identifier, reportingMetadata: gesture.reportingMetadata ) ) self.process( behaviors: gesture.behavior.behaviors, actions: gesture.behavior.actions ) } case .start: guard !pagerState.isFirstPage, self.pagerState.canGoBack, isAccessibilityScrollAction || self.isLegacyPageSwipeEnabled else { return } // Treat a11y swipes as page requests so they animate if let result = pagerState.process(request: .back) { self.handleEvents(.defaultSwipe(result)) } case .end: guard !pagerState.isLastPage, isAccessibilityScrollAction || self.isLegacyPageSwipeEnabled else { return } // Treat a11y swipes as page requests so they animate if let result = pagerState.process(request: .next) { self.handleEvents(.defaultSwipe(result)) } } } private func handleTouch(isPressed: Bool) { self.info.retrieveGestures(type: ThomasViewInfo.Pager.Gesture.Hold.self).forEach { gesture in let behavior = isPressed ? gesture.pressBehavior : gesture.releaseBehavior if !isPressed { handleEvents( .gesture( identifier: gesture.identifier, reportingMetadata: gesture.reportingMetadata ) ) } self.process( behaviors: behavior.behaviors, actions: behavior.actions ) } } private func onTimer() { guard !isVoiceOverRunning, let automatedActions = self.pagerState.pageItems[self.pagerState.pageIndex].automatedActions else { return } let duration = self.pagerState.pageStates[pagerState.pageIndex].delay let safeDuration = (duration > 0 && duration.isFinite) ? duration : 1.0 if self.pagerState.inProgress && (self.pagerState.pageIndex < pagerState.pageItems.count) { if (self.pagerState.progress < 1) { self.pagerState.progress += Pager.timerTransition / safeDuration } // Check for any automated action past the current duration that have not been executed yet automatedActions.filter { let isExecuted = (self.pagerState.currentPageState?.automatedActionStatus[$0.identifier] == true) let isOlder = (self.pagerState.progress * duration) >= ($0.delay ?? 0.0) return !isExecuted && isOlder }.forEach { action in self.processAutomatedAction(action) } } } private func processAutomatedAction(_ automatedAction: ThomasAutomatedAction) { self.handleEvents( .automated( identifier: automatedAction.identifier, reportingMetadata: automatedAction.reportingMetadata ) ) self.process( behaviors: automatedAction.behaviors, actions: automatedAction.actions ) self.pagerState.markAutomatedActionExecuted(automatedAction.identifier) } private func process( stateActions: [ThomasStateAction]? = nil, behaviors: [ThomasButtonClickBehavior]? = nil, actions: [ThomasActionsPayload]? = nil ) { Task { @MainActor in // Handle state first if let stateActions { thomasState.processStateActions(stateActions) // WORKAROUND: SwiftUI state updates are not immediately available to child views. // Yielding allows the state changes to propagate through the view hierarchy // before executing behaviors that may depend on the updated state. await Task.yield() } // Behaviors behaviors?.sortedBehaviors.forEach { behavior in switch(behavior) { case .dismiss: self.thomasEnvironment.dismiss(layoutState: layoutState) case .cancel: self.thomasEnvironment.dismiss(cancel: true, layoutState: layoutState) case .pagerNext: self.pagerState.process(request: .next) case .pagerPrevious: self.pagerState.process(request: .back) case .pagerNextOrDismiss: if pagerState.isLastPage { self.thomasEnvironment.dismiss() } else { self.pagerState.process(request: .next) } case .pagerNextOrFirst: if self.pagerState.isLastPage { self.pagerState.process(request: .first) } else { self.pagerState.process(request: .next) } case .pagerPause: self.pagerState.pause() case .pagerResume: self.pagerState.resume() case .pagerPauseToggle: pagerState.togglePause() case .formSubmit, .formValidate: // not supported break case .videoPlay, .videoPause, .videoTogglePlay, .videoMute, .videoUnmute, .videoToggleMute: // Video behaviors handled by VideoController, not Pager break } } // Actions if let actions = actions { actions.forEach { action in self.thomasEnvironment.runActions(action, layoutState: layoutState) } } } } private func handleEvents(_ event: PagerEvent) { AirshipLogger.debug("Processing pager event: \(event)") switch event { case .defaultSwipe(let navigationResult): if let from = navigationResult.fromPage { thomasEnvironment.pageSwiped( pagerState: self.pagerState, from: from, to: navigationResult.toPage, layoutState: layoutState ) } case .gesture(let identifier, let reportingMetadata): thomasEnvironment.pageGesture( identifier: identifier, reportingMetadata: reportingMetadata, layoutState: layoutState ) case .automated(let identifier, let reportingMetadata): thomasEnvironment.pageAutomated( identifier: identifier, reportingMetadata: reportingMetadata, layoutState: layoutState ) case .accessibilityAction(_): /// TODO add accessibility action analytics event break } } private func reportCompleted() { guard isVisible, !hasReportedCompleted else { return } self.hasReportedCompleted = true self.thomasEnvironment.pagerCompleted( pagerState: pagerState, layoutState: layoutState ) } private func reportPage(pageID: String) { guard isVisible, self.lastReportedPageID != pageID, let page = pagerState.pageItems.first(where: { $0.identifier == pageID }) else { return } self.thomasEnvironment.pageViewed( pagerState: self.pagerState, pageInfo: self.pagerState.pageInfo(pageIdentifier: pageID), layoutState: layoutState ) self.lastReportedPageID = pageID if isVoiceOverRunning { // Small delay to allow the UI to settle after navigation DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { #if os(watchOS) // watchOS handles accessibility focus via the system pager automatically #elseif os(macOS) // For macOS, notify that the layout has changed within the app NSAccessibility.post( element: (NSApp.mainWindow ?? NSApp) as Any, notification: .layoutChanged ) #else // For iOS, tvOS, and visionOS UIAccessibility.post(notification: .layoutChanged, argument: nil) #endif } } // Run any actions set on the current page let displayActions: [ThomasActionsPayload]? = if let actions = page.displayActions { [actions] } else { nil } self.process( stateActions: page.stateActions, actions: displayActions ) // Process any automated navigation actions onTimer() } private func calcDragOffset(index: Int) -> CGFloat { var dragOffSet = self.translation if index <= 0 { dragOffSet = min(dragOffSet, 0) } else if index >= pagerState.pageItems.count - 1 { dragOffSet = max(dragOffSet, 0) } return dragOffSet } } ================================================ FILE: Airship/AirshipCore/Source/PagerController.swift ================================================ import Foundation import SwiftUI @MainActor struct PagerController: View { private let info: ThomasViewInfo.PagerController private let constraints: ViewConstraints @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var environment: ThomasEnvironment @EnvironmentObject private var state: ThomasState init( info: ThomasViewInfo.PagerController, constraints: ViewConstraints ) { self.info = info self.constraints = constraints } var body: some View { Content( info: self.info, constraints: constraints, environment: environment, formDataCollector: formDataCollector, parentState: state ) } @MainActor struct Content: View { private let info: ThomasViewInfo.PagerController private let constraints: ViewConstraints @Environment(\.layoutState) private var layoutState @ObservedObject private var pagerState: PagerState @StateObject private var formDataCollector: ThomasFormDataCollector @Environment(\.isVoiceOverRunning) private var isVoiceOverRunning @StateObject private var state: ThomasState init( info: ThomasViewInfo.PagerController, constraints: ViewConstraints, environment: ThomasEnvironment, formDataCollector: ThomasFormDataCollector, parentState: ThomasState ) { self.info = info self.constraints = constraints // Use the environment to create or retrieve the state in case the view // stack changes and we lose our state. let pagerState = environment.retrieveState(identifier: info.properties.identifier) { PagerState( identifier: info.properties.identifier, branching: info.properties.branching ) } self._pagerState = ObservedObject(wrappedValue: pagerState) self._formDataCollector = StateObject( wrappedValue: formDataCollector.with(pagerState: pagerState) ) self._state = StateObject( wrappedValue: parentState.with(pagerState: pagerState) ) } var body: some View { ViewFactory.createView(self.info.properties.view, constraints: constraints) .constraints(constraints) .airshipOnChangeOf(self.isVoiceOverRunning, initial: true) { value in pagerState.isVoiceOverRunning = value } .onAppear { pagerState.isVoiceOverRunning = isVoiceOverRunning } .thomasCommon(self.info) .environmentObject(self.pagerState) .environmentObject(self.formDataCollector) .environmentObject(self.state) .environment(\.layoutState, layoutState.override(pagerState: pagerState)) .accessibilityElement(children: .contain) } } } ================================================ FILE: Airship/AirshipCore/Source/PagerGestureMap.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI fileprivate struct TopTrapezoid: Shape { func path(in rect: CGRect) -> Path { let thirdWidth = rect.width * 0.3 let thirdHeight = rect.height * 0.3 var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX - thirdWidth, y: rect.minY + thirdHeight)) path.addLine(to: CGPoint(x: rect.minX + thirdWidth, y: rect.minY + thirdHeight)) path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) return path } } fileprivate struct BottomTrapezoid: Shape { func path(in rect: CGRect) -> Path { let thirdWidth = rect.width * 0.3 let thirdHeight = rect.height * 0.3 var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX - thirdWidth, y: rect.maxY - thirdHeight)) path.addLine(to: CGPoint(x: rect.minX + thirdWidth, y: rect.maxY - thirdHeight)) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) return path } } fileprivate struct RightTrapezoid: Shape { func path(in rect: CGRect) -> Path { let thirdWidth = rect.width * 0.3 let thirdHeight = rect.height * 0.3 var path = Path() path.move(to: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX - thirdWidth, y: rect.maxY - thirdHeight)) path.addLine(to: CGPoint(x: rect.maxX - thirdWidth, y: rect.minY + thirdHeight)) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) return path } } fileprivate struct LeftTrapezoid: Shape { func path(in rect: CGRect) -> Path { let thirdWidth = rect.width * 0.3 let thirdHeight = rect.height * 0.3 var path = Path() path.move(to: CGPoint(x: rect.minX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.minX + thirdWidth, y: rect.maxY - thirdHeight)) path.addLine(to: CGPoint(x: rect.minX + thirdWidth, y: rect.minY + thirdHeight)) path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) return path } } fileprivate struct CenterRectangle: Shape { func path(in rect: CGRect) -> Path { let thirdWidth = rect.width * 0.3 let thirdHeight = rect.height * 0.3 var path = Path() path.move(to: CGPoint(x: rect.minX + thirdWidth, y: rect.minY + thirdHeight)) path.addLine(to: CGPoint(x: rect.minX + thirdWidth, y: rect.minY + thirdHeight)) path.addLine(to: CGPoint(x: rect.maxX - thirdWidth, y: rect.minY + thirdHeight)) path.addLine(to: CGPoint(x: rect.maxX - thirdWidth, y: rect.maxY - thirdHeight)) path.addLine(to: CGPoint(x: rect.minX + thirdWidth, y: rect.maxY - thirdHeight)) return path } } struct PagerGestureMapExplorer { let topTrapezoidPath: Path let bottomTrapezoidPath: Path let leftTrapezoidPath: Path let rightTrapezoidPath: Path let centerSquarePath: Path init(_ rect: CGRect) { topTrapezoidPath = TopTrapezoid().path(in: rect) bottomTrapezoidPath = BottomTrapezoid().path(in: rect) leftTrapezoidPath = LeftTrapezoid().path(in: rect) rightTrapezoidPath = RightTrapezoid().path(in: rect) centerSquarePath = CenterRectangle().path(in: rect) } func location( layoutDirection: LayoutDirection, forPoint point: CGPoint ) -> [ThomasViewInfo.Pager.Gesture.GestureLocation] { if topTrapezoidPath.contains(point) { return [.top, .any] } if bottomTrapezoidPath.contains(point) { return [.bottom, .any] } if leftTrapezoidPath.contains(point) { if (layoutDirection == .leftToRight) { return [.left, .start, .any] } else { return [.left, .end, .any] } } if rightTrapezoidPath.contains(point) { if (layoutDirection == .leftToRight) { return [.right, .end, .any] } else { return [.right, .start, .any] } } if centerSquarePath.contains(point) { return [.any] } return [] } } struct PagerGestureMap: View { var body: some View { Rectangle() .overlayView { TopTrapezoid() .fill(.blue) BottomTrapezoid() .fill(.red) RightTrapezoid() .fill(.yellow) LeftTrapezoid() .fill(.purple) CenterRectangle() .fill(.green) } .border(.red, width: 1) } } struct PagerGestureMap_Previews: PreviewProvider { static var previews: some View { PagerGestureMap() } } ================================================ FILE: Airship/AirshipCore/Source/PagerIndicator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct PagerIndicator: View { let info: ThomasViewInfo.PagerIndicator let constraints: ViewConstraints @EnvironmentObject var pagerState: PagerState @Environment(\.colorScheme) var colorScheme @ViewBuilder private func createChild( binding: ThomasViewInfo.PagerIndicator.Properties.Binding, constraints: ViewConstraints ) -> some View { ZStack { if let shapes = binding.shapes { ForEach(0..<shapes.count, id: \.self) { index in Shapes.shape( info: shapes[index], constraints: constraints, colorScheme: colorScheme ) } } if let iconModel = binding.icon { Icons.icon( info: iconModel, colorScheme: colorScheme ) } } } func announcePage(info: ThomasViewInfo.PagerIndicator) -> Bool { return info.properties.automatedAccessibilityActions?.contains{ $0.type == .announce} ?? false } var body: some View { let size: Double = if let height = constraints.height { height - (self.info.commonProperties.border?.strokeWidth ?? 0) } else { 32.0 } let childConstraints = ViewConstraints( width: size, height: size ) HStack(spacing: self.info.properties.spacing) { ForEach(0..<self.pagerState.pageStates.count, id: \.self) { index in if self.pagerState.pageIndex == index { createChild( binding: self.info.properties.bindings.selected, constraints: childConstraints ) } else { createChild( binding: self.info.properties.bindings.unselected, constraints: childConstraints ) } } } .padding(.horizontal, self.info.properties.spacing) .animation(.interactiveSpring(duration: Pager.animationSpeed), value: self.info) .constraints(constraints) .thomasCommon(self.info) .airshipApplyIf(announcePage(info: self.info), transform: { view in view.accessibilityLabel(String(format: "ua_pager_progress".airshipLocalizedString( fallback: "Page %@ of %@" ), (self.pagerState.pageIndex + 1).airshipLocalizedForVoiceOver(), self.pagerState.pageStates.count.airshipLocalizedForVoiceOver())) }) .accessibilityHidden(true) } } ================================================ FILE: Airship/AirshipCore/Source/PagerState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine struct PageState: ThomasSerializable { var identifier: String var delay: Double // represent the automated action identifier and it's status (true if it's executed and false if not) var automatedActionStatus: [String: Bool] = [:] init( identifier: String, delay: Double, automatedActions: [String]? ) { self.identifier = identifier self.delay = delay if let automatedActions = automatedActions { automatedActions.forEach { automatedAction in self.automatedActionStatus[automatedAction] = false } } } mutating func markAutomatedActionExecuted( _ identifier: String ) { self.automatedActionStatus[identifier] = true } mutating func resetExecutedActions() { automatedActionStatus.keys.forEach { key in automatedActionStatus[key] = false } } } enum PageRequest { case next case back case first } struct ThomasPageInfo: Sendable { var identifier: String var index: Int var viewCount: Int } @MainActor class PagerState: ObservableObject { struct NavigationResult: Sendable { var fromPage: ThomasPageInfo? var toPage: ThomasPageInfo } var pageIndex: Int { pageItems.firstIndex(where: { $0.identifier == currentPageId }) ?? 0 } @Published private(set) var currentPageId: String? { didSet { guard let page = currentPageId, page != oldValue else { return } self.pageViewCounts[page] = (self.pageViewCounts[page] ?? 0) + 1 updateInProgress(pageId: page) resetExecutedActions(for: oldValue) branchControl?.addToHistoryPage(id: page) updateCompleted() } } @Published private(set) var pageStates: [PageState] = [] @Published private(set) var pageItems: [ThomasViewInfo.Pager.Item] = [] @Published var progress: Double = 0.0 @Published private(set) var completed: Bool = false @Published private(set) var isScrollingDisabled = false @Published private(set) var isNavigationInProgress = false /// Used to pause/resume a story @Published var inProgress: Bool = true private var isManuallyPaused: Bool = false private var navigationCooldownTask: Task<Void, Never>? private var pageViewCounts: [String: Int] = [:] @Published var isVoiceOverRunning = false private var mediaReadyState: [MediaKey: Bool] = [:] var currentPageState: PageState? { get { pageStates.isEmpty ? nil : pageStates[pageIndex] } set { guard let newValue, !pageStates.isEmpty else { return } pageStates[pageIndex] = newValue } } private static let navigationCooldownInterval: TimeInterval = 0.3 let identifier: String private let branchControl: BranchControl? private var thomasStateSubscription: AnyCancellable? = nil private let taskSleeper: any AirshipTaskSleeper // Used for reporting var reportingPageCount: Int { get { branchControl == nil ? pageItems.count : -1 } } init( identifier: String, branching: ThomasPagerControllerBranching?, taskSleeper: any AirshipTaskSleeper = DefaultAirshipTaskSleeper.shared ) { self.identifier = identifier self.taskSleeper = taskSleeper if let branching { branchControl = BranchControl(completionChecker: branching) } else { branchControl = nil } if let branchControl { branchControl.$pages .map { pages in pages.map { $0.toPageState() } } .assign(to: &$pageStates) branchControl.$pages.assign(to: &$pageItems) branchControl.$isComplete.assign(to: &$completed) } } func setPagesAndListenForUpdates( pages: [ThomasViewInfo.Pager.Item], thomasState: ThomasState, swipeDisableSelectors: [ThomasViewInfo.Pager.DisableSwipeSelector]? ) { let pagesChanged = pages != self.pageItems if let branchControl { branchControl.configureAndAttachTo( pages: pages, thomasState: thomasState ) } else { self.pageStates = pages.map({ $0.toPageState() }) self.pageItems = pages } thomasStateSubscription?.cancel() if let selectors = swipeDisableSelectors { thomasStateSubscription = thomasState.$state .receive(on: DispatchQueue.main) .sink { @MainActor [weak self] newState in self?.reEvaluateScrollability(state: newState, selectors: selectors) } } if self.currentPageId == nil || pagesChanged { self.currentPageId = pageItems.first?.identifier } } func pause() { self.isManuallyPaused = true if let currentPageId { updateInProgress(pageId: currentPageId) } } func togglePause() { if self.isManuallyPaused { resume() } else { pause() } } func resume() { self.isManuallyPaused = false if let currentPageId { updateInProgress(pageId: currentPageId) } } var isFirstPage: Bool { return pageIndex == 0 } var isLastPage: Bool { return pageIndex == pageItems.count - 1 } var canGoBack: Bool { return pageIndex > 0 } var canGoForward: Bool { return pageIndex < pageItems.count - 1 } @discardableResult func navigateToPage(id: String) -> NavigationResult? { guard self.pageItems.contains(where: { $0.identifier == id }), id != self.currentPageId else { return nil } let fromPage: ThomasPageInfo? = if let currentPageId { self.pageInfo(pageIdentifier: currentPageId) } else { nil } let toPage = self.pageInfo(pageIdentifier: id) branchControl?.clearHistoryAfter(id: id) self.progress = 0.0 self.currentPageId = id return NavigationResult(fromPage: fromPage, toPage: toPage) } func pageInfo(pageIdentifier: String) -> ThomasPageInfo { return ThomasPageInfo( identifier: pageIdentifier, index: self.pageItems.firstIndex(where: { item in item.identifier == pageIdentifier }) ?? -1, viewCount: self.pageViewCounts[pageIdentifier] ?? 0 ) } func pageInfo(index: Int) -> ThomasPageInfo { let pageIdentifier = self.pageItems[index].identifier return ThomasPageInfo( identifier: pageIdentifier, index: index, viewCount: self.pageViewCounts[pageIdentifier] ?? 0 ) } @discardableResult func process(request: PageRequest) -> NavigationResult? { let id = pageItems[nextIndexNoBranching(request: request)].identifier guard let result = self.navigateToPage(id: id) else { return nil } branchControl?.onPageRequest(request) return result } private func reEvaluateScrollability( state: AirshipJSON, selectors: [ThomasViewInfo.Pager.DisableSwipeSelector] ) { let selector = selectors.first(where: { $0.predicate?.evaluate(json: state) ?? true }) switch(selector?.direction) { case .horizontal: isScrollingDisabled = true case .none: isScrollingDisabled = false } } private func resetExecutedActions(for pageId: String?) { guard let pageId, let index = pageStates.firstIndex(where: { $0.identifier == pageId }) else { return } pageStates[index].resetExecutedActions() } func disableTouchDuringNavigation() { // WORKAROUND: SwiftUI's scrollPosition(id:) has a race condition where rapid touch // during scroll animation causes scrollPosition state to desync from actual position. self.isNavigationInProgress = true self.navigationCooldownTask?.cancel() self.navigationCooldownTask = Task { @MainActor [weak self] in guard let self = self else { return } try? await taskSleeper.sleep(timeInterval: Self.navigationCooldownInterval) guard !Task.isCancelled else { return } self.navigationCooldownTask = nil self.isNavigationInProgress = false } } private func nextIndexNoBranching(request: PageRequest) -> Int { return switch request { case .next: min(pageIndex + 1, pageItems.count - 1) case .back: max(pageIndex - 1, 0) case .first: 0 } } private func updateCompleted() { if branchControl != nil || completed { return } completed = pageIndex == (pageItems.count - 1) } func registerMedia(pageId: String, id: UUID) { let key = MediaKey(pageId: pageId, id: id) guard mediaReadyState[key] == nil else { return } mediaReadyState[key] = false updateInProgress(pageId: pageId) } func setMediaReady(pageId: String, id: UUID, isReady: Bool) { let key = MediaKey(pageId: pageId, id: id) mediaReadyState[key] = isReady updateInProgress(pageId: pageId) } func markAutomatedActionExecuted(_ identifier: String) { self.currentPageState?.markAutomatedActionExecuted(identifier) } private func updateInProgress(pageId: String) { let isMediaReady = !mediaReadyState.contains(where: { key, isReady in key.pageId == pageId && isReady == false }) let update = isMediaReady && !isManuallyPaused && !isVoiceOverRunning if self.inProgress != update { self.inProgress = update } } struct MediaKey: Hashable, Equatable { let pageId: String let id: UUID } } @MainActor private class BranchControl: Sendable { let completionChecker: ThomasPagerControllerBranching private var allPages: [ThomasViewInfo.Pager.Item] = [] @Published private(set) var pages: [ThomasViewInfo.Pager.Item] = [] @Published private(set) var isComplete: Bool = false private var thomasState: ThomasState? private var history: [ThomasViewInfo.Pager.Item] = [] private var subscriptions: Set<AnyCancellable> = [] init(completionChecker: ThomasPagerControllerBranching) { self.completionChecker = completionChecker } var payload: AirshipJSON { return self.thomasState?.state ?? .null } func configureAndAttachTo( pages: [ThomasViewInfo.Pager.Item], thomasState: ThomasState ) { detach() self.thomasState = thomasState allPages = pages thomasState.$state .receive(on: RunLoop.main) .sink { [weak self] _ in self?.updateState() } .store(in: &subscriptions) updateState() } func detach() { subscriptions.forEach({ $0.cancel() }) subscriptions.removeAll() } private func updateState() { self.reEvaluatePath() self.evaluateCompletion() } func onPageRequest(_ request: PageRequest) { self.updateState() switch request { case .next, .back: break case .first: history.removeAll() } } func clearHistoryAfter(id: String) { guard let index = history.firstIndex(where: { $0.identifier == id }) else { return } history.removeSubrange((index + 1)...) } func addToHistoryPage(id: String) { guard let page = allPages.first(where: { $0.identifier == id }), !history.contains(page) else { return } history.append(page) } private func reEvaluatePath() { if history.isEmpty, !allPages.isEmpty { history = [allPages[0]] } var historyCopy = history guard let current = historyCopy.popLast() else { return } pages = historyCopy + buildPathFrom(page: current, payload: payload) } private func buildPathFrom( page: ThomasViewInfo.Pager.Item, payload: AirshipJSON ) -> [ThomasViewInfo.Pager.Item] { guard var pageIndex = allPages.firstIndex(of: page) else { return [] } var result: [ThomasViewInfo.Pager.Item] = [] while(pageIndex >= 0 && pageIndex < allPages.count) { let current = allPages[pageIndex] if result.contains(current) { AirshipLogger.warn("Trying to add a duplicate \(current)") break } result.append(current) guard let branching = current.branching, let nextPage = branching.nextPageId(json: payload), let nextPageIndex = allPages.firstIndex(where: { $0.identifier == nextPage }) else { break } pageIndex = nextPageIndex } return result } private func evaluateCompletion() { guard !isComplete else { return } var result = false for indicator in completionChecker.completions { if indicator.predicate?.evaluate(json: payload) != false { result = true break } } if result, result != isComplete { performCompletionStateActions() } self.isComplete = result } private func performCompletionStateActions() { guard let thomasState else { return } let actions = completionChecker.completions .filter { $0.predicate?.evaluate(json: payload) != false } .compactMap { $0.stateActions } .flatMap { $0 } thomasState.processStateActions(actions) } } fileprivate extension ThomasPageBranching { func nextPageId(json: AirshipJSON) -> String? { return nextPage? .first(where: { selector in selector.predicate?.evaluate(json: json) != false })? .pageId } } fileprivate extension ThomasViewInfo.Pager.Item { func toPageState() -> PageState { return PageState( identifier: identifier, delay: automatedActions?.earliestNavigationAction?.delay ?? 0.0, automatedActions: automatedActions?.compactMap { automatedAction in automatedAction.identifier } ) } } //MARK: - ThomasStateProvider extension PagerState: ThomasStateProvider { typealias SnapshotType = Snapshot struct Snapshot: Codable, Equatable { let pageStates: [PageState] let currentPageId: String? let progress: Double } var updates: AnyPublisher<any Codable, Never> { return Publishers .CombineLatest3($pageStates, $currentPageId, $progress) .map(Snapshot.init) .removeDuplicates() .map(\.self) .eraseToAnyPublisher() } func persistentStateSnapshot() -> SnapshotType { Snapshot( pageStates: self.pageStates, currentPageId: self.currentPageId, progress: self.progress ) } func restorePersistentState(_ state: Snapshot) { DispatchQueue.main.async { self.pageStates = state.pageStates self.currentPageId = state.currentPageId self.progress = state.progress } } } ================================================ FILE: Airship/AirshipCore/Source/PagerSwipeDirection.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI enum PagerSwipeDirection: Sendable { case up case down case start case end } extension PagerSwipeDirection { private static let flingSpeed: CGFloat = 150.0 private static let offsetPercent: CGFloat = 0.50 static func from( edge: Edge, layoutDirection: LayoutDirection ) -> PagerSwipeDirection { switch (edge) { case .top: return .down case .leading: return if (layoutDirection == .leftToRight) { .end } else { .start } case .bottom: return .up case .trailing: return if (layoutDirection == .leftToRight) { .start } else { .end } } } #if !os(tvOS) static func from( dragValue: DragGesture.Value, size: CGSize, layoutDirection: LayoutDirection ) -> PagerSwipeDirection? { let xVelocity = dragValue.predictedEndLocation.x - dragValue.location.x let yVelocity = dragValue.predictedEndLocation.y - dragValue.location.y let widthOffset = dragValue.translation.width / size.width let heightOffset = dragValue.translation.height / size.height var swipeDirection: PagerSwipeDirection? = nil if (abs(xVelocity) > abs(yVelocity)) { if abs(xVelocity) >= Self.flingSpeed { if (xVelocity > 0) { swipeDirection = (layoutDirection == .leftToRight) ? .start : .end } else { swipeDirection = (layoutDirection == .leftToRight) ? .end : .start } } else if abs(widthOffset) >= Self.offsetPercent { if (widthOffset > 0) { swipeDirection = (layoutDirection == .leftToRight) ? .start : .end } else { swipeDirection = (layoutDirection == .leftToRight) ? .end : .start } } } else { if abs(yVelocity) >= Self.flingSpeed { swipeDirection = (yVelocity > 0) ? .down : .up } else if abs(heightOffset) >= Self.offsetPercent { swipeDirection = (heightOffset > 0) ? .down : .up } } return swipeDirection } #endif } ================================================ FILE: Airship/AirshipCore/Source/PagerUtils.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI extension ThomasViewInfo.Pager { var isDefaultSwipeEnabled: Bool { return self.properties.disableSwipe != true && self.properties.items.count > 1 } func retrieveGestures<T: ThomasViewInfo.Pager.Gesture.Info>(type: T.Type) -> [T] { guard let gestures = self.properties.gestures else { return [] } return gestures.compactMap { gesture in switch gesture { case .tapGesture(let model): return model as? T case .swipeGesture(let model): return model as? T case .holdGesture(let model): return model as? T } } } func containsGestures(_ types: [ThomasViewInfo.Pager.Gesture.GestureType]) -> Bool { guard let gestures = self.properties.gestures else { return false } return gestures.contains(where: { gesture in switch(gesture) { case .swipeGesture(let gesture): return types.contains(gesture.type) case .tapGesture(let gesture): return types.contains(gesture.type) case .holdGesture(let gesture): return types.contains(gesture.type) } }) } } extension Array where Element == ThomasAutomatedAction { var earliestNavigationAction: ThomasAutomatedAction? { return self.first { return $0.behaviors?.filter { return switch($0) { case .dismiss: true case .cancel: true case .pagerNext: true case .pagerPrevious: true case .pagerNextOrDismiss: true case .pagerNextOrFirst: true case .formValidate: false case .formSubmit: false case .pagerPause: false case .pagerResume: false case .pagerPauseToggle: false case .videoPlay: false case .videoPause: false case .videoTogglePlay: false case .videoMute: false case .videoUnmute: false case .videoToggleMute: false } }.isEmpty == false } } } extension View { #if !os(tvOS) @ViewBuilder func addPagerTapGesture(onTouch: @escaping (Bool) -> Void, onTap: @escaping (CGPoint) -> Void) -> some View { self.onTouch { isPressed in onTouch(isPressed) } .simultaneousGesture( SpatialTapGesture() .onEnded { event in onTap(event.location) } ) } #endif } ================================================ FILE: Airship/AirshipCore/Source/PasteboardAction.swift ================================================ /* Copyright Airship and Contributors */ #if !os(watchOS) import Foundation /// Sets the pasteboard's string. /// /// Expected argument values: String or an Object with the pasteboard's string /// under the 'text' key. /// /// Valid situations: `ActionSituation.launchedFromPush`, /// `ActionSituation.webViewInvocation`, `ActionSituation.manualInvocation`, /// `ActionSituation.foregroundInteractiveButton`, `ActionSituation.backgroundInteractiveButton`, /// and `ActionSituation.automation` /// /// Result value: The arguments value. @available(tvOS, unavailable) public final class PasteboardAction: AirshipAction { /// Default names - "clipboard_action", "^c" public static let defaultNames: [String] = ["clipboard_action", "^c"] private let pasteboard: any AirshipPasteboardProtocol init(pasteboard: any AirshipPasteboardProtocol = DefaultAirshipPasteboard()) { self.pasteboard = pasteboard } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .manualInvocation, .webViewInvocation, .launchedFromPush, .backgroundInteractiveButton, .foregroundInteractiveButton, .automation: return pasteboardString(arguments) != nil case .backgroundPush, .foregroundPush: return false } } @MainActor public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { if let string = pasteboardString(arguments) { self.pasteboard.copy(value: string) } return arguments.value } func pasteboardString(_ arguments: ActionArguments) -> String? { if let value = arguments.value.unWrap() as? String { return value } if let dict = arguments.value.unWrap() as? [AnyHashable: Any] { return dict["text"] as? String } return nil } } #endif ================================================ FILE: Airship/AirshipCore/Source/Permission.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Airship permissions. Used with `PermissionsManager` public enum AirshipPermission: String, Sendable, Codable { /// Post notifications case displayNotifications = "display_notifications" /// Location case location } ================================================ FILE: Airship/AirshipCore/Source/PermissionDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Permissions manager delegate. Allows for extending permission gathering. public protocol AirshipPermissionDelegate: Sendable { /// Called when a permission needs to be checked. /// - Returns: the permission status. @MainActor func checkPermissionStatus() async -> AirshipPermissionStatus /// Called when a permission should be requested. /// /// - Note: A permission might be already granted when this method is called. /// /// - Returns: the permission status. @MainActor func requestPermission() async -> AirshipPermissionStatus } ================================================ FILE: Airship/AirshipCore/Source/PermissionPrompter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine typealias PermissionResultReceiver = @Sendable ( AirshipPermission, AirshipPermissionStatus, AirshipPermissionStatus ) async -> Void protocol PermissionPrompter: Sendable { func prompt( permission: AirshipPermission, enableAirshipUsage: Bool, fallbackSystemSettings: Bool ) async -> AirshipPermissionResult } struct AirshipPermissionPrompter: PermissionPrompter { private let permissionsManager: any AirshipPermissionsManager init( permissionsManager: any AirshipPermissionsManager ) { self.permissionsManager = permissionsManager } @MainActor func prompt( permission: AirshipPermission, enableAirshipUsage: Bool, fallbackSystemSettings: Bool ) async -> AirshipPermissionResult { return await self.permissionsManager.requestPermission( permission, enableAirshipUsageOnGrant: enableAirshipUsage, fallback: fallbackSystemSettings ? .systemSettings : .none ) } } ================================================ FILE: Airship/AirshipCore/Source/PermissionStatus.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Permission status public enum AirshipPermissionStatus: String, Sendable, Codable { /// Could not determine the permission status. case notDetermined = "not_determined" /// Permission is granted. case granted /// Permission is denied. case denied } ================================================ FILE: Airship/AirshipCore/Source/PermissionsManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif /// Airship permissions manager. /// /// Airship will provide the default handling for `Permission.postNotifications`. All other permissions will need /// to be configured by the app by providing a `PermissionDelegate` for the given permissions. public protocol AirshipPermissionsManager: Sendable { /// The set of permissions that have a configured delegate. var configuredPermissions: Set<AirshipPermission> { get } /// Returns an async stream with status updates for the given permission /// /// - Parameters: /// - permission: The permission. func statusUpdate( for permission: AirshipPermission ) -> AsyncStream<AirshipPermissionStatus> /// - Note: For internal use only. :nodoc: func permissionStatusMap() async -> [String: String] /// Sets a permission delegate. /// /// - Note: The delegate will be strongly retained. /// /// - Parameters: /// - delegate: The delegate. /// - permission: The permission. func setDelegate( _ delegate: (any AirshipPermissionDelegate)?, permission: AirshipPermission ) /// Checks a permission status. /// /// - Note: If no delegate is set for the given permission this will always return `.notDetermined`. /// /// - Parameters: /// - permission: The permission. @MainActor func checkPermissionStatus( _ permission: AirshipPermission ) async -> AirshipPermissionStatus /// Requests a permission. /// /// - Note: If no permission delegate is set for the given permission this will always return `.notDetermined` /// /// - Parameters: /// - permission: The permission. @MainActor func requestPermission( _ permission: AirshipPermission ) async -> AirshipPermissionStatus /// Requests a permission. /// /// - Note: If no permission delegate is set for the given permission this will always return `.notDetermined` /// /// - Parameters: /// - permission: The permission. /// - enableAirshipUsageOnGrant: `true` to allow any Airship features that need the permission to be enabled as well, e.g., enabling push privacy manager feature and user notifications if `.displayNotifications` is granted. @MainActor func requestPermission( _ permission: AirshipPermission, enableAirshipUsageOnGrant: Bool ) async -> AirshipPermissionStatus /// Requests a permission. /// /// - Parameters: /// - permission: The permission. /// - enableAirshipUsageOnGrant: `true` to allow any Airship features that need the permission to be enabled as well, e.g., enabling push privacy manager feature and user notifications if `.displayNotifications` is granted. /// - fallback: The fallback behavior if the permission is alreay denied. /// - Returns: A `AirshipPermissionResult` with the starting and ending status If no permission delegate is /// set for the given permission the status will be `.notDetermined` @MainActor func requestPermission( _ permission: AirshipPermission, enableAirshipUsageOnGrant: Bool, fallback: PromptPermissionFallback ) async -> AirshipPermissionResult /// - Note: for internal use only. :nodoc: func addRequestExtender( permission: AirshipPermission, extender: @escaping @Sendable (AirshipPermissionStatus) async -> Void ) /// - Note: for internal use only. :nodoc: func addAirshipEnabler( permission: AirshipPermission, onEnable: @escaping @Sendable () async -> Void ) } final class DefaultAirshipPermissionsManager: AirshipPermissionsManager { private let delegateMap: AirshipAtomicValue<[AirshipPermission: any AirshipPermissionDelegate]> = AirshipAtomicValue([AirshipPermission: any AirshipPermissionDelegate]()) private let airshipEnablers: AirshipAtomicValue<[AirshipPermission: [@Sendable () async -> Void]]> = AirshipAtomicValue([AirshipPermission: [@Sendable () async -> Void]]()) private let queue: AirshipSerialQueue = AirshipSerialQueue() private let extenders: AirshipAtomicValue<[AirshipPermission: [@Sendable (AirshipPermissionStatus) async -> Void]]> = AirshipAtomicValue([AirshipPermission: [@Sendable (AirshipPermissionStatus) async -> Void]]()) private let statusUpdates: AirshipAsyncChannel<(AirshipPermission, AirshipPermissionStatus)> = AirshipAsyncChannel() private let appStateTracker: any AppStateTrackerProtocol private let systemSettingsNavigator: any SystemSettingsNavigatorProtocol @MainActor init( appStateTracker: (any AppStateTrackerProtocol)? = nil, systemSettingsNavigator: any SystemSettingsNavigatorProtocol = SystemSettingsNavigator() ) { self.appStateTracker = appStateTracker ?? AppStateTracker.shared self.systemSettingsNavigator = systemSettingsNavigator Task { @MainActor [weak self] in guard let updates = self?.appStateTracker.stateUpdates else { return } for await update in updates { if (update == .active) { await self?.refreshPermissionStatuses() } } } } public var configuredPermissions: Set<AirshipPermission> { return Set(delegateMap.value.keys) } public func statusUpdate(for permission: AirshipPermission) -> AsyncStream<AirshipPermissionStatus> { return AsyncStream<AirshipPermissionStatus> { [weak self, statusUpdates] continuation in let task = Task { [weak self, statusUpdates] in if let startingStatus = await self?.checkPermissionStatus(permission) { continuation.yield(startingStatus) } let updates = await statusUpdates.makeStream() .filter({ $0.0 == permission }) .map({ $0.1 }) for await item in updates { continuation.yield(item) } continuation.finish() } continuation.onTermination = { _ in task.cancel() } } } /// - Note: For internal use only. :nodoc: public func permissionStatusMap() async -> [String: String] { var map: [String: String] = [:] for permission in configuredPermissions { let status = await checkPermissionStatus(permission) map[permission.rawValue] = status.rawValue } return map } public func setDelegate( _ delegate: (any AirshipPermissionDelegate)?, permission: AirshipPermission ) { delegateMap.update { input in var mutable = input mutable[permission] = delegate return mutable } } @MainActor public func checkPermissionStatus( _ permission: AirshipPermission ) async -> AirshipPermissionStatus { guard let delegate = self.permissionDelegate(permission) else { return .notDetermined } return await delegate.checkPermissionStatus() } @MainActor public func requestPermission( _ permission: AirshipPermission ) async -> AirshipPermissionStatus { return await requestPermission( permission, enableAirshipUsageOnGrant: false ) } @MainActor public func requestPermission( _ permission: AirshipPermission, enableAirshipUsageOnGrant: Bool ) async -> AirshipPermissionStatus { return await requestPermission( permission, enableAirshipUsageOnGrant: enableAirshipUsageOnGrant, fallback: .none ).endStatus } @MainActor public func requestPermission( _ permission: AirshipPermission, enableAirshipUsageOnGrant: Bool, fallback: PromptPermissionFallback ) async -> AirshipPermissionResult { let status: AirshipPermissionResult? = try? await self.queue.run { @MainActor in guard let delegate = self.permissionDelegate(permission) else { return AirshipPermissionResult.notDetermined } let startingStatus = await delegate.checkPermissionStatus() var endStatus: AirshipPermissionStatus = await delegate.requestPermission() if startingStatus == .denied, endStatus == .denied { switch fallback { case .none: endStatus = .denied case .systemSettings: if await self.systemSettingsNavigator.open(for: permission) { await self.appStateTracker.waitForActive() endStatus = await delegate.checkPermissionStatus() } else { endStatus = .denied } case .callback(let callback): await callback() endStatus = await delegate.checkPermissionStatus() } } if endStatus == .granted { await self.onPermissionEnabled( permission, enableAirshipUsage: enableAirshipUsageOnGrant ) } await self.onExtend(permission: permission, status: endStatus) return AirshipPermissionResult(startStatus: startingStatus, endStatus: endStatus) } let result = status ?? AirshipPermissionResult.notDetermined await statusUpdates.send((permission, result.endStatus)) return result } public func addRequestExtender( permission: AirshipPermission, extender: @escaping @Sendable (AirshipPermissionStatus) async -> Void ) { extenders.update { current in var mutable = current if mutable[permission] == nil { mutable[permission] = [extender] } else { mutable[permission]?.append(extender) } return mutable } } public func addAirshipEnabler( permission: AirshipPermission, onEnable: @escaping @Sendable () async -> Void ) { airshipEnablers.update { current in var mutable = current if mutable[permission] == nil { mutable[permission] = [onEnable] } else { mutable[permission]?.append(onEnable) } return mutable } } private func onPermissionEnabled( _ permission: AirshipPermission, enableAirshipUsage: Bool ) async { guard enableAirshipUsage else { return } let enablers = airshipEnablers.value[permission] ?? [] for enabler in enablers { await enabler() } } private func permissionDelegate( _ permission: AirshipPermission ) -> (any AirshipPermissionDelegate)? { return delegateMap.value[permission] } @MainActor private func refreshPermissionStatuses() async { for permission in configuredPermissions { let status = await checkPermissionStatus(permission) await statusUpdates.send((permission, status)) } } @MainActor func onExtend( permission: AirshipPermission, status: AirshipPermissionStatus ) async { let extenders = self.extenders.value[permission] ?? [] for extender in extenders { await extender(status) } } } /// Permission request result. public struct AirshipPermissionResult: Sendable { /// Starting status public var startStatus: AirshipPermissionStatus /// Ending status public var endStatus: AirshipPermissionStatus public init(startStatus: AirshipPermissionStatus, endStatus: AirshipPermissionStatus) { self.startStatus = startStatus self.endStatus = endStatus } static var notDetermined: AirshipPermissionResult { AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } } /// Prompt permission fallback to be used if the requested permission is already denied. public enum PromptPermissionFallback: Sendable { /// No fallback case none /// Navigate to system settings case systemSettings // Custom callback case callback(@MainActor @Sendable () async -> Void) } extension PromptPermissionFallback { var isNone: Bool { switch self { case .none: return true default: return false } } } protocol SystemSettingsNavigatorProtocol: Sendable { @MainActor func open(for: AirshipPermission) async -> Bool } struct SystemSettingsNavigator: SystemSettingsNavigatorProtocol { @MainActor func open(for permission: AirshipPermission) async -> Bool { #if os(watchOS) return false #else guard let url = systemSettingURLForPermission(permission) else { return false } // Use our unified cross-platform opener return await DefaultURLOpener.shared.openURL(url) #endif } @MainActor private func systemSettingURLForPermission(_ permission: AirshipPermission) -> URL? { #if os(macOS) let path = switch(permission) { case .displayNotifications: "x-apple.systempreferences:com.apple.Notifications-Settings.extension" case .location: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices" } return URL(string: path) #elseif !os(watchOS) let string = switch(permission) { case .displayNotifications: UIApplication.openNotificationSettingsURLString case .location: UIApplication.openSettingsURLString } return URL(string: string) #else return nil #endif } } ================================================ FILE: Airship/AirshipCore/Source/PreferenceDataStore.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Preference data store. /// - Note: For internal use only. :nodoc: public final class PreferenceDataStore: @unchecked Sendable { private let defaults: UserDefaults private let appKey: String static let deviceIDKey: String = "deviceID" private var pending: [String: [Any?]] = [:] private var cache: [String: Cached] = [:] private let lock: AirshipLock = AirshipLock() private let dispatcher: any UADispatcher private var deviceID: any AirshipDeviceIDProtocol var isAppRestore: Bool { get async { let deviceIDValue = await deviceID.value var restored: Bool = false lock.sync { let previousDeviceID = self.string(forKey: PreferenceDataStore.deviceIDKey) if (deviceIDValue != previousDeviceID) { restored = previousDeviceID != nil self.setObject(deviceIDValue, forKey: PreferenceDataStore.deviceIDKey) } } if (restored) { AirshipLogger.info("App restored") } return restored } } public convenience init(appKey: String) { self.init( appKey: appKey, dispatcher: DefaultDispatcher.serial(), deviceID: AirshipDeviceID(appKey: appKey) ) } init(appKey: String, dispatcher: any UADispatcher, deviceID: any AirshipDeviceIDProtocol) { self.defaults = PreferenceDataStore.createDefaults(appKey: appKey) self.appKey = appKey self.dispatcher = dispatcher self.deviceID = deviceID mergeKeys() } class func createDefaults(appKey: String) -> UserDefaults { let suiteName = "\(Bundle.main.bundleIdentifier ?? "").airship.settings" guard let defaults = UserDefaults(suiteName: suiteName) else { AirshipLogger.error("Failed to create defaults \(suiteName)") return UserDefaults.standard } let legacyPrefix = legacyKeyPrefix(appKey: appKey) for (key, value) in UserDefaults.standard.dictionaryRepresentation() { if key.hasPrefix(appKey) || key.hasPrefix(legacyPrefix) { defaults.set(value, forKey: key) UserDefaults.standard.removeObject(forKey: key) } } return defaults } public func value(forKey key: String) -> Any? { return read(key) } public func setValue(_ value: Any?, forKey key: String) { write(key, value: value) } func storeValue(_ value: Any?, forKey key: String) { write(key, value: value) } @objc public func removeObject(forKey key: String) { write(key, value: nil) } public func keyExists(_ key: String) -> Bool { return object(forKey: key) != nil } @objc public func object(forKey key: String) -> Any? { return read(key) } public func string(forKey key: String) -> String? { return read(key) } public func array(forKey key: String) -> [AnyHashable]? { return read(key) } public func dictionary(forKey key: String) -> [AnyHashable: Any]? { return read(key) } public func data(forKey key: String) -> Data? { return read(key) } public func stringArray(forKey key: String) -> [AnyHashable]? { return read(key) } public func integer(forKey key: String) -> Int { return read(key) ?? 0 } public func unsignedInteger(forKey key: String) -> UInt? { return read(key) } public func float(forKey key: String) -> Float { return read(key) ?? 0.0 } public func double(forKey key: String) -> Double { return read(key) ?? 0.0 } public func double(forKey key: String, defaultValue: Double) -> Double { return read(key, defaultValue: defaultValue) } @objc public func bool(forKey key: String) -> Bool { return read(key) ?? false } public func bool(forKey key: String, defaultValue: Bool) -> Bool { return read(key, defaultValue: defaultValue) } public func setInteger(_ int: Int, forKey key: String) { write(key, value: int) } public func setUnsignedInteger(_ value: UInt, forKey key: String) { write(key, value: value) } public func setFloat(_ float: Float, forKey key: String) { write(key, value: float) } public func setDouble(_ double: Double, forKey key: String) { write(key, value: double) } public func setBool(_ bool: Bool, forKey key: String) { write(key, value: bool) } @objc public func setObject(_ object: Any?, forKey key: String) { write(key, value: object) } public func codable<T: Codable>(forKey key: String) throws -> T? { guard let data: Data = read(key) else { return nil } return try JSONDecoder().decode(T.self, from: data) } public func safeCodable<T: Codable>(forKey key: String) -> T? { do { return try codable(forKey: key) } catch { AirshipLogger.error("Failed to read codable for key \(key)") return nil } } public func setSafeCodable<T: Codable>( _ codable: T?, forKey key: String ) { do { try setCodable(codable, forKey: key) } catch { AirshipLogger.error("Failed to write codable for key \(key)") } } public func setCodable<T: Codable>( _ codable: T?, forKey key: String ) throws { guard let codable = codable else { write(key, value: nil) return } let data = try JSONEncoder().encode(codable) write(key, value: data) } /// Merges old key formats `com.urbanairship.<APP_KEY>.<PREFERENCE>` to /// the new key formats `<APP_KEY><PREFERENCE>`. Fixes a bug in SDK 15.x-16.0.1 /// where the key changed but we didn't migrate the data. private func mergeKeys() { let legacyKeyPrefix = PreferenceDataStore.legacyKeyPrefix( appKey: self.appKey ) for (key, value) in self.defaults.dictionaryRepresentation() { // Check for old key if key.hasPrefix(legacyKeyPrefix) { let preference = String(key.dropFirst(legacyKeyPrefix.count)) let newValue = object(forKey: preference) if newValue == nil { // Value not updated on new key, restore value setObject(value, forKey: preference) } else if preference == "com.urbanairship.channel.tags" { // Both old and new tag keys have data, merge if let old = value as? [String], let new = newValue as? [String] { let combined = AudienceUtils.normalizeTags(old + new) setObject(combined, forKey: preference) } } // Delete the old key self.defaults.removeObject(forKey: key) } } } private class func legacyKeyPrefix(appKey: String) -> String { return "com.urbanairship.\(appKey)." } private func read<T>(_ key: String, defaultValue: T) -> T { return read(key) ?? defaultValue } private func read<T>(_ key: String) -> T? { let key = prefixKey(key) let defaults = self.defaults var result: Any? lock.sync { if let cached = self.cache[key] { result = cached.value } else { result = defaults.object(forKey: key) } } guard let result = result else { return nil } return result as? T } func write(_ key: String, value: Any?) { let key = prefixKey(key) let value = value lock.sync { self.cache[key] = Cached(value: value) } self.dispatcher.dispatchAsync { self.lock.sync { if let value = self.cache[key]?.value { self.defaults.set(value, forKey: key) } else { self.defaults.removeObject(forKey: key) } } } } private func prefixKey(_ key: String) -> String { return (appKey) + key } } private struct Cached { let value: Any? } ================================================ FILE: Airship/AirshipCore/Source/PromptPermissionAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Action that prompts for permission using `PermissionsManager` /// /// Expected arguments, dictionary with keys: /// -`enable_airship_usage`: Bool?. If related airship features should be enabled if the permission is granted. /// -`fallback_system_settings`: Bool?. If denied, fallback to system settings. /// -`permission`: String. The name of the permission. `post_notifications`, `bluetooth`, `mic`, `location`, `contacts`, `camera`, etc... /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush`, /// `ActionSituation.webViewInvocation`, `ActionSituation.manualInvocation`, /// `ActionSituation.foregroundInteractiveButton`, and `ActionSituation.automation` public final class PromptPermissionAction: AirshipAction { /// Default names - "prompt_permission_action", "^pp" public static let defaultNames: [String] = ["prompt_permission_action", "^pp"] /// Default predicate - rejects foreground pushes with visible display options public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } /// Metadata key for the result receiver. Must be (Permission, PermissionStatus, PermissionStatus) -> Void /// - Note: For internal use only. :nodoc: public static let resultReceiverMetadataKey: String = "permission_result" private let permissionPrompter: @Sendable () -> any PermissionPrompter public convenience init() { self.init { return AirshipPermissionPrompter( permissionsManager: Airship.permissionsManager ) } } required init(permissionPrompter: @escaping @Sendable () -> any PermissionPrompter) { self.permissionPrompter = permissionPrompter } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .automation, .manualInvocation, .launchedFromPush, .webViewInvocation, .foregroundInteractiveButton, .foregroundPush: return true case .backgroundPush: fallthrough case .backgroundInteractiveButton: fallthrough @unknown default: return false } } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let unwrapped = arguments.value.unWrap() guard let arg = unwrapped else { return nil } let data = try JSONSerialization.data( withJSONObject: arg, options: [] ) let args = try JSONDecoder().decode(Args.self, from: data) let result = await self.permissionPrompter().prompt( permission: args.permission, enableAirshipUsage: args.enableAirshipUsage ?? false, fallbackSystemSettings: args.fallbackSystemSettings ?? false ) let resultReceiver = arguments.metadata[ PromptPermissionAction.resultReceiverMetadataKey ] as? PermissionResultReceiver await resultReceiver?(args.permission, result.startStatus, result.endStatus) return nil } internal struct Args: Decodable { let enableAirshipUsage: Bool? let fallbackSystemSettings: Bool? let permission: AirshipPermission enum CodingKeys: String, CodingKey { case enableAirshipUsage = "enable_airship_usage" case fallbackSystemSettings = "fallback_system_settings" case permission = "permission" } } } ================================================ FILE: Airship/AirshipCore/Source/ProximityRegion.swift ================================================ /* Copyright Airship and Contributors */ /// A proximity region defines an identifier, major and minor. public class ProximityRegion { let latitude: Double? let longitude: Double? let rssi: Double? let proximityID: String let major: Double let minor: Double /** * Default constructor. * * - Parameter proximityID: The ID of the proximity region. * - Parameter major: The major. * - Parameter minor: The minor. * - Parameter rssi: The rssi. * - Parameter latitude: The latitude of the circular region's center point in degrees. * - Parameter longitude: The longitude of the circular region's center point in degrees. * * - Returns: Proximity region object or `nil` if error occurs. */ public init?( proximityID: String, major: Double, minor: Double, rssi: Double? = nil, latitude: Double? = nil, longitude: Double? = nil ) { if (latitude != nil || longitude != nil) && (latitude == nil || longitude == nil) { AirshipLogger.error( "Invalid proximity region. Both lat and long must both be defined if one is provided." ) return nil } if let latitude = latitude { guard EventUtils.isValid(latitude: latitude) else { return nil } } if let longitude = longitude { guard EventUtils.isValid(longitude: longitude) else { return nil } } if let rssi = rssi { guard ProximityRegion.isValid(rssi: rssi) else { return nil } } guard ProximityRegion.isValid(proximityID: proximityID) else { return nil } guard ProximityRegion.isValid(major: major) else { return nil } guard ProximityRegion.isValid(minor: minor) else { return nil } self.proximityID = proximityID self.major = major self.minor = minor self.rssi = rssi self.latitude = latitude self.longitude = longitude } /** * Factory method for creating a proximity region. * * - Parameter proximityID: The ID of the proximity region. * - Parameter major: The major. * - Parameter minor: The minor. * * - Returns: Proximity region object or `nil` if error occurs. */ public class func proximityRegion( proximityID: String, major: Double, minor: Double ) -> ProximityRegion? { return ProximityRegion( proximityID: proximityID, major: major, minor: minor ) } /** * Factory method for creating a proximity region. * * - Parameter proximityID: The ID of the proximity region. * - Parameter major: The major. * - Parameter minor: The minor. * - Parameter rssi: The rssi. * * - Returns: Proximity region object or `nil` if error occurs. */ public class func proximityRegion( proximityID: String, major: Double, minor: Double, rssi: Double ) -> ProximityRegion? { return ProximityRegion( proximityID: proximityID, major: major, minor: minor, rssi: rssi ) } /** * Factory method for creating a proximity region. * * - Parameter proximityID: The ID of the proximity region. * - Parameter major: The major. * - Parameter minor: The minor. * - Parameter latitude: The latitude of the circular region's center point in degrees. * - Parameter longitude: The longitude of the circular region's center point in degrees. * * - Returns: Proximity region object or `nil` if error occurs. */ public class func proximityRegion( proximityID: String, major: Double, minor: Double, latitude: Double, longitude: Double ) -> ProximityRegion? { return ProximityRegion( proximityID: proximityID, major: major, minor: minor, latitude: latitude, longitude: longitude ) } /** * Factory method for creating a proximity region. * * - Parameter proximityID: The ID of the proximity region. * - Parameter major: The major. * - Parameter minor: The minor. * - Parameter rssi: The rssi. * - Parameter latitude: The latitude of the circular region's center point in degrees. * - Parameter longitude: The longitude of the circular region's center point in degrees. * * - Returns: Proximity region object or `nil` if error occurs. */ public class func proximityRegion( proximityID: String, major: Double, minor: Double, rssi: Double, latitude: Double, longitude: Double ) -> ProximityRegion? { return ProximityRegion( proximityID: proximityID, major: major, minor: minor, rssi: rssi, latitude: latitude, longitude: longitude ) } private class func isValid(proximityID: String) -> Bool { guard proximityID.count > 0 && proximityID.count <= 255 else { AirshipLogger.error( "Invalid proximityID \(proximityID). Must be between 1 and 255 characters" ) return false } return true } private class func isValid(rssi: Double) -> Bool { guard rssi >= -100 && rssi <= 100 else { AirshipLogger.error( "Invalid RSSI \(rssi). Must be between -100 and 100" ) return false } return true } private class func isValid(major: Double) -> Bool { guard major >= 0 && major <= 65535 else { AirshipLogger.error( "Invalid major \(major). Must be between 0 and 65535" ) return false } return true } private class func isValid(minor: Double) -> Bool { guard minor >= 0 && minor <= 65535 else { AirshipLogger.error( "Invalid minor \(minor). Must be between 0 and 65535" ) return false } return true } } ================================================ FILE: Airship/AirshipCore/Source/PushNotificationDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(WatchKit) public import WatchKit #endif #if canImport(UIKit) && !os(watchOS) public import UIKit #endif public import UserNotifications /// Protocol to be implemented by push notification clients. All methods are optional. public protocol PushNotificationDelegate: AnyObject, Sendable { /// Called when a notification is received in the foreground. /// /// - Parameters: /// - userInfo: The notification info @MainActor func receivedForegroundNotification(_ userInfo: [AnyHashable: Any]) async #if os(watchOS) /// Called when a notification is received in the background. /// /// - Parameters: /// - userInfo: The notification info @MainActor func receivedBackgroundNotification(_ userInfo: [AnyHashable: Any]) async -> WKBackgroundFetchResult #elseif os(macOS) /// Called when a notification is received in the background. /// /// - Parameters: /// - userInfo: The notification info @MainActor func receivedBackgroundNotification(_ userInfo: [AnyHashable: Any]) async -> Void #else /// Called when a notification is received in the background. /// /// - Parameters: /// - userInfo: The notification info @MainActor func receivedBackgroundNotification(_ userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult #endif #if !os(tvOS) /// Called when a notification is received in the background or foreground and results in a user interaction. /// User interactions can include launching the application from the push, or using an interactive control on the notification interface /// such as a button or text field. /// /// - Parameters: /// - notificationResponse: UNNotificationResponse object representing the user's response /// to the notification and the associated notification contents. @MainActor func receivedNotificationResponse(_ notificationResponse: UNNotificationResponse) async #endif /// Called when a notification has arrived in the foreground and is available for display. /// /// - Parameters: /// - options: The notification presentation options. /// - notification: The notification. @MainActor func extendPresentationOptions( _ options: UNNotificationPresentationOptions, notification: UNNotification ) async -> UNNotificationPresentationOptions } ================================================ FILE: Airship/AirshipCore/Source/RadioInput.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct RadioInput: View { private let info: ThomasViewInfo.RadioInput private let constraints: ViewConstraints @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var radioInputState: RadioInputState @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver init(info: ThomasViewInfo.RadioInput, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .radioInput, thomasState: thomasState ) } private var isOnBinding: Binding<Bool> { return radioInputState.makeBinding( identifier: nil, reportingValue: info.properties.reportingValue, attributeValue: info.properties.attributeValue ) } @ViewBuilder var body: some View { Toggle(isOn: self.isOnBinding.animation()) {} .thomasToggleStyle( self.info.properties.style, constraints: self.constraints ) .constraints(constraints) .thomasCommon(self.info) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() .accessibilityRemoveTraits(.isSelected) } } ================================================ FILE: Airship/AirshipCore/Source/RadioInputController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI @MainActor struct RadioInputController: View { private let info: ThomasViewInfo.RadioInputController private let constraints: ViewConstraints @EnvironmentObject private var environment: ThomasEnvironment init(info: ThomasViewInfo.RadioInputController, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var body: some View { Content( info: self.info, constraints: constraints, environment: environment ) .id(info.properties.identifier) .accessibilityElement(children: .contain) } @MainActor struct Content: View { private let info: ThomasViewInfo.RadioInputController private let constraints: ViewConstraints @Environment(\.pageIdentifier) private var pageID @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var thomasState: ThomasState @ObservedObject private var radioInputState: RadioInputState @EnvironmentObject private var validatableHelper: ValidatableHelper @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .radioInputController, thomasState: thomasState ) } init( info: ThomasViewInfo.RadioInputController, constraints: ViewConstraints, environment: ThomasEnvironment ) { self.info = info self.constraints = constraints // Use the environment to create or retrieve the state in case the view // stack changes and we lose our state. let radioInputState = environment.retrieveState(identifier: info.properties.identifier) { RadioInputState() } self._radioInputState = ObservedObject(wrappedValue: radioInputState) } var body: some View { ViewFactory.createView(self.info.properties.view, constraints: constraints) .constraints(constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() .environmentObject(radioInputState) .airshipOnChangeOf(self.radioInputState.selected) { incoming in updateFormState(selected: incoming) } .onAppear { updateFormState(selected: self.radioInputState.selected) if self.formState.validationMode == .onDemand { validatableHelper.subscribe( forIdentifier: info.properties.identifier, formState: formState, initialValue: radioInputState.selected, valueUpdates: radioInputState.$selected, validatables: info.validation ) { [weak thomasState, weak radioInputState] actions in guard let thomasState, let radioInputState else { return } thomasState.processStateActions( actions, formFieldValue: .radio( radioInputState.selected?.reportingValue ) ) } } } } private func checkValid(value: AirshipJSON?) -> Bool { return value != nil || info.validation.isRequired != true } private func makeAttribute( selected: RadioInputState.Selected? ) -> [ThomasFormField.Attribute]? { guard let name = info.properties.attributeName, let value = selected?.attributeValue else { return nil } return [ ThomasFormField.Attribute( attributeName: name, attributeValue: value ) ] } private func updateFormState(selected: RadioInputState.Selected?) { let field: ThomasFormField = if checkValid(value: selected?.reportingValue) { ThomasFormField.validField( identifier: self.info.properties.identifier, input: .radio(selected?.reportingValue), result: .init( value: .radio(selected?.reportingValue), attributes: makeAttribute(selected: selected) ) ) } else { ThomasFormField.invalidField( identifier: self.info.properties.identifier, input: .radio(selected?.reportingValue) ) } self.formDataCollector.updateField(field, pageID: pageID) } } } ================================================ FILE: Airship/AirshipCore/Source/RadioInputState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine @MainActor class RadioInputState: ObservableObject { @Published private(set) var selected: Selected? func setSelected( identifier: String?, reportingValue: AirshipJSON, attributeValue: ThomasAttributeValue? ) { let incoming = Selected( identifier: identifier, reportingValue: reportingValue, attributeValue: attributeValue ) if (incoming != self.selected) { self.selected = incoming } } struct Selected: ThomasSerializable, Hashable { var identifier: String? var reportingValue: AirshipJSON var attributeValue: ThomasAttributeValue? } } extension RadioInputState { func makeBinding( identifier: String?, reportingValue: AirshipJSON, attributeValue: ThomasAttributeValue? ) -> Binding<Bool> { return Binding<Bool>( get: { if let identifier { self.selected?.identifier == identifier } else { self.selected?.reportingValue == reportingValue } }, set: { if $0 { self.setSelected( identifier: identifier, reportingValue: reportingValue, attributeValue: attributeValue ) } } ) } } // MARK: - ThomasStateProvider extension RadioInputState: ThomasStateProvider { typealias SnapshotType = Selected? var updates: AnyPublisher<any Codable, Never> { return $selected .removeDuplicates() .map(\.self) .eraseToAnyPublisher() } func persistentStateSnapshot() -> SnapshotType { return selected } func restorePersistentState(_ state: SnapshotType) { self.selected = state } } ================================================ FILE: Airship/AirshipCore/Source/RadioInputToggleLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI @MainActor struct RadioInputToggleLayout: View { @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var radioInputState: RadioInputState @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .radioInputToggleLayout, thomasState: thomasState ) } private let info: ThomasViewInfo.RadioInputToggleLayout private let constraints: ViewConstraints init(info: ThomasViewInfo.RadioInputToggleLayout, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var isOnBinding: Binding<Bool> { return radioInputState.makeBinding( identifier: info.properties.identifier, reportingValue: info.properties.reportingValue, attributeValue: info.properties.attributeValue ) } var body: some View { ToggleLayout( isOn: self.isOnBinding, onToggleOn: self.info.properties.onToggleOn, onToggleOff: self.info.properties.onToggleOff ) { ViewFactory.createView( self.info.properties.view, constraints: constraints ) } .constraints(self.constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() } } ================================================ FILE: Airship/AirshipCore/Source/RateAppAction.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) && !os(macOS) import Foundation import StoreKit /// Links directly to app store review page or opens an app rating prompt. /// /// This action is registered under the names rate_app_action and ^ra. /// /// The rate app action requires your application to provide an itunes ID as an argument value, or have it /// set on the Airship Config `Config.itunesID` instance used for takeoff. /// /// Expected argument values: /// - ``show_link_prompt``: Optional Boolean, true to show prompt, false to link to the app store. /// - ``itunes_id``: Optional String, the iTunes ID for the application to be rated. /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush`, `ActionSituation.webViewInvocation` /// `ActionSituation.manualInvocation`, `ActionSituation.foregroundInteractiveButton`, and `ActionSituation.automation` /// /// Result value: nil public final class RateAppAction: AirshipAction, Sendable { public static let defaultNames: [String] = ["rate_app_action", "^ra"] public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.situation != .foregroundPush } let itunesID: @Sendable () -> String? let appRater: any AppRaterProtocol init( appRater: any AppRaterProtocol, itunesID: @escaping @Sendable () -> String? ) { self.appRater = appRater self.itunesID = itunesID } public convenience init() { self.init(appRater: DefaultAppRater()) { return Airship.config.airshipConfig.itunesID } } public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .manualInvocation, .launchedFromPush, .foregroundPush, .webViewInvocation, .foregroundInteractiveButton, .automation: return true case .backgroundPush: fallthrough case .backgroundInteractiveButton: fallthrough @unknown default: return false } } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { var args: Args? = nil if !arguments.value.isNull { args = try arguments.value.decode() } if args?.showPrompt == true { try await appRater.showPrompt() } else { guard let itunesID = args?.itunesID ?? self.itunesID() else { throw AirshipErrors.error("Missing itunes ID") } try await appRater.openStore(itunesID: itunesID) } return nil } private struct Args: Decodable { let itunesID: String? let showPrompt: Bool? enum CodingKeys: String, CodingKey { case itunesID = "itunes_id" case showPrompt = "show_link_prompt" } } } protocol AppRaterProtocol: Sendable { func openStore(itunesID: String) async throws func showPrompt() async throws } private struct DefaultAppRater: AppRaterProtocol { @MainActor func openStore(itunesID: String) async throws { let urlString = "itms-apps://itunes.apple.com/app/id\(itunesID)?action=write-review" guard let url = URL(string: urlString) else { throw AirshipErrors.error("Unable to generate URL") } guard await UIApplication.shared.open(url) else { throw AirshipErrors.error("Failed to open url \(url)") } } @MainActor func showPrompt() async throws { guard let scene = self.findScene() else { throw AirshipErrors.error( "Unable to find scene for rate app prompt" ) } AppStore.requestReview(in: scene) } @MainActor private func findScene() -> UIWindowScene? { return try? AirshipSceneManager.shared.lastActiveScene } } #endif ================================================ FILE: Airship/AirshipCore/Source/RegionEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Represents the boundary crossing event type. public enum AirshipBoundaryEvent: Int, Sendable { /** * Enter event */ case enter = 1 /** * Exit event */ case exit = 2 } /// A region event captures information regarding a region event for analytics. public class RegionEvent { public static let eventType: String = "region_event" public static let regionIDKey: String = "region_id" static let sourceKey: String = "source" static let boundaryEventKey: String = "action" static let boundaryEventEnterValue: String = "enter" static let boundaryEventExitValue: String = "exit" static let latitudeKey: String = "latitude" static let longitudeKey: String = "longitude" static let proximityRegionKey: String = "proximity" static let proximityRegionIDKey: String = "proximity_id" static let proximityRegionMajorKey: String = "major" static let proximityRegionMinorKey: String = "minor" static let proximityRegionRSSIKey: String = "rssi" static let circularRegionKey: String = "circular_region" static let circularRegionRadiusKey: String = "radius" /** * The region's identifier. */ public let regionID: String /** * The source of the event. */ public let source: String /** * The type of boundary event. */ public let boundaryEvent: AirshipBoundaryEvent /** * A circular region with a radius, and latitude/longitude from its center. */ public let circularRegion: CircularRegion? /** * A proximity region with an identifier, major and minor. */ public let proximityRegion: ProximityRegion? /** * Default constructor. * * - Parameter regionID: The ID of the region. * - Parameter source: The source of the event. * - Parameter boundaryEvent: The type of boundary crossing event. * - Parameter circularRegion: The circular region info. * - Parameter proximityRegion: The proximity region info. * * - Returns: Region event object or `nil` if error occurs. */ public init?( regionID: String, source: String, boundaryEvent: AirshipBoundaryEvent, circularRegion: CircularRegion? = nil, proximityRegion: ProximityRegion? = nil ) { guard RegionEvent.isValid(regionID: regionID) else { return nil } guard RegionEvent.isValid(source: source) else { return nil } self.regionID = regionID self.source = source self.boundaryEvent = boundaryEvent self.circularRegion = circularRegion self.proximityRegion = proximityRegion } /** * Factory method for creating a region event. * * - Parameter regionID: The ID of the region. * - Parameter source: The source of the event. * - Parameter boundaryEvent: The type of boundary crossing event. * * - Returns: Region event object or `nil` if error occurs. */ public class func regionEvent( regionID: String, source: String, boundaryEvent: AirshipBoundaryEvent ) -> RegionEvent? { return RegionEvent( regionID: regionID, source: source, boundaryEvent: boundaryEvent ) } /** * Factory method for creating a region event. * * - Parameter regionID: The ID of the region. * - Parameter source: The source of the event. * - Parameter boundaryEvent: The type of boundary crossing event. * - Parameter circularRegion: The circular region info. * - Parameter proximityRegion: The proximity region info. * * - Returns: Region event object or `nil` if error occurs. */ public class func regionEvent( regionID: String, source: String, boundaryEvent: AirshipBoundaryEvent, circularRegion: CircularRegion?, proximityRegion: ProximityRegion? ) -> RegionEvent? { return RegionEvent( regionID: regionID, source: source, boundaryEvent: boundaryEvent, circularRegion: circularRegion, proximityRegion: proximityRegion ) } private class func isValid(regionID: String) -> Bool { guard regionID.count >= 1 && regionID.count <= 255 else { AirshipLogger.error( "Invalid region ID \(regionID). Must be between 1 and 255 characters" ) return false } return true } private class func isValid(source: String) -> Bool { guard source.count >= 1 && source.count <= 255 else { AirshipLogger.error( "Invalid source ID \(source). Must be between 1 and 255 characters" ) return false } return true } func eventBody(stringifyFields: Bool) throws -> AirshipJSON { var dictionary: [String: Any] = [:] dictionary[RegionEvent.sourceKey] = self.source dictionary[RegionEvent.regionIDKey] = self.regionID switch self.boundaryEvent { case .enter: dictionary[RegionEvent.boundaryEventKey] = RegionEvent.boundaryEventEnterValue case .exit: dictionary[RegionEvent.boundaryEventKey] = RegionEvent.boundaryEventExitValue } if let proximityRegion = self.proximityRegion { var proximityData: [String: Any] = [:] proximityData[RegionEvent.proximityRegionIDKey] = proximityRegion.proximityID proximityData[RegionEvent.proximityRegionMajorKey] = proximityRegion.major proximityData[RegionEvent.proximityRegionMinorKey] = proximityRegion.minor proximityData[RegionEvent.proximityRegionRSSIKey] = proximityRegion.rssi if proximityRegion.latitude != nil && proximityRegion.longitude != nil { if stringifyFields { proximityData[RegionEvent.latitudeKey] = String( format: "%.7f", proximityRegion.latitude! ) proximityData[RegionEvent.longitudeKey] = String( format: "%.7f", proximityRegion.longitude! ) } else { proximityData[RegionEvent.latitudeKey] = proximityRegion.latitude proximityData[RegionEvent.longitudeKey] = proximityRegion.longitude } } dictionary[RegionEvent.proximityRegionKey] = proximityData } if let circularRegion = self.circularRegion { var circularData: [String: Any] = [:] if stringifyFields { circularData[RegionEvent.circularRegionRadiusKey] = String( format: "%.1f", circularRegion.radius ) circularData[RegionEvent.latitudeKey] = String( format: "%.7f", circularRegion.latitude ) circularData[RegionEvent.longitudeKey] = String( format: "%.7f", circularRegion.longitude ) } else { circularData[RegionEvent.circularRegionRadiusKey] = circularRegion.radius circularData[RegionEvent.latitudeKey] = circularRegion.latitude circularData[RegionEvent.longitudeKey] = circularRegion.longitude } dictionary[RegionEvent.circularRegionKey] = circularData } return try AirshipJSON.wrap(dictionary) } } ================================================ FILE: Airship/AirshipCore/Source/RegistrationDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import UserNotifications /// Implement this protocol and add as a Push.registrationDelegate to receive /// registration success and failure callbacks. /// public protocol RegistrationDelegate: AnyObject { #if !os(tvOS) /// Called when APNS registration completes. /// /// - Parameters: /// - authorizedSettings: The settings that were authorized at the time of registration. /// - categories: Set of the categories that were most recently registered. /// - status: The authorization status. @MainActor func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, categories: Set<UNNotificationCategory>, status: UNAuthorizationStatus ) #endif /// Called when APNS registration completes. /// /// - Parameters: /// - authorizedSettings: The settings that were authorized at the time of registration. /// - status: The authorization status. @MainActor func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, status: UNAuthorizationStatus ) /// Called when notification authentication changes with the new authorized settings. /// /// - Parameter authorizedSettings: AirshipAuthorizedNotificationSettings The newly changed authorized settings. @MainActor func notificationAuthorizedSettingsDidChange( _ authorizedSettings: AirshipAuthorizedNotificationSettings ) /// Called when the UIApplicationDelegate's application:didRegisterForRemoteNotificationsWithDeviceToken: /// delegate method is called. /// /// - Parameter deviceToken: The APNS device token. @MainActor func apnsRegistrationSucceeded( withDeviceToken deviceToken: Data ) /// Called when the UIApplicationDelegate's application:didFailToRegisterForRemoteNotificationsWithError: /// delegate method is called. /// /// - Parameter error: An NSError object that encapsulates information why registration did not succeed. @MainActor func apnsRegistrationFailedWithError(_ error: any Error) } public extension RegistrationDelegate { #if !os(tvOS) func notificationRegistrationFinished(withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, categories: Set<UNNotificationCategory>, status: UNAuthorizationStatus) { } #endif func notificationRegistrationFinished(withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, status: UNAuthorizationStatus) {} func notificationAuthorizedSettingsDidChange(_ authorizedSettings: AirshipAuthorizedNotificationSettings) {} func apnsRegistrationSucceeded(withDeviceToken deviceToken: Data) {} func apnsRegistrationFailedWithError(_ error: any Error) {} } ================================================ FILE: Airship/AirshipCore/Source/RemoteConfig.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: 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 } let remoteDataRefreshIntervalMilliseconds: Int64? init( airshipConfig: AirshipConfig? = nil, meteredUsageConfig: MeteredUsageConfig? = nil, fetchContactRemoteData: Bool? = nil, contactConfig: ContactConfig? = nil, disabledFeatures: AirshipFeature? = nil, remoteDataRefreshIntervalMilliseconds: Int64? = nil, iaaConfig: IAAConfig? = nil ) { self.airshipConfig = airshipConfig self.meteredUsageConfig = meteredUsageConfig self.fetchContactRemoteData = fetchContactRemoteData self.contactConfig = contactConfig self.disabledFeatures = disabledFeatures self.remoteDataRefreshIntervalMilliseconds = remoteDataRefreshIntervalMilliseconds self.iaaConfig = iaaConfig } enum CodingKeys: String, CodingKey { case airshipConfig = "airship_config" case meteredUsageConfig = "metered_usage" case fetchContactRemoteData = "fetch_contact_remote_data" case contactConfig = "contact_config" case disabledFeatures = "disabled_features" case remoteDataRefreshIntervalMilliseconds = "remote_data_refresh_interval" case iaaConfig = "in_app_config" } struct ContactConfig: Codable, Equatable, Sendable { let foregroundIntervalMilliseconds: Int64? let channelRegistrationMaxResolveAgeMilliseconds: Int64? var foregroundInterval: TimeInterval? { return foregroundIntervalMilliseconds?.timeInterval } var channelRegistrationMaxResolveAge: TimeInterval? { return channelRegistrationMaxResolveAgeMilliseconds?.timeInterval } enum CodingKeys: String, CodingKey { case foregroundIntervalMilliseconds = "foreground_resolve_interval_ms" case channelRegistrationMaxResolveAgeMilliseconds = "max_cra_resolve_age_ms" } } struct MeteredUsageConfig: Codable, Equatable, Sendable { let isEnabled: Bool? let initialDelayMilliseconds: Int64? let intervalMilliseconds: Int64? var intialDelay: TimeInterval? { return initialDelayMilliseconds?.timeInterval } var interval: TimeInterval? { return intervalMilliseconds?.timeInterval } enum CodingKeys: String, CodingKey { case isEnabled = "enabled" case initialDelayMilliseconds = "initial_delay_ms" case intervalMilliseconds = "interval_ms" } } struct AirshipConfig: Codable, Equatable, Sendable { public let remoteDataURL: String? public let deviceAPIURL: String? public let analyticsURL: String? public let meteredUsageURL: String? enum CodingKeys: String, CodingKey { case remoteDataURL = "remote_data_url" case deviceAPIURL = "device_api_url" case analyticsURL = "analytics_url" 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 { var timeInterval: TimeInterval { Double(self)/1000 } } ================================================ FILE: Airship/AirshipCore/Source/RemoteConfigCache.swift ================================================ /* Copyright Airship and Contributors */ /// NOTE: For internal use only. :nodoc: final class RemoteConfigCache: Sendable { private static let dataStoreKey: String = "com.urbanairship.config.remote_config_cache" private let dataStore: PreferenceDataStore private let _remoteConfig: AirshipAtomicValue<RemoteConfig> var remoteConfig: RemoteConfig { get { return _remoteConfig.value } set { _remoteConfig.value = newValue do { try self.dataStore.setCodable( newValue, forKey: RemoteConfigCache.dataStoreKey ) } catch { AirshipLogger.error("Failed to store remote config cache \(error)") } } } init(dataStore: PreferenceDataStore) { self.dataStore = dataStore var fromStore: RemoteConfig? = nil do { fromStore = try dataStore.codable( forKey: RemoteConfigCache.dataStoreKey ) } catch { AirshipLogger.error("Failed to read remote config cache \(error)") } self._remoteConfig = AirshipAtomicValue(fromStore ?? RemoteConfig()) } } ================================================ FILE: Airship/AirshipCore/Source/RemoteConfigManager.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation /// NOTE: For internal use only. :nodoc: final class RemoteConfigManager: @unchecked Sendable { private var subscription: AnyCancellable? private let remoteData: any RemoteDataProtocol private let privacyManager: any AirshipPrivacyManager private let notificationCenter: AirshipNotificationCenter private let appVersion: String private let lock: AirshipLock = AirshipLock() private let config: RuntimeConfig init( config: RuntimeConfig, remoteData: any RemoteDataProtocol, privacyManager: any AirshipPrivacyManager, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, appVersion: String = AirshipUtils.bundleShortVersionString() ?? "" ) { self.config = config self.remoteData = remoteData self.privacyManager = privacyManager self.notificationCenter = notificationCenter self.appVersion = appVersion } func airshipReady() { self.notificationCenter.addObserver( self, selector: #selector(updateRemoteConfigSubscription), name: AirshipNotifications.PrivacyManagerUpdated.name, object: nil ) self.updateRemoteConfigSubscription() } func processRemoteConfig(_ payloads: [RemoteDataPayload]?) { var combinedData: [String: Any] = [:] // Combine the data, overriding the common config (first) with // the platform config (second). payloads?.forEach { payload in combinedData.merge((payload.data.object ?? [:])) { (_, new) in new } } //Remote config applyRemoteConfig(combinedData) } private func applyRemoteConfig(_ data: [String: Any]) { do { let remoteConfig: RemoteConfig = try AirshipJSON.wrap(data).decode() Task { @MainActor [config] in config.updateRemoteConfig(remoteConfig) } } catch { AirshipLogger.error("Invalid remote config \(error)") return } } @objc private func updateRemoteConfigSubscription() { lock.sync { if self.privacyManager.isAnyFeatureEnabled() { if self.subscription == nil { self.subscription = self.remoteData.publisher( types: ["app_config", "app_config:ios"] ) .removeDuplicates() .sink { [weak self] remoteConfig in self?.processRemoteConfig(remoteConfig) } } } else { self.subscription?.cancel() self.subscription = nil } } } } ================================================ FILE: Airship/AirshipCore/Source/RemoteData.swift ================================================ /* Copyright Airship and Contributors */ @preconcurrency import Combine import Foundation #if canImport(UIKit) import UIKit #endif import UserNotifications /// NOTE: For internal use only. :nodoc: final class RemoteData: AirshipComponent, RemoteDataProtocol { fileprivate enum RefreshStatus: Sendable { case none case success case failed } static let refreshTaskID: String = "RemoteData.refresh" static let defaultRefreshInterval: TimeInterval = 10 static let refreshRemoteDataPushPayloadKey: String = "com.urbanairship.remote-data.update" // Datastore keys private static let randomValueKey: String = "remotedata.randomValue" private static let changeTokenKey: String = "remotedata.CHANGE_TOKEN" private let config: RuntimeConfig private let providers: [any RemoteDataProviderProtocol] private let dataStore: PreferenceDataStore private let date: any AirshipDateProtocol private let localeManager: any AirshipLocaleManager private let workManager: any AirshipWorkManagerProtocol private let privacyManager: any InternalAirshipPrivacyManager private let appVersion: String private let statusUpdates: AirshipAsyncChannel<[RemoteDataSource: RemoteDataSourceStatus]> = AirshipAsyncChannel() private let currentSourceStatus: AirshipAtomicValue<[RemoteDataSource: RemoteDataSourceStatus]> = .init([:]) private let refreshResultSubject: PassthroughSubject<(source: RemoteDataSource, result: RemoteDataRefreshResult), Never> = PassthroughSubject<(source: RemoteDataSource, result: RemoteDataRefreshResult), Never>() private let refreshStatusSubjectMap: [RemoteDataSource: CurrentValueSubject<RefreshStatus, Never>] private let lastActiveRefreshDate: AirshipMainActorValue<Date> = AirshipMainActorValue(Date.distantPast) private let changeTokenLock: AirshipLock = AirshipLock() private let contactSubscription: AirshipUnsafeSendableWrapper<AnyCancellable?> = AirshipUnsafeSendableWrapper(nil) let serialQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() private var randomValue: Int { if let value = self.dataStore.object(forKey: RemoteData.randomValueKey) as? Int { return value } let randomValue = Int.random(in: 0...9999) self.dataStore.setObject(randomValue, forKey: RemoteData.randomValueKey) return randomValue } @MainActor convenience init( config: RuntimeConfig, dataStore: PreferenceDataStore, localeManager: any AirshipLocaleManager, privacyManager: any InternalAirshipPrivacyManager, contact: any InternalAirshipContact ) { let client = RemoteDataAPIClient(config: config) self.init( config: config, dataStore: dataStore, localeManager: localeManager, privacyManager: privacyManager, contact: contact, providers: [ // App RemoteDataProvider( dataStore: dataStore, delegate: AppRemoteDataProviderDelegate( config: config, apiClient: client ) ), // Contact RemoteDataProvider( dataStore: dataStore, delegate: ContactRemoteDataProviderDelegate( config: config, apiClient: client, contact: contact ), defaultEnabled: false ) ] ) } @MainActor init( config: RuntimeConfig, dataStore: PreferenceDataStore, localeManager: any AirshipLocaleManager, privacyManager: any InternalAirshipPrivacyManager, contact: any InternalAirshipContact, providers: [any RemoteDataProviderProtocol], workManager: any AirshipWorkManagerProtocol = AirshipWorkManager.shared, date: any AirshipDateProtocol = AirshipDate.shared, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, appVersion: String = AirshipUtils.bundleShortVersionString() ?? "" ) { self.config = config self.dataStore = dataStore self.localeManager = localeManager self.privacyManager = privacyManager self.providers = providers self.workManager = workManager self.date = date self.appVersion = appVersion self.refreshStatusSubjectMap = self.providers.reduce( into: [RemoteDataSource: CurrentValueSubject<RefreshStatus, Never>]() ) { $0[$1.source] = CurrentValueSubject(.none) } self.contactSubscription.value = contact.contactIDUpdates .map { $0.contactID } .removeDuplicates() .sink { [weak self] _ in self?.enqueueRefreshTask() } notificationCenter.addObserver( self, selector: #selector(enqueueRefreshTask), name: AirshipNotifications.LocaleUpdated.name, object: nil ) notificationCenter.addObserver( self, selector: #selector(applicationDidForeground), name: AppStateTracker.didTransitionToForeground, object: nil ) notificationCenter.addObserver( self, selector: #selector(enqueueRefreshTask), name: AirshipNotifications.PrivacyManagerUpdated.name, object: nil ) self.workManager.registerWorker( RemoteData.refreshTaskID ) { [weak self] _ in return try await self?.handleRefreshTask() ?? .success } onConfigUpdated(config.remoteConfig, isUpdate: false) config.addRemoteConfigListener(notifyCurrent: false) { [weak self] _, new in self?.onConfigUpdated(new, isUpdate: true) } updateChangeToken() } private func onConfigUpdated(_ remoteConfig: RemoteConfig?, isUpdate: Bool) { self.serialQueue.enqueue { [providers] in let provider = providers.first { $0.source == .contact } let updated = await provider?.setEnabled(remoteConfig?.fetchContactRemoteData ?? false) if (isUpdate || updated == true) { await self.enqueueRefreshTask() } } } public func status( source: RemoteDataSource ) async -> RemoteDataSourceStatus { let result = await sourceStatus(source: source) await recordStatusFor([source]) return result } private func sourceStatus( source: RemoteDataSource ) async -> RemoteDataSourceStatus { return if let provider = providers.first(where: { $0.source == source }) { await provider.status( changeToken: self.changeToken, locale: self.localeManager.currentLocale, randomeValue: self.randomValue ) } else { .outOfDate } } private func recordStatusFor(_ sources: [RemoteDataSource]) async { var updates: [RemoteDataSource: RemoteDataSourceStatus] = self.currentSourceStatus.value for source in sources { updates[source] = await sourceStatus(source: source) } guard updates != self.currentSourceStatus.value else { return } self.currentSourceStatus.update(onModify: { _ in updates }) await statusUpdates.send(updates) } public func isCurrent( remoteDataInfo: RemoteDataInfo ) async -> Bool { let locale = localeManager.currentLocale for provider in self.providers { if (provider.source == remoteDataInfo.source) { return await provider.isCurrent( locale: locale, randomeValue: randomValue, remoteDataInfo: remoteDataInfo ) } } AirshipLogger.error("No remote data handler for \(remoteDataInfo.source)") return false } public func notifyOutdated(remoteDataInfo: RemoteDataInfo) async { for provider in self.providers { if (provider.source == remoteDataInfo.source) { if (await provider.notifyOutdated(remoteDataInfo: remoteDataInfo)) { await enqueueRefreshTask() } return } } } @MainActor public func statusUpdates<T:Sendable>( sources: [RemoteDataSource], map: @escaping (@Sendable (_ statuses: [RemoteDataSource: RemoteDataSourceStatus]) -> T) ) async -> AsyncStream<T> { return AsyncStream { [weak self] continuation in let task = Task { [weak self] in await self?.recordStatusFor(sources) let isInSource: ((RemoteDataSource, RemoteDataSourceStatus)) -> Bool = { sources.contains($0.0) } let current = self?.currentSourceStatus.value.filter(isInSource) ?? [:] let mappedStatuses = map(current) continuation.yield(mappedStatuses) if let updates = await self?.statusUpdates.makeStream() { for await item in updates { let filtered = item.filter(isInSource) continuation.yield(map(filtered)) } } continuation.finish() } continuation.onTermination = { _ in task.cancel() } } } @MainActor public func airshipReady() { self.enqueueRefreshTask() } var refreshInterval: TimeInterval { return self.config.remoteConfig.remoteDataRefreshInterval ?? Self.defaultRefreshInterval } @objc @MainActor private func applicationDidForeground() { let now = self.date.now let nextUpdate = self.lastActiveRefreshDate.value.advanced(by: self.refreshInterval ) if now >= nextUpdate { updateChangeToken() self.enqueueRefreshTask() self.lastActiveRefreshDate.set(now) } } @objc @MainActor private func enqueueRefreshTask() { self.refreshStatusSubjectMap.values.forEach { subject in subject.sendMainActor(.none) } self.workManager.dispatchWorkRequest( AirshipWorkRequest( workID: RemoteData.refreshTaskID, initialDelay: 0, requiresNetwork: true, conflictPolicy: .replace ) ) } private func updateChangeToken() { self.changeTokenLock.sync { self.dataStore.setObject(UUID().uuidString, forKey: RemoteData.changeTokenKey) } } /// The change token is just an easy way to know when we need to require an actual update vs checking the remote-data info if it has /// changed. We will create a new token on foreground (if its passed the refresh interval) or background push. private var changeToken: String { var token: String! self.changeTokenLock.sync { let fromStore = self.dataStore.string(forKey: RemoteData.changeTokenKey) if let fromStore = fromStore { token = fromStore } else { token = UUID().uuidString self.dataStore.setObject(token, forKey: RemoteData.changeTokenKey) } } return token + self.appVersion } private func handleRefreshTask() async throws -> AirshipWorkResult { guard self.privacyManager.isAnyFeatureEnabled(ignoringRemoteConfig: true) else { for provider in providers { await refreshResultSubject.sendMainActor((provider.source, .skipped)) await refreshStatusSubjectMap[provider.source]?.sendMainActor(.success) } return .success } let changeToken = self.changeToken let locale = self.localeManager.currentLocale let randomValue = self.randomValue let success = await withTaskGroup( of: (RemoteDataSource, RemoteDataRefreshResult).self ) { [providers, refreshResultSubject, refreshStatusSubjectMap] group in for provider in providers { group.addTask{ let result = await provider.refresh( changeToken: changeToken, locale: locale, randomeValue: randomValue ) return (provider.source, result) } } var success: Bool = true for await (source, result) in group { await refreshResultSubject.sendMainActor((source, result)) if (result == .failed) { success = false await refreshStatusSubjectMap[source]?.sendMainActor(.failed) } else { await refreshStatusSubjectMap[source]?.sendMainActor(.success) } } return success } await recordStatusFor(providers.map({ $0.source })) return success ? .success : .failure } public func forceRefresh() async { self.updateChangeToken() await enqueueRefreshTask() let sources = self.providers.map { $0.source } for source in sources { await self.waitRefreshAttempt(source: source) } } public func waitRefresh(source: RemoteDataSource) async { await waitRefresh(source: source, maxTime: nil) } public func waitRefresh( source: RemoteDataSource, maxTime: TimeInterval? ) async { AirshipLogger.trace("Waiting for remote data to refresh succesfully \(source)") await waitRefreshStatus(source: source, maxTime: maxTime) { status in status == .success } } public func waitRefreshAttempt(source: RemoteDataSource) async { await waitRefreshAttempt(source: source, maxTime: nil) } public func waitRefreshAttempt( source: RemoteDataSource, maxTime: TimeInterval? ) async { AirshipLogger.trace("Waiting for remote refresh attempt \(source)") await waitRefreshStatus(source: source, maxTime: maxTime) { status in status != .none } } private func waitRefreshStatus( source: RemoteDataSource, maxTime: TimeInterval?, statusPredicate: @escaping @Sendable (RefreshStatus) -> Bool ) async { guard let subject = self.refreshStatusSubjectMap[source] else { return } let result: RefreshStatus = await withUnsafeContinuation { continuation in var cancellable: AnyCancellable? var publisher: AnyPublisher<RefreshStatus, Never> = subject.first(where: statusPredicate).eraseToAnyPublisher() if let maxTime = maxTime, maxTime > 0.0 { publisher = Publishers.Merge( Just(.none).delay( for: .seconds(maxTime), scheduler: RunLoop.main ), publisher ).eraseToAnyPublisher() } cancellable = publisher.first() .sink { result in continuation.resume(returning: result) cancellable?.cancel() } } AirshipLogger.trace("Remote data refresh: \(source), status: \(result)") } public func payloads(types: [String]) async -> [RemoteDataPayload] { var payloads: [RemoteDataPayload] = [] for provider in self.providers { payloads += await provider.payloads(types: types) } return payloads.sortedByType(types) } private func payloadsFuture(types: [String]) -> Future<[RemoteDataPayload], Never> { return Future { promise in let wrapped = SendablePromise(promise: promise) Task { @MainActor in let payloads = await self.payloads(types: types) wrapped.promise(.success(payloads)) } } } public func publisher( types: [String] ) -> AnyPublisher<[RemoteDataPayload], Never> { // We use the refresh subject to know when to update // the current values by listening for a `newData` result return self.refreshResultSubject .filter { result in result.result == .newData } .flatMap { _ in // Fetch data self.payloadsFuture(types: types) } .prepend( // Prepend current data self.payloadsFuture(types: types) ) .eraseToAnyPublisher() } } extension RemoteData: AirshipPushableComponent { public func receivedRemoteNotification( _ notification: AirshipJSON ) async -> UABackgroundFetchResult { guard let userInfo = notification.unWrap() as? [AnyHashable: Any], userInfo[RemoteData.refreshRemoteDataPushPayloadKey] != nil else { return .noData } self.updateChangeToken() self.enqueueRefreshTask() return .newData } #if !os(tvOS) public func receivedNotificationResponse(_ response: UNNotificationResponse) async { // no-op } #endif } extension Sequence where Iterator.Element == RemoteDataPayload { func sortedByType(_ types: [String]) -> [Iterator.Element] { return self.sorted { first, second in let firstIndex = types.firstIndex(of: first.type) ?? 0 let secondIndex = types.firstIndex(of: second.type) ?? 0 return firstIndex < secondIndex } } } fileprivate struct SendablePromise<O, E>: @unchecked Sendable where E : Error { let promise: Future<O,E>.Promise } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol RemoteDataAPIClientProtocol: Sendable { func fetchRemoteData( url: URL, auth: AirshipRequestAuth, lastModified: String?, remoteDataInfoBlock: @Sendable @escaping (String?) throws -> RemoteDataInfo ) async throws -> AirshipHTTPResponse<RemoteDataResult> } final class RemoteDataAPIClient: RemoteDataAPIClientProtocol { private let session: any AirshipRequestSession private let config: RuntimeConfig private var 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: any AirshipRequestSession) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init(config: config, session: config.requestSession) } func fetchRemoteData( url: URL, auth: AirshipRequestAuth, lastModified: String?, remoteDataInfoBlock: @Sendable @escaping (String?) throws -> RemoteDataInfo ) async throws -> AirshipHTTPResponse<RemoteDataResult> { var headers: [String: String] = [ "X-UA-Appkey": self.config.appCredentials.appKey, "Accept": "application/vnd.urbanairship+json; version=3;" ] if let lastModified = lastModified { headers["If-Modified-Since"] = lastModified } let request = AirshipRequest( url: url, headers: headers, method: "GET", auth: auth ) 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")) let payloads = (remoteDataResponse.payloads ?? []) .map { payload in RemoteDataPayload( type: payload.type, timestamp: payload.timestamp, data: payload.data, remoteDataInfo: remoteDataInfo ) } return RemoteDataResult(payloads: payloads, remoteDataInfo: remoteDataInfo) } } } struct RemoteDataResult: Equatable { let payloads: [RemoteDataPayload] let remoteDataInfo: RemoteDataInfo } fileprivate struct RemoteDataResponse: Codable { let payloads: [Payload]? struct Payload: Codable { let type: String let timestamp: Date let data: AirshipJSON } } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataInfo.swift ================================================ public import Foundation /// NOTE: For internal use only. :nodoc: public struct RemoteDataInfo: Sendable, Codable, Equatable, Hashable { public let url: URL public let lastModifiedTime: String? public let source: RemoteDataSource public let contactID: String? public init(url: URL, lastModifiedTime: String?, source: RemoteDataSource, contactID: String? = nil) { self.url = url self.lastModifiedTime = lastModifiedTime self.source = source self.contactID = contactID } static func fromJSON(data: Data) throws -> RemoteDataInfo { try JSONDecoder().decode(RemoteDataInfo.self, from: data) } func toEncodedJSONData() throws -> Data { try JSONEncoder().encode(self) } } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataPayload.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public struct RemoteDataPayload: Sendable, Equatable, Hashable { /// The payload type public let type: String /// The timestamp of the most recent change to this data payload public let timestamp: Date /// The actual data associated with this payload public let data: AirshipJSON public let remoteDataInfo: RemoteDataInfo? public init( type: String, timestamp: Date, data: AirshipJSON, remoteDataInfo: RemoteDataInfo? ) { self.type = type self.timestamp = timestamp self.data = data self.remoteDataInfo = remoteDataInfo } } public extension RemoteDataPayload { func data(key: String) -> AnyHashable? { guard case .object(let map) = self.data else { return nil } return map[key]?.unWrap() } } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataProtocol.swift ================================================ /* Copyright Airship and Contributors */ public import Combine public import Foundation /// NOTE: For internal use only. :nodoc: public protocol RemoteDataProtocol: AnyObject, Sendable { /// Gets the update status for the given source /// - Parameter source: The source. /// - Returns The status of the source. func status(source: RemoteDataSource) async -> RemoteDataSourceStatus /// Checks if the remote data info is current or not. /// - Parameter remoteDataInfo: The remote data info. /// - Returns `true` if current, otherwise `false`. func isCurrent(remoteDataInfo: RemoteDataInfo) async -> Bool func notifyOutdated(remoteDataInfo: RemoteDataInfo) async func publisher(types: [String]) -> AnyPublisher<[RemoteDataPayload], Never> func payloads(types: [String]) async -> [RemoteDataPayload] /// Waits for a successful refresh /// - Parameters: /// - source: The remote data source. /// - maxTime: The max time to wait func waitRefresh(source: RemoteDataSource, maxTime: TimeInterval?) async /// Waits for a successful refresh /// - Parameters: /// - source: The remote data source. func waitRefresh(source: RemoteDataSource) async /// Waits for a refresh attempt for the session. /// - Parameters: /// - source: The remote data source. /// - maxTime: The max time to wait func waitRefreshAttempt(source: RemoteDataSource, maxTime: TimeInterval?) async /// Waits for a refresh attempt for the session. /// - Parameters: /// - source: The remote data source. func waitRefreshAttempt(source: RemoteDataSource) async /// Forces a refresh attempt. This should generally never be called externally. Currently being exposed for /// test apps. func forceRefresh() async /// Gets the status updates using the given mapping. /// - Returns:a stream of status updates. func statusUpdates<T:Sendable>(sources: [RemoteDataSource], map: @escaping (@Sendable (_ statuses: [RemoteDataSource: RemoteDataSourceStatus]) -> T)) async -> AsyncStream<T> } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: actor RemoteDataProvider: RemoteDataProviderProtocol { // Old private static let lastRefreshMetadataKey: String = "remotedata.LAST_REFRESH_METADATA" private static let lastRefreshTimeKey: String = "remotedata.LAST_REFRESH_TIME" private static let lastRefreshAppVersionKey: String = "remotedata.LAST_REFRESH_APP_VERSION" private static let maxStaleTime: TimeInterval = 3 * 24 * 60.0 // 3 days private let dataStore: PreferenceDataStore private let delegate: any RemoteDataProviderDelegate private let remoteDataStore: RemoteDataStore private let date: AirshipDate private let sourceName: String private let defaultEnabled: Bool private var requiresRefresh: Bool = false nonisolated var source: RemoteDataSource { return self.delegate.source } init( dataStore: PreferenceDataStore, delegate: any RemoteDataProviderDelegate, defaultEnabled: Bool = true, inMemory: Bool = false, date: AirshipDate = AirshipDate.shared ) { self.dataStore = dataStore self.delegate = delegate self.defaultEnabled = defaultEnabled self.date = date self.remoteDataStore = RemoteDataStore( storeName: delegate.storeName, inMemory: inMemory ) self.sourceName = delegate.source.name if (delegate.source == .app) { // If we have an old key if (self.dataStore.value(forKey: RemoteDataProvider.lastRefreshMetadataKey) != nil) { // Remove the old metadata to force an update if the SDK is downgraded self.dataStore.removeObject(forKey: RemoteDataProvider.lastRefreshMetadataKey) self.dataStore.removeObject(forKey: RemoteDataProvider.lastRefreshTimeKey) self.dataStore.removeObject(forKey: RemoteDataProvider.lastRefreshAppVersionKey) // This prevents an issue going from 17 -> 16 -> 17 where remote-data is not refreshed self.dataStore.removeObject(forKey: "remotedata.\(self.sourceName)_state") } } } func payloads(types: [String]) async -> [RemoteDataPayload] { guard self.isEnabled else { return [] } do { return try await self.remoteDataStore.fetchRemoteDataFromCache( types: types ) .sortedByType(types) } catch { AirshipLogger.error("Failed to get contact remote data \(error)") return [] } } func setEnabled(_ enabled: Bool) -> Bool { guard enabled != self.isEnabled else { return false } if (!enabled) { self.refreshState = nil } self.isEnabled = enabled return true } private var refreshState: LastRefreshState? { get { self.dataStore.safeCodable( forKey: "remotedata.\(self.sourceName)_state" ) } set { self.dataStore.setSafeCodable( newValue, forKey: "remotedata.\(self.sourceName)_state" ) } } private var isEnabled: Bool { get { self.dataStore.bool( forKey: "remotedata.\(self.sourceName)_enabled", defaultValue: defaultEnabled ) } set { self.dataStore.setBool( newValue, forKey: "remotedata.\(self.sourceName)_enabled" ) } } func notifyOutdated(remoteDataInfo: RemoteDataInfo) -> Bool { guard self.refreshState?.remoteDataInfo == remoteDataInfo else { return false } self.refreshState = nil return true } func isCurrent(locale: Locale, randomeValue: Int, remoteDataInfo: RemoteDataInfo) async -> Bool { guard self.isEnabled else { return false } guard let refreshState = self.refreshState else { return false } guard remoteDataInfo == refreshState.remoteDataInfo else { return false } return await self.delegate.isRemoteDataInfoUpToDate( refreshState.remoteDataInfo, locale: locale, randomValue: randomeValue ) } /// Assumes no reentry func refresh( changeToken: String, locale: Locale, randomeValue: Int ) async -> RemoteDataRefreshResult { AirshipLogger.trace("Checking \(self.sourceName) remote data") guard self.isEnabled else { do { if (try await self.remoteDataStore.hasData()) { try await self.remoteDataStore.clear() return .newData } } catch { AirshipLogger.trace("Failed to clear \(self.sourceName) remote data: \(error)") return .failed } return .skipped } let refreshState = self.refreshState let shouldRefresh = await self.status( refreshState: refreshState, changeToken: changeToken, locale: locale, randomeValue: randomeValue ) != .upToDate guard shouldRefresh else { AirshipLogger.trace("Skipping update, \(self.sourceName) remote data already up to date") return .skipped } AirshipLogger.trace("Requesting \(self.sourceName) remote data") do { let response = try await self.delegate.fetchRemoteData( locale: locale, randomValue: randomeValue, lastRemoteDataInfo: refreshState?.remoteDataInfo ) AirshipLogger.trace("Refresh result for \(self.sourceName) remote data: \(response)") guard response.isSuccess || response.statusCode == 304 else { return .failed } if response.isSuccess, let remoteData = response.result { try await self.remoteDataStore.overwriteCachedRemoteData(remoteData.payloads) self.refreshState = LastRefreshState( changeToken: changeToken, remoteDataInfo: remoteData.remoteDataInfo, date: date.now ) return .newData } else { guard let refreshState = refreshState else { throw AirshipErrors.error("Received 304 without a last modified time.") } self.refreshState = LastRefreshState( changeToken: changeToken, remoteDataInfo: refreshState.remoteDataInfo, date: date.now ) return .skipped } } catch { AirshipLogger.trace("Refresh failed for \(self.sourceName) remote data: \(error)") return .failed } } func status(changeToken: String, locale: Locale, randomeValue: Int) async -> RemoteDataSourceStatus { return await status( refreshState: self.refreshState, changeToken: changeToken, locale: locale, randomeValue: randomeValue ) } private func status( refreshState: LastRefreshState?, changeToken: String, locale: Locale, randomeValue: Int ) async ->RemoteDataSourceStatus { guard self.isEnabled, let refreshState = refreshState, let refreshDate = refreshState.date, self.date.now.timeIntervalSince(refreshDate) <= RemoteDataProvider.maxStaleTime else { return .outOfDate } let isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( refreshState.remoteDataInfo, locale: locale, randomValue: randomeValue ) guard isUpToDate else { return .outOfDate } guard changeToken == refreshState.changeToken else { return .stale } return .upToDate } } fileprivate struct LastRefreshState: Codable { let changeToken: String let remoteDataInfo: RemoteDataInfo let date: Date? } fileprivate extension RemoteDataSource { var name: String { switch(self) { case .app: return "app" case .contact: return "contact" } } } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataProviderDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol RemoteDataProviderDelegate: Sendable { var source: RemoteDataSource { get } var storeName: String { get } func isRemoteDataInfoUpToDate( _ remoteDataInfo: RemoteDataInfo, locale: Locale, randomValue: Int ) async -> Bool func fetchRemoteData( locale: Locale, randomValue: Int, lastRemoteDataInfo: RemoteDataInfo? ) async throws -> AirshipHTTPResponse<RemoteDataResult> } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataProviderProtocol.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Remote data provider protocol protocol RemoteDataProviderProtocol: Actor { /// The remote-data source. nonisolated var source: RemoteDataSource { get } /// Gets the payloads from remote-data. /// - Parameter types: The payload types. /// - Returns An array of payloads. func payloads(types: [String]) async -> [RemoteDataPayload] /// Notifies that the remote-data info is outdated. This will cause the next refresh to /// to fetch data. /// - Parameter remoteDataInfo: The remote data info. /// - Returns true if cleared, otherwise false. func notifyOutdated(remoteDataInfo: RemoteDataInfo) -> Bool /// Checks if the source is current. /// - Parameter locale: The current locale. /// - Parameter randomeValue: The remote-data random value. func isCurrent(locale: Locale, randomeValue: Int, remoteDataInfo: RemoteDataInfo) async -> Bool /// Checks if the source update status. /// - Parameter changeToken: The change token. /// - Parameter locale: The current locale. /// - Parameter randomeValue: The remote-data random value. /// - Returns The update status. func status(changeToken: String, locale: Locale, randomeValue: Int) async -> RemoteDataSourceStatus /// Refreshes remote data /// - Parameter changeToken: The change token. Used to control checking for a refresh /// even if the remote data info is up to date. /// - Parameter locale: The current locale. /// - Parameter randomeValue: The remote-data random value. /// - Returns true if the value changed, false if not. func refresh( changeToken: String, locale: Locale, randomeValue: Int ) async -> RemoteDataRefreshResult /// Enables/Disables the provider. /// - Parameter enabled: true to enable, false to disable /// - Returns true if the value changed, false if not. func setEnabled(_ enabled: Bool) -> Bool } /// Refresh result enum RemoteDataRefreshResult: Equatable, Sendable { /// Refresh was skipped either because it was disabled or data is up to date. case skipped /// Source was refreshed with new data. case newData // Refresh failed. case failed } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataSource.swift ================================================ import Foundation /// NOTE: For internal use only. :nodoc: public enum RemoteDataSource: Int, Sendable, Codable, Equatable, Hashable, CaseIterable { case app case contact } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataSourceStatus.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public enum RemoteDataSourceStatus: Sendable { case upToDate case stale case outOfDate } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataStore.swift ================================================ /* Copyright Airship and Contributors */ import CoreData final class RemoteDataStore: Sendable { static let remoteDataEntity: String = "UARemoteDataStorePayload" private let coreData: UACoreData private let inMemory: Bool public init(storeName: String, inMemory: Bool) { self.inMemory = inMemory let modelURL = AirshipCoreResources.bundle.url( forResource: "UARemoteData", withExtension: "momd" ) self.coreData = UACoreData( name: "UARemoteData", modelURL: modelURL!, inMemory: inMemory, stores: [storeName] ) } convenience init(storeName: String) { self.init(storeName: storeName, inMemory: false) } func hasData() async throws -> Bool { return try await self.coreData.performWithResult { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: RemoteDataStore.remoteDataEntity ) return try context.count(for: request) > 0 } } public func fetchRemoteDataFromCache( types: [String]? = nil ) async throws -> [RemoteDataPayload] { AirshipLogger.trace( "Fetching remote data from cache with types: \(String(describing: types))" ) return try await self.coreData.performWithResult { context in let request = NSFetchRequest<any NSFetchRequestResult>( entityName: RemoteDataStore.remoteDataEntity ) if let types = types { let predicate = AirshipCoreDataPredicate(format: "(type IN %@)", args: [types]) request.predicate = predicate.toNSPredicate() } let result = try context.fetch(request) as? [RemoteDataStorePayload] ?? [] return result.compactMap { var remoteDataInfo: RemoteDataInfo? = nil do { if let data = $0.remoteDataInfo { remoteDataInfo = try RemoteDataInfo.fromJSON(data: data) } } catch { AirshipLogger.error("Unable to parse remote-data info from data \(error.localizedDescription)") } var data: AirshipJSON = AirshipJSON.null do { data = try AirshipJSON.from(data: $0.data) } catch { AirshipLogger.error("Unable to parse remote-data data \(error.localizedDescription)") } return RemoteDataPayload( type: $0.type, timestamp: $0.timestamp, data: data, remoteDataInfo: remoteDataInfo ) } } } public func clear() async throws { try await self.coreData.perform({ context in try self.deleteAll(context: context) }) } public func overwriteCachedRemoteData( _ payloads: [RemoteDataPayload] ) async throws { try await self.coreData.perform({ context in try self.deleteAll(context: context) payloads.forEach { self.addPayload($0, context: context) } }) } private func deleteAll( context: NSManagedObjectContext ) throws { let fetchRequest = NSFetchRequest<any NSFetchRequestResult>( entityName: RemoteDataStore.remoteDataEntity ) if self.inMemory { fetchRequest.includesPropertyValues = false let payloads = try context.fetch(fetchRequest) as? [NSManagedObject] payloads?.forEach { context.delete($0) } } else { let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) try context.execute(deleteRequest) } } nonisolated private func addPayload( _ payload: RemoteDataPayload, context: NSManagedObjectContext ) { // create the NSManagedObject guard let remoteDataStorePayload = NSEntityDescription.insertNewObject( forEntityName: RemoteDataStore.remoteDataEntity, into: context ) as? RemoteDataStorePayload else { return } // set the properties remoteDataStorePayload.type = payload.type remoteDataStorePayload.timestamp = payload.timestamp remoteDataStorePayload.data = AirshipJSONUtils.toData(payload.data.unWrap() as? [AnyHashable : Any]) ?? Data() do { remoteDataStorePayload.remoteDataInfo = try payload.remoteDataInfo?.toEncodedJSONData() } catch { AirshipLogger.error("Unable to transform remote-data info to data \(error)") } } } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataStorePayload.swift ================================================ /* Copyright Airship and Contributors */ import CoreData import Foundation /// NOTE: For internal use only. :nodoc: @objc(UARemoteDataStorePayload) class RemoteDataStorePayload: NSManagedObject { /// The payload type @objc @NSManaged public var type: String /// The timestamp of the most recent change to this data payload @objc @NSManaged public var timestamp: Date /// The actual data associated with this payload @objc @NSManaged public var data: Data /// The remote data info as json encoded data. @objc @NSManaged public var remoteDataInfo: Data? } ================================================ FILE: Airship/AirshipCore/Source/RemoteDataURLFactory.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct RemoteDataURLFactory: Sendable { static func makeURL(config: RuntimeConfig, path: String, locale: Locale, randomValue: Int) throws -> URL { guard var components = URLComponents(string: config.remoteDataAPIURL) else { throw AirshipErrors.error("URL is null") } components.path = path var queryItems: [URLQueryItem] = [] let languageItem = URLQueryItem( name: "language", value: locale.getLanguageCode() ) if languageItem.value?.isEmpty == false { queryItems.append(languageItem) } let countryItem = URLQueryItem( name: "country", value: locale.getRegionCode() ) if countryItem.value?.isEmpty == false { queryItems.append(countryItem) } let versionItem = URLQueryItem( name: "sdk_version", value: AirshipVersion.version ) if versionItem.value?.isEmpty == false { queryItems.append(versionItem) } let randomValueItem = URLQueryItem( name: "random_value", value: String(randomValue) ) if randomValueItem.value?.isEmpty == false { queryItems.append(randomValueItem) } components.queryItems = queryItems guard let url = components.url else { throw AirshipErrors.error("URL is null") } return url } } ================================================ FILE: Airship/AirshipCore/Source/RemoveTagsAction.swift ================================================ /* Copyright Airship and Contributors */ /// Removes tags. /// /// Expected argument values: `String` (single tag), `[String]` (single or multiple tags), or an object. /// An example tag group JSON payload: /// { /// "channel": { /// "channel_tag_group": ["channel_tag_1", "channel_tag_2"], /// "other_channel_tag_group": ["other_channel_tag_1"] /// }, /// "named_user": { /// "named_user_tag_group": ["named_user_tag_1", "named_user_tag_2"], /// "other_named_user_tag_group": ["other_named_user_tag_1"] /// }, /// "device": [ "tag", "another_tag"] /// } /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush` /// `ActionSituation.webViewInvocation`, `ActionSituation.foregroundInteractiveButton`, /// `ActionSituation.backgroundInteractiveButton`, `ActionSituation.manualInvocation` and /// `ActionSituation.automation` public final class RemoveTagsAction: AirshipAction { /// Default names - "remove_tags_action", "^-t" public static let defaultNames: [String] = ["remove_tags_action", "^-t"] /// Default predicate - rejects foreground pushes with visible display options public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } private let channel: @Sendable () -> any AirshipChannel private let contact: @Sendable () -> any AirshipContact private let tagMutationsChannel: AirshipAsyncChannel<TagActionMutation> = AirshipAsyncChannel<TagActionMutation>() public var tagMutations: AsyncStream<TagActionMutation> { get async { return await tagMutationsChannel.makeStream() } } public convenience init() { self.init( channel: Airship.componentSupplier(), contact: Airship.componentSupplier() ) } init( channel: @escaping @Sendable () -> any AirshipChannel, contact: @escaping @Sendable () -> any AirshipContact ) { self.channel = channel self.contact = contact } public func accepts(arguments: ActionArguments) async -> Bool { guard arguments.situation != .backgroundPush else { return false } return true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let unwrapped = arguments.value.unWrap() if let tag = unwrapped as? String { channel().editTags { editor in editor.remove(tag) } sendTagMutation(.channelTags([tag])) } else if let tags = arguments.value.unWrap() as? [String] { channel().editTags { editor in editor.remove(tags) } sendTagMutation(.channelTags(tags)) } else if let args: TagsActionsArgs = try arguments.value.decode() { if let channelTagGroups = args.channel { channel().editTagGroups { editor in channelTagGroups.forEach { group, tags in editor.remove(tags, group: group) } } sendTagMutation(.channelTagGroups(channelTagGroups)) } if let contactTagGroups = args.namedUser { contact().editTagGroups { editor in contactTagGroups.forEach { group, tags in editor.remove(tags, group: group) } } sendTagMutation(.contactTagGroups(contactTagGroups)) } if let deviceTags = args.device { channel().editTags() { editor in editor.remove(deviceTags) } sendTagMutation(.channelTags(deviceTags)) } } return nil } private func sendTagMutation(_ mutation: TagActionMutation) { Task { @MainActor in await tagMutationsChannel.send(mutation) } } } ================================================ FILE: Airship/AirshipCore/Source/RetailEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public extension CustomEvent { /// Retail templates enum RetailTemplate: Sendable { /// Browsed case browsed /// Added to cart case addedToCart /// Starred case starred /// Purchased case purchased /// Shared /// - Parameters: /// - source: Optional source. /// - medium: Optional medium. case shared(source: String? = nil, medium: String? = nil) /// Added to wishlist /// - Parameters: /// - id: Optional id. /// - name: Optional name. case wishlist(id: String? = nil, name: String? = nil) fileprivate static let templateName: String = "retail" fileprivate var eventName: String { return switch self { case .browsed: "browsed" case .addedToCart: "added_to_cart" case .starred: "starred_product" case .purchased: "purchased" case .shared: "shared_product" case .wishlist: "wishlist" } } } /// Additional retail template properties struct RetailProperties: Encodable, Sendable { /// The event's ID. public var id: String? /// The event's category. public var category: String? /// The event's type. public var type: String? /// The event's description. public var eventDescription: String? /// The brand. public var brand: String? /// If its a new item or not. public var isNewItem: Bool? /// The currency. public var currency: String? /// If the value is a lifetime value or not. public var isLTV: Bool // Set from templates fileprivate var source: String? = nil fileprivate var medium: String? = nil fileprivate var wishlistName: String? = nil fileprivate var wishlistID: String? = nil public init( id: String? = nil, category: String? = nil, type: String? = nil, eventDescription: String? = nil, isLTV: Bool = false, brand: String? = nil, isNewItem: Bool? = nil, currency: String? = nil ) { self.id = id self.category = category self.type = type self.eventDescription = eventDescription self.brand = brand self.isNewItem = isNewItem self.currency = currency self.isLTV = isLTV } enum CodingKeys: String, CodingKey { case id case category case type case eventDescription = "description" case brand case isNewItem = "new_item" case currency case isLTV = "ltv" case source case medium case wishlistName = "wishlist_name" case wishlistID = "wishlist_id" } } /// Constructs a custom event using the retail template. /// - Parameters: /// - accountTemplate: The retail template. /// - properties: Optional additional properties /// - encoder: Encoder used to encode the additional properties. Defaults to `CustomEvent.defaultEncoder`. init( retailTemplate: RetailTemplate, properties: RetailProperties = RetailProperties(), encoder: @autoclosure () -> JSONEncoder = CustomEvent.defaultEncoder() ) { self = .init(name: retailTemplate.eventName) self.templateType = RetailTemplate.templateName var mutableProperties = properties switch retailTemplate { case .browsed: break case .addedToCart: break case .starred: break case .purchased: break case .shared(source: let source, medium: let medium): mutableProperties.source = source mutableProperties.medium = medium case .wishlist(id: let id, name: let name): mutableProperties.wishlistID = id mutableProperties.wishlistName = name } do { try self.setProperties(mutableProperties, encoder: encoder()) } catch { /// Should never happen so we are just catching the exception and logging AirshipLogger.error("Failed to generate event \(error)") } } } ================================================ FILE: Airship/AirshipCore/Source/RootView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct RootView<Content: View>: View { #if !os(tvOS) && !os(watchOS) @Environment(\.verticalSizeClass) private var verticalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass #endif @State private var currentOrientation: ThomasOrientation = RootView.resolveOrientation() @State private var isForeground: Bool = true @State private var isVisible: Bool = false @State private var isVoiceOverRunning: Bool = Self.resolveIsVoiceOverRunning() @ObservedObject var thomasEnvironment: ThomasEnvironment @StateObject var thomasState: ThomasState @StateObject var validatableHelper: ValidatableHelper = ValidatableHelper() @StateObject var formInputCollector: ThomasFormDataCollector = ThomasFormDataCollector() // Default form state so @EnvironmentObject does not crash @StateObject private var defaultFormState: ThomasFormState = ThomasFormState( identifier: "", formType: .form, formResponseType: nil, validationMode: .onDemand ) // Default pager state so @EnvironmentObject does not crash @StateObject private var defaultPagerState: PagerState = PagerState( identifier: "", branching: nil ) // Default video state so @EnvironmentObject does not crash @StateObject private var defaultVideoState: VideoState = VideoState( identifier: "" ) let layout: AirshipLayout let content: (ThomasOrientation, ThomasWindowSize) -> Content let associatedLabelResolver: ThomasAssociatedLabelResolver init( thomasEnvironment: ThomasEnvironment, layout: AirshipLayout, @ViewBuilder content: @escaping (ThomasOrientation, ThomasWindowSize) -> Content ) { self.thomasEnvironment = thomasEnvironment self.layout = layout self.content = content self.isForeground = AppStateTracker.shared.isForegrounded self._thomasState = StateObject( wrappedValue: ThomasState() { [weak thomasEnvironment] state in thomasEnvironment?.onStateChange(state) } ) self.associatedLabelResolver = ThomasAssociatedLabelResolver(layout: layout) } @ViewBuilder var body: some View { content(currentOrientation, resolveWindowSize()) .environmentObject(self.thomasEnvironment) .environmentObject(self.thomasState) .environmentObject(self.formInputCollector) .environmentObject(self.validatableHelper) .environmentObject(self.defaultPagerState) .environmentObject(self.defaultFormState) .environmentObject(self.defaultVideoState) .environment(\.orientation, currentOrientation) .environment(\.windowSize, resolveWindowSize()) .environment(\.isVisible, isVisible) .environment(\.isVoiceOverRunning, isVoiceOverRunning) .environment(\.thomasAssociatedLabelResolver, associatedLabelResolver) .onReceive(NotificationCenter.default.publisher(for: AppStateTracker.didTransitionToForeground)) { (_) in self.isForeground = true self.thomasEnvironment.onVisibilityChanged(isVisible: self.isVisible, isForegrounded: self.isForeground) } .onReceive(NotificationCenter.default.publisher(for: AppStateTracker.didTransitionToBackground)) { (_) in self.isForeground = false self.thomasEnvironment.onVisibilityChanged(isVisible: self.isVisible, isForegrounded: self.isForeground) } #if os(macOS) .onReceive(NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.accessibilityDisplayOptionsDidChangeNotification)) { _ in updateVoiceoverRunningState() } #elseif !os(watchOS) // iOS, tvOS, visionOS .onReceive(NotificationCenter.default.publisher(for: UIAccessibility.voiceOverStatusDidChangeNotification)) { _ in updateVoiceoverRunningState() } #endif .onAppear { updateVoiceoverRunningState() self.currentOrientation = RootView.resolveOrientation() self.isVisible = true self.thomasEnvironment.onVisibilityChanged(isVisible: self.isVisible, isForegrounded: self.isForeground) } .onDisappear { self.isVisible = false self.thomasEnvironment.onVisibilityChanged(isVisible: self.isVisible, isForegrounded: self.isForeground) } #if os(iOS) .onReceive( NotificationCenter.default.publisher( for: UIDevice.orientationDidChangeNotification ) ) { _ in self.currentOrientation = RootView.resolveOrientation() } #endif } /// Uses the vertical and horizontal class size to determine small, medium, large window size: /// - large: regular x regular = large /// - medium: regular x compact or compact x regular /// - small: compact x compact func resolveWindowSize() -> ThomasWindowSize { #if os(watchOS) return .small #elseif os(tvOS) return .large #else switch (verticalSizeClass, horizontalSizeClass) { case (.regular, .regular): return .large case (.compact, .compact): return .small default: return .medium } #endif } static func resolveOrientation() -> ThomasOrientation { #if os(tvOS) || os(watchOS) || os(macOS) return .landscape #else let scene = try? AirshipSceneManager.shared.lastActiveScene if let scene = scene { if scene.interfaceOrientation.isLandscape { return .landscape } else if scene.interfaceOrientation.isPortrait { return .portrait } } return .portrait #endif } private func updateVoiceoverRunningState() { isVoiceOverRunning = Self.resolveIsVoiceOverRunning() } private static func resolveIsVoiceOverRunning() -> Bool { #if os(watchOS) // watchOS does not expose a public property to check VoiceOver status return false #elseif os(macOS) // macOS equivalent return NSWorkspace.shared.isVoiceOverEnabled #else // iOS, tvOS, visionOS return UIAccessibility.isVoiceOverRunning #endif } } ================================================ FILE: Airship/AirshipCore/Source/RuntimeConfig.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation import Combine /// Airship config needed for runtime. Generated from `AirshipConfig` during takeOff. public final class RuntimeConfig: Sendable { /// - NOTE: This option is reserved for internal debugging. :nodoc: public static let configUpdatedEvent: Notification.Name = Notification.Name( "com.urbanairship.runtime_config_updated" ) struct DefaultURLs { let deviceURL: String let analyticsURL: String let remoteDataURL: String static let us: DefaultURLs = DefaultURLs( deviceURL: "https://device-api.urbanairship.com", analyticsURL: "https://combine.urbanairship.com", remoteDataURL: "https://remote-data.urbanairship.com" ) static let eu: DefaultURLs = DefaultURLs( deviceURL: "https://device-api.asnapieu.com", analyticsURL: "https://combine.asnapieu.com", remoteDataURL: "https://remote-data.asnapieu.com" ) } /// The resolved app credentials. public let appCredentials: AirshipAppCredentials /// The request session used to perform authenticated interactions with the API public let requestSession: any AirshipRequestSession /// The airship config public let airshipConfig: AirshipConfig private let remoteConfigCache: RemoteConfigCache private let notificationCenter: NotificationCenter private let defaultURLs: DefaultURLs /// - NOTE: For internal use only. :nodoc: public var remoteConfig: RemoteConfig { return self.remoteConfigCache.remoteConfig } /// - NOTE: For internal use only. :nodoc: public var deviceAPIURL: String? { if let url = remoteConfig.airshipConfig?.deviceAPIURL { return url } guard !self.airshipConfig.requireInitialRemoteConfigEnabled else { return nil } return defaultURLs.deviceURL } /// - NOTE: For internal use only. :nodoc: var remoteDataAPIURL: String { if let url = remoteConfig.airshipConfig?.remoteDataURL { return url } if let initialConfigURL = airshipConfig.initialConfigURL?.normalizeURLString(), !initialConfigURL.isEmpty { return initialConfigURL } return defaultURLs.remoteDataURL } /// - NOTE: For internal use only. :nodoc: var analyticsURL: String? { if let url = remoteConfig.airshipConfig?.analyticsURL { return url } guard !self.airshipConfig.requireInitialRemoteConfigEnabled else { return nil } return defaultURLs.analyticsURL } /// - NOTE: For internal use only. :nodoc: var meteredUsageURL: String? { return remoteConfigCache.remoteConfig.airshipConfig?.meteredUsageURL } public convenience init( airshipConfig: AirshipConfig, appCredentials: AirshipAppCredentials, dataStore: PreferenceDataStore, notificationCenter: NotificationCenter = NotificationCenter.default ) { self.init( airshipConfig: airshipConfig, appCredentials: appCredentials, dataStore: dataStore, requestSession: DefaultAirshipRequestSession( appKey: appCredentials.appKey, appSecret: appCredentials.appSecret ), notificationCenter: notificationCenter ) } init( airshipConfig: AirshipConfig, appCredentials: AirshipAppCredentials, dataStore: PreferenceDataStore, requestSession: any AirshipRequestSession, notificationCenter: NotificationCenter = NotificationCenter.default ) { self.airshipConfig = airshipConfig self.appCredentials = appCredentials self.requestSession = requestSession self.remoteConfigCache = RemoteConfigCache(dataStore: dataStore) self.notificationCenter = notificationCenter self.defaultURLs = switch(airshipConfig.site) { case .eu: DefaultURLs.eu case .us: DefaultURLs.us } } @MainActor func updateRemoteConfig(_ config: RemoteConfig) { let old = self.remoteConfig if config != old { self.remoteConfigCache.remoteConfig = config self.notificationCenter.post( name: RuntimeConfig.configUpdatedEvent, object: nil ) self.remoteConfigListeners.value.forEach { listener in listener(old, config) } } } @MainActor func addRemoteConfigListener( notifyCurrent: Bool = true, listener: @MainActor @Sendable @escaping (RemoteConfig?, RemoteConfig) -> Void ) { if (notifyCurrent) { listener(nil, self.remoteConfig) } self.remoteConfigListeners.update { $0.append(listener) } } let remoteConfigListeners: AirshipMainActorValue<[@MainActor @Sendable (RemoteConfig?, RemoteConfig) -> Void]> = AirshipMainActorValue([]) } extension String { fileprivate func normalizeURLString() -> String { guard hasSuffix("/") else { return self } var copy = self copy.removeLast() return copy } } ================================================ FILE: Airship/AirshipCore/Source/SMSRegistrationOptions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// SMS registration options public struct SMSRegistrationOptions: Codable, Sendable, Equatable, Hashable { /** * Sender ID */ let senderID: String private init(senderID: String) { self.senderID = senderID } /// Returns a SMS registration options with opt-in status /// - Parameter senderID: The sender ID /// - Returns: A SMS registration options. public static func optIn(senderID: String) -> SMSRegistrationOptions { return SMSRegistrationOptions(senderID: senderID) } } ================================================ FILE: Airship/AirshipCore/Source/SMSValidatorAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum SMSValidatorAPIClientResult: Decodable, Equatable, Sendable { case valid(String) case invalid enum CodingKeys: CodingKey { case valid case msisdn } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) if try container.decode(Bool.self, forKey: .valid) { let msisdn = try container.decode(String.self, forKey: .msisdn) self = .valid(msisdn) } else { self = .invalid } } } protocol SMSValidatorAPIClientProtocol: Sendable { func validateSMS( msisdn: String, sender: String ) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> func validateSMS( msisdn: String, prefix: String ) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> } final class SMSValidatorAPIClient: SMSValidatorAPIClientProtocol { private let config: RuntimeConfig private let session: any AirshipRequestSession init(config: RuntimeConfig, session: any 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<SMSValidatorAPIClientResult> { return try await performSMSValidation( requestBody: RequestBody( msisdn: msisdn, sender: sender, prefix: nil ) ) } func validateSMS( msisdn: String, prefix: String ) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> { return try await performSMSValidation( requestBody: RequestBody( msisdn: msisdn, sender: nil, prefix: prefix ) ) } fileprivate func performSMSValidation<T: Encodable>( requestBody: T ) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> { let request = AirshipRequest( url: try makeURL(path: "/api/channels/sms/format"), headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json" ], method: "POST", auth: .generatedAppToken, body: try JSONEncoder().encode(requestBody) ) return try await self.session.performHTTPRequest( request ) { (data, response) in AirshipLogger.debug( "SMS validation finished with response: \(response)" ) guard let data = data, response.statusCode >= 200, response.statusCode < 300 else { throw AirshipErrors.error("Invalid request made in performSMSValidation") } return try JSONDecoder().decode(SMSValidatorAPIClientResult.self, from: data) } } 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 RequestBody: Encodable { let msisdn: String let sender: String? let prefix: String? enum CodingKeys: String, CodingKey { case msisdn case sender case prefix } } } ================================================ FILE: Airship/AirshipCore/Source/ScopedSubscriptionListEdit.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Represents an edit made to a scoped subscription list through the SDK. public enum ScopedSubscriptionListEdit: Equatable { /// Subscribed case subscribe(String, ChannelScope) /// Unsubscribed case unsubscribe(String, ChannelScope) } ================================================ FILE: Airship/AirshipCore/Source/ScopedSubscriptionListEditor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Scoped subscription list editor. public class ScopedSubscriptionListEditor { private var subscriptionListUpdates: [ScopedSubscriptionListUpdate] = [] private let date: any AirshipDateProtocol private let completionHandler: ([ScopedSubscriptionListUpdate]) -> Void init( date: any AirshipDateProtocol, completionHandler: @escaping ([ScopedSubscriptionListUpdate]) -> Void ) { self.date = date self.completionHandler = completionHandler } /** * Subscribes to a list. * - Parameters: * - subscriptionListID: The subscription list identifier. * - scope: Defines the channel types that the change applies to. */ public func subscribe(_ subscriptionListID: String, scope: ChannelScope) { let subscriptionListUpdate = ScopedSubscriptionListUpdate( listId: subscriptionListID, type: .subscribe, scope: scope, date: self.date.now ) subscriptionListUpdates.append(subscriptionListUpdate) } /** * Unsubscribes from a list. * - Parameters: * - subscriptionListID: The subscription list identifier. * - scope: Defines the channel types that the change applies to. */ public func unsubscribe(_ subscriptionListID: String, scope: ChannelScope) { let subscriptionListUpdate = ScopedSubscriptionListUpdate( listId: subscriptionListID, type: .unsubscribe, scope: scope, date: date.now ) subscriptionListUpdates.append(subscriptionListUpdate) } /** * Internal helper that uses a boolean flag to indicate whether to subscribe or unsubscribe. * - Parameters: * - subscriptionListID: The subscription list identifier. * - scopes: The scopes. * - subscribe:`true` to subscribe, `false`to unsubscribe */ public func mutate( _ subscriptionListID: String, scopes: [ChannelScope], subscribe: Bool ) { if subscribe { scopes.forEach { self.subscribe(subscriptionListID, scope: $0) } } else { scopes.forEach { self.unsubscribe(subscriptionListID, scope: $0) } } } /** * Applies subscription list changes. */ public func apply() { completionHandler(subscriptionListUpdates) subscriptionListUpdates.removeAll() } } ================================================ FILE: Airship/AirshipCore/Source/ScopedSubscriptionListUpdate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: struct ScopedSubscriptionListUpdate: Codable, Equatable, Sendable { let listId: String let type: SubscriptionListUpdateType let scope: ChannelScope let date: Date } ================================================ FILE: Airship/AirshipCore/Source/Score.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine struct Score: View { private let info: ThomasViewInfo.Score private let constraints: ViewConstraints @MainActor fileprivate class ViewModel: ObservableObject { let style: ThomasViewInfo.Score.ScoreStyle init(style: ThomasViewInfo.Score.ScoreStyle) { self.style = style } @Published var score: AirshipJSON? @Published var index: Int? var accessibilityValue: String? { guard let index else { return nil } switch(style) { case .numberRange(let rangeStyle): let totalItems = rangeStyle.end - rangeStyle.start + 1 return String(format: "ua_x_of_y".airshipLocalizedString(fallback: "%@ of %@"), index.airshipLocalizedForVoiceOver(), totalItems.airshipLocalizedForVoiceOver()) } } func incrementScore() { switch(style) { case .numberRange(let rangeStyle): guard var index else { self.index = rangeStyle.start self.score = .number(Double(rangeStyle.start)) return } index = min(rangeStyle.end, index + 1) if self.index != index { self.index = index self.score = .number(Double(index)) } } } func decrementScore() { switch(style) { case .numberRange(let rangeStyle): guard var index else { self.index = rangeStyle.start self.score = .number(Double(rangeStyle.start)) return } index = max(rangeStyle.start, index - 1) if self.index != index { self.index = index self.score = .number(Double(index)) } } } } @Environment(\.pageIdentifier) private var pageID @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var thomasState: ThomasState @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var validatableHelper: ValidatableHelper @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .score, thomasState: thomasState ) } @StateObject private var viewModel: ViewModel init(info: ThomasViewInfo.Score, constraints: ViewConstraints) { self.info = info self.constraints = constraints _viewModel = .init(wrappedValue: ViewModel(style: info.properties.style)) } @ViewBuilder private func makeNumberRangeScoreItems(style: ThomasViewInfo.Score.ScoreStyle.NumberRange, constraints: ViewConstraints) -> some View { ForEach((style.start...style.end), id: \.self) { index in let isOn = Binding<Bool>( get: { self.viewModel.index == index }, set: { if $0 { self.viewModel.index = index self.viewModel.score = .number(Double(index)) } } ) Toggle(isOn: isOn.animation()) {} .toggleStyle( AirshipNumberRangeToggleStyle( style: style, viewConstraints: constraints, value: index, colorScheme: colorScheme, disabled: !formState.isFormInputEnabled ) ) .airshipGeometryGroupCompat() } } @ViewBuilder private func createScore(_ constraints: ViewConstraints) -> some View { switch self.info.properties.style { case .numberRange(let style): if style.wrapping != nil { let itemSpacing = CGFloat(style.spacing ?? 0) let lineSpacing = CGFloat(style.wrapping?.lineSpacing ?? 0) let maxItemsPerLine = style.wrapping?.maxItemsPerLine WrappingLayout( viewConstraints: constraints, itemSpacing: itemSpacing, lineSpacing: lineSpacing, maxItemsPerLine: maxItemsPerLine ) { makeNumberRangeScoreItems(style: style, constraints: constraints) } } else { HStack(spacing: style.spacing ?? 0) { makeNumberRangeScoreItems(style: style, constraints: constraints) } .constraints(constraints) } } } var body: some View { let constraints = modifiedConstraints() createScore(constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessibilityElement(children: .ignore) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .accessibilityAdjustableAction { direction in switch(direction) { case .increment: viewModel.incrementScore() case .decrement: viewModel.decrementScore() @unknown default: break } } .accessibilityValue(self.viewModel.accessibilityValue ?? "") .formElement() .airshipOnChangeOf(self.viewModel.score) { score in self.updateScore(score) } .onAppear { self.restoreFormState() if self.formState.validationMode == .onDemand { validatableHelper.subscribe( forIdentifier: info.properties.identifier, formState: formState, initialValue: self.viewModel.score, valueUpdates: self.viewModel.$score, validatables: info.validation ) { [weak thomasState, weak viewModel] actions in guard let thomasState, let viewModel else { return } thomasState.processStateActions( actions, formFieldValue: .score(viewModel.score) ) } } } } private func modifiedConstraints() -> ViewConstraints { var constraints = self.constraints if self.constraints.width == nil && self.constraints.height == nil { constraints.height = 32 } else { switch self.info.properties.style { case .numberRange(let style): constraints.height = self.calculateHeight( style: style, width: constraints.width ) } } return constraints } func calculateHeight( style: ThomasViewInfo.Score.ScoreStyle.NumberRange, width: CGFloat? ) -> CGFloat? { guard let width = width else { return nil } let count = Double((style.start...style.end).count) let spacing = (count - 1.0) * (style.spacing ?? 0.0) let remainingSpace = width - spacing if remainingSpace <= 0 { return nil } return min(remainingSpace / count, 66.0) } private func attributes(value: AirshipJSON?) -> [ThomasFormField.Attribute]? { guard let value, let name = info.properties.attributeName else { return nil } let attributeValue: ThomasAttributeValue? = if let string = value.string { .string(string) } else if let number = value.number { .number(number) } else { nil } guard let attributeValue else { return nil } return [ ThomasFormField.Attribute( attributeName: name, attributeValue: attributeValue ) ] } private func checkValid(_ value: AirshipJSON?) -> Bool { return value != nil || self.info.validation.isRequired != true } private func updateScore(_ value: AirshipJSON?) { let field: ThomasFormField = if checkValid(value) { ThomasFormField.validField( identifier: self.info.properties.identifier, input: .score(value), result: .init( value: .score(value), attributes: self.attributes(value: value) ) ) } else { ThomasFormField.invalidField( identifier: self.info.properties.identifier, input: .score(value) ) } self.formDataCollector.updateField(field, pageID: pageID) } private func restoreFormState() { guard case .score(let value) = self.formState.fieldValue( identifier: self.info.properties.identifier ), let value, let index = value.number else { self.updateScore(self.viewModel.score) return } self.viewModel.score = value self.viewModel.index = Int(index) } } private struct AirshipNumberRangeToggleStyle: ToggleStyle { let style: ThomasViewInfo.Score.ScoreStyle.NumberRange let viewConstraints: ViewConstraints let value: Int let colorScheme: ColorScheme let disabled: Bool func makeBody(configuration: Self.Configuration) -> some View { let isOn = configuration.isOn // Pick which text appearance we should use let selectedAppearance = style.bindings.selected.textAppearance let unselectedAppearance = style.bindings.unselected.textAppearance let maxDimension = max(measureForAppearance(selectedAppearance), measureForAppearance(unselectedAppearance)) /// Inject new constraints let viewConstraints = ViewConstraints( width: maxDimension, height: maxDimension, maxWidth: viewConstraints.maxWidth, maxHeight: viewConstraints.maxHeight, isHorizontalFixedSize: viewConstraints.isHorizontalFixedSize, isVerticalFixedSize: viewConstraints.isVerticalFixedSize, safeAreaInsets: viewConstraints.safeAreaInsets ) return Button(action: { configuration.isOn.toggle() }) { ZStack { // Drawing both with 1 hidden in case the content size changes between the two // it will prevent the parent from resizing on toggle Group { if let shapes = style.bindings.selected.shapes { ForEach(0..<shapes.count, id: \.self) { index in Shapes.shape( info: shapes[index], constraints: viewConstraints, colorScheme: colorScheme ) } .opacity(isOn ? 1 : 0) } Text(String(self.value)) .textAppearance(style.bindings.selected.textAppearance, colorScheme: colorScheme) } .opacity(isOn ? 1 : 0) .airshipApplyIf(disabled) { view in view.colorMultiply(ThomasConstants.disabledColor) } Group { if let shapes = style.bindings.unselected.shapes { ForEach(0..<shapes.count, id: \.self) { index in Shapes.shape( info: shapes[index], constraints: viewConstraints, colorScheme: colorScheme ) } } Text(String(self.value)) .textAppearance(style.bindings.unselected.textAppearance, colorScheme: colorScheme) } .opacity(isOn ? 0 : 1) } .aspectRatio(1, contentMode: .fit) } .animation(Animation.easeInOut(duration: 0.05), value: configuration.isOn) #if os(tvOS) .buttonStyle(TVButtonStyle()) #endif } private func measureForAppearance(_ appearance: ThomasTextAppearance?) -> CGFloat { let measuredSize = measureTextSize("\(style.end)", with: appearance) let minTappableDimension: CGFloat = 44.0 let scaledWidthSpacing = AirshipFont.scaledSize(measuredSize.width) let scaledHeightSpacing = AirshipFont.scaledSize(measuredSize.height) let minWidth = max(minTappableDimension, measuredSize.width + scaledWidthSpacing) let minHeight = max(minTappableDimension, measuredSize.height + scaledHeightSpacing) return max(minWidth, minHeight) } private func measureTextSize(_ text: String, with appearance: ThomasTextAppearance?) -> CGSize { guard let appearance = appearance else { return CGSizeZero } let font = appearance.nativeFont return (text as String).size(withAttributes: [.font: font]) } private func measureTextHeight(_ text: String, with appearance: ThomasTextAppearance?) -> CGFloat { guard let appearance = appearance else { return 0 } let font = appearance.nativeFont return (text as String).size(withAttributes: [.font: font]).height } } ================================================ FILE: Airship/AirshipCore/Source/ScoreController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI @MainActor struct ScoreController: View { private let info: ThomasViewInfo.ScoreController private let constraints: ViewConstraints @EnvironmentObject private var environment: ThomasEnvironment init(info: ThomasViewInfo.ScoreController, constraints: ViewConstraints) { self.info = info self.constraints = constraints } var body: some View { Content( info: self.info, constraints: constraints, environment: environment ) .id(info.properties.identifier) } @MainActor struct Content: View { private let info: ThomasViewInfo.ScoreController private let constraints: ViewConstraints @Environment(\.pageIdentifier) private var pageID @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var thomasState: ThomasState @ObservedObject private var scoreState: ScoreState @EnvironmentObject private var validatableHelper: ValidatableHelper @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .scoreController, thomasState: thomasState ) } init( info: ThomasViewInfo.ScoreController, constraints: ViewConstraints, environment: ThomasEnvironment ) { self.info = info self.constraints = constraints // Use the environment to create or retrieve the state in case the view // stack changes and we lose our state. let scoreState = environment.retrieveState(identifier: info.properties.identifier) { ScoreState(info: info) } self._scoreState = ObservedObject(wrappedValue: scoreState) } var body: some View { ViewFactory.createView(self.info.properties.view, constraints: constraints) .constraints(constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: true ) .formElement() .accessibilityElement(children: .ignore) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .accessibilityAdjustableAction { direction in switch(direction) { case .increment: self.scoreState.incrementScore() case .decrement: self.scoreState.decrementScore() @unknown default: break } } .accessibilityValue(self.scoreState.accessibilityValue ?? "") .environmentObject(scoreState) .airshipOnChangeOf(self.scoreState.selected) { incoming in updateFormState(selected: incoming) } .onAppear { updateFormState(selected: self.scoreState.selected) if self.formState.validationMode == .onDemand { validatableHelper.subscribe( forIdentifier: info.properties.identifier, formState: formState, initialValue: scoreState.selected, valueUpdates: scoreState.$selected, validatables: info.validation ) { [weak thomasState, weak scoreState] actions in guard let thomasState, let scoreState else { return } thomasState.processStateActions( actions, formFieldValue: .score(scoreState.selected?.reportingValue) ) } } } } private func checkValid(value: AirshipJSON?) -> Bool { return value != nil || info.validation.isRequired != true } private func makeAttribute( selected: ScoreState.Selected? ) -> [ThomasFormField.Attribute]? { guard let name = info.properties.attributeName, let value = selected?.attributeValue else { return nil } return [ ThomasFormField.Attribute( attributeName: name, attributeValue: value ) ] } private func updateFormState(selected: ScoreState.Selected?) { let field: ThomasFormField = if checkValid(value: selected?.reportingValue) { ThomasFormField.validField( identifier: self.info.properties.identifier, input: .score(selected?.reportingValue), result: .init( value: .score(selected?.reportingValue), attributes: makeAttribute(selected: selected) ) ) } else { ThomasFormField.invalidField( identifier: self.info.properties.identifier, input: .score(selected?.reportingValue) ) } self.formDataCollector.updateField(field, pageID: pageID) } } } ================================================ FILE: Airship/AirshipCore/Source/ScoreState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @MainActor final class ScoreState: ObservableObject { @Published private(set) var selected: Selected? let entries: [ThomasViewInfo.ScoreToggleLayout] init(info: ThomasViewInfo.ScoreController) { self.entries = info.properties.view.extractDescendants { info in return if case let .scoreToggleLayout(score) = info { score } else { nil } } } func setSelected( identifier: String, reportingValue: AirshipJSON, attributeValue: ThomasAttributeValue? ) { let incoming = Selected( identifier: identifier, reportingValue: reportingValue, attributeValue: attributeValue ) if (incoming != self.selected) { self.selected = incoming } } private var currentIndex: Int? { guard let selected else { return nil } return entries.firstIndex { layout in layout.properties.identifier == selected.identifier } } var accessibilityValue: String? { guard let currentIndex, entries.isEmpty == false else { return nil } return entries[currentIndex].accessible.resolveContentDescription } func incrementScore() { guard entries.isEmpty == false else { return } guard let currentIndex else { updateSelected(entry: entries[0]) return } let nextEntry = min(currentIndex + 1, entries.count - 1) updateSelected(entry: entries[nextEntry]) } func decrementScore() { guard entries.isEmpty == false else { return } guard let currentIndex else { updateSelected(entry: entries[0]) return } let nextEntry = max(currentIndex - 1, 0) updateSelected(entry: entries[nextEntry]) } private func updateSelected(entry: ThomasViewInfo.ScoreToggleLayout) { self.selected = .init( identifier: entry.properties.identifier, reportingValue: entry.properties.reportingValue, attributeValue: entry.properties.attributeValue ) } struct Selected: ThomasSerializable, Hashable { var identifier: String var reportingValue: AirshipJSON var attributeValue: ThomasAttributeValue? } } //MARK: - State provider extension ScoreState: ThomasStateProvider { typealias SnapshotType = Selected? var updates: AnyPublisher<any Codable, Never> { return $selected .removeDuplicates() .map(\.self) .eraseToAnyPublisher() } func persistentStateSnapshot() -> SnapshotType { return selected } func restorePersistentState(_ state: SnapshotType) { self.selected = state } } ================================================ FILE: Airship/AirshipCore/Source/ScoreToggleLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI @MainActor struct ScoreToggleLayout: View { @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var scoreState: ScoreState @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .scoreToggleLayout, thomasState: thomasState ) } private let info: ThomasViewInfo.ScoreToggleLayout private let constraints: ViewConstraints init(info: ThomasViewInfo.ScoreToggleLayout, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var isOnBinding: Binding<Bool> { return Binding<Bool>( get: { self.scoreState.selected?.identifier == self.info.properties.identifier }, set: { if $0 { self.scoreState.setSelected( identifier: self.info.properties.identifier, reportingValue: self.info.properties.reportingValue, attributeValue: self.info.properties.attributeValue ) } } ) } var body: some View { ToggleLayout( isOn: self.isOnBinding, onToggleOn: self.info.properties.onToggleOn, onToggleOff: self.info.properties.onToggleOff ) { ViewFactory.createView( self.info.properties.view, constraints: constraints ) } .constraints(self.constraints) .thomasCommon(self.info, formInputID: self.info.properties.identifier) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() } } ================================================ FILE: Airship/AirshipCore/Source/ScrollLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Scroll view layout struct ScrollLayout: View { /// ScrollLayout model. private let info: ThomasViewInfo.ScrollLayout /// View constraints. private let constraints: ViewConstraints @State private var contentSize: CGSize? = nil @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @State private var scrollTask: (String, Task<Void, Never>)? private static let scrollInterval: TimeInterval = 0.01 init(info: ThomasViewInfo.ScrollLayout, constraints: ViewConstraints) { self.info = info self.constraints = constraints } @ViewBuilder private func makeScrollView(axis: Axis.Set) -> some View { ScrollView(axis) { makeContent() .background( GeometryReader(content: { contentMetrics -> Color in let size = contentMetrics.size DispatchQueue.main.async { if (self.contentSize != size) { self.contentSize = size } } return Color.clear }) ) } #if os(iOS) .scrollDismissesKeyboard( self.thomasEnvironment.focusedID != nil ? .immediately : .never ) #endif } @ViewBuilder private func makeScrollView() -> some View { let isVertical = self.info.properties.direction == .vertical let axis = isVertical ? Axis.Set.vertical : Axis.Set.horizontal ScrollViewReader { proxy in makeScrollView(axis: axis) .clipped() .airshipOnChangeOf(self.thomasEnvironment.keyboardState) { [weak thomasEnvironment] newValue in if let focusedID = thomasEnvironment?.focusedID { switch newValue { case .hidden: scrollTask?.1.cancel() case .displaying(let duration): let task = Task { await self.startScrolling( scrollID: focusedID, proxy: proxy, duration: duration ) } self.scrollTask = (focusedID, task) case .visible: scrollTask?.1.cancel() proxy.scrollTo(focusedID) } } else { scrollTask?.1.cancel() } } } .airshipApplyIf(self.shouldApplyFrameSize) { switch (info.properties.direction) { case .vertical: $0.frame(maxHeight: self.contentSize?.height ?? 0) case .horizontal: $0.frame(maxWidth: self.contentSize?.width ?? 0) } } } private var shouldApplyFrameSize: Bool { switch (info.properties.direction) { case .vertical: self.constraints.height == nil case .horizontal: self.constraints.width == nil } } @ViewBuilder func makeContent() -> some View { ZStack { ViewFactory.createView( self.info.properties.view, constraints: self.childConstraints() ) .fixedSize( horizontal: self.info.properties.direction == .horizontal, vertical: self.info.properties.direction == .vertical ) } .frame(alignment: .topLeading) } @ViewBuilder var body: some View { makeScrollView() .constraints(self.constraints) .thomasCommon(self.info) #if os(tvOS) .focusSection() #endif } private func childConstraints() -> ViewConstraints { var childConstraints = constraints if self.info.properties.direction == .vertical { childConstraints.height = nil childConstraints.maxHeight = nil childConstraints.isVerticalFixedSize = false } else { childConstraints.width = nil childConstraints.maxWidth = nil childConstraints.isHorizontalFixedSize = false } return childConstraints } @MainActor private func startScrolling( scrollID: String, proxy: ScrollViewProxy, duration: TimeInterval ) async { var remaining = duration repeat { proxy.scrollTo(scrollID, anchor: .center) remaining = remaining - ScrollLayout.scrollInterval try? await Task.sleep( nanoseconds: UInt64(ScrollLayout.scrollInterval * 1_000_000_000) ) } while remaining > 0 && !Task.isCancelled } } ================================================ FILE: Airship/AirshipCore/Source/SearchEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public extension CustomEvent { /// Search template enum SearchTemplate: Sendable { /// Search case search fileprivate static let templateName: String = "search" fileprivate var eventName: String { return switch self { case .search: "search" } } } /// Additional search template properties struct SearchProperties: Encodable, Sendable { /// The event's ID. public var id: String? /// The search query. public var query: String? /// The total search results public var totalResults: Int? /// The event's category. public var category: String? /// The event's type. public var type: String? /// If the value is a lifetime value or not. public var isLTV: Bool public init( id: String? = nil, category: String? = nil, type: String? = nil, isLTV: Bool = false, query: String? = nil, totalResults: Int? = nil ) { self.id = id self.query = query self.totalResults = totalResults self.category = category self.type = type self.isLTV = isLTV } enum CodingKeys: String, CodingKey { case id case query case totalResults = "total_results" case category case type case isLTV = "ltv" } } /// Constructs a custom event using the search template. /// - Parameters: /// - accountTemplate: The search template. /// - properties: Optional additional properties /// - encoder: Encoder used to encode the additional properties. Defaults to `CustomEvent.defaultEncoder`. init( searchTemplate: SearchTemplate, properties: SearchProperties = SearchProperties(), encoder: @autoclosure () -> JSONEncoder = CustomEvent.defaultEncoder() ) { self = .init(name: searchTemplate.eventName) self.templateType = SearchTemplate.templateName do { try self.setProperties(properties, encoder: encoder()) } catch { /// Should never happen so we are just catching the exception and logging AirshipLogger.error("Failed to generate event \(error)") } } } ================================================ FILE: Airship/AirshipCore/Source/SerialQueue.swift ================================================ /* Copyright Airship and Contributors */ import Foundation // An actor that will run a task with a result in order. /// NOTE: For internal use only. :nodoc: public actor AirshipSerialQueue { private var nextTaskNumber: Int = 0 private var currentTaskNumber: Int = 0 private var currentTask: Task<Void, Never>? public init() {} public func run<T: Sendable>(work: @escaping @Sendable () async throws -> T) async throws -> T { let myTaskNumber = nextTaskNumber nextTaskNumber = nextTaskNumber + 1 while myTaskNumber != currentTaskNumber { await self.currentTask?.value if myTaskNumber != currentTaskNumber { await Task.yield() } } let task: Task<T, any Error> = Task { return try await work() } currentTask = Task { let _ = try? await task.value currentTaskNumber += 1 self.currentTask = nil } return try await task.value } public func runSafe<T: Sendable>(work: @escaping @Sendable () async -> T) async -> T { let myTaskNumber = nextTaskNumber nextTaskNumber = nextTaskNumber + 1 while myTaskNumber != currentTaskNumber { await self.currentTask?.value if myTaskNumber != currentTaskNumber { await Task.yield() } } let task: Task<T, Never> = Task { return await work() } currentTask = Task { let _ = await task.value currentTaskNumber += 1 self.currentTask = nil } return await task.value } } ================================================ FILE: Airship/AirshipCore/Source/SessionEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct SessionEvent: Sendable, Equatable { let type: SessionEventType let date: Date let sessionState: SessionState enum SessionEventType: Sendable, Equatable { case foregroundInit case backgroundInit case foreground case background } } ================================================ FILE: Airship/AirshipCore/Source/SessionEventFactory.swift ================================================ import Foundation protocol SessionEventFactoryProtocol: Sendable { @MainActor func make(event: SessionEvent) -> AirshipEvent } struct SessionEventFactory: SessionEventFactoryProtocol { let push: @Sendable () -> any AirshipPush init(push: @escaping @Sendable () -> any AirshipPush = Airship.componentSupplier()) { self.push = push } @MainActor func make(event: SessionEvent) -> AirshipEvent { AirshipEvents.sessionEvent(sessionEvent: event, push: self.push()) } } ================================================ FILE: Airship/AirshipCore/Source/SessionState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct SessionState: Equatable, Sendable { var sessionID: String var conversionSendID: String? var conversionMetadata: String? init( sessionID: String = UUID().uuidString, conversionSendID: String? = nil, conversionMetadata: String? = nil ) { self.sessionID = sessionID self.conversionSendID = conversionSendID self.conversionMetadata = conversionMetadata } } ================================================ FILE: Airship/AirshipCore/Source/SessionTracker.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol SessionTrackerProtocol: Sendable { var sessionState: SessionState { get } var events: AsyncStream<SessionEvent> { get } @MainActor func airshipReady() @MainActor func launchedFromPush(sendID: String?, metadata: String?) } final class SessionTracker: SessionTrackerProtocol { // Time to wait for the initial app init event when we start tracking // the session. We wait to generate an app init event for either a foreground, // background, or notification response so we can capture the push info for // conversion tracking private static let appInitWaitTime: TimeInterval = 1.0 private let eventsContinuation: AsyncStream<SessionEvent>.Continuation public let events: AsyncStream<SessionEvent> private let date: any AirshipDateProtocol private let taskSleeper: any AirshipTaskSleeper private let isForeground: AirshipMainActorValue<Bool?> = AirshipMainActorValue(nil) private let initialized: AirshipMainActorValue<Bool> = AirshipMainActorValue(false) private let _sessionState: AirshipAtomicValue<SessionState> private let appStateTracker: any AppStateTrackerProtocol private let sessionStateFactory: @Sendable () -> SessionState nonisolated var sessionState: SessionState { return _sessionState.value } @MainActor init( date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = .shared, notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter.shared, appStateTracker: (any AppStateTrackerProtocol)? = nil, sessionStateFactory: @Sendable @escaping () -> SessionState = { SessionState() } ) { self.date = date self.taskSleeper = taskSleeper self.appStateTracker = appStateTracker ?? AppStateTracker.shared (self.events, self.eventsContinuation) = AsyncStream<SessionEvent>.airshipMakeStreamWithContinuation() self.sessionStateFactory = sessionStateFactory self._sessionState = AirshipAtomicValue(sessionStateFactory()) notificationCenter.addObserver( self, selector: #selector(didBecomeActiveNotification), name: AppStateTracker.didBecomeActiveNotification, object: nil ) notificationCenter.addObserver( self, selector: #selector(didEnterBackgroundNotification), name: AppStateTracker.didEnterBackgroundNotification, object: nil ) } @MainActor func launchedFromPush(sendID: String?, metadata: String?) { AirshipLogger.debug("Launched from push") self._sessionState.update { state in var state = state state.conversionMetadata = metadata state.conversionSendID = sendID return state } self.ensureInit(isForeground: true) { AirshipLogger.debug("App init - launched from push") } } @MainActor func airshipReady() { let date = self.date.now Task { @MainActor in try await self.taskSleeper.sleep(timeInterval: SessionTracker.appInitWaitTime) let isForeground = self.appStateTracker.state != .background self.ensureInit(isForeground: isForeground, date: date) { AirshipLogger.debug("App init - AirshipReady") } } } @MainActor private func ensureInit(isForeground: Bool, date: Date? = nil, onInit: () -> Void) { guard self.initialized.value else { self.initialized.set(true) self.isForeground.set(isForeground) self.addEvent(isForeground ? .foregroundInit : .backgroundInit, date: date) onInit() return } } @MainActor private func addEvent(_ type: SessionEvent.SessionEventType, date: Date? = nil) { AirshipLogger.debug("Added session event \(type) state: \(self.sessionState)") self.eventsContinuation.yield( SessionEvent(type: type, date: date ?? self.date.now, sessionState: self.sessionState) ) } @objc @MainActor private func didBecomeActiveNotification() { AirshipLogger.debug("Application did become active.") // Ensure the app init event ensureInit(isForeground: true) { AirshipLogger.debug("App init - foreground") } // Background -> foreground if isForeground.value == false { isForeground.set(true) self._sessionState.update { [sessionStateFactory] old in var session = sessionStateFactory() session.conversionMetadata = old.conversionMetadata session.conversionSendID = old.conversionSendID return session } addEvent(.foreground) } } @objc @MainActor private func didEnterBackgroundNotification() { AirshipLogger.debug("Application entered background") // Ensure the app init event ensureInit(isForeground: false) { AirshipLogger.debug("App init - background") } // Foreground -> background if isForeground.value == true { isForeground.set(false) addEvent(.background) self._sessionState.value = self.sessionStateFactory() } } } ================================================ FILE: Airship/AirshipCore/Source/Shapes.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct Shapes { @ViewBuilder @MainActor static func shape( info: ThomasShapeInfo, constraints: ViewConstraints, colorScheme: ColorScheme ) -> some View { switch info { case .ellipse(let info): ellipse( info: info, constraints: constraints, colorScheme: colorScheme ) case .rectangle(let info): rectangle( info: info, constraints: constraints, colorScheme: colorScheme ) } } @ViewBuilder private static func rectangle( colorScheme: ColorScheme, border: ThomasBorder? ) -> some View { let strokeColor = border?.strokeColor?.toColor(colorScheme) ?? Color.clear let strokeWidth = border?.strokeWidth ?? 0 let cornerRadius = border?.maxRadius ?? 0 if cornerRadius > 0 { let cornerRadii = CustomCornerRadii(outerRadiiFor: border) let shape = CustomRoundedRectangle(cornerRadii: cornerRadii, style: .continuous) if strokeWidth > 0 { shape.strokeBorder(strokeColor, lineWidth: strokeWidth) } else { shape.fill(Color.clear) } } else { if strokeWidth > 0 { Rectangle() .strokeBorder(strokeColor, lineWidth: strokeWidth) } else { Rectangle().fill(Color.clear) } } } @ViewBuilder private static func rectangleBackground( border: ThomasBorder?, color: Color ) -> some View { let cornerRadius = border?.maxRadius ?? 0 if cornerRadius > 0 { let cornerRadii = CustomCornerRadii(outerRadiiFor: border) CustomRoundedRectangle(cornerRadii: cornerRadii, style: .continuous) .fill(color) } else { Rectangle() .fill(color) } } @ViewBuilder @MainActor private static func rectangle( info: ThomasShapeInfo.Rectangle, constraints: ViewConstraints, colorScheme: ColorScheme ) -> some View { let resolvedColor = info.color?.toColor(colorScheme) ?? Color.clear if let border = info.border { rectangle(colorScheme: colorScheme, border: border) .background( rectangleBackground(border: border, color: resolvedColor) ) .aspectRatio(info.aspectRatio ?? 1, contentMode: .fit) .airshipApplyIf(info.scale != nil) { view in view.constraints( scaledConstraints(constraints, scale: info.scale) ) } .constraints(constraints) } else { Rectangle() .fill(resolvedColor) .aspectRatio(info.aspectRatio ?? 1, contentMode: .fit) .airshipApplyIf(info.scale != nil) { view in view.constraints( scaledConstraints(constraints, scale: info.scale) ) } .constraints(constraints) } } private static func scaledConstraints( _ constraints: ViewConstraints, scale: Double? ) -> ViewConstraints { guard let scale = scale else { return constraints } var scaled = constraints if let width = scaled.width { scaled.width = width * scale } if let height = scaled.height { scaled.height = height * scale } return scaled } @ViewBuilder private static func ellipse(colorScheme: ColorScheme, border: ThomasBorder?) -> some View { let strokeColor = border?.strokeColor?.toColor(colorScheme) let strokeWidth = border?.strokeWidth ?? 0 if let strokeColor = strokeColor, strokeWidth > 0 { Ellipse().strokeBorder(strokeColor, lineWidth: strokeWidth) } else { Ellipse() } } @ViewBuilder @MainActor private static func ellipse( info: ThomasShapeInfo.Ellipse, constraints: ViewConstraints, colorScheme: ColorScheme ) -> some View { let scaled = scaledConstraints(constraints, scale: info.scale) let color = info.color?.toColor(colorScheme) ?? Color.clear if let border = info.border { ellipse(colorScheme: colorScheme, border: border) .aspectRatio(info.aspectRatio ?? 1, contentMode: .fit) .background(Ellipse().fill(color)) .airshipApplyIf(info.scale != nil) { view in view.constraints(scaled) } .constraints(constraints) } else { Ellipse() .fill(color) .aspectRatio(info.aspectRatio ?? 1, contentMode: .fit) .airshipApplyIf(info.scale != nil) { view in view.constraints(scaled) } .constraints(constraints) } } } ================================================ FILE: Airship/AirshipCore/Source/ShareAction.swift ================================================ /* Copyright Airship and Contributors */ /** * Shares text using ActivityViewController. * * * Expected argument value is a `String`. * * Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush`, * `ActionSituation.webViewInvocation`, `ActionSituation.manualInvocation`, * `ActionSituation.foregroundInteractiveButton`, and `ActionSituation.automation` */ #if os(iOS) import UIKit public final class ShareAction: AirshipAction { /// Default names - "share_action", "^s" public static let defaultNames: [String] = ["share_action", "^s"] /// Default predicate - rejects `ActionSituation.foregroundPush` public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.situation != .foregroundPush } public static let name: String = "share_action" public static let shortName: String = "^s" public func accepts(arguments: ActionArguments) async -> Bool { guard arguments.situation != .backgroundPush, arguments.situation != .backgroundInteractiveButton else { return false } return true } @MainActor public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { AirshipLogger.debug("Running share action: \(arguments)") let activityViewController = ActivityViewController( activityItems: [arguments.value.unWrap() as Any], applicationActivities: nil ) activityViewController.excludedActivityTypes = [ .assignToContact, .print, .saveToCameraRoll, .airDrop, .postToFacebook, ] let viewController = UIViewController() var window: UIWindow? = Self.presentInNewWindow( viewController, windowLevel: .alert ) activityViewController.dismissalBlock = { window?.windowLevel = .normal window?.isHidden = true window = nil } if let popoverPresentationController = activityViewController.popoverPresentationController { popoverPresentationController.permittedArrowDirections = [] // Set the delegate, center the popover on the screen popoverPresentationController.delegate = activityViewController popoverPresentationController.sourceRect = activityViewController.sourceRect() popoverPresentationController.sourceView = viewController.view } viewController.present( activityViewController, animated: true ) return nil } @MainActor class func presentInNewWindow( _ rootViewController: UIViewController, windowLevel: UIWindow.Level = .normal ) -> UIWindow? { do { let scene = try AirshipSceneManager.shared.lastActiveScene let window = AirshipWindowFactory.shared.makeWindow(windowScene: scene) window.rootViewController = rootViewController window.windowLevel = windowLevel window.makeKeyAndVisible() return window } catch { AirshipLogger.error("\(error)") return nil } } } #endif ================================================ FILE: Airship/AirshipCore/Source/SmsLocalePicker.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if !os(watchOS) struct SmsLocalePicker: View { @Binding private var selectedLocale: ThomasSMSLocale? private let availableLocales: [ThomasSMSLocale] private let fontSize: Double init( selectedLocale: Binding<ThomasSMSLocale?>, availableLocales: [ThomasSMSLocale], fontSize: Double ) { self._selectedLocale = selectedLocale self.availableLocales = availableLocales self.fontSize = fontSize } var body: some View { Menu { ForEach(availableLocales, id: \.countryCode) { locale in Button([ locale.countryCode.toFlagEmoji(), locale.countryCode, locale.prefix ].joined(separator: " ")) { selectedLocale = locale } } } label: { HStack(spacing: 0) { if let selectedLocale { Text(selectedLocale.countryCode.toFlagEmoji()) .font(.system(size: fontSize)) .minimumScaleFactor(0.1) .scaledToFit() .padding(.trailing, 5) } Image(systemName: "chevron.down") .resizable() .aspectRatio(contentMode: .fit) .frame(width: fontSize * 0.75, height: fontSize * 0.75) .foregroundStyle(.gray) } } } } private extension String { private static let base = UnicodeScalar("🇦").value - UnicodeScalar("A").value func toFlagEmoji() -> String { guard self.count == 2 else { return self } return self .uppercased() .unicodeScalars .compactMap({ UnicodeScalar(Self.base + $0.value)?.description }) .joined() } } #Preview { let locale: ThomasSMSLocale? = .init(countryCode: "US", prefix: "+1", registration: nil) SmsLocalePicker( selectedLocale: .constant(locale), availableLocales: [ .init( countryCode: "US", prefix: "+1", registration: nil ), .init( countryCode: "FR", prefix: "+33", registration: nil ), .init( countryCode: "MO", prefix: "+853", registration: nil ), ], fontSize: 34 ) } #endif ================================================ FILE: Airship/AirshipCore/Source/StackImageButton.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine struct StackImageButton : View { /// Image Button model. private let info: ThomasViewInfo.StackImageButton /// View constraints. private let constraints: ViewConstraints @Environment(\.colorScheme) private var colorScheme @Environment(\.layoutState) private var layoutState @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @EnvironmentObject private var thomasState: ThomasState @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver init(info: ThomasViewInfo.StackImageButton, constraints: ViewConstraints) { self.info = info self.constraints = constraints } private var resolveItems: [ThomasViewInfo.StackImageButton.Item] { ThomasPropertyOverride.resolveRequired( state: thomasState, overrides: info.overrides?.items, defaultValue: info.properties.items ) } private var resolvedLocalizedContentDescription: ThomasAccessibleInfo.Localized? { ThomasPropertyOverride.resolveOptional( state: thomasState, overrides: info.overrides?.localizedContentDescription, defaultValue: info.accessible.localizedContentDescription ) } private var resolvedContentDescription: String? { if let contentDescription = ThomasPropertyOverride.resolveOptional( state: thomasState, overrides: info.overrides?.contentDescription, defaultValue: info.accessible.contentDescription ) { return contentDescription } guard let localized = resolvedLocalizedContentDescription else { return nil } if let refs = localized.refs { for ref in refs { if let string = AirshipResources.localizedString(key: ref) { return string } } } else if let ref = localized.ref { if let string = AirshipResources.localizedString(key: ref) { return string } } return localized.fallback } private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .imageButton, thomasState: thomasState ) } @ViewBuilder var body: some View { AirshipButton( identifier: self.info.properties.identifier, reportingMetadata: self.info.properties.reportingMetadata, description: self.resolvedContentDescription, clickBehaviors: self.info.properties.clickBehaviors, eventHandlers: self.info.commonProperties.eventHandlers, actions: self.info.properties.actions, tapEffect: self.info.properties.tapEffect ) { makeInnerButton() .constraints(constraints, fixedSize: true) .thomasCommon(self.info, scope: [.background]) .accessible( self.info.accessible, associatedLabel: self.associatedLabel, hideIfDescriptionIsMissing: false ) .background(Color.airshipTappableClear) } .thomasCommon(self.info, scope: [.enableBehaviors, .visibility]) .environment( \.layoutState, layoutState.override( buttonState: ButtonState(identifier: self.info.properties.identifier) ) ) .accessibilityHidden(info.accessible.accessibilityHidden ?? false) } @ViewBuilder private func makeInnerButton() -> some View { let items = resolveItems ZStack { ForEach(0..<items.count, id: \.self) { index in let item = items[index] switch(item) { case .icon(let item): Icons.icon(info: item.icon, colorScheme: colorScheme) case .imageURL(let info): ThomasAsyncImage( url: info.url, imageLoader: thomasEnvironment.imageLoader, image: { image, imageSize in image.fitMedia( mediaFit: info.mediaFit, cropPosition: info.cropPosition, constraints: constraints, imageSize: imageSize ) }, placeholder: { AirshipProgressView() } ) case .shape(let info): Shapes.shape( info: info.shape, constraints: constraints, colorScheme: colorScheme ) } } } } } ================================================ FILE: Airship/AirshipCore/Source/StateController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine @MainActor struct StateController: View { private let info: ThomasViewInfo.StateController private let constraints: ViewConstraints @EnvironmentObject private var thomasEnvironment: ThomasEnvironment init( info: ThomasViewInfo.StateController, constraints: ViewConstraints ) { self.info = info self.constraints = constraints } var body: some View { Content( info: self.info, constraints: self.constraints, thomasEnvironment: self.thomasEnvironment ) } private struct Content: View { let info: ThomasViewInfo.StateController let constraints: ViewConstraints @EnvironmentObject private var state: ThomasState @StateObject private var scopedStateCache: ScopedStateCache @StateObject private var mutableState: ThomasState.MutableState init( info: ThomasViewInfo.StateController, constraints: ViewConstraints, thomasEnvironment: ThomasEnvironment ) { self.info = info self.constraints = constraints let scopedStateCache = StateObject( wrappedValue: thomasEnvironment .retrieveState( identifier: info.identifier, create: ScopedStateCache.init ) ) self._scopedStateCache = scopedStateCache self._mutableState = StateObject( wrappedValue: ThomasState.MutableState(initialState: info.properties.initialState) ) } var body: some View { ViewFactory .createView(self.info.properties.view, constraints: constraints) .constraints(constraints) .thomasCommon(self.info) .environmentObject( scopedStateCache.getOrCreate { state.with(mutableState: mutableState) } ) .accessibilityElement(children: .contain) } } } ================================================ FILE: Airship/AirshipCore/Source/StateSubscriptionsModifier.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI internal struct StateTriggerModifier: ViewModifier { let triggers: [ThomasStateTriggers] @EnvironmentObject var thomasState: ThomasState @State private var triggered: Set<String> = Set() @ViewBuilder func body(content: Content) -> some View { content.airshipOnChangeOf(thomasState.state, initial: true) { state in triggers.forEach { trigger in if triggered.contains(trigger.id), trigger.resetWhenStateMatches?.evaluate(json: state) == true { triggered.remove(trigger.id) } if !triggered.contains(trigger.id), trigger.triggerWhenStateMatches.evaluate(json: state) { triggered.insert(trigger.id) if let stateActions = trigger.onTrigger.stateActions { thomasState.processStateActions(stateActions) } } } } } } ================================================ FILE: Airship/AirshipCore/Source/StoryIndicator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct StoryIndicator: View { private static let defaultSpacing = 10.0 private static let defaultHeight = 6.0 private static let defaultInactiveSegmentScaler = 0.5 let info: ThomasViewInfo.StoryIndicator let constraints: ViewConstraints @EnvironmentObject var pagerState: PagerState @Environment(\.colorScheme) var colorScheme func announcePage(info: ThomasViewInfo.StoryIndicator) -> Bool { return info.properties.automatedAccessibilityActions?.contains{ $0.type == .announce} ?? false } var style: ThomasViewInfo.StoryIndicator.Style.LinearProgress { switch (self.info.properties.style) { case .linearProgress(let style): return style } } @ViewBuilder private func createStoryIndicatorView( progressDelay: Binding<Double>, childConstraints: ViewConstraints ) -> some View { if (self.info.properties.source.type == .pager) { let totalDelay = pagerState.pageStates.compactMap{ $0.delay }.reduce(0, +) GeometryReader { metrics in HStack(spacing: style.spacing ?? StoryIndicator.defaultSpacing) { ForEach(0..<self.pagerState.pageStates.count, id: \.self) { index in let isCurrentPage = self.pagerState.pageIndex == index let isCurrentPageProgressing = progressDelay.wrappedValue < 1 let delay = (isCurrentPage && isCurrentPageProgressing) ? progressDelay : nil let currentDelay = pagerState.pageStates[index].delay createChild( index: index, progressDelay: delay, constraints: childConstraints ) .airshipApplyIf(self.style.sizing == .pageDuration && totalDelay > 0) { view in view.frame( width: (metrics.size.width * (currentDelay / totalDelay)).safeValue ) } } }.airshipApplyIf(announcePage(info: info), transform: { view in view.accessibilityLabel(String(format: "ua_pager_progress".airshipLocalizedString( fallback: "Page %@ of %@" ), (self.pagerState.pageIndex + 1).airshipLocalizedForVoiceOver(), self.pagerState.pageStates.count.airshipLocalizedForVoiceOver())) }) } } else if (self.info.properties.source.type == .currentPage) { createChild( progressDelay: progressDelay, constraints: childConstraints ) } } @ViewBuilder private func createChild( index: Int? = nil, progressDelay: Binding<Double>? = nil, constraints: ViewConstraints ) -> some View { let scaler = style.inactiveSegmentScaler ?? StoryIndicator.defaultInactiveSegmentScaler Rectangle() .fill(indicatorColor(index)) .airshipApplyIf(progressDelay == nil) { view in view.frame( height: (constraints.height ?? StoryIndicator.defaultHeight) * scaler ) } .overlayView { if let progressDelay = progressDelay { GeometryReader { metrics in Rectangle() .frame(width: (metrics.size.width * progressDelay.wrappedValue).safeValue) .foregroundColor(self.style.progressColor.toColor(colorScheme)) .animation(.linear(duration: Pager.animationSpeed), value: self.info) } } } } var body: some View { let childConstraints = ViewConstraints( height: constraints.height ?? StoryIndicator.defaultHeight ) let progress = Binding<Double> ( get: { self.pagerState.progress }, set: { self.pagerState.progress = $0 } ) createStoryIndicatorView( progressDelay: progress, childConstraints: childConstraints) .animation(nil, value: self.info) .frame(height: constraints.height ?? StoryIndicator.defaultHeight) .constraints(constraints) .thomasCommon(self.info) } private func indicatorColor(_ index: Int?) -> Color { guard let index = index else { return self.style.progressColor.toColor(colorScheme) } if pagerState.completed && pagerState.progress >= 1 { return self.style.progressColor.toColor(colorScheme) } return index < pagerState.pageIndex ? self.style.progressColor.toColor(colorScheme) : self.style.trackColor.toColor(colorScheme) } } ================================================ FILE: Airship/AirshipCore/Source/SubjectExtension.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation extension Subject { @MainActor func sendMainActor(_ value: Self.Output) { self.send(value) } } ================================================ FILE: Airship/AirshipCore/Source/SubscriptionListAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: protocol SubscriptionListAPIClientProtocol: Sendable { func get( channelID: String ) async throws -> AirshipHTTPResponse<[String]> } /// NOTE: For internal use only. :nodoc: final class SubscriptionListAPIClient: SubscriptionListAPIClientProtocol { private static let getPath: String = "/api/subscription_lists/channels/" private let config: RuntimeConfig private let session: any AirshipRequestSession init(config: RuntimeConfig, session: any AirshipRequestSession) { self.config = config self.session = session } convenience init(config: RuntimeConfig) { self.init( config: config, session: config.requestSession ) } func get(channelID: String) async throws -> AirshipHTTPResponse<[String]> { AirshipLogger.debug("Retrieving subscription lists") guard let deviceAPIURL = config.deviceAPIURL else { throw AirshipErrors.error("App config not available.") } let url = URL( string: "\(deviceAPIURL)\(SubscriptionListAPIClient.getPath)\(channelID)" ) let request = AirshipRequest( url: url, headers: [ "Accept": "application/vnd.urbanairship+json; version=3;" ], method: "GET", auth: .channelAuthToken(identifier: channelID) ) 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 } guard let data = data, let jsonResponse = try JSONSerialization.jsonObject( with: data, options: .allowFragments ) as? [AnyHashable: Any] else { throw AirshipErrors.error("Invalid response body \(String(describing: data))") } return jsonResponse["list_ids"] as? [String] ?? [] } } private func map(subscriptionListsUpdates: [SubscriptionListUpdate]) -> [[AnyHashable: Any]] { return subscriptionListsUpdates.map { (list) -> ([AnyHashable: Any]) in switch list.type { case .subscribe: return [ "action": "subscribe", "list_id": list.listId, ] case .unsubscribe: return [ "action": "unsubscribe", "list_id": list.listId, ] } } } } ================================================ FILE: Airship/AirshipCore/Source/SubscriptionListAction.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// Subscribes to/unsubscribes from a subscription list. /// /// Valid situations: `ActionSituation.foregroundPush`, `ActionSituation.launchedFromPush` /// `ActionSituation.webViewInvocation`, `ActionSituation.foregroundInteractiveButton`, /// `ActionSituation.backgroundInteractiveButton`, `ActionSituation.manualInvocation`, and /// `ActionSituation.automation` public final class SubscriptionListAction: AirshipAction { /// Default names - "subscription_list_action", "^sl", "edit_subscription_list_action", "^sla" public static let defaultNames: [String] = [ "subscription_list_action", "^sl", "edit_subscription_list_action", "^sla" ] /// Default predicate - rejects foreground pushes with visible display options public static let defaultPredicate: @Sendable (ActionArguments) -> Bool = { args in return args.metadata[ActionArguments.isForegroundPresentationMetadataKey] as? Bool != true } private let channel: @Sendable () -> any AirshipChannel private let contact: @Sendable () -> any AirshipContact private var decoder: JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder } public var _decoder: JSONDecoder { return decoder } public convenience init() { self.init( channel: Airship.componentSupplier(), contact: Airship.componentSupplier() ) } init( channel: @escaping @Sendable () -> any AirshipChannel, contact: @escaping @Sendable () -> any AirshipContact ) { self.channel = channel self.contact = contact } public func accepts(arguments: ActionArguments) async -> Bool { guard arguments.situation != .backgroundPush else { return false } return true } public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { let edits = try parse(args: arguments) applyChannelEdits(edits) applyContactEdits(edits) return arguments.value } private func parse(args: ActionArguments) throws -> [Edit] { var edits: Any? = args.value.unWrap() let unwrapped = args.value.unWrap() if let value = unwrapped as? [String: Any] { edits = value["edits"] } guard let edits = edits else { throw AirshipErrors.error( "Invalid argument \(String(describing: args.value))" ) } let data = try JSONSerialization.data( withJSONObject: edits, options: [] ) return try self.decoder.decode([Edit].self, from: data) } private func applyContactEdits(_ edits: [Edit]) { let contactEdits = edits.compactMap { (edit: Edit) -> ContactEdit? in if case .contact(let contactEdit) = edit { return contactEdit } return nil } if !contactEdits.isEmpty { self.contact() .editSubscriptionLists { editor in contactEdits.forEach { edit in switch edit.action { case .subscribe: editor.subscribe(edit.list, scope: edit.scope) case .unsubscribe: editor.unsubscribe(edit.list, scope: edit.scope) } } } } } private func applyChannelEdits(_ edits: [Edit]) { let channelEdits = edits.compactMap { (edit: Edit) -> ChannelEdit? in if case .channel(let channelEdit) = edit { return channelEdit } return nil } if !channelEdits.isEmpty { self.channel() .editSubscriptionLists { editor in channelEdits.forEach { edit in switch edit.action { case .subscribe: editor.subscribe(edit.list) case .unsubscribe: editor.unsubscribe(edit.list) } } } } } internal enum SubscriptionAction: String, Decodable { case subscribe case unsubscribe } internal enum SubscriptionType: String, Decodable { case channel case contact } internal struct ChannelEdit: Decodable { let list: String let action: SubscriptionAction enum CodingKeys: String, CodingKey { case list = "list" case action = "action" } } internal struct ContactEdit: Decodable { let list: String let action: SubscriptionAction let scope: ChannelScope enum CodingKeys: String, CodingKey { case list = "list" case action = "action" case scope = "scope" } } enum Edit: Decodable { case channel(ChannelEdit) case contact(ContactEdit) enum CodingKeys: String, CodingKey { case type = "type" } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode( SubscriptionType.self, forKey: .type ) let singleValueContainer = try decoder.singleValueContainer() switch type { case .channel: self = .channel( (try singleValueContainer.decode(ChannelEdit.self)) ) case .contact: self = .contact( (try singleValueContainer.decode(ContactEdit.self)) ) } } } } ================================================ FILE: Airship/AirshipCore/Source/SubscriptionListEdit.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Represents an edit made to a subscription list through the SDK. public enum SubscriptionListEdit: Equatable { /// Subscribed case subscribe(String) /// Unsubscribed case unsubscribe(String) } ================================================ FILE: Airship/AirshipCore/Source/SubscriptionListEditor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Subscription list editor. public class SubscriptionListEditor { private var subscriptionListUpdates: [SubscriptionListUpdate] = [] private let completionHandler: ([SubscriptionListUpdate]) -> Void init(completionHandler: @escaping ([SubscriptionListUpdate]) -> Void) { self.completionHandler = completionHandler } /** * Subscribes to a list. * - Parameters: * - subscriptionListID: The subscription list identifier. */ public func subscribe(_ subscriptionListID: String) { let subscriptionListUpdate = SubscriptionListUpdate( listId: subscriptionListID, type: .subscribe ) subscriptionListUpdates.append(subscriptionListUpdate) } /** * Unsubscribes from a list. * - Parameters: * - subscriptionListID: The subscription list identifier. */ public func unsubscribe(_ subscriptionListID: String) { let subscriptionListUpdate = SubscriptionListUpdate( listId: subscriptionListID, type: .unsubscribe ) subscriptionListUpdates.append(subscriptionListUpdate) } /** * Applies subscription list changes. */ public func apply() { completionHandler(AudienceUtils.collapse(subscriptionListUpdates)) subscriptionListUpdates.removeAll() } } ================================================ FILE: Airship/AirshipCore/Source/SubscriptionListProvider.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine /** * Subscription list provider protocol for receiving contact updates. * @note For internal use only. :nodoc: */ protocol SubscriptionListProviderProtocol: Sendable { func subscriptionList(stableContactIDUpdates: AsyncStream<String>) -> AsyncStream<SubscriptionListResult> func fetch(contactID: String) async throws -> [String: [ChannelScope]] func refresh() async } final class SubscriptionListProvider: SubscriptionListProviderProtocol { private let actor: BaseCachingRemoteDataProvider<SubscriptionListResult, ContactAudienceOverrides> init( audienceOverrides: any AudienceOverridesProvider, apiClient: any ContactSubscriptionListAPIClientProtocol, date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = .shared, maxChannelListCacheAgeSeconds: TimeInterval = 600, privacyManager: any AirshipPrivacyManager ) { self.actor = BaseCachingRemoteDataProvider( remoteFetcher: { contactID in return try await apiClient .fetchSubscriptionLists(contactID: contactID) .map(onMap: { response in guard let result = response.result else { return nil } return .success(result) }) }, overridesProvider: { identifier in return await audienceOverrides.contactOverrideUpdates(contactID: identifier) }, overridesApplier: { result, overrides in guard case .success(let list) = result else { return result } let updated = AudienceUtils.applySubscriptionListsUpdates(list, updates: overrides.subscriptionLists) return .success(updated) }, isEnabled: { privacyManager.isEnabled(.contacts) }, date: date, taskSleeper: taskSleeper, cacheTtl: maxChannelListCacheAgeSeconds ) } func subscriptionList(stableContactIDUpdates: AsyncStream<String>) -> AsyncStream<SubscriptionListResult> { return actor.updates(identifierUpdates: stableContactIDUpdates) } func refresh() async { await actor.refresh() } func fetch(contactID: String) async throws -> [String: [ChannelScope]] { var stream = actor.updates(identifierUpdates: AsyncStream { continuation in continuation.yield(contactID) continuation.finish() }) .makeAsyncIterator() guard let result = await stream.next() else { throw AirshipErrors.error("Failed to get subscription list") } switch result { case .fail(let error): throw error case .success(let list): return list } } } enum SubscriptionListResult: Equatable, Sendable, Hashable, CachingRemoteDataProviderResult { static func error(_ error: CachingRemoteDataError) -> any CachingRemoteDataProviderResult { return SubscriptionListResult.fail(error) } case success([String: [ChannelScope]]) case fail(CachingRemoteDataError) public var subscriptionList: [String: [ChannelScope]] { get throws { switch(self) { case .fail(let error): throw error case .success(let list): return list } } } public var isSuccess: Bool { switch(self) { case .fail(_): return false case .success(_): return true } } } ================================================ FILE: Airship/AirshipCore/Source/SubscriptionListUpdate.swift ================================================ import Foundation /// NOTE: For internal use only. :nodoc: enum SubscriptionListUpdateType: Int, Codable, Equatable, Sendable { case subscribe case unsubscribe } /// NOTE: For internal use only. :nodoc: struct SubscriptionListUpdate: Codable, Equatable, Sendable { let listId: String let type: SubscriptionListUpdateType } extension SubscriptionListUpdate { var operation: SubscriptionListOperation { switch self.type { case .subscribe: return SubscriptionListOperation( action: .subscribe, listID: self.listId ) case .unsubscribe: return SubscriptionListOperation( action: .unsubscribe, listID: self.listId ) } } } /// NOTE: For internal use only. :nodoc: // Used by ChannelBulkAPIClient and DeferredAPIClient struct SubscriptionListOperation: Encodable { enum SubscriptionAction: String, Encodable { case subscribe case unsubscribe } var action: SubscriptionAction var listID: String enum CodingKeys: String, CodingKey { case action = "action" case listID = "list_id" } } ================================================ FILE: Airship/AirshipCore/Source/TagActionMutation.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Tag mutations from `AddTagsAction` and `RemoveTagsAction` public enum TagActionMutation: Sendable, Equatable { /// Represents a mutation for applying a set of tags to a channel. /// Associated value: A set of unique strings representing the tags to be applied to the channel case channelTags([String]) /// Represents a mutation for applying tag group changes to the channel. /// Associated value: A map of tag group to tags. case channelTagGroups([String: [String]]) /// Represents a mutation for applying tag group changes to the contact. /// Associated value: A map of tag group to tags. case contactTagGroups([String: [String]]) } ================================================ FILE: Airship/AirshipCore/Source/TagEditor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Tag editor. public class TagEditor { typealias TagApplicator = ([String]) -> [String] private var tagOperations: [([String]) -> [String]] = [] private let onApply: (TagApplicator) -> Void init(onApply: @escaping (TagApplicator) -> Void) { self.onApply = onApply } /** * Adds tags. * - Parameters: * - tags: The tags. */ public func add(_ tags: [String]) { let normalizedTags = AudienceUtils.normalizeTags(tags) self.tagOperations.append({ incoming in var mutable = incoming mutable.append(contentsOf: normalizedTags) return mutable }) } /** * Adds a single tag. * - Parameters: * - tag: The tag. */ public func add(_ tag: String) { self.add([tag]) } /** * Removes tags from the given group. * - Parameters: * - tags: The tags. */ public func remove(_ tags: [String]) { let normalizedTags = AudienceUtils.normalizeTags(tags) self.tagOperations.append({ incoming in var mutable = incoming mutable.removeAll(where: { normalizedTags.contains($0) }) return mutable }) } /** * Removes a single tag. * - Parameters: * - tag: The tag. */ public func remove(_ tag: String) { self.remove([tag]) } /** * Sets tags on the given group. * - Parameters: * - tags: The tags. */ public func set(_ tags: [String]) { let normalizedTags = AudienceUtils.normalizeTags(tags) self.tagOperations.append({ incoming in return normalizedTags }) } /** * Clears tags. */ public func clear() { self.tagOperations.append({ _ in return [] }) } /** * Applies tag changes. */ public func apply() { let operations = tagOperations tagOperations.removeAll() self.onApply({ tags in return operations.reduce(tags) { result, operation in return operation(result) } }) } } ================================================ FILE: Airship/AirshipCore/Source/TagGroupMutations.swift ================================================ import Foundation /// Used to migrate data to TagGroupUpdate in contact and channels. @objc(UATagGroupsMutation) class TagGroupsMutation: NSObject, NSSecureCoding { static let codableAddKey: String = "add" static let codableRemoveKey: String = "remove" static let codableSetKey: String = "set" public static let supportsSecureCoding: Bool = true private let adds: [String: Set<String>]? private let removes: [String: Set<String>]? private let sets: [String: Set<String>]? public init( adds: [String: Set<String>]?, removes: [String: Set<String>]?, sets: [String: Set<String>]? ) { self.adds = adds self.removes = removes self.sets = sets super.init() } public var tagGroupUpdates: [TagGroupUpdate] { var updates: [TagGroupUpdate] = [] self.adds? .forEach { updates.append( TagGroupUpdate( group: $0.key, tags: Array($0.value), type: .add ) ) } self.removes? .forEach { updates.append( TagGroupUpdate( group: $0.key, tags: Array($0.value), type: .remove ) ) } self.sets? .forEach { updates.append( TagGroupUpdate( group: $0.key, tags: Array($0.value), type: .set ) ) } return updates } func encode(with coder: NSCoder) { coder.encode(self.adds, forKey: TagGroupsMutation.codableAddKey) coder.encode(self.removes, forKey: TagGroupsMutation.codableRemoveKey) coder.encode(self.sets, forKey: TagGroupsMutation.codableSetKey) } required init?(coder: NSCoder) { self.adds = coder.decodeObject( of: [NSDictionary.self, NSString.self, NSSet.self], forKey: TagGroupsMutation.codableAddKey ) as? [String: Set<String>] self.removes = coder.decodeObject( of: [NSDictionary.self, NSString.self, NSSet.self], forKey: TagGroupsMutation.codableRemoveKey ) as? [String: Set<String>] self.sets = coder.decodeObject( of: [NSDictionary.self, NSString.self, NSSet.self], forKey: TagGroupsMutation.codableSetKey ) as? [String: Set<String>] } } ================================================ FILE: Airship/AirshipCore/Source/TagGroupUpdate.swift ================================================ import Foundation /// NOTE: For internal use only. :nodoc: enum TagGroupUpdateType: Int, Codable, Equatable, Hashable, Sendable { case add case remove case set } /// NOTE: For internal use only. :nodoc: struct TagGroupUpdate: Codable, Sendable, Equatable, Hashable { let group: String let tags: [String] let type: TagGroupUpdateType } /// NOTE: For internal use only. :nodoc: // Used by ChannelBulkAPIClient and DeferredAPIClient struct TagGroupOverrides: Encodable, Sendable { var add: [String: [String]]? = nil var remove: [String: [String]]? = nil var set: [String: [String]]? = nil init(add: [String : [String]]? = nil, remove: [String : [String]]? = nil, set: [String : [String]]? = nil) { self.add = add self.remove = remove self.set = set } static func from(updates: [TagGroupUpdate]?) -> TagGroupOverrides? { guard let updates = updates, !updates.isEmpty else { return nil } var overrides = TagGroupOverrides() AudienceUtils.collapse(updates).forEach { tagUpdate in switch tagUpdate.type { case .set: if overrides.set == nil { overrides.set = [:] } overrides.set?[tagUpdate.group] = tagUpdate.tags case .remove: if overrides.remove == nil { overrides.remove = [:] } overrides.remove?[tagUpdate.group] = tagUpdate.tags case .add: if overrides.add == nil { overrides.add = [:] } overrides.add?[tagUpdate.group] = tagUpdate.tags } } return overrides } } ================================================ FILE: Airship/AirshipCore/Source/TagGroupsEditor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Tag groups editor. public class TagGroupsEditor { private var tagUpdates: [TagGroupUpdate] = [] private var allowDeviceTagGroup: Bool = false private let completionHandler: ([TagGroupUpdate]) -> Void init( allowDeviceTagGroup: Bool, completionHandler: @escaping ([TagGroupUpdate]) -> Void ) { self.allowDeviceTagGroup = allowDeviceTagGroup self.completionHandler = completionHandler } convenience init(completionHandler: @escaping ([TagGroupUpdate]) -> Void) { self.init( allowDeviceTagGroup: false, completionHandler: completionHandler ) } /** * Adds tags to the given group. * - Parameters: * - tags: The tags. * - group: The tag group. */ public func add(_ tags: [String], group: String) { let group = AudienceUtils.normalizeTagGroup(group) let tags = AudienceUtils.normalizeTags(tags) guard isValid(group: group) else { return } guard !tags.isEmpty else { return } let update = TagGroupUpdate(group: group, tags: tags, type: .add) tagUpdates.append(update) } /** * Removes tags from the given group. * - Parameters: * - tags: The tags. * - group: The tag group. */ public func remove(_ tags: [String], group: String) { let group = AudienceUtils.normalizeTagGroup(group) let tags = AudienceUtils.normalizeTags(tags) guard isValid(group: group) else { return } guard !tags.isEmpty else { return } let update = TagGroupUpdate(group: group, tags: tags, type: .remove) tagUpdates.append(update) } /** * Sets tags on the given group. * - Parameters: * - tags: The tags. * - group: The tag group. */ public func set(_ tags: [String], group: String) { let group = AudienceUtils.normalizeTagGroup(group) let tags = AudienceUtils.normalizeTags(tags) guard isValid(group: group) else { return } let update = TagGroupUpdate(group: group, tags: tags, type: .set) tagUpdates.append(update) } /** * Applies tag changes. */ public func apply() { self.completionHandler(tagUpdates) tagUpdates.removeAll() } private func isValid(group: String) -> Bool { guard !group.isEmpty else { AirshipLogger.error("Invalid tag group \(group)") return false } if group == "ua_device" && !allowDeviceTagGroup { AirshipLogger.error("Unable to modify device tag group") return false } return true } } ================================================ FILE: Airship/AirshipCore/Source/TagsActionArgs.swift ================================================ import Foundation struct TagsActionsArgs: Decodable { let channel: [String: [String]]? let namedUser: [String: [String]]? let device: [String]? enum CodingKeys: String, CodingKey { case channel = "channel" case namedUser = "named_user" case device = "device" } } ================================================ FILE: Airship/AirshipCore/Source/TextInput.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI struct TextInput: View { private let info: ThomasViewInfo.TextInput private let constraints: ViewConstraints @Environment(\.pageIdentifier) private var pageID @Environment(\.sizeCategory) private var sizeCategory @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var formDataCollector: ThomasFormDataCollector @EnvironmentObject private var formState: ThomasFormState @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @EnvironmentObject private var thomasState: ThomasState @EnvironmentObject private var validatableHelper: ValidatableHelper @Environment(\.thomasAssociatedLabelResolver) private var associatedLabelResolver @State private var isEditing: Bool = false @StateObject private var viewModel: ViewModel private var associatedLabel: String? { associatedLabelResolver?.labelFor( identifier: info.properties.identifier, viewType: .textInput, thomasState: thomasState ) } private var scaledFontSize: Double { AirshipFont.scaledSize(self.info.properties.textAppearance.fontSize) } init( info: ThomasViewInfo.TextInput, constraints: ViewConstraints ) { self.info = info self.constraints = constraints self._viewModel = StateObject( wrappedValue: ViewModel( inputProperties: info.properties, isRequired: info.validation.isRequired ?? false ) ) } #if !os(watchOS) && !os(macOS) private var keyboardType: UIKeyboardType { switch self.info.properties.inputType { case .email: return .emailAddress case .number: return .decimalPad case .text: return .default case .textMultiline: return .default case .sms: return .phonePad } } #endif @ViewBuilder private func makeTextEditor() -> some View { AirshipTextField( info: self.info, constraints: constraints, alignment: self.textFieldAlignment, binding: self.$viewModel.input, isEditing: $isEditing ) } var showSMSPicker: Bool { guard self.info.properties.inputType == .sms, self.viewModel.availableLocales != nil else { return false } return true } @ViewBuilder private func smsPicker() -> some View { #if !os(watchOS) SmsLocalePicker( selectedLocale: $viewModel.selectedSMSLocale, availableLocales: self.viewModel.availableLocales ?? [], fontSize: scaledFontSize ) #else EmptyView() #endif } var textFieldAlignment: Alignment { return switch(self.info.properties.inputType) { case .email, .text, .number, .sms: .center case .textMultiline: .top } } var placeHolderAlignment: Alignment { let textAlignment = self.info.properties.textAppearance.alignment ?? .start let horizontalAlignment: HorizontalAlignment = switch(textAlignment) { case .start: .leading case .end: .trailing case .center: .center } return Alignment( horizontal: horizontalAlignment, vertical: self.textFieldAlignment.vertical ) } @ViewBuilder private func textInputContent() -> some View { ZStack { if let hint = self.info.properties.placeholder ?? self.viewModel.selectedSMSLocale?.prefix { Text(hint) .textAppearance(placeHolderTextAppearance(), colorScheme: colorScheme) .padding(5) .constraints(constraints, alignment: self.placeHolderAlignment) .opacity(self.viewModel.input.isEmpty && !isEditing ? 1 : 0) .animation(.linear(duration: 0.1), value: self.info.properties.placeholder) .accessibilityHidden(true) } HStack { makeTextEditor() #if !os(watchOS) && !os(macOS) .airshipApplyIf(self.info.properties.inputType == .email) { view in view.textInputAutocapitalization(.never) } #endif .id(self.info.properties.identifier) if let resolvedIconEndInfo = resolvedIconEndInfo?.icon { let size = scaledFontSize Icons.icon(info: resolvedIconEndInfo, colorScheme: colorScheme, resizable: false) .frame(maxWidth: size, maxHeight: size) .padding(5) } } } } @ViewBuilder var body: some View { HStack { if showSMSPicker { smsPicker() .padding(.vertical, 5) .padding(.leading, 5) } textInputContent() } #if !os(watchOS) && !os(macOS) .keyboardType(keyboardType) .airshipApplyIf(self.info.properties.inputType == .email) { view in view.textContentType(.emailAddress) } .airshipApplyIf(self.info.properties.inputType == .sms) { view in view.textContentType(.telephoneNumber) } #endif .thomasCommon(self.info) .accessible( self.info.accessible, associatedLabel: associatedLabel, hideIfDescriptionIsMissing: false ) .formElement() .onAppear { let (value, locale) = restoredValue() viewModel.setInitialValue(value, locale: locale) validatableHelper.subscribe( forIdentifier: info.properties.identifier, formState: formState, initialValue: self.viewModel.input, valueUpdates: self.viewModel.$input, validatables: info.validation ) { [weak thomasState, weak viewModel] actions in guard let thomasState, let viewModel else { return } thomasState.processStateActions( actions, formFieldValue: viewModel.formField?.input ) } } .onReceive(self.viewModel.$formField) { field in guard let field else { return } self.formDataCollector.updateField(field, pageID: pageID) } } private var resolvedIconEndInfo: ThomasViewInfo.TextInput.IconEndInfo? { return ThomasPropertyOverride.resolveOptional( state: thomasState, overrides: self.info.overrides?.iconEnd, defaultValue: self.info.properties.iconEnd ?? nil ) } private func handleStateActions(_ stateActions: [ThomasStateAction]) { thomasState.processStateActions( stateActions, formFieldValue: self.viewModel.formField?.input ) } private func restoredValue() -> (String?, ThomasSMSLocale?) { let identifier = self.info.properties.identifier switch(self.info.properties.inputType, formState.fieldValue(identifier: identifier)) { case(.email, .email(let value)), (.number, .text(let value)), (.text, .text(let value)), (.textMultiline, .text(let value)): return (value, nil) case (.sms, .sms(let value, let locale)): return (value, locale) default: return (nil, nil) } } private func placeHolderTextAppearance() -> ThomasTextAppearance { guard let color = self.info.properties.textAppearance.placeHolderColor else { return self.info.properties.textAppearance } var appearance = self.info.properties.textAppearance appearance.color = color return appearance } @MainActor fileprivate final class ViewModel: ObservableObject { private let inputProperties: ThomasViewInfo.TextInput.Properties private let isRequired: Bool private var inputValidator: (any AirshipInputValidation.Validator)? { guard Airship.isFlying else { return nil } return Airship.inputValidator } @Published var formField: ThomasFormField? private var lastInput: String? @Published var selectedSMSLocale: ThomasSMSLocale? let availableLocales: [ThomasSMSLocale]? @Published var input: String = "" { didSet { if !self.input.isEmpty, !didEdit { didEdit = true } self.updateFormData() } } @Published var didEdit: Bool = false init( inputProperties: ThomasViewInfo.TextInput.Properties, isRequired: Bool, ) { self.inputProperties = inputProperties self.isRequired = isRequired self.availableLocales = inputProperties.smsLocales self.selectedSMSLocale = inputProperties.smsLocales?.first } func setInitialValue(_ value: String?, locale: ThomasSMSLocale?) { guard self.formField == nil else { return } if let locale, inputProperties.smsLocales?.contains(where: { $0 == locale }) == true { self.selectedSMSLocale = locale } self.formField = self.makeFormField(input: value ?? "") self.input = value ?? "" } private func updateFormData() { guard lastInput != self.input else { return } self.lastInput = self.input self.formField = self.makeFormField(input: input) } private func makeAttributes(value: String) -> [ThomasFormField.Attribute]? { guard !value.isEmpty, let name = inputProperties.attributeName else { return nil } return [ ThomasFormField.Attribute( attributeName: name, attributeValue: .string(value) ) ] } private func makeChannels( value: String, selectedSMSLocale: ThomasSMSLocale? = nil ) -> [ThomasFormField.Channel]? { guard !value.isEmpty else { return nil } switch(self.inputProperties.inputType) { case .email: return if let options = self.inputProperties.emailRegistration { [.email(value, options)] } else { nil } case .sms: return if let options = selectedSMSLocale?.registration { [.sms(value, options)] } else { nil } case .number, .text, .textMultiline: return nil } } private func makeFormField(input: String) -> ThomasFormField { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) switch(self.inputProperties.inputType) { case .email: guard !trimmed.isEmpty else { return if isRequired { ThomasFormField.invalidField( identifier: inputProperties.identifier, input: .email(input) ) } else { ThomasFormField.validField( identifier: inputProperties.identifier, input: .email(input), result: .init(value: .email(nil)) ) } } let request: AirshipInputValidation.Request = .email( AirshipInputValidation.Request.Email( rawInput: input ) ) return ThomasFormField.asyncField( identifier: inputProperties.identifier, input: .email(input), processDelay: 1.5 ) { [inputValidator, weak self] in guard let inputValidator else { return .invalid } let result = try await inputValidator.validateRequest( request ) guard let self else { return .invalid } switch (result) { case .invalid: return .invalid case .valid(let address): return .valid( .init( value: .email(address), channels: self.makeChannels(value: address), attributes: self.makeAttributes(value: address) ) ) } } case .sms: guard !trimmed.isEmpty, let selectedSMSLocale else { return if isRequired { ThomasFormField.invalidField( identifier: inputProperties.identifier, input: .sms(input, selectedSMSLocale) ) } else { ThomasFormField.validField( identifier: inputProperties.identifier, input: .sms(input, selectedSMSLocale), result: .init(value: .sms(nil, nil)) ) } } let request: AirshipInputValidation.Request = .sms( AirshipInputValidation.Request.SMS( rawInput: input, validationOptions: .prefix(prefix: selectedSMSLocale.prefix), validationHints: .init( minDigits: selectedSMSLocale.validationHints?.minDigits, maxDigits: selectedSMSLocale.validationHints?.maxDigits ) ) ) return ThomasFormField.asyncField( identifier: inputProperties.identifier, input: .sms(input, selectedSMSLocale) ) { [weak self, inputValidator] in guard let inputValidator else { return .invalid } let result = try await inputValidator.validateRequest(request) guard let self else { return .invalid } switch (result) { case .invalid: return .invalid case .valid(let address): return .valid( .init( value: .sms(address, selectedSMSLocale), channels: self.makeChannels( value: address, selectedSMSLocale: selectedSMSLocale ), attributes: self.makeAttributes(value: address) ) ) } } case .number, .text, .textMultiline: return if trimmed.isEmpty, isRequired { ThomasFormField.invalidField( identifier: inputProperties.identifier, input: .text(input) ) } else { ThomasFormField.validField( identifier: inputProperties.identifier, input: .text(input), result: .init( value: .text(trimmed), attributes: self.makeAttributes(value: trimmed) ) ) } } } } } struct AirshipTextField: View { @Environment(\.sizeCategory) private var sizeCategory private let info: ThomasViewInfo.TextInput private let constraints: ViewConstraints private let alignment: Alignment @Binding private var binding: String @Binding private var isEditing: Bool @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var thomasEnvironment: ThomasEnvironment @EnvironmentObject private var viewState: ThomasState @FocusState private var focused: Bool @State private var icon: ThomasViewInfo.TextInput.IconEndInfo? init( info: ThomasViewInfo.TextInput, constraints: ViewConstraints, alignment: Alignment, binding: Binding<String>, isEditing: Binding<Bool> ) { self.info = info self.constraints = constraints self.alignment = alignment self._binding = binding self._isEditing = isEditing } var body: some View { let isMultiline = self.info.properties.inputType == .textMultiline let axis: Axis = isMultiline ? .vertical : .horizontal return TextField("", text: $binding, axis: axis) .padding(5) .constraints(constraints, alignment: alignment) .focused($focused) .foregroundColor(self.info.properties.textAppearance.color.toColor(colorScheme)) .contentShape(Rectangle()) .textFieldStyle(.plain) .onTapGesture { self.focused = true } .applyViewAppearance(self.info.properties.textAppearance, colorScheme: colorScheme) .airshipApplyIf(isUnderlined, transform: { content in content.underline() }) .airshipOnChangeOf(focused) { newValue in if (newValue) { self.thomasEnvironment.focusedID = self.info.properties.identifier } else if (self.thomasEnvironment.focusedID == self.info.properties.identifier) { self.thomasEnvironment.focusedID = nil } isEditing = newValue } .airshipApplyIf(isMultiline) { view in view.airshipOnChangeOf(binding) { [binding] newValue in let oldCount = binding.filter { $0 == "\n" }.count let newCount = newValue.filter { $0 == "\n" }.count if (newCount == oldCount + 1) { // Only update if values are different if newValue != binding { self.binding = binding } self.focused = false } } } } private var isUnderlined : Bool { if let styles = self.info.properties.textAppearance.styles { if styles.contains(.underlined) { return true } } return false } } fileprivate extension String { var nilIfEmpty: String? { return isEmpty ? nil : self } } ================================================ FILE: Airship/AirshipCore/Source/Thomas.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Airship rendering engine. /// - Note: for internal use only. :nodoc: public final class Thomas { #if !os(watchOS) @MainActor @discardableResult public class func display( layout: AirshipLayout, displayTarget: AirshipDisplayTarget, extensions: ThomasExtensions? = nil, delegate: any ThomasDelegate, extras: AirshipJSON?, priority: Int ) throws -> any AirshipMainActorCancellable { switch layout.presentation { case .banner(let presentation): return try displayBanner( presentation, displayTarget: displayTarget, layout: layout, extensions: extensions, delegate: delegate ) case .modal(let presentation): return try displayModal( presentation, displayTarget: displayTarget, layout: layout, extensions: extensions, delegate: delegate ) case .embedded(let presentation): return AirshipEmbeddedViewManager.shared.addPending( presentation: presentation, layout: layout, extensions: extensions, delegate: delegate, extras: extras, priority: priority ) } } @MainActor private class func displayBanner( _ presentation: ThomasPresentationInfo.Banner, displayTarget: AirshipDisplayTarget, layout: AirshipLayout, extensions: ThomasExtensions?, delegate: any ThomasDelegate ) throws -> any AirshipMainActorCancellable { let displayable = displayTarget.prepareDisplay(for: .banner) let options = ThomasViewControllerOptions() let environment = ThomasEnvironment( delegate: delegate, extensions: extensions ) try displayable.display { windowInfo in let bannerConstraints = ThomasBannerConstraints( windowSize: windowInfo.size ) let rootView = BannerView( viewControllerOptions: options, presentation: presentation, layout: layout, thomasEnvironment: environment, bannerConstraints: bannerConstraints, ) { displayable.dismiss() } return ThomasBannerViewController( rootView: rootView, position: presentation.defaultPlacement.position, options: options, constraints: bannerConstraints ) } return AirshipMainActorCancellableBlock { [weak environment] in environment?.dismiss() } } @MainActor private class func displayModal( _ presentation: ThomasPresentationInfo.Modal, displayTarget: AirshipDisplayTarget, layout: AirshipLayout, extensions: ThomasExtensions?, delegate: any ThomasDelegate ) throws -> any AirshipMainActorCancellable { let displayable = displayTarget.prepareDisplay(for: .modal) let options = ThomasViewControllerOptions() options.orientation = presentation.defaultPlacement.device?.orientationLock let environment = ThomasEnvironment( delegate: delegate, extensions: extensions ) { displayable.dismiss() } let rootView = ModalView( presentation: presentation, layout: layout, thomasEnvironment: environment, viewControllerOptions: options ) try displayable.display { window in return ThomasModalViewController( rootView: rootView, options: options ) } return AirshipMainActorCancellableBlock { [weak environment] in environment?.dismiss() } } #endif } /// Airship rendering engine extensions. /// - Note: for internal use only. :nodoc: public struct ThomasExtensions { #if !os(tvOS) && !os(watchOS) var nativeBridgeExtension: (any NativeBridgeExtensionDelegate)? #endif var imageProvider: (any AirshipImageProvider)? var actionRunner: (any ThomasActionRunner)? #if os(tvOS) || os(watchOS) public init( imageProvider: (any AirshipImageProvider)? = nil, actionRunner: (any ThomasActionRunner)? = nil ) { self.imageProvider = imageProvider } #else public init( nativeBridgeExtension: (any NativeBridgeExtensionDelegate)? = nil, imageProvider: (any AirshipImageProvider)? = nil, actionRunner: (any ThomasActionRunner)? = nil ) { self.nativeBridgeExtension = nativeBridgeExtension self.imageProvider = imageProvider self.actionRunner = actionRunner } #endif } /// Thomas action runner /// - Note: for internal use only. :nodoc: public protocol ThomasActionRunner: Sendable { @MainActor func runAsync(actions: AirshipJSON, layoutContext: ThomasLayoutContext) @MainActor func run(actionName: String, arguments: ActionArguments, layoutContext: ThomasLayoutContext) async -> ActionResult } ================================================ FILE: Airship/AirshipCore/Source/ThomasAccessibilityAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasAccessibilityAction: ThomasSerializable { enum ActionType: String, ThomasSerializable { case `default` = "default" case escape = "escape" } struct Properties: ThomasSerializable { var type: ActionType var reportingMetadata: AirshipJSON? var actions: [ThomasActionsPayload]? var behaviors: [ThomasButtonClickBehavior]? enum CodingKeys: String, CodingKey { case type case reportingMetadata = "reporting_metadata" case actions case behaviors } } var accessible: ThomasAccessibleInfo var properties: Properties func encode(to encoder: any Encoder) throws { try accessible.encode(to: encoder) try properties.encode(to: encoder) } init(from decoder: any Decoder) throws { self.accessible = try ThomasAccessibleInfo(from: decoder) self.properties = try Properties(from: decoder) } } ================================================ FILE: Airship/AirshipCore/Source/ThomasAccessibleInfo.swift ================================================ /* Copyright Airship and Contributors */ struct ThomasAccessibleInfo: ThomasSerializable { var contentDescription: String? var localizedContentDescription: Localized? var accessibilityHidden: Bool? struct Localized: ThomasSerializable { var ref: String? var refs: [String]? var fallback: String } enum CodingKeys: String, CodingKey { case contentDescription = "content_description" case localizedContentDescription = "localized_content_description" case accessibilityHidden = "accessibility_hidden" } } extension ThomasAccessibleInfo { var resolveContentDescription: String? { if let contentDescription = self.contentDescription { return contentDescription } guard let localizedContentDescription else { return nil } if let refs = localizedContentDescription.refs { for ref in refs { if let string = AirshipResources.localizedString(key: ref) { return string } } } else if let ref = localizedContentDescription.ref { if let string = AirshipResources.localizedString(key: ref) { return string } } return localizedContentDescription.fallback } } ================================================ FILE: Airship/AirshipCore/Source/ThomasActionsPayload.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasActionsPayload: ThomasSerializable, Hashable { static let keyActionOverride = "platform_action_overrides" private let original: AirshipJSON private let merged: AirshipJSON var value: AirshipJSON { return merged } init(value: AirshipJSON) { self.original = value self.merged = Self.overridingPlatformActions(value) } init(from decoder: any Decoder) throws { let json = try AirshipJSON.init(from: decoder) guard case .object = json else { throw AirshipErrors.error("Invalid actions payload.") } self.original = json self.merged = Self.overridingPlatformActions(json) } func encode(to encoder: any Encoder) throws { try self.original.encode(to: encoder) } static func overridingPlatformActions(_ input: AirshipJSON) -> AirshipJSON { guard case .object(var actions) = input, let override = actions.removeValue(forKey: Self.keyActionOverride), case .object(let platforms) = override, case .object(let overridenActions) = platforms["ios"] else { return input } actions.merge(overridenActions) { _, overriden in overriden } return .object(actions) } } ================================================ FILE: Airship/AirshipCore/Source/ThomasAssociatedLabelResolver.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @MainActor struct ThomasAssociatedLabelResolver: Sendable { var labelMap: [String: (ThomasState) -> String?] = [:] init(layout: AirshipLayout) { var labelMap: [String: (ThomasState) -> String?] = [:] let labels = layout.labels ?? [] labels.forEach { info in if let labels = info.properties.labels, labels.type == .labels { labelMap[Self.makeKey(for: labels.viewID, viewType: labels.viewType)] = { state in let resolvedString = info.resolveLabelString(thomasState: state) return if info.properties.markdown?.disabled == true { resolvedString } else { String(AttributedString(resolvedString).characters) } } } } self.labelMap = labelMap } func labelFor( identifier: String?, viewType: ThomasViewInfo.ViewType, thomasState: ThomasState ) -> String? { guard let identifier else { return nil } return labelMap[Self.makeKey(for: identifier, viewType: viewType)]?(thomasState) } private static func makeKey(for identifier: String, viewType: ThomasViewInfo.ViewType) -> String { return "\(identifier):\(viewType.rawValue)" } } fileprivate extension AirshipLayout { var labels: [ThomasViewInfo.Label]? { return extract { info in return if case let .label(label) = info { label } else { nil } } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasAsyncImage.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI public struct ThomasAsyncImage<Placeholder: View, ImageView: View>: View { let url: String let imageLoader: AirshipImageLoader let image: (Image, CGSize) -> ImageView let placeholder: () -> Placeholder public init( url: String, imageLoader: AirshipImageLoader = AirshipImageLoader(), image: @escaping (Image, CGSize) -> ImageView, placeholder: @escaping () -> Placeholder ) { self.url = url self.imageLoader = imageLoader self.image = image self.placeholder = placeholder } @State private var loadedURL: String? @State private var loadedImage: AirshipImageData? @State private var currentImage: AirshipNativeImage? @State private var imageIndex: Int = 0 @State private var imageTask: Task<Void, Never>? @State private var loopsCompleted: Int = 0 @Environment(\.isVisible) var isVisible: Bool // we use this value not for updating view tree, but for starting stopping animation, // that's why we need to store the actual value in a separate @State variable @State private var isImageVisible: Bool = false public var body: some View { content .task(id: url) { self.isImageVisible = self.isVisible guard loadedURL != url else { animateIfNeeded() return } self.loadedImage = nil self.currentImage = nil do { let image = try await imageLoader.load(url: url) self.loadedURL = url self.loadedImage = image animateIfNeeded() } catch is CancellationError { } catch { AirshipLogger.error("Unable to load image \(error)") } } .airshipOnChangeOf(isVisible) { newValue in self.isImageVisible = newValue if newValue { self.loopsCompleted = 0 // Reset gif frame loop counter every time isVisible changes } animateIfNeeded() } } private var content: some View { Group { if let image = currentImage { self.image(Image(airshipNativeImage: image), image.size) .animation(nil, value: self.imageIndex) .onDisappear { imageTask?.cancel() } } else { self.placeholder() } } } private func animateIfNeeded() { self.imageTask?.cancel() if isImageVisible { self.imageTask = Task { @MainActor in await animateImage() } } else { self.imageTask = Task { @MainActor in await preloadFirstImage() } } } @MainActor private func preloadFirstImage() async { guard let loadedImage = self.loadedImage, self.currentImage == nil else { return } guard loadedImage.isAnimated else { self.currentImage = await loadedImage.loadFrames().first?.image return } let image = await loadedImage.getActor().loadFrame(at: 0)?.image if !Task.isCancelled { self.currentImage = image } } @MainActor private func animateImage() async { guard let loadedImage = self.loadedImage else { return } guard loadedImage.isAnimated else { self.currentImage = await loadedImage.loadFrames().first?.image return } let frameActor = loadedImage.getActor() imageIndex = 0 var frame = await frameActor.loadFrame(at: imageIndex) self.currentImage = frame?.image /// GIFs will sometimes have a 0 in their loop count metadata to denote infinite loops let loopCount = loadedImage.loopCount ?? 0 /// Continue looping if loop count is nil (coalesces to zero), zero or nonzero and greater than the loops completed while !Task.isCancelled && (loopCount <= 0 || loopCount > loopsCompleted) { let duration = frame?.duration ?? AirshipImageData.minFrameDuration async let delay: () = Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000)) let nextIndex = (imageIndex + 1) % loadedImage.imageFramesCount do { let (_, nextFrame) = try await (delay, frameActor.loadFrame(at: nextIndex)) frame = nextFrame } catch {} // most likely it's a task cancelled exception when animation is stopped imageIndex = nextIndex /// Consider a loop completed when we reach the last frame if imageIndex == loadedImage.imageFramesCount - 1 { /// Stops the GIF when loopsCompleted == loopCount when loopCount is specified self.loopsCompleted += 1 } if !Task.isCancelled { self.currentImage = frame?.image } } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasAttributeName.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasAttributeName: ThomasSerializable, Hashable { var channel: String? var contact: String? } ================================================ FILE: Airship/AirshipCore/Source/ThomasAttributeValue.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasAttributeValue: ThomasSerializable, Hashable { case string(String) case number(Double) init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let string = try? container.decode(String.self) { self = .string(string) } else if let number = try? container.decode(Double.self) { self = .number(number) } else { throw AirshipErrors.error("Invalid attribute value") } } func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .string(let value): try container.encode(value) case .number(let value): try container.encode(value) } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasAutomatedAccessibilityAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasAutomatedAccessibilityAction: ThomasSerializable { let type: ActionType enum ActionType: String, ThomasSerializable { case announce = "announce" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasAutomatedAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasAutomatedAction: Codable, Equatable, Sendable { var identifier: String var delay: Double? var actions: [ThomasActionsPayload]? var behaviors: [ThomasButtonClickBehavior]? var reportingMetadata: AirshipJSON? enum CodingKeys: String, CodingKey { case identifier case delay case actions case behaviors case reportingMetadata = "reporting_metadata" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasBorder.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasBorder: Codable, Equatable, Sendable { struct CornerRadius: Codable, Equatable, Sendable { var topLeft: Double? var topRight: Double? var bottomLeft: Double? var bottomRight: Double? enum CodingKeys: String, CodingKey { case topLeft = "top_left" case topRight = "top_right" case bottomLeft = "bottom_left" case bottomRight = "bottom_right" } } var radius: Double? var cornerRadius: CornerRadius? var strokeWidth: Double? var strokeColor: ThomasColor? enum CodingKeys: String, CodingKey { case radius case cornerRadius = "corner_radius" case strokeWidth = "stroke_width" case strokeColor = "stroke_color" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasButtonClickBehavior.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasButtonClickBehavior: String, ThomasSerializable { case dismiss case cancel case pagerNext = "pager_next" case pagerPrevious = "pager_previous" case pagerNextOrDismiss = "pager_next_or_dismiss" case pagerNextOrFirst = "pager_next_or_first" case formSubmit = "form_submit" case formValidate = "form_validate" case pagerPause = "pager_pause" case pagerResume = "pager_resume" case pagerPauseToggle = "pager_toggle_pause" case videoPlay = "video_play" case videoPause = "video_pause" case videoTogglePlay = "video_toggle_play" case videoMute = "video_mute" case videoUnmute = "video_unmute" case videoToggleMute = "video_toggle_mute" } extension ThomasButtonClickBehavior { fileprivate var sortOrder: Int { switch self { case .dismiss: return 3 case .cancel: return 3 case .pagerPause: return 2 case .pagerResume: return 2 case .pagerPauseToggle: return 2 case .videoPlay: return 2 case .videoPause: return 2 case .videoTogglePlay: return 2 case .videoMute: return 2 case .videoUnmute: return 2 case .videoToggleMute: return 2 case .pagerNextOrFirst: return 1 case .pagerNextOrDismiss: return 1 case .pagerNext: return 1 case .pagerPrevious: return 1 case .formSubmit: return 0 case .formValidate: return -1 } } } extension Array where Element == ThomasButtonClickBehavior { var sortedBehaviors: [Element] { return self.sorted { $0.sortOrder < $1.sortOrder } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasButtonTapEffect.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasButtonTapEffect: ThomasSerializable { case `default` case none private enum CodingKeys: String, CodingKey { case type } enum EffectType: String, Codable { case `default` = "default" case none = "none" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type: EffectType = try container.decode(EffectType.self, forKey: .type) self = switch(type) { case .default: .default case .none: .none } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch (self) { case .default: try container.encode(EffectType.default, forKey: .type) case .none: try container.encode(EffectType.none, forKey: .type) } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasColor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct ThomasColor: ThomasSerializable { let defaultColor: HexColor let selectors: [Selector]? enum CodingKeys: String, CodingKey { case defaultColor = "default" case selectors } struct HexColor: ThomasSerializable { let type: String = "hex" var hex: String var alpha: Double? enum CodingKeys: String, CodingKey { case type case hex case alpha } } struct Selector: ThomasSerializable { let darkMode: Bool? let platform: ThomasPlatform? let color: HexColor enum CodingKeys: String, CodingKey { case platform case darkMode = "dark_mode" case color } } } extension ThomasColor.HexColor { func toColor() -> Color { // Use the new AirshipColor resolver instead of AirshipColorUtils let color = AirshipColor.resolveColor(self.hex) let alpha = self.alpha ?? 1.0 // Combine the hex color with the explicit alpha multiplier let finalColor = color.opacity(alpha) /// Clear needs to be replaced by tappable clear to prevent SwiftUI from passing through tap events /// Note: We check if the resulting alpha is 0 (either from hex or explicit alpha) if alpha == 0 || color == .clear { return ThomasConstants.tappableClearColor } return finalColor } } extension ThomasColor { func toColor(_ colorScheme: ColorScheme) -> Color { let isDarkMode = colorScheme == .dark for selector in selectors ?? [] { // Platform filtering if let platform = selector.platform { if platform != .ios { continue } } // Dark mode filtering if let selectorDarkMode = selector.darkMode, isDarkMode != selectorDarkMode { continue } return selector.color.toColor() } // Fallback to default if no selectors matched return defaultColor.toColor() } } ================================================ FILE: Airship/AirshipCore/Source/ThomasConstants.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct ThomasConstants { static let disabledColor = Color.gray.opacity(0.5) static let tappableClearColor = Color.white.opacity(0.001) private init() {} } ================================================ FILE: Airship/AirshipCore/Source/ThomasConstrainedSize.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasConstrainedSize: ThomasSerializable { var minWidth: ThomasSizeConstraint? var width: ThomasSizeConstraint var maxWidth: ThomasSizeConstraint? var minHeight: ThomasSizeConstraint? var height: ThomasSizeConstraint var maxHeight: ThomasSizeConstraint? private enum CodingKeys: String, CodingKey { case minWidth = "min_width" case width case maxWidth = "max_width" case minHeight = "min_height" case height case maxHeight = "max_height" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public protocol ThomasDelegate: Sendable { @MainActor func onVisibilityChanged(isVisible: Bool, isForegrounded: Bool) @MainActor func onReportingEvent(_ event: ThomasReportingEvent) @MainActor func onDismissed(cancel: Bool) @MainActor func onStateChanged(_ state: AirshipJSON) } public extension ThomasDelegate { @MainActor func onStateChanged(_ state: AirshipJSON) { // no-op } } @MainActor public final class ThomasDismissHandle { private var onDismissBlocks: [(Bool) -> Void] = [] public init() {} /// Adds a block to be called when ``dismiss(cancel:)`` is invoked. All blocks are run; order is the order they were added. func addOnDismiss(_ block: @escaping (Bool) -> Void) { onDismissBlocks.append(block) } public func dismiss(cancel: Bool = false) { for block in onDismissBlocks { block(cancel) } onDismissBlocks.removeAll() } } ================================================ FILE: Airship/AirshipCore/Source/ThomasDirection.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasDirection: String, ThomasSerializable { case vertical case horizontal } ================================================ FILE: Airship/AirshipCore/Source/ThomasDisplayListener.swift ================================================ import Foundation /// NOTE: For internal use only. :nodoc: public protocol ThomasLayoutMessageAnalyticsProtocol: AnyObject, Sendable { @MainActor func recordEvent( _ event: any ThomasLayoutEvent, layoutContext: ThomasLayoutContext? ) } /// NOTE: For internal use only. :nodoc: @MainActor public final class ThomasDisplayListener: ThomasDelegate { /// NOTE: For internal use only. :nodoc: public enum DisplayResult: Sendable, Equatable { case cancel case finished } private let analytics: any ThomasLayoutMessageAnalyticsProtocol private var onDismiss: (@MainActor @Sendable (DisplayResult) -> Void)? public init( analytics: any ThomasLayoutMessageAnalyticsProtocol, onDismiss: @escaping @MainActor @Sendable (DisplayResult) -> Void ) { self.analytics = analytics self.onDismiss = onDismiss } public func onVisibilityChanged(isVisible: Bool, isForegrounded: Bool) { if isVisible, isForegrounded { analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) } } public func onReportingEvent(_ event: ThomasReportingEvent) { switch(event) { case .buttonTap(let event, let layoutContext): analytics.recordEvent( ThomasLayoutButtonTapEvent(data: event), layoutContext: layoutContext ) case .formDisplay(let event, let layoutContext): analytics.recordEvent( ThomasLayoutFormDisplayEvent(data: event), layoutContext: layoutContext ) case .formResult(let event, let layoutContext): analytics.recordEvent( ThomasLayoutFormResultEvent(data: event), layoutContext: layoutContext ) case .gesture(let event, let layoutContext): analytics.recordEvent( ThomasLayoutGestureEvent(data: event), layoutContext: layoutContext ) case .pageAction(let event, let layoutContext): analytics.recordEvent( ThomasLayoutPageActionEvent(data: event), layoutContext: layoutContext ) case .pagerCompleted(let event, let layoutContext): analytics.recordEvent( ThomasLayoutPagerCompletedEvent(data: event), layoutContext: layoutContext ) case .pageSwipe(let event, let layoutContext): analytics.recordEvent( ThomasLayoutPageSwipeEvent(data: event), layoutContext: layoutContext ) case .pageView(let event, let layoutContext): analytics.recordEvent( ThomasLayoutPageViewEvent(data: event), layoutContext: layoutContext ) case .pagerSummary(let event, let layoutContext): analytics.recordEvent( ThomasLayoutPagerSummaryEvent(data: event), layoutContext: layoutContext ) case .dismiss(let event, let displayTime, let layoutContext): switch(event) { case .buttonTapped(identifier: let identifier, description: let description): analytics.recordEvent( ThomasLayoutResolutionEvent.buttonTap( identifier: identifier, description: description, displayTime: displayTime ), layoutContext: layoutContext ) case .timedOut: analytics.recordEvent( ThomasLayoutResolutionEvent.timedOut(displayTime: displayTime), layoutContext: layoutContext ) case .userDismissed: analytics.recordEvent( ThomasLayoutResolutionEvent.userDismissed(displayTime: displayTime), layoutContext: layoutContext ) @unknown default: AirshipLogger.error("Unhandled dismiss type event \(event)") analytics.recordEvent( ThomasLayoutResolutionEvent.userDismissed(displayTime: displayTime), layoutContext: layoutContext ) } @unknown default: AirshipLogger.error("Unhandled IAX event \(event)") } } public func onDismissed(cancel: Bool) { self.onDismiss?(cancel ? .cancel : .finished) self.onDismiss = nil } } ================================================ FILE: Airship/AirshipCore/Source/ThomasEmailRegistrationOptions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasEmailRegistrationOption: ThomasSerializable, Hashable { case doubleOptIn(DoubleOptIn) case commercial(Commercial) case transactional(Transactional) struct DoubleOptIn: ThomasSerializable, Hashable { let type: EmailRegistrationType = .doubleOptIn var properties: AirshipJSON? enum CodingKeys: String, CodingKey { case type case properties } } struct Commercial: ThomasSerializable, Hashable { let type: EmailRegistrationType = .commercial var optedIn: Bool var properties: AirshipJSON? enum CodingKeys: String, CodingKey { case type case properties case optedIn = "commercial_opted_in" } } struct Transactional: ThomasSerializable, Hashable { let type: EmailRegistrationType = .transactional var properties: AirshipJSON? enum CodingKeys: String, CodingKey { case type case properties } } enum EmailRegistrationType: String, Codable { case doubleOptIn = "double_opt_in" case commercial = "commercial" case transactional = "transactional" } private enum CodingKeys: String, CodingKey { case type } func encode(to encoder: any Encoder) throws { switch self { case .doubleOptIn(let properties): try properties.encode(to: encoder) case .commercial(let properties): try properties.encode(to: encoder) case .transactional(let properties): try properties.encode(to: encoder) } } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(EmailRegistrationType.self, forKey: .type) switch type { case .doubleOptIn: self = .doubleOptIn( try DoubleOptIn(from: decoder) ) case .commercial: self = .commercial( try Commercial(from: decoder) ) case .transactional: self = .transactional( try Transactional(from: decoder) ) } } } extension ThomasEmailRegistrationOption { func makeContactOptions(date: Date = Date.now) -> EmailRegistrationOptions { switch (self) { case .commercial(let properties): return .commercialOptions( transactionalOptedIn: nil, commercialOptedIn: properties.optedIn ? date : nil, properties: properties.properties?.unWrap() as? [String: Any] ) case .doubleOptIn(let properties): return .options( properties: properties.properties?.unWrap() as? [String: Any], doubleOptIn: true ) case .transactional(let properties): return .options( transactionalOptedIn: nil, properties: properties.properties?.unWrap() as? [String: Any], doubleOptIn: false ) } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasEnableBehavior.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasEnableBehavior: String, ThomasSerializable { case formValidation = "form_validation" case formSubmission = "form_submission" case pagerNext = "pager_next" case pagerPrevious = "pager_previous" } ================================================ FILE: Airship/AirshipCore/Source/ThomasEnvironment.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation #if canImport(UIKit) import UIKit #endif @MainActor class ThomasEnvironment: ObservableObject { private let delegate: any ThomasDelegate private let pagerTracker: ThomasPagerTracker private let timer: any AirshipTimerProtocol private let stateStorage: (any ThomasStateStorage)? let extensions: ThomasExtensions? let imageLoader: AirshipImageLoader private var state: [String: Any] = [:] func retrieveState<T: ThomasStateProvider>(identifier: String, create: () -> T) -> T { let key = "\(identifier):\(T.self)" if let existing = self.state[key] as? T { return existing } if let stored = stateStorage?.retrieve(identifier: identifier, builder: create) { self.state[key] = stored return stored } let new = create() state[key] = new stateStorage?.store(new, identifier: identifier) return new } @Published var isDismissed = false @Published var focusedID: String? = nil private var onDismiss: (() -> Void)? private var dismissHandle: ThomasDismissHandle? private var subscriptions: Set<AnyCancellable> = Set() @Published private(set) var keyboardState: KeyboardState = .hidden @MainActor init( delegate: any ThomasDelegate, extensions: ThomasExtensions?, pagerTracker: ThomasPagerTracker? = nil, timer: (any AirshipTimerProtocol)? = nil, stateStorage: (any ThomasStateStorage)? = nil, dismissHandle: ThomasDismissHandle? = nil, onDismiss: (() -> Void)? = nil ) { self.delegate = delegate self.extensions = extensions self.pagerTracker = pagerTracker ?? ThomasPagerTracker() self.timer = timer ?? AirshipTimer() self.onDismiss = onDismiss self.imageLoader = AirshipImageLoader( imageProvider: extensions?.imageProvider ) self.stateStorage = stateStorage #if !os(tvOS) && !os(watchOS) && !os(macOS) self.subscribeKeyboard() #endif self.dismissHandle = dismissHandle dismissHandle?.addOnDismiss { [weak self] cancel in self?.dismiss(cancel: cancel) } } @MainActor func onVisibilityChanged(isVisible: Bool, isForegrounded: Bool) { if isVisible, isForegrounded { timer.start() } else { timer.stop() } self.delegate.onVisibilityChanged( isVisible: isVisible, isForegrounded: isForegrounded ) } @MainActor func submitForm( result: ThomasFormResult, channels: [ThomasFormField.Channel], attributes: [ThomasFormField.Attribute], layoutState: LayoutState ) { self.delegate.onReportingEvent( .formResult( .init(forms: result.formData), makeLayoutContext(layoutState: layoutState) ) ) applyAttributes(attributes) registerChannels(channels) } private func registerChannels( _ channels: [ThomasFormField.Channel] ) { channels.forEach { channelRegistration in switch(channelRegistration) { case .email(let address, let options): Airship.contact.registerEmail( address, options: options.makeContactOptions() ) case .sms(let address, let options): Airship.contact.registerSMS( address, options: options.makeContactOptions() ) } } } private func applyAttributes( _ attributes: [ThomasFormField.Attribute] ) { guard !attributes.isEmpty else { return } let channelEditor = Airship.channel.editAttributes() let contactEditor = Airship.contact.editAttributes() attributes.forEach { attribute in if let name = attribute.attributeName.channel { channelEditor.set( attributeValue: attribute.attributeValue, attribute: name ) } if let name = attribute.attributeName.contact { contactEditor.set( attributeValue: attribute.attributeValue, attribute: name ) } } channelEditor.apply() contactEditor.apply() } @MainActor func formDisplayed(_ formState: ThomasFormState, layoutState: LayoutState) { self.delegate.onReportingEvent( .formDisplay( .init( identifier: formState.identifier, formType: formState.formTypeString ), makeLayoutContext(layoutState: layoutState) ) ) } @MainActor func buttonTapped( buttonIdentifier: String, reportingMetadata: AirshipJSON?, layoutState: LayoutState ) { self.delegate.onReportingEvent( .buttonTap( .init( identifier: buttonIdentifier, reportingMetadata: reportingMetadata ), makeLayoutContext(layoutState: layoutState) ) ) } @MainActor func pageViewed( pagerState: PagerState, pageInfo: ThomasPageInfo, layoutState: LayoutState ) { let pageViewedEvent = ThomasReportingEvent.PageViewEvent( identifier: pagerState.identifier, pageIdentifier: pageInfo.identifier, pageIndex: pageInfo.index, pageViewCount: pageInfo.viewCount, pageCount: pagerState.reportingPageCount, completed: pagerState.completed ) pagerTracker.onPageView(pageEvent: pageViewedEvent, currentDisplayTime: timer.time) self.delegate.onReportingEvent( .pageView( pageViewedEvent, makeLayoutContext(layoutState: layoutState) ) ) } @MainActor func pagerCompleted( pagerState: PagerState, layoutState: LayoutState ) { self.delegate.onReportingEvent( .pagerCompleted( .init( identifier: pagerState.identifier, pageIndex: pagerState.pageIndex, pageCount: pagerState.reportingPageCount, pageIdentifier: pagerState.currentPageId ?? "" ), makeLayoutContext(layoutState: layoutState) ) ) } @MainActor func dismiss( buttonIdentifier: String, buttonDescription: String, cancel: Bool, layoutState: LayoutState ) { tryDismiss { displayTime in self.delegate.onReportingEvent( .dismiss( .buttonTapped( identifier: buttonIdentifier, description: buttonDescription ), displayTime, makeLayoutContext(layoutState: layoutState) ) ) self.delegate.onDismissed(cancel: cancel) } } @MainActor func dismiss(cancel: Bool = false, layoutState: LayoutState? = nil) { tryDismiss { displayTime in self.delegate.onReportingEvent( .dismiss( .userDismissed, displayTime, makeLayoutContext(layoutState: layoutState) ) ) self.delegate.onDismissed(cancel: cancel) } } @MainActor func timedOut(layoutState: LayoutState? = nil) { tryDismiss { displayTime in self.delegate.onReportingEvent( .dismiss( .timedOut, displayTime, makeLayoutContext(layoutState: layoutState) ) ) self.delegate.onDismissed(cancel: false) } } @MainActor func pageGesture( identifier: String?, reportingMetadata: AirshipJSON?, layoutState: LayoutState ) { if let identifier { self.delegate.onReportingEvent( .gesture( .init( identifier: identifier, reportingMetadata: reportingMetadata ), makeLayoutContext(layoutState: layoutState) ) ) } } @MainActor func pageAutomated( identifier: String?, reportingMetadata: AirshipJSON?, layoutState: LayoutState ) { if let identifier { self.delegate.onReportingEvent( .pageAction( .init( identifier: identifier, reportingMetadata: reportingMetadata ), makeLayoutContext(layoutState: layoutState) ) ) } } @MainActor func pageSwiped( pagerState: PagerState, from: ThomasPageInfo, to: ThomasPageInfo, layoutState: LayoutState ) { self.delegate.onReportingEvent( .pageSwipe( .init( identifier: pagerState.identifier, toPageIndex: to.index, toPageIdentifier: to.identifier, fromPageIndex: from.index, fromPageIdentifier: from.identifier ), makeLayoutContext(layoutState: layoutState) ) ) } @MainActor func onStateChange(_ state: AirshipJSON) { self.delegate.onStateChanged(state) } private func emitPagerSummaryEvents() { pagerTracker.summary.forEach { summary in delegate.onReportingEvent( .pagerSummary( summary, makeLayoutContext(layoutState: nil) ) ) } } @MainActor private func tryDismiss(callback: (TimeInterval) -> Void) { if !self.isDismissed { self.isDismissed = true timer.stop() pagerTracker.stopAll(currentDisplayTime: timer.time) emitPagerSummaryEvents() callback(timer.time) onDismiss?() self.onDismiss = nil } } @MainActor func runActions( _ actionsPayload: ThomasActionsPayload?, layoutState: LayoutState? ) { guard let actionsPayload = actionsPayload?.value else { return } guard let runner = extensions?.actionRunner else { Task { await ActionRunner.run(actionsPayload: actionsPayload, situation: .automation, metadata: [:]) } return } runner.runAsync( actions: actionsPayload, layoutContext: makeLayoutContext(layoutState: layoutState) ) } @MainActor func runAction( _ actionName: String, arguments: ActionArguments, layoutState: LayoutState? ) async -> ActionResult { guard let runner = extensions?.actionRunner else { return await ActionRunner.run(actionName: actionName, arguments: arguments) } return await runner.run( actionName: actionName, arguments: arguments, layoutContext: makeLayoutContext(layoutState: layoutState) ) } private func makeLayoutContext(layoutState: LayoutState?) -> ThomasLayoutContext { var context = ThomasLayoutContext() if let pager = layoutState?.pagerState { context.pager = .init( identifier: pager.identifier, pageIdentifier: pager.currentPageId ?? "", pageIndex: pager.pageIndex, completed: pager.completed, count: pager.reportingPageCount, pageHistory: pagerTracker.viewedPages( pagerIdentifier: pager.identifier ) ) } if let form = layoutState?.formState { context.form = .init( identifier: form.identifier, submitted: form.status == .submitted, type: form.formTypeString, responseType: form.formResponseType ) } if let form = layoutState?.buttonState { context.button = .init( identifier: form.identifier ) } return context } #if !os(tvOS) && !os(watchOS) && !os(macOS) @MainActor private func subscribeKeyboard() { Publishers.Merge3( NotificationCenter.default .publisher(for: UIResponder.keyboardDidShowNotification) .map { _ in return KeyboardState.visible }, NotificationCenter.default .publisher(for: UIResponder.keyboardWillShowNotification) .map { notification in let duration = notification.userInfo?[ UIResponder.keyboardAnimationDurationUserInfoKey ] as? Double return KeyboardState.displaying(duration ?? 0.25) }, NotificationCenter.default .publisher(for: UIResponder.keyboardDidHideNotification) .map { _ in return KeyboardState.hidden } ) .removeDuplicates() .subscribe(on: DispatchQueue.main) .sink { [weak self] value in self?.keyboardState = value } .store(in: &self.subscriptions) } #endif } extension ThomasFormState { fileprivate var formTypeString: String { switch self.formType { case .form: return "form" case .nps(_): return "nps" } } } extension AttributesEditor { fileprivate func set( attributeValue: ThomasAttributeValue, attribute: String ) { switch attributeValue { case .string(let value): self.set(string: value, attribute: attribute) case .number(let value): self.set(double: value, attribute: attribute) } } } enum KeyboardState: Equatable { case hidden case displaying(Double) case visible } ================================================ FILE: Airship/AirshipCore/Source/ThomasEvent.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: for internal use only. :nodoc: public enum ThomasReportingEvent: Sendable { case buttonTap(ButtonTapEvent, ThomasLayoutContext) case formDisplay(FormDisplayEvent, ThomasLayoutContext) case formResult(FormResultEvent, ThomasLayoutContext) case gesture(GestureEvent, ThomasLayoutContext) case pageAction(PageActionEvent, ThomasLayoutContext) case pagerCompleted(PagerCompletedEvent, ThomasLayoutContext) case pageSwipe(PageSwipeEvent, ThomasLayoutContext) case pageView(PageViewEvent, ThomasLayoutContext) case pagerSummary(PagerSummaryEvent, ThomasLayoutContext) case dismiss(DismissEvent, TimeInterval, ThomasLayoutContext) public enum DismissEvent: Sendable { case buttonTapped(identifier: String, description: String) case timedOut case userDismissed } public struct PageViewEvent: Encodable, Sendable { public var identifier: String public var pageIdentifier: String public var pageIndex: Int public var pageViewCount: Int public var pageCount: Int public var completed: Bool public init(identifier: String, pageIdentifier: String, pageIndex: Int, pageViewCount: Int, pageCount: Int, completed: Bool) { self.identifier = identifier self.pageIdentifier = pageIdentifier self.pageIndex = pageIndex self.pageViewCount = pageViewCount self.pageCount = pageCount self.completed = completed } enum CodingKeys: String, CodingKey { case identifier = "pager_identifier" case pageIndex = "page_index" case pageCount = "page_count" case pageViewCount = "viewed_count" case pageIdentifier = "page_identifier" case completed } } public struct PagerCompletedEvent: Encodable, Sendable { public var identifier: String public var pageIndex: Int public var pageCount: Int public var pageIdentifier: String public init(identifier: String, pageIndex: Int, pageCount: Int, pageIdentifier: String) { self.identifier = identifier self.pageIndex = pageIndex self.pageCount = pageCount self.pageIdentifier = pageIdentifier } enum CodingKeys: String, CodingKey { case identifier = "pager_identifier" case pageIndex = "page_index" case pageCount = "page_count" case pageIdentifier = "page_identifier" } } public struct PageSwipeEvent: Encodable, Sendable { public var identifier: String public var toPageIndex: Int public var toPageIdentifier: String public var fromPageIndex: Int public var fromPageIdentifier: String public init(identifier: String, toPageIndex: Int, toPageIdentifier: String, fromPageIndex: Int, fromPageIdentifier: String) { self.identifier = identifier self.toPageIndex = toPageIndex self.toPageIdentifier = toPageIdentifier self.fromPageIndex = fromPageIndex self.fromPageIdentifier = fromPageIdentifier } enum CodingKeys: String, CodingKey { case identifier = "pager_identifier" case toPageIndex = "to_page_index" case toPageIdentifier = "to_page_identifier" case fromPageIndex = "from_page_index" case fromPageIdentifier = "from_page_identifier" } } public struct GestureEvent: Encodable, Sendable { public var identifier: String public var reportingMetadata: AirshipJSON? public init(identifier: String, reportingMetadata: AirshipJSON? = nil) { self.identifier = identifier self.reportingMetadata = reportingMetadata } enum CodingKeys: String, CodingKey { case identifier = "gesture_identifier" case reportingMetadata = "reporting_metadata" } } public struct PageActionEvent: Encodable, Sendable { public var identifier: String public var reportingMetadata: AirshipJSON? public init(identifier: String, reportingMetadata: AirshipJSON? = nil) { self.identifier = identifier self.reportingMetadata = reportingMetadata } enum CodingKeys: String, CodingKey { case identifier = "action_identifier" case reportingMetadata = "reporting_metadata" } } public struct ButtonTapEvent: Encodable, Sendable { public var identifier: String public var reportingMetadata: AirshipJSON? public init(identifier: String, reportingMetadata: AirshipJSON? = nil) { self.identifier = identifier self.reportingMetadata = reportingMetadata } enum CodingKeys: String, CodingKey { case identifier = "button_identifier" case reportingMetadata = "reporting_metadata" } } public struct FormResultEvent: Encodable, Sendable { public var forms: AirshipJSON public init(forms: AirshipJSON) { self.forms = forms } } public struct FormDisplayEvent: Encodable, Sendable { public var identifier: String public var formType: String public var responseType: String? public init(identifier: String, formType: String, responseType: String? = nil) { self.identifier = identifier self.formType = formType self.responseType = responseType } enum CodingKeys: String, CodingKey { case identifier = "form_identifier" case formType = "form_type" case responseType = "form_response_type" } } public struct PagerSummaryEvent: Encodable, Sendable, Equatable, Hashable { public var identifier: String public var viewedPages: [ThomasViewedPageInfo] public var pageCount: Int public var completed: Bool public init( identifier: String, viewedPages: [ThomasViewedPageInfo], pageCount: Int, completed: Bool ) { self.identifier = identifier self.viewedPages = viewedPages self.pageCount = pageCount self.completed = completed } enum CodingKeys: String, CodingKey { case identifier = "pager_identifier" case viewedPages = "viewed_pages" case pageCount = "page_count" case completed = "completed" } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasEventHandler.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasEventHandler: ThomasSerializable { let type: EventType let stateActions: [ThomasStateAction] enum EventType: String, ThomasSerializable { case tap case formInput = "form_input" } private enum CodingKeys: String, CodingKey { case type case stateActions = "state_actions" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormDataCollector.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @MainActor class ThomasFormDataCollector: ObservableObject { private let formState: ThomasFormState? private let pagerState: PagerState? private var subscriptions: Set<AnyCancellable> = Set() init(formState: ThomasFormState? = nil, pagerState: PagerState? = nil) { self.formState = formState self.pagerState = pagerState pagerState?.$currentPageId .removeDuplicates() // Using this over RunLoop.main as it seems to prevent // some unwanted UI jank with form validation enablement .receive(on: DispatchQueue.main) .sink { [weak formState] _ in formState?.dataChanged() } .store(in: &subscriptions) } func with( formState: ThomasFormState? = nil, pagerState: PagerState? = nil ) -> ThomasFormDataCollector { let newFormState = formState ?? self.formState let newPagerState = pagerState ?? self.pagerState if newFormState === self.formState, newPagerState === self.pagerState { return self } return .init( formState: newFormState, pagerState: newPagerState ) } func updateField(_ field: ThomasFormField, pageID: String?) { formState?.updateField(field) { [weak pagerState] in guard let pageID else { return true } guard let pagerState else { return false } let pageIDs = pagerState.pageItems.map { $0.id } // Make sure the page ID is within the current history guard let current = pagerState.currentPageId, let currentIndex = pageIDs.lastIndex(of: current), let lastIndex = pageIDs.lastIndex(of: pageID), lastIndex <= currentIndex else { return false } return true } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormField.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @MainActor final class ThomasFormField: Sendable { struct Result: Equatable, Sendable { var value: Value var channels: [Channel]? = nil var attributes: [Attribute]? = nil } enum Status: Equatable, Sendable { /// The field is valid and passes all validation checks. case valid(Result) /// The field is invalid due to errors or missing information. case invalid /// The field is awaiting validation, meaning it's in a pending state. case pending /// An error occurred while validating the field. case error var isValid: Bool { if case .valid(_) = self { return true } return false } } enum Channel: Sendable, Equatable, Hashable { case email(String, ThomasEmailRegistrationOption) case sms(String, ThomasSMSRegistrationOption) } struct Attribute: Sendable, Equatable, Hashable { let attributeName: ThomasAttributeName let attributeValue: ThomasAttributeValue } enum Value: ThomasSerializable { case toggle(Bool) case radio(AirshipJSON?) case multipleCheckbox(Set<AirshipJSON>) case form(responseType: String?, children: [String: Value]) case npsForm(responseType: String?, scoreID: String, children: [String: Value]) case text(String?) case email(String?) case sms(String?, ThomasSMSLocale?) case score(AirshipJSON?) } private enum FieldType: Sendable { // Immediate result. If nil, its invalid. case just(Result?) // Async result case async(any ThomasFormFieldPendingRequest) } var status: Status { switch(self.fieldType) { case .just(let result): if let result { .valid(result) } else { .invalid } case .async(let pending): pending.result?.status ?? .pending } } func cancel() { switch(self.fieldType) { case .just: break case .async(let operation): operation.cancel() } } private let fieldType: FieldType let identifier: String let input: Value /// Initializes a validator instance. /// - Parameter method: The method used to perform the validation. private init( identifier: String, input: Value, fieldType: FieldType ) { self.identifier = identifier self.input = input self.fieldType = fieldType } static func asyncField( identifier: String, input: Value, processDelay: TimeInterval = 1.0, processor: (any ThomasFormFieldProcessor)? = nil, resultBlock: @escaping @MainActor @Sendable () async throws -> ThomasFormFieldPendingResult ) -> Self { let actualProcessor = processor ?? DefaultThomasFormFieldProcessor() return .init( identifier: identifier, input: input, fieldType: .async( actualProcessor.submit( processDelay: processDelay, resultBlock: resultBlock ) ) ) } static func invalidField( identifier: String, input: Value ) -> Self { return .init( identifier: identifier, input: input, fieldType: .just(nil) ) } static func validField( identifier: String, input: Value, result: Result ) -> Self { return .init( identifier: identifier, input: input, fieldType: .just(result) ) } var statusUpdates: AsyncStream<Status> { switch(self.fieldType) { case .async(let pending): return pending.resultUpdates { $0?.status ?? .pending } case .just: return AsyncStream { continuation in continuation.yield(status) continuation.finish() } } } func process(retryErrors: Bool = true) async { switch(self.fieldType) { case .async(let pending): await pending.process(retryErrors: retryErrors) case .just: break } } } fileprivate extension ThomasFormFieldPendingResult { var status: ThomasFormField.Status { switch (self) { case .valid(let result): .valid(result) case .invalid: .invalid case .error: .error } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormFieldProcessor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasFormFieldPendingResult: Equatable, Sendable { case valid(ThomasFormField.Result) case invalid case error var isError: Bool { if case .error = self { return true } return false } } @MainActor protocol ThomasFormFieldPendingRequest: Sendable { var result: ThomasFormFieldPendingResult? { get } func resultUpdates<T: Sendable>(mapper: @escaping @Sendable (ThomasFormFieldPendingResult?) -> T) -> AsyncStream<T> func process(retryErrors: Bool) async func cancel() } @MainActor protocol ThomasFormFieldProcessor: Sendable { func submit( processDelay: TimeInterval, resultBlock: @escaping @MainActor @Sendable () async throws -> ThomasFormFieldPendingResult ) -> any ThomasFormFieldPendingRequest } final class DefaultThomasFormFieldProcessor: ThomasFormFieldProcessor { private let date: any AirshipDateProtocol private let taskSleeper: any AirshipTaskSleeper init( date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = DefaultAirshipTaskSleeper() ) { self.date = date self.taskSleeper = taskSleeper } @MainActor func submit( processDelay: TimeInterval, resultBlock: @escaping @MainActor @Sendable () async throws -> ThomasFormFieldPendingResult ) -> any ThomasFormFieldPendingRequest { AsyncOperation( date: self.date, taskSleeper: self.taskSleeper, processDelay: processDelay, resultBlock: resultBlock ) } @MainActor final class AsyncOperation: ThomasFormFieldPendingRequest { var result: ThomasFormFieldPendingResult? { self.lastResult } func resultUpdates<T: Sendable>(mapper: @escaping @Sendable (ThomasFormFieldPendingResult?) -> T) -> AsyncStream<T> { return AsyncStream { continuation in let id = UUID().uuidString onResults[id] = { [continuation] result in continuation.yield(mapper(result)) } continuation.yield(mapper(lastResult)) continuation.onTermination = { [weak self] _ in Task { @MainActor in self?.onResults[id] = nil } } } } private var onResults: [String: (ThomasFormFieldPendingResult) -> Void] = [:] private let resultBlock: @MainActor @Sendable () async throws -> ThomasFormFieldPendingResult private let date: any AirshipDateProtocol private let taskSleeper: any AirshipTaskSleeper private var processingTask: Task<Void, Never>? private(set) var lastResult: ThomasFormFieldPendingResult? private var nextBackOff: TimeInterval? = nil private var lastAttempt: Date? private static let initialBackOff: TimeInterval = 3.0 private static let maxBackfOff: TimeInterval = 15.0 /// Initializes an asynchronous validator. /// - Parameters: /// - date: The `AirshipDateProtocol` instance for date handling. /// - taskSleeper: The `AirshipTaskSleeper` instance for sleeping tasks. /// - processBlock: The async block that produces the result. init( date: any AirshipDateProtocol, taskSleeper: any AirshipTaskSleeper, processDelay: TimeInterval, resultBlock: @escaping @MainActor @Sendable () async throws -> ThomasFormFieldPendingResult ) { self.resultBlock = resultBlock self.date = date self.taskSleeper = taskSleeper startProcessing(additionalDelay: processDelay) } func cancel() { self.processingTask?.cancel() self.processingTask = nil } deinit { self.processingTask?.cancel() } func process(retryErrors: Bool) async { guard lastResult == nil || (lastResult?.isError == true && retryErrors == true) else { return } guard let processingTask, !processingTask.isCancelled else { await startProcessing().value return } await processingTask.value } /// Starts the validation process. /// - Returns: The task performing the validation. @discardableResult private func startProcessing(additionalDelay: TimeInterval? = nil) -> Task<Void, Never> { self.lastResult = nil let task: Task<Void, Never> = Task { @MainActor [weak self] in do { if let additionalDelay { try await self?.taskSleeper.sleep(timeInterval: additionalDelay) } try await self?.processBackOff() try Task.checkCancellation() let result = try await self?.resultBlock() try Task.checkCancellation() self?.processResult(result ?? .error) } catch { self?.processResult(.error) } } processingTask = task return task } /// Handles backoff logic if validation fails and a retry is needed. /// - Throws: An error if task is cancelled. private func processBackOff() async throws { guard let nextBackOff, let lastAttempt else { return } let remaining = nextBackOff - date.now.timeIntervalSince(lastAttempt) if (remaining > 0) { try await taskSleeper.sleep(timeInterval: remaining) } } /// Processes the result of a validation, including handling backoff logic. /// - Parameter result: The result of the validation. private func processResult(_ result: ThomasFormFieldPendingResult) { self.lastResult = result self.lastAttempt = self.date.now self.onResults.values.forEach { $0(result) } self.processingTask = nil if case .error = result { self.nextBackOff = if let last = self.nextBackOff { min(last * 2, Self.maxBackfOff) } else { Self.initialBackOff } } else { self.nextBackOff = nil } } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormPayloadGenerator.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @MainActor struct ThomasFormPayloadGenerator { private static let typeKey = "type" private static let valueKey = "value" private static let childrenKey = "children" private static let scoreIDKey = "score_id" private static let responseTypeKey = "response_type" private static let statusKey = "status" private static let resultKey = "result" private static let dataKey = "data" /** * This is using an opaque AirshipJSON instead of structured types so we could expose the value to * the automation framework when it was written in obj-c. Eventually we should use structured types * that are encodable so we can have better type safety. */ static func makeFormStatePayload( status: ThomasFormState.Status, fields: [ThomasFormField], formType: ThomasFormState.FormType ) -> AirshipJSON { let data = AirshipJSON.makeObject { builder in let childData = AirshipJSON.makeObject { builder in fields.forEach { builder.set( json: makeValuePayload($0.input, status: $0.status), key: $0.identifier ) } } builder.set(json: childData, key: Self.childrenKey) switch(formType) { case .nps(let scoreID): builder.set(string: "nps", key: Self.typeKey) builder.set(string: scoreID, key: Self.scoreIDKey) case .form: builder.set(string: "form", key: Self.typeKey) } } return .object( [ Self.dataKey: data, Self.statusKey: makeFormStatusPayload(status) ] ) } static func makeFormEventPayload( identifier: String, formValue: ThomasFormField.Value ) throws -> AirshipJSON { let isForm = switch(formValue) { case .form, .npsForm: true default: false } guard isForm else { throw AirshipErrors.error("Form value should be form or npsForm") } return .object([identifier: makeValuePayload(formValue) ?? .object([:])]) } private static func makeValuePayload( _ value: ThomasFormField.Value, status: ThomasFormField.Status? = nil ) -> AirshipJSON? { switch value { case .toggle(let value): return AirshipJSON.makeObject { builder in builder.set(string: "toggle", key: Self.typeKey) builder.set(bool: value, key: Self.valueKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } } case .radio(let value): return AirshipJSON.makeObject { builder in builder.set(string: "single_choice", key: Self.typeKey) builder.set(json: value, key: Self.valueKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } } case .multipleCheckbox(let value): return AirshipJSON.makeObject { builder in builder.set(string: "multiple_choice", key: Self.typeKey) builder.set(array: Array(value), key: Self.valueKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } } case .text(let value): return AirshipJSON.makeObject { builder in builder.set(string: "text_input", key: Self.typeKey) builder.set(string: value, key: Self.valueKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } } case .email(let value): return AirshipJSON.makeObject { builder in builder.set(string: "email_input", key: Self.typeKey) builder.set(string: value, key: Self.valueKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } } case .sms(let value, _): return AirshipJSON.makeObject { builder in builder.set(string: "sms_input", key: Self.typeKey) builder.set(string: value, key: Self.valueKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } } case .score(let value): return AirshipJSON.makeObject { builder in builder.set(string: "score", key: Self.typeKey) builder.set(json: value, key: Self.valueKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } } case .form(let responseType, let children): return AirshipJSON.makeObject { builder in builder.set(string: "form", key: Self.typeKey) builder.set(string: responseType, key: Self.responseTypeKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } let children = AirshipJSON.makeObject { builder in children.forEach { builder.set(json: Self.makeValuePayload($0.value), key: $0.key) } } builder.set(json: children, key: Self.childrenKey) } case .npsForm(let responseType, let scoreID, let children): return AirshipJSON.makeObject { builder in builder.set(string: "nps", key: Self.typeKey) builder.set(string: responseType, key: Self.responseTypeKey) builder.set(string: scoreID, key: Self.scoreIDKey) if let status { builder.set(json: makeFieldStatusPayload(status), key: Self.statusKey) } let children = AirshipJSON.makeObject { builder in children.forEach { builder.set(json: Self.makeValuePayload($0.value), key: $0.key) } } builder.set(json: children, key: Self.childrenKey) } } } private static func makeFieldStatusPayload(_ status: ThomasFormField.Status) -> AirshipJSON { AirshipJSON.makeObject { builder in switch(status) { case .valid(let result): builder.set(string: "valid", key: Self.typeKey) builder.set(json: makeValuePayload(result.value), key: Self.resultKey) case .invalid: builder.set(string: "invalid", key: Self.typeKey) case .pending: builder.set(string: "pending", key: Self.typeKey) case .error: builder.set(string: "error", key: Self.typeKey) } } } private static func makeFormStatusPayload(_ status: ThomasFormState.Status) -> AirshipJSON { AirshipJSON.makeObject { builder in builder.set(string: status.rawValue, key: Self.typeKey) } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormResult.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public struct ThomasFormResult: Sendable, Hashable, Equatable { public var identifier: String public var formData: AirshipJSON public init(identifier: String, formData: AirshipJSON) { self.identifier = identifier self.formData = formData } } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @MainActor class ThomasFormState: ObservableObject { /// Represents the possible statuses of a form during its lifecycle. enum Status: String, ThomasSerializable, Hashable { /// The form is valid and all its fields are correctly filled out. case valid /// The form is invalid, possibly due to incorrect or missing information. case invalid /// An error occurred during form validation or submission. case error /// The form is currently being validated. case validating /// The form is awaiting validation to be processed. case pendingValidation = "pending_validation" /// The form has been submitted. case submitted } enum FormType: Sendable, Equatable { case nps(String) case form } @MainActor private struct Child { var field: ThomasFormField var watchTask: Task<Void, Never> var predicate: (@MainActor @Sendable () -> Bool)? } // Minimum time to wait when doing async validation if // all the form results are yet to be ready onValidate private static let minAsyncValidationTime: TimeInterval = 1.0 @Published private(set) var status: Status { didSet { updateFormInputEnabled( isParentEnabled: self.parentFormState?.isFormInputEnabled ) } } @Published private(set) var activeFields: [String: ThomasFormField] = [:] @Published private(set) var isVisible: Bool = false @Published private(set) var isFormInputEnabled: Bool = true @Published var isEnabled: Bool = true { didSet { updateFormInputEnabled( isParentEnabled: self.parentFormState?.isFormInputEnabled ) } } // On submit block var onSubmit: (@Sendable @MainActor (String, ThomasFormField.Result, LayoutState) throws -> Void)? let identifier: String let formType: FormType let formResponseType: String? let validationMode: ThomasFormValidationMode private var children: [Child] = [] private var subscriptions: Set<AnyCancellable> = Set() private var processTask: Task<Bool, Never>? private var lastChildStatus: [String: ThomasFormField.Status] = [:] private var parentFormState: ThomasFormState? private var initialValues: [String: ThomasFormField.Value] = [:] init( identifier: String, formType: FormType, formResponseType: String?, validationMode: ThomasFormValidationMode, parentFormState: ThomasFormState? = nil ) { self.identifier = identifier self.formType = formType self.formResponseType = formResponseType self.validationMode = validationMode self.parentFormState = parentFormState self.status = if validationMode == .immediate { .invalid } else { .pendingValidation } parentFormState?.$isFormInputEnabled.sink { [weak self] parentEnabled in self?.updateFormInputEnabled(isParentEnabled: parentEnabled) }.store(in: &subscriptions) } func field(identifier: String) -> ThomasFormField? { return self.children.first { child in child.field.identifier == identifier }?.field } func fieldValue(identifier: String) -> ThomasFormField.Value? { if let child = field(identifier: identifier)?.input { return child } return initialValues[identifier] } func lastFieldStatus(identifier: String) -> ThomasFormField.Status? { return self.lastChildStatus[identifier] } func markVisible() { guard !self.isVisible else { return } parentFormState?.markVisible() self.isVisible = true } func validate() async -> Bool { return await self.processChildren(children: self.filteredChildren()) } func submit(layoutState: LayoutState) async throws { guard self.status != .submitted else { throw AirshipErrors.error("Form already submitted") } guard let onSubmit else { throw AirshipErrors.error("onSubmit block missing") } // Grab a snapshot of the children since this is a async // task so data might change while we process and submit let children = self.filteredChildren() guard await self.processChildren(children: children) else { throw AirshipErrors.error("Form not valid") } var attributesResult: [ThomasFormField.Attribute] = [] var channelsResult: [ThomasFormField.Channel] = [] var resultMap: [String: ThomasFormField.Value] = [:] try children.forEach { if case let .valid(result) = $0.field.status { resultMap[$0.field.identifier] = result.value if let channels = result.channels { channelsResult.append(contentsOf: channels) } if let attributes = result.attributes { attributesResult.append(contentsOf: attributes) } } else { throw AirshipErrors.error("Form is not valid") } } guard !resultMap.isEmpty else { throw AirshipErrors.error("Form has no data") } let formResult = ThomasFormField.Result( value: self.formType.makeField( responseType: self.formResponseType, children: resultMap ), channels: channelsResult, attributes: attributesResult ) try onSubmit(self.identifier, formResult, layoutState) updateStatus(.submitted) } func dataChanged() { guard self.status != .submitted else { return } let children = self.filteredChildren() self.updateActiveFields(children: children) switch(self.status) { case .error, .valid, .invalid, .pendingValidation: let childStatuses = children.compactMap { lastChildStatus[$0.field.identifier] } switch (self.validationMode) { case .onDemand: // On demand we want to leave the child in an invalid or error state // until the next validation request. if childStatuses.contains(.invalid) { self.updateStatus(.invalid) } else if status == .error, !childStatuses.contains(.error) { self.updateStatus(.pendingValidation) } else if childStatuses.contains(.pending) { self.updateStatus(.pendingValidation) } else if status != .pendingValidation { if childStatuses.contains(.error) { self.updateStatus(.error) } else { self.updateStatus(.valid) } } case .immediate: // Immediate we want to go to pending if any pending since // and schedule a task to validate if childStatuses.contains(.pending) { self.updateStatus(.pendingValidation) } else if childStatuses.contains(.invalid) { self.updateStatus(.invalid) } else if status == .error, !childStatuses.contains(.error) { self.updateStatus(.pendingValidation) } else if status != .pendingValidation { if childStatuses.contains(.error) { self.updateStatus(.error) } else { self.updateStatus(.valid) } } } case .submitted, .validating: break } guard validationMode == .immediate else { return } if status != .valid, status != .invalid { Task { [weak self] in await self?.validate() } } } func updateField( _ field: ThomasFormField, predicate: (@Sendable @MainActor () -> Bool)? = nil ) { guard self.status != .submitted else { return } self.processTask?.cancel() if self.status == .validating { updateStatus(.pendingValidation) } // Skip updating the field to pending if its invalid and the incoming data // is invalid. if field.status != .invalid || lastChildStatus[field.identifier] != .invalid { lastChildStatus[field.identifier] = .pending } self.children.removeAll { child in if child.field.identifier == field.identifier { child.watchTask.cancel() child.field.cancel() return true } return false } if self.activeFields[field.identifier] != nil { self.activeFields[field.identifier] = field } self.children.append( Child( field: field, watchTask: Task { [weak self, field] in for await _ in field.statusUpdates { guard !Task.isCancelled else { return } self?.updateActiveFields() } }, predicate: predicate ) ) self.dataChanged() } private func processChildren(children: [Child]) async -> Bool { guard self.status != .submitted else { return false } self.processTask?.cancel() let needsAsync = children.contains { child in child.field.status == .error || child.field.status == .pending } await Task.yield() // Yield to allow the UI to update before processing updateStatus(.validating) let task = Task { [weak self] in guard needsAsync else { return self?.processingFinished(children: children) ?? false } let start = AirshipDate.shared.now await withTaskGroup(of: Void.self) { group in for child in children { group.addTask { await child.field.process(retryErrors: true) } } await group.waitForAll() } // Make sure it took the min time to avoid UI glitches let end = AirshipDate.shared.now let remaining = Self.minAsyncValidationTime - end.timeIntervalSince(start) if (remaining > 0) { try? await DefaultAirshipTaskSleeper.shared.sleep(timeInterval: remaining) } guard !Task.isCancelled else { return false } return self?.processingFinished(children: children) ?? false } self.processTask = task return await task.value } private func updateStatus(_ status: Status) { guard self.status != .submitted, self.status != status else { return } AirshipLogger.trace("Updating status \(self.status) => \(status)") self.status = status } private func updateActiveFields(children: [Child]) { let currentKeys: Set<String> = Set(self.activeFields.values.map { $0.identifier }) let incomingKeys: Set<String> = Set(children.map { $0.field.identifier }) guard currentKeys != incomingKeys else { return } self.activeFields = Dictionary( uniqueKeysWithValues: children.map { ($0.field.identifier, $0.field) } ) } private func updateActiveFields() { self.updateActiveFields(children: self.filteredChildren()) } private func filteredChildren() -> [Child] { return self.children.filter { value in value.predicate?() ?? true } } private func processingFinished(children: [Child]) -> Bool { defer { self.dataChanged() } var containsError: Bool = false var containsInvalid: Bool = false var containsValid: Bool = false for child in children { let status = child.field.status if status == .error { containsError = true } if status == .invalid { containsInvalid = true } if status.isValid { containsValid = true } lastChildStatus[child.field.identifier] = status } if containsInvalid { updateStatus(.invalid) return false } else if containsError { updateStatus(.error) // If we are in immediate validation mode and we hit an error we need // to retry right away since the submit button to update wont be available // to retrigger a retry. if validationMode == .immediate { Task { [weak self] in _ = await self?.validate() } } return false } else if containsValid { updateStatus(.valid) return true } else { updateStatus(.invalid) return false } } private func updateFormInputEnabled(isParentEnabled: Bool?) { // Check if we are in a state that allows editing // inputs. let statusCheck = switch(status) { case .valid, .invalid, .error, .pendingValidation: true case .submitted: false case .validating: validationMode == .immediate } guard self.isEnabled, statusCheck, isParentEnabled ?? true else { if self.isFormInputEnabled { self.isFormInputEnabled = false } return } if !self.isFormInputEnabled { self.isFormInputEnabled = true } } } fileprivate extension ThomasFormState.FormType { @MainActor func makeField( responseType: String?, children: [String: ThomasFormField.Value] ) -> ThomasFormField.Value { return switch(self) { case .form: .form(responseType: responseType, children: children) case .nps(let scoreID): .npsForm(responseType: responseType, scoreID: scoreID, children: children) } } } //MARK: - ThomasStateProvider extension ThomasFormState: ThomasStateProvider { typealias SnapshotType = Snapshot struct Snapshot: Codable, Equatable { let initialValues: [String: ThomasFormField.Value] let isVisible: Bool let isFormInputEnabled: Bool let status: Status } var updates: AnyPublisher<any Codable, Never> { return Publishers .CombineLatest4($activeFields, $isVisible, $isFormInputEnabled, $status) .map { activeFields, isVisible, isFormInputEnabled, status in Snapshot( initialValues: activeFields.mapValues(\.input), isVisible: isVisible, isFormInputEnabled: isFormInputEnabled, status: status ) } .removeDuplicates() .map(\.self) .eraseToAnyPublisher() } func restorePersistentState(_ state: Snapshot) { self.initialValues = state.initialValues self.isVisible = state.isVisible self.isFormInputEnabled = state.isFormInputEnabled self.status = state.status } func persistentStateSnapshot() -> SnapshotType { return Snapshot( initialValues: activeFields.mapValues(\.input), isVisible: isVisible, isFormInputEnabled: isFormInputEnabled, status: status ) } } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormStatus.swift ================================================ /* Copyright Airship and Contributors */ import Foundation ================================================ FILE: Airship/AirshipCore/Source/ThomasFormSubmitBehavior.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasFormSubmitBehavior: String, ThomasSerializable { case submitEvent = "submit_event" } ================================================ FILE: Airship/AirshipCore/Source/ThomasFormValidationMode.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Represents the validation modes for a form. enum ThomasFormValidationMode: ThomasSerializable { /// The form is validated only when a `ThomasButtonClickBehavior.formSubmit` /// or `ThomasButtonClickBehavior.formValidate` is triggered. case onDemand /// The form is validated immediately after any changes are made. case immediate private enum CodingKeys: String, CodingKey { case type } private enum ValidationType: String, Codable { case onDemand = "on_demand" case immediate } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type: ValidationType = try container.decode(ValidationType.self, forKey: .type) self = switch(type) { case .onDemand: .onDemand case .immediate: .immediate } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch (self) { case .onDemand: try container.encode(ValidationType.onDemand, forKey: .type) case .immediate: try container.encode(ValidationType.immediate, forKey: .type) } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasIcon.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasIconInfo: ThomasSerializable { let type: String = "icon" var icon: Icon var color: ThomasColor var scale: Double? enum Icon: String, ThomasSerializable { case close case checkmark case forwardArrow = "forward_arrow" case backArrow = "back_arrow" case exclamationmarkCircleFill = "exclamationmark_circle_fill" case progressSpinner = "progress_spinner" case asterisk case asteriskCicleFill = "asterisk_circle_fill" case star = "star" case starFill = "star_fill" case heart = "heart" case heartFill = "heart_fill" case chevronBackward = "chevron_backward" case chevronForward = "chevron_forward" case pause case play case mute case unmute } enum CodingKeys: String, CodingKey { case icon case color case scale case type } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutButtonTapEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutButtonTapEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppButtonTap public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.ButtonTapEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutContext.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// - Note: for internal use only. :nodoc: public struct ThomasLayoutContext: Encodable, Equatable, Sendable { public struct Pager: Encodable, Equatable, Sendable { public var identifier: String public var pageIdentifier: String public var pageIndex: Int public var completed: Bool public var count: Int public var pageHistory: [ThomasViewedPageInfo] = [] public init( identifier: String, pageIdentifier: String, pageIndex: Int, completed: Bool, count: Int, pageHistory: [ThomasViewedPageInfo] = [] ) { self.identifier = identifier self.pageIdentifier = pageIdentifier self.pageIndex = pageIndex self.completed = completed self.count = count self.pageHistory = pageHistory } enum CodingKeys: String, CodingKey { case identifier case pageIdentifier = "page_identifier" case pageIndex = "page_index" case completed case count case pageHistory = "page_history" } } public struct Form: Encodable, Equatable, Sendable { public var identifier: String public var submitted: Bool public var type: String public var responseType: String? public init(identifier: String, submitted: Bool, type: String, responseType: String? = nil) { self.identifier = identifier self.submitted = submitted self.type = type self.responseType = responseType } enum CodingKeys: String, CodingKey { case identifier case submitted case type case responseType = "response_type" } } public struct Button: Encodable, Equatable, Sendable { public var identifier: String public init(identifier: String) { self.identifier = identifier } enum CodingKeys: String, CodingKey { case identifier } } public var pager: Pager? public var button: Button? public var form: Form? public init(pager: Pager? = nil, button: Button? = nil, form: Form? = nil) { self.pager = pager self.button = button self.form = form } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutDisplayEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct ThomasLayoutDisplayEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppDisplay public let data: (any Sendable & Encodable)? = nil public init() {} } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public protocol ThomasLayoutEvent: Sendable { var name: EventType { get } var data: (any Sendable&Encodable)? { get } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutEventContext.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct ThomasLayoutEventContext: Encodable, Equatable, Sendable { public struct Display: Encodable, Equatable, Sendable { public var triggerSessionID: String public var isFirstDisplay: Bool public var isFirstDisplayTriggerSessionID: Bool public init(triggerSessionID: String, isFirstDisplay: Bool, isFirstDisplayTriggerSessionID: Bool) { self.triggerSessionID = triggerSessionID self.isFirstDisplay = isFirstDisplay self.isFirstDisplayTriggerSessionID = isFirstDisplayTriggerSessionID } enum CodingKeys: String, CodingKey { case triggerSessionID = "trigger_session_id" case isFirstDisplay = "is_first_display" case isFirstDisplayTriggerSessionID = "is_first_display_trigger_session" } } enum CodingKeys: String, CodingKey { case pager case button case form case reportingContext = "reporting_context" case experimentsReportingData = "experiments" case display } var pager: ThomasLayoutContext.Pager? var button: ThomasLayoutContext.Button? var form: ThomasLayoutContext.Form? var display: Display? var reportingContext: AirshipJSON? var experimentsReportingData: [AirshipJSON]? } public extension ThomasLayoutEventContext { static func makeContext( reportingContext: AirshipJSON?, experimentsResult: ExperimentResult?, layoutContext: ThomasLayoutContext?, displayContext: ThomasLayoutEventContext.Display? ) -> ThomasLayoutEventContext? { let pager = layoutContext?.pager let button = layoutContext?.button let form = layoutContext?.form let reportingContext = reportingContext let experimentsReportingData = experimentsResult?.reportingMetadata guard pager == nil, button == nil, form == nil, reportingContext == nil, experimentsReportingData?.isEmpty != false, displayContext == nil else { return ThomasLayoutEventContext( pager: pager, button: button, form: form, display: displayContext, reportingContext: reportingContext, experimentsReportingData: experimentsReportingData ) } return nil } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutEventMessageID.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public enum ThomasLayoutEventMessageID: Encodable, Equatable, Sendable { case legacy(identifier: String) case airship(identifier: String, campaigns: AirshipJSON?) case appDefined(identifier: String) enum CodingKeys: String, CodingKey { case messageID = "message_id" case campaigns } public func encode(to encoder: any Encoder) throws { switch self { case .legacy(identifier: let identifier): var container = encoder.singleValueContainer() try container.encode(identifier) case .airship(identifier: let identifier, campaigns: let campaigns): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(identifier, forKey: .messageID) try container.encodeIfPresent(campaigns, forKey: .campaigns) case .appDefined(identifier: let identifier): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(identifier, forKey: .messageID) } } public var identifier: String { switch self { case .legacy(let identifier): return identifier case .airship(let identifier, _): return identifier case .appDefined(let identifier): return identifier } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutEventRecorder.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct ThomasLayoutEventData { let event: any ThomasLayoutEvent let context: ThomasLayoutEventContext? let source: ThomasLayoutEventSource let messageID: ThomasLayoutEventMessageID let renderedLocale: AirshipJSON? public init( event: any ThomasLayoutEvent, context: ThomasLayoutEventContext?, source: ThomasLayoutEventSource, messageID: ThomasLayoutEventMessageID, renderedLocale: AirshipJSON? ) { self.event = event self.context = context self.source = source self.messageID = messageID self.renderedLocale = renderedLocale } } /// NOTE: For internal use only. :nodoc: public protocol ThomasLayoutEventRecorderProtocol: Sendable { func recordEvent(inAppEventData: ThomasLayoutEventData) func recordImpressionEvent(_ event: AirshipMeteredUsageEvent) } /// NOTE: For internal use only. :nodoc: public struct ThomasLayoutEventRecorder: ThomasLayoutEventRecorderProtocol { private let airshipAnalytics: any InternalAirshipAnalytics private let meteredUsage: any AirshipMeteredUsage public init(airshipAnalytics: any InternalAirshipAnalytics, meteredUsage: any AirshipMeteredUsage) { self.airshipAnalytics = airshipAnalytics self.meteredUsage = meteredUsage } public func recordEvent(inAppEventData: ThomasLayoutEventData) { let eventBody = EventBody( identifier: inAppEventData.messageID, source: inAppEventData.source, context: inAppEventData.context, conversionSendID: airshipAnalytics.conversionSendID, conversionPushMetadata: airshipAnalytics.conversionPushMetadata, renderedLocale: inAppEventData.renderedLocale, baseData: inAppEventData.event.data ) do { airshipAnalytics.recordEvent( AirshipEvent( eventType: inAppEventData.event.name, eventData: try AirshipJSON.wrap(eventBody) ) ) } catch { AirshipLogger.error("Failed to add event \(inAppEventData) error \(error)") } } public func recordImpressionEvent(_ event: AirshipMeteredUsageEvent) { Task { [meteredUsage] in do { try await meteredUsage.addEvent(event) } catch { AirshipLogger.error("Failed to record impression: \(error)") } } } } fileprivate struct EventBody: Encodable, Sendable { var identifier: ThomasLayoutEventMessageID var source: ThomasLayoutEventSource var context: ThomasLayoutEventContext? var conversionSendID: String? var conversionPushMetadata: String? var renderedLocale: AirshipJSON? var baseData: (any Encodable&Sendable)? enum CodingKeys: String, CodingKey { case identifier = "id" case source case context case conversionSendID = "conversion_send_id" case conversionPushMetadata = "conversion_metadata" case renderedLocale = "rendered_locale" case deviceInfo = "device" } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: Self.CodingKeys.self) try container.encode(self.identifier, forKey: Self.CodingKeys.identifier) try container.encode(self.source, forKey: Self.CodingKeys.source) try container.encodeIfPresent(self.context, forKey: Self.CodingKeys.context) try container.encodeIfPresent(self.conversionSendID, forKey: Self.CodingKeys.conversionSendID) try container.encodeIfPresent(self.conversionPushMetadata, forKey: Self.CodingKeys.conversionPushMetadata) try container.encodeIfPresent(self.renderedLocale, forKey: Self.CodingKeys.renderedLocale) try self.baseData?.encode(to: encoder) } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutEventSource.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public enum ThomasLayoutEventSource: String, Encodable, Sendable { case airship = "urban-airship" case appDefined = "app-defined" } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutFormDisplayEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutFormDisplayEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppFormDisplay public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.FormDisplayEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutFormResultEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutFormResultEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppFormResult public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.FormResultEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutGestureEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutGestureEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppGesture public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.GestureEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutPageActionEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutPageActionEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppPageAction public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.PageActionEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutPageSwipeEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutPageSwipeEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppPageSwipe public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.PageSwipeEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutPageViewEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutPageViewEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppPageView public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.PageViewEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutPagerCompletedEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutPagerCompletedEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppPagerCompleted public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.PagerCompletedEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutPagerSummaryEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasLayoutPagerSummaryEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppPagerSummary public let data: (any Sendable & Encodable)? public init(data: ThomasReportingEvent.PagerSummaryEvent) { self.data = data } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutPermissionResultEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// NOTE: For internal use only. :nodoc: public struct ThomasLayoutPermissionResultEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppPermissionResult public let data: (any Sendable & Encodable)? public init( permission: AirshipPermission, startingStatus: AirshipPermissionStatus, endingStatus: AirshipPermissionStatus ) { self.data = PermissionResultData( permission: permission, startingStatus: startingStatus, endingStatus: endingStatus ) } private struct PermissionResultData: Encodable, Sendable { var permission: AirshipPermission var startingStatus: AirshipPermissionStatus var endingStatus: AirshipPermissionStatus enum CodingKeys: String, CodingKey { case permission case startingStatus = "starting_permission_status" case endingStatus = "ending_permission_status" } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasLayoutResolutionEvent.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// NOTE: For internal use only. :nodoc: public struct ThomasLayoutResolutionEvent: ThomasLayoutEvent { public let name: EventType = EventType.inAppResolution public let data: (any Sendable & Encodable)? private init(data: any Sendable & Encodable) { self.data = data } public static func buttonTap( identifier: String, description: String, displayTime: TimeInterval ) -> ThomasLayoutResolutionEvent { return ThomasLayoutResolutionEvent( data: ResolutionData( resolutionType: .buttonTap( identifier: identifier, description: description ), displayTime: displayTime ) ) } public static func messageTap(displayTime: TimeInterval) -> ThomasLayoutResolutionEvent { return ThomasLayoutResolutionEvent( data: ResolutionData( resolutionType: .messageTap, displayTime: displayTime ) ) } public static func userDismissed(displayTime: TimeInterval) -> ThomasLayoutResolutionEvent { return ThomasLayoutResolutionEvent( data: ResolutionData( resolutionType: .userDismissed, displayTime: displayTime ) ) } public static func timedOut(displayTime: TimeInterval) -> ThomasLayoutResolutionEvent { return ThomasLayoutResolutionEvent( data: ResolutionData( resolutionType: .timedOut, displayTime: displayTime ) ) } public static func interrupted() -> ThomasLayoutResolutionEvent { return ThomasLayoutResolutionEvent( data: ResolutionData( resolutionType: .interrupted, displayTime: 0.0 ) ) } public static func control( experimentResult: ExperimentResult ) -> ThomasLayoutResolutionEvent { return ThomasLayoutResolutionEvent( data: ResolutionData( resolutionType: .control, displayTime: 0.0, device: DeviceInfo( channel: experimentResult.channelID, contact: experimentResult.contactID ) ) ) } public static func audienceExcluded() -> ThomasLayoutResolutionEvent { return ThomasLayoutResolutionEvent( data: ResolutionData( resolutionType: .audienceCheckExcluded, displayTime: 0.0 ) ) } private struct DeviceInfo: Encodable, Sendable { var channel: String? var contact: String? enum CodingKeys: String, CodingKey { case channel = "channel_id" case contact = "contact_id" } } private struct ResolutionData: Encodable, Sendable { enum ResolutionType { case buttonTap(identifier: String, description: String) case messageTap case userDismissed case timedOut case interrupted case control case audienceCheckExcluded } let resolutionType: ResolutionType let displayTime: TimeInterval var device: DeviceInfo? enum CodingKeys: String, CodingKey { case resolutionType = "type" case displayTime = "display_time" case buttonID = "button_id" case buttonDescription = "button_description" } enum ContainerCodingKeys: String, CodingKey { case resolution = "resolution" case device = "device" } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: ContainerCodingKeys.self) var resolution = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .resolution) try resolution.encode( String(format: "%.2f", displayTime), forKey: .displayTime ) try container.encodeIfPresent(self.device, forKey: .device) switch (self.resolutionType) { case .buttonTap(let identifier, let description): try resolution.encode("button_click", forKey: .resolutionType) try resolution.encode(identifier, forKey: .buttonID) try resolution.encode(description, forKey: .buttonDescription) case .messageTap: try resolution.encode("message_click", forKey: .resolutionType) case .userDismissed: try resolution.encode("user_dismissed", forKey: .resolutionType) case .timedOut: try resolution.encode("timed_out", forKey: .resolutionType) case .interrupted: try resolution.encode("interrupted", forKey: .resolutionType) case .control: try resolution.encode("control", forKey: .resolutionType) case .audienceCheckExcluded: try resolution.encode("audience_check_excluded", forKey: .resolutionType) } } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasMargin.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasMargin: ThomasSerializable { var top: CGFloat? var bottom: CGFloat? var start: CGFloat? var end: CGFloat? } ================================================ FILE: Airship/AirshipCore/Source/ThomasMarkdownOptions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasMarkDownOptions: ThomasSerializable { var disabled: Bool? var appearance: Appearance? struct Appearance: ThomasSerializable { var anchor: Anchor? var highlight: Highlight? struct Highlight: ThomasSerializable { var color: ThomasColor? var cornerRadius: Double? enum CodingKeys: String, CodingKey { case color case cornerRadius = "corner_radius" } } struct Anchor: ThomasSerializable { var color: ThomasColor? // Currently we only support underlined styles var styles: [ThomasTextAppearance.TextStyle]? } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasMediaFit.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasMediaFit: String, ThomasSerializable { case center case fitCrop = "fit_crop" case centerInside = "center_inside" case centerCrop = "center_crop" } ================================================ FILE: Airship/AirshipCore/Source/ThomasOrientation.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasOrientation: String, ThomasSerializable { case portrait case landscape } ================================================ FILE: Airship/AirshipCore/Source/ThomasPagerControllerBranching.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /** * Pager branching directives. These control the branching behavior of the * `PagerController`. */ struct ThomasPagerControllerBranching: ThomasSerializable { /** * Determines when a pager is completed, since we can not rely on the last * page meaning "completed" in branching. The given `PagerCompletions` are * evaluated to determine that completion. Evaluated in order, first match * wins. */ let completions: [ThomasPageControllerCompletion] enum CodingKeys: String, CodingKey { case completions = "pager_completions" } } /** * Pager completion directives; used to determine when a pager has been * completed, and optional actions to take upon completion. */ struct ThomasPageControllerCompletion: ThomasSerializable { /** * Predicate to match when evaluating completion. If not provided, it is an * implicit match. */ let predicate: JSONPredicate? /** * State actions to run when the pager completes. */ let stateActions: [ThomasStateAction]? enum CodingKeys: String, CodingKey { case predicate = "when_state_matches" case stateActions = "state_actions" } } /** * Page branching directives, used to evaluate page behavior when the page's * parent controller has branching enabled. */ struct ThomasPageBranching: ThomasSerializable { /** * Controls which page should be used as the next page; only evaluated when * the `PagerController` is configured for branching logic. Predicates are * evaluated in order, and the first matching predicate is used. If no * predicates are matched, or if this directive is not present, proceeding to * the next page is blocked. */ let nextPage: [ThomasNextPageSelector]? enum CodingKeys: String, CodingKey { case nextPage = "next_page" } private enum NextPageSelectorKeys: String, CodingKey { case selectors } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let nextPageContainer = try container.nestedContainer( keyedBy: NextPageSelectorKeys.self, forKey: .nextPage) self.nextPage = try nextPageContainer.decodeIfPresent([ThomasNextPageSelector].self, forKey: .selectors) } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) var nextPageContainer = container.nestedContainer(keyedBy: NextPageSelectorKeys.self, forKey: .nextPage) try nextPageContainer.encode(self.nextPage, forKey: .selectors) } } struct ThomasNextPageSelector: ThomasSerializable { /** * Predicate which is matched for the given `page_id`. When `undefined`, it is * an implicit match. */ let predicate: JSONPredicate? /** * ID of the page to be used as the next page. */ let pageId: String enum CodingKeys: String, CodingKey { case predicate = "when_state_matches" case pageId = "page_id" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasPagerTracker.swift ================================================ import Foundation @MainActor final class ThomasPagerTracker { // Map of pager ID to trackers private var trackers: [String: Tracker] = [:] private var lastPagerPageEvent: [String: ThomasReportingEvent.PageViewEvent] = [:] func onPageView( pageEvent: ThomasReportingEvent.PageViewEvent, currentDisplayTime: TimeInterval ) { if trackers[pageEvent.identifier] == nil { trackers[pageEvent.identifier] = Tracker() } let page = Page( identifier: pageEvent.pageIdentifier, index: pageEvent.pageIndex ) trackers[pageEvent.identifier]?.start( page: page, currentDisplayTime: currentDisplayTime ) lastPagerPageEvent[pageEvent.identifier] = pageEvent } func stopAll(currentDisplayTime: TimeInterval) { self.trackers.values.forEach { $0.stop(currentDisplayTime: currentDisplayTime) } } func viewedPages(pagerIdentifier: String) -> [ThomasViewedPageInfo] { return trackers[pagerIdentifier]?.viewed ?? [] } var summary: Set<ThomasReportingEvent.PagerSummaryEvent> { let summary = lastPagerPageEvent.map { id, event in ThomasReportingEvent.PagerSummaryEvent( identifier: id, viewedPages: trackers[id]?.viewed ?? [], pageCount: event.pageCount, completed: event.completed ) } return Set(summary) } @MainActor fileprivate final class Tracker { private var currentPage: Page? var viewed: [ThomasViewedPageInfo] = [] private var startTime: TimeInterval? func start(page: Page, currentDisplayTime: TimeInterval) { guard currentPage != page else { return } stop(currentDisplayTime: currentDisplayTime) self.currentPage = page self.startTime = currentDisplayTime } func stop(currentDisplayTime: TimeInterval) { guard let startTime, let currentPage else { return } viewed.append( ThomasViewedPageInfo( identifier: currentPage.identifier, index: currentPage.index, displayTime: currentDisplayTime - startTime ) ) self.startTime = nil self.currentPage = nil } } fileprivate struct Page: Equatable { let identifier: String let index: Int } } ================================================ FILE: Airship/AirshipCore/Source/ThomasPlatform.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI enum ThomasPlatform: String, ThomasSerializable { case android case ios case web } ================================================ FILE: Airship/AirshipCore/Source/ThomasPosition.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct ThomasPosition: ThomasSerializable { var horizontal: Horizontal var vertical: Vertical enum Horizontal: String, ThomasSerializable { case center case start case end } enum Vertical: String, ThomasSerializable { case center case top case bottom } } extension ThomasPosition { var alignment: Alignment { Alignment(horizontal: horizontal.alignment, vertical: vertical.alignment) } } extension ThomasPosition.Vertical { var alignment: VerticalAlignment { switch self { case .top: return VerticalAlignment.top case .center: return VerticalAlignment.center case .bottom: return VerticalAlignment.bottom } } } extension ThomasPosition.Horizontal { var alignment: HorizontalAlignment { switch self { case .start: return HorizontalAlignment.leading case .center: return HorizontalAlignment.center case .end: return HorizontalAlignment.trailing } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasPresentationInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasPresentationInfo: ThomasSerializable { case banner(Banner) case modal(Modal) case embedded(Embedded) enum CodingKeys: String, CodingKey { case type } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(PresentationType.self, forKey: .type) self = switch type { case .banner: .banner(try Banner(from: decoder)) case .modal: .modal(try Modal(from: decoder)) case .embedded: .embedded(try Embedded(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .banner(let info): try info.encode(to: encoder) case .modal(let info): try info.encode(to: encoder) case .embedded(let info): try info.encode(to: encoder) } } enum PresentationType: String, ThomasSerializable { case modal case banner case embedded } struct Device: ThomasSerializable { let orientationLock: ThomasOrientation? private enum CodingKeys: String, CodingKey { case orientationLock = "lock_orientation" } } /// Keyboard avoidance methods enum KeyboardAvoidanceMethod: String, ThomasSerializable { /// Slide keyboard over the top case overTheTop = "over_the_top" /// Treat it as safe area case safeArea = "safe_area" } struct iOS: ThomasSerializable { var keyboardAvoidance: KeyboardAvoidanceMethod? private enum CodingKeys: String, CodingKey { case keyboardAvoidance = "keyboard_avoidance" } } struct Banner: ThomasSerializable { let type: PresentationType = .banner var duration: Int? var placementSelectors: [PlacementSelector<Placement>]? var defaultPlacement: Placement var ios: iOS? private enum CodingKeys: String, CodingKey { case duration = "duration_milliseconds" case placementSelectors = "placement_selectors" case defaultPlacement = "default_placement" case type } enum Position: String, ThomasSerializable { case top case bottom } struct Placement: ThomasSerializable { var margin: ThomasMargin? var size: ThomasConstrainedSize var position: Position var ignoreSafeArea: Bool? var border: ThomasBorder? var backgroundColor: ThomasColor? var nubInfo: ThomasViewInfo.NubInfo? var cornerRadius: ThomasViewInfo.CornerRadiusInfo? private enum CodingKeys: String, CodingKey { case margin case size case position case ignoreSafeArea = "ignore_safe_area" case border case backgroundColor = "background_color" case nubInfo = "nub" case cornerRadius = "corner_radius" } } } struct Modal: ThomasSerializable { let type: PresentationType = .modal var placementSelectors: [PlacementSelector<Placement>]? var defaultPlacement: Placement var dismissOnTouchOutside: Bool? var device: Device? var ios: iOS? private enum CodingKeys: String, CodingKey { case placementSelectors = "placement_selectors" case defaultPlacement = "default_placement" case dismissOnTouchOutside = "dismiss_on_touch_outside" case device case type case ios } struct Placement: ThomasSerializable { var margin: ThomasMargin? var size: ThomasConstrainedSize var position: ThomasPosition? var shade: ThomasColor? var ignoreSafeArea: Bool? var device: Device? var border: ThomasBorder? var backgroundColor: ThomasColor? var shadow: ThomasShadow? private enum CodingKeys: String, CodingKey { case margin case size case position case shade = "shade_color" case ignoreSafeArea = "ignore_safe_area" case device case border case backgroundColor = "background_color" case shadow } } } struct Embedded: ThomasSerializable { let type: PresentationType = .embedded var placementSelectors: [PlacementSelector<Placement>]? var defaultPlacement: Placement var embeddedID: String private enum CodingKeys: String, CodingKey { case defaultPlacement = "default_placement" case placementSelectors = "placement_selectors" case embeddedID = "embedded_id" case type } struct Placement: ThomasSerializable { let margin: ThomasMargin? let size: ThomasConstrainedSize let border: ThomasBorder? let backgroundColor: ThomasColor? private enum CodingKeys: String, CodingKey { case margin case size case border case backgroundColor = "background_color" } } } struct PlacementSelector<Placement: ThomasSerializable>: ThomasSerializable { var placement: Placement var windowSize: ThomasWindowSize? var orientation: ThomasOrientation? private enum CodingKeys: String, CodingKey { case placement case windowSize = "window_size" case orientation } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasPropertyOverride.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasPropertyOverride<T: Codable&Sendable&Equatable>: ThomasSerializable { let whenStateMatches: JSONPredicate? let value: T? enum CodingKeys: String, CodingKey { case whenStateMatches = "when_state_matches" case value } } extension ThomasPropertyOverride { @MainActor static func resolveOptional( state: ThomasState, overrides: [ThomasPropertyOverride<T>]?, defaultValue: T? = nil ) -> T? { let override = overrides?.first { override in return override.whenStateMatches?.evaluate( json: state.state ) ?? true } guard let override else { return defaultValue } return override.value } @MainActor static func resolveRequired(state: ThomasState, overrides: [ThomasPropertyOverride<T>]?, defaultValue: T) -> T { return resolveOptional(state: state, overrides: overrides) ?? defaultValue } } ================================================ FILE: Airship/AirshipCore/Source/ThomasSerializable.swift ================================================ /* Copyright Airship and Contributors */ import Foundation protocol ThomasSerializable: Codable, Sendable, Equatable {} ================================================ FILE: Airship/AirshipCore/Source/ThomasShadow.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasShadow: ThomasSerializable { let selectors: [Selector]? enum CodingKeys: String, CodingKey { case selectors } struct Selector: ThomasSerializable { var shadow: Shadow var platform: ThomasPlatform? private enum CodingKeys: String, CodingKey { case platform case shadow } } struct Shadow: ThomasSerializable { var boxShadow: BoxShadow? private enum CodingKeys: String, CodingKey { case boxShadow = "box_shadow" } } struct BoxShadow: ThomasSerializable { var color: ThomasColor var radius: Double var blurRadius: Double var offsetY: Double? var offsetX: Double? private enum CodingKeys: String, CodingKey { case color case radius case blurRadius = "blur_radius" case offsetY = "offset_y" case offsetX = "offset_x" } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasShapeInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasShapeInfo: ThomasSerializable { case rectangle(Rectangle) case ellipse(Ellipse) enum CodingKeys: String, CodingKey { case type = "type" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(ShapeType.self, forKey: .type) self = switch type { case .ellipse: .ellipse(try Ellipse(from: decoder)) case .rectangle: .rectangle(try Rectangle(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .ellipse(let shape): try shape.encode(to: encoder) case .rectangle(let shape): try shape.encode(to: encoder) } } enum ShapeType: String, ThomasSerializable { case rectangle case ellipse } struct Ellipse: Codable, Equatable, Sendable { let type: ShapeType = .ellipse var border: ThomasBorder? var scale: Double? var color: ThomasColor? var aspectRatio: Double? enum CodingKeys: String, CodingKey { case border case color case scale case aspectRatio = "aspect_ratio" case type } } struct Rectangle: Codable, Equatable, Sendable { let type: ShapeType = .rectangle var border: ThomasBorder? var scale: Double? var color: ThomasColor? var aspectRatio: Double? enum CodingKeys: String, CodingKey { case border case color case scale case aspectRatio = "aspect_ratio" case type } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasSize.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasSize: Codable, Equatable, Sendable { var width: ThomasSizeConstraint var height: ThomasSizeConstraint } ================================================ FILE: Airship/AirshipCore/Source/ThomasSizeConstraint.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasSizeConstraint: ThomasSerializable { case points(Double) case percent(Double) case auto init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let sizeString = try? container.decode(String.self) { if sizeString == "auto" { self = .auto } else if sizeString.last == "%" { var perecent = sizeString perecent.removeLast() self = .percent(Double(perecent) ?? 0) } else { throw AirshipErrors.parseError("invalid size: \(sizeString)") } } else if let double = try? container.decode(Double.self) { self = .points(double) } else { throw AirshipErrors.parseError("invalid size") } } func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case .auto: try container.encode("auto") case .percent(let value): try container.encode(String(format: "%.0f%%", value)) case .points(let value): try container.encode(value) } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasSmsLocale.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Locale configuration for a phone number struct ThomasSMSLocale: ThomasSerializable { /// Country locale code (two letters) let countryCode: String /// Country phone code let prefix: String /// Registration info let registration: ThomasSMSRegistrationOption? // Validation hints let validationHints: ValidationHints? init( countryCode: String, prefix: String, registration: ThomasSMSRegistrationOption? = nil, validationHints: ValidationHints? = nil ) { self.countryCode = countryCode self.prefix = prefix self.registration = registration self.validationHints = validationHints } struct ValidationHints: ThomasSerializable { var minDigits: Int? var maxDigits: Int? enum CodingKeys: String, CodingKey { case minDigits = "min_digits" case maxDigits = "max_digits" } } enum CodingKeys: String, CodingKey { case countryCode = "country_code" case prefix case registration case validationHints = "validation_hints" } } enum ThomasSMSRegistrationOption: ThomasSerializable, Hashable { case optIn(OptIn) struct OptIn: ThomasSerializable, Hashable { let type: RegistrationType = .optIn var senderID: String enum CodingKeys: String, CodingKey { case type case senderID = "sender_id" } } enum RegistrationType: String, Codable { case optIn = "opt_in" } private enum CodingKeys: String, CodingKey { case type } func encode(to encoder: any Encoder) throws { switch self { case .optIn(let properties): try properties.encode(to: encoder) } } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(RegistrationType.self, forKey: .type) switch type { case .optIn: self = .optIn( try OptIn(from: decoder) ) } } } extension ThomasSMSRegistrationOption { func makeContactOptions(date: Date = Date.now) -> SMSRegistrationOptions { switch (self) { case .optIn(let properties): return .optIn(senderID: properties.senderID) } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @MainActor class ThomasState: ObservableObject { @Published private(set) var state: AirshipJSON = [:] private var subscriptions: Set<AnyCancellable> = [] // Child State Objects private let formState: ThomasFormState? private let pagerState: PagerState? private let videoState: VideoState? private let mutableState: MutableState? private let onStateChange: @Sendable @MainActor (AirshipJSON) -> Void // Internal state snapshot that tracks current values @MainActor private struct StateSnapshot { var formStatus: ThomasFormState.Status? var formActiveFields: [String: ThomasFormField] = [:] var formType: ThomasFormState.FormType? var pagerInProgress: Bool? var videoPlaying: Bool? var videoMuted: Bool? var mutableStateValue: AirshipJSON? /// Generates the final AirshipJSON based strictly on this snapshot data func toAirshipJSON() -> AirshipJSON { // Start with the base mutable state object var result: [String: AirshipJSON] = mutableStateValue?.object ?? [:] // Add $forms if let formStatus, let formType { result["$forms"] = [ "current": ThomasFormPayloadGenerator.makeFormStatePayload( status: formStatus, fields: formActiveFields.map { $0.value }, formType: formType ) ] } // Add $pagers if let pagerInProgress { result["$pagers"] = [ "current": [ "paused": .bool(!pagerInProgress) ] ] } if let videoPlaying, let videoMuted { result["$video"] = [ "current": [ "playing": .bool(videoPlaying), "muted": .bool(videoMuted) ] ] } return .object(result) } } private var stateSnapshot: StateSnapshot = StateSnapshot() private var lastOutput: AirshipJSON = [:] init( formState: ThomasFormState? = nil, pagerState: PagerState? = nil, videoState: VideoState? = nil, mutableState: MutableState? = nil, onStateChange: @escaping @Sendable @MainActor (AirshipJSON) -> Void ) { self.formState = formState self.pagerState = pagerState self.videoState = videoState self.mutableState = mutableState self.onStateChange = onStateChange setupSubscriptions() // Initialize snapshot with current values from the passed objects self.updateSnapshot( formStatus: formState?.status, formActiveFields: formState?.activeFields, formType: formState?.formType, pagerInProgress: pagerState?.inProgress, videoPlaying: videoState?.isPlaying, videoMuted: videoState?.isMuted, mutableStateValue: mutableState?.state, ) } private func setupSubscriptions() { formState?.$status.sink { [weak self] in self?.updateSnapshot(formStatus: $0) }.store(in: &subscriptions) formState?.$activeFields.sink { [weak self] in self?.updateSnapshot(formActiveFields: $0) }.store(in: &subscriptions) pagerState?.$inProgress.sink { [weak self] in self?.updateSnapshot(pagerInProgress: $0) }.store(in: &subscriptions) videoState?.isPlayingPublisher.sink { [weak self] in self?.updateSnapshot(videoPlaying: $0) }.store(in: &subscriptions) videoState?.isMutedPublisher.sink { [weak self] in self?.updateSnapshot(videoMuted: $0) }.store(in: &subscriptions) mutableState?.$state.sink { [weak self] in self?.updateSnapshot(mutableStateValue: $0) }.store(in: &subscriptions) } private func updateSnapshot( formStatus: ThomasFormState.Status? = nil, formActiveFields: [String: ThomasFormField]? = nil, formType: ThomasFormState.FormType? = nil, pagerInProgress: Bool? = nil, videoPlaying: Bool? = nil, videoMuted: Bool? = nil, mutableStateValue: AirshipJSON? = nil, ) { // Update the snapshot with provided values if let val = formStatus { stateSnapshot.formStatus = val } if let val = formActiveFields { stateSnapshot.formActiveFields = val } if let val = formType { stateSnapshot.formType = val } if let val = pagerInProgress { stateSnapshot.pagerInProgress = val } if let val = videoPlaying { stateSnapshot.videoPlaying = val } if let val = videoMuted { stateSnapshot.videoMuted = val } if let val = mutableStateValue { stateSnapshot.mutableStateValue = val } // Compute new output directly from the snapshot let newOutput = stateSnapshot.toAirshipJSON() // Only update if output actually changed if newOutput != lastOutput { AirshipLogger.trace("State updated: \(newOutput.prettyJSONString) old: \(lastOutput.prettyJSONString)") self.state = newOutput self.lastOutput = newOutput self.onStateChange(newOutput) } } func with( formState: ThomasFormState? = nil, pagerState: PagerState? = nil, videoState: VideoState? = nil, mutableState: MutableState? = nil, ) -> ThomasState { let newFormState = formState ?? self.formState let newPagerState = pagerState ?? self.pagerState let newVideoState = videoState ?? self.videoState let newMutableState = mutableState ?? self.mutableState // Return self if nothing changed to avoid redundant copies if newFormState === self.formState, newPagerState === self.pagerState, newVideoState === self.videoState, newMutableState === self.mutableState { return self } return .init( formState: newFormState, pagerState: newPagerState, videoState: newVideoState, mutableState: newMutableState, onStateChange: self.onStateChange ) } func processStateActions( _ stateActions: [ThomasStateAction], formFieldValue: ThomasFormField.Value? = nil ) { stateActions.forEach { action in switch action { case .setState(let details): self.mutableState?.set(key: details.key, value: details.value, ttl: details.ttl) case .clearState: self.mutableState?.clearState() case .formValue(let details): self.mutableState?.set(key: details.key, value: formFieldValue?.stateFormValue) } } } @MainActor class MutableState: ObservableObject { @Published private(set) var state: AirshipJSON private var appliedState: [String: AirshipJSON] = [:] private var tempMutations: [String: TempMutation] = [:] private let taskSleeper: any AirshipTaskSleeper init( initialState: AirshipJSON? = nil, taskSleeper: any AirshipTaskSleeper = DefaultAirshipTaskSleeper.shared ) { self.state = initialState ?? [:] self.taskSleeper = taskSleeper } fileprivate func clearState() { tempMutations.removeAll() appliedState.removeAll() updateState() } private func updateState() { var state = self.appliedState tempMutations.forEach { key, mutation in state[key] = mutation.value } self.state = .object(state) } private func removeTempMutation(_ mutation: TempMutation) { guard tempMutations[mutation.key] == mutation else { return } tempMutations[mutation.key] = nil self.updateState() } fileprivate func set(key: String, value: AirshipJSON?, ttl: TimeInterval? = nil) { if let ttl = ttl { let mutation = TempMutation(id: UUID().uuidString, key: key, value: value) tempMutations[key] = mutation appliedState[key] = nil updateState() Task { [weak self] in try? await self?.taskSleeper.sleep(timeInterval: ttl) self?.removeTempMutation(mutation) } } else { tempMutations[key] = nil appliedState[key] = value updateState() } } } fileprivate struct TempMutation: Sendable, Equatable, Hashable { let id: String let key: String let value: AirshipJSON? } } fileprivate extension ThomasFormField.Value { var stateFormValue: AirshipJSON? { switch(self) { case .toggle(let value): return .bool(value) case .multipleCheckbox(let value): return .array(Array(value)) case .radio(let value): return value case .sms(let value, _), .email(let value), .text(let value): guard let value else { return nil } return .string(value) case .score(let value): return value case .form, .npsForm: return nil } } } fileprivate extension AirshipJSON { var prettyJSONString: String { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted] return (try? self.toString(encoder: encoder)) ?? "Invalid JSON" } } @MainActor final class ScopedStateCache: ObservableObject { private var cachedState: ThomasState? private let updateSubject = PassthroughSubject<any Codable, Never>() private var subscription: AnyCancellable? private var pendingUpdate: SnapshotType? = nil func getOrCreate(_ createState: () -> ThomasState) -> ThomasState { if let cached = cachedState { return cached } let scoped = createState() if let pendingUpdate { scoped.restorePersistentState(pendingUpdate) self.pendingUpdate = nil } cachedState = scoped rebroadcastUpdates(scoped) return scoped } func invalidate() { cachedState = nil rebroadcastUpdates(nil) pendingUpdate = nil } private func rebroadcastUpdates(_ state: ThomasState?) { guard let state else { subscription?.cancel() subscription = nil updateSubject.send(ThomasState.PersistentState( formState: nil, pagerState: nil, mutableState: nil) ) return } subscription = state.updates .sink { [weak self] update in self?.updateSubject.send(update) } } } //MARK: - ThomasStateProvider extension ThomasState.MutableState: ThomasStateProvider { typealias StateSnapshot = [String: AirshipJSON] var updates: AnyPublisher<any Codable, Never> { return $state.removeDuplicates().map(\.self).eraseToAnyPublisher() } func persistentStateSnapshot() -> StateSnapshot { return self.appliedState } func restorePersistentState(_ state: [String: AirshipJSON]) { self.appliedState = state DispatchQueue.main.async { self.updateState() } } } extension ThomasState: ThomasStateProvider { typealias SnapshotType = PersistentState struct PersistentState: Codable { let formState: ThomasFormState.SnapshotType? let pagerState: PagerState.SnapshotType? let mutableState: MutableState.SnapshotType? } var updates: AnyPublisher<any Codable, Never> { return $state.removeDuplicates().map(\.self).eraseToAnyPublisher() } func persistentStateSnapshot() -> PersistentState { return PersistentState( formState: formState?.persistentStateSnapshot(), pagerState: pagerState?.persistentStateSnapshot(), mutableState: mutableState?.persistentStateSnapshot() ) } func restorePersistentState(_ state: PersistentState) { if let form = state.formState { self.formState?.restorePersistentState(form) } if let pager = state.pagerState { self.pagerState?.restorePersistentState(pager) } if let mutable = state.mutableState { self.mutableState?.restorePersistentState(mutable) } } } extension ScopedStateCache: ThomasStateProvider { typealias SnapshotType = ThomasState.PersistentState var updates: AnyPublisher<any Codable, Never> { return updateSubject .compactMap({ [weak self] _ in self?.makeSnapshot(self?.cachedState) }) .eraseToAnyPublisher() } func persistentStateSnapshot() -> SnapshotType { return makeSnapshot(cachedState) } func restorePersistentState(_ state: SnapshotType) { if let thomasState = cachedState { thomasState.restorePersistentState(state) } else { pendingUpdate = state } } private func makeSnapshot(_ state: ThomasState?) -> ThomasState.PersistentState { return state?.persistentStateSnapshot() ?? ThomasState.PersistentState( formState: nil, pagerState: nil, mutableState: nil ) } } ================================================ FILE: Airship/AirshipCore/Source/ThomasStateAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasStateAction: ThomasSerializable { case setState(SetState) case clearState case formValue(SetFormValue) private enum CodingKeys: String, CodingKey { case type } enum ActionType: String, ThomasSerializable { case setState = "set" case clearState = "clear" case formValue = "set_form_value" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(ActionType.self, forKey: .type) self = switch type { case .setState: .setState(try SetState(from: decoder)) case .clearState: .clearState case .formValue: .formValue(try SetFormValue(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .setState(let action): try action.encode(to: encoder) case .clearState: var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(ActionType.clearState, forKey: .type) case .formValue(let action): try action.encode(to: encoder) } } struct SetState: ThomasSerializable { let type: ActionType = .setState let key: String let value: AirshipJSON? let ttl: TimeInterval? enum CodingKeys: String, CodingKey { case key case value case type case ttl = "ttl_seconds" } } struct SetFormValue: ThomasSerializable { let type: ActionType = .formValue let key: String enum CodingKeys: String, CodingKey { case key case type } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasStateStorage.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation import Combine @MainActor protocol ThomasStateProvider: ObservableObject { associatedtype SnapshotType: Codable var updates: AnyPublisher<any Codable, Never> { get } func persistentStateSnapshot() -> SnapshotType func restorePersistentState(_ state: SnapshotType) } @MainActor public protocol LayoutDataStorage: Sendable { var messageID: String { get } func prepare(restoreID: String) async func store(_ state: Data?, key: String) func retrieve(_ key: String) -> Data? func clear() } /// - Note: for internal use only. :nodoc: @MainActor protocol ThomasStateStorage: Sendable { func store(_ provider: any ThomasStateProvider, identifier: String) func retrieve<T: ThomasStateProvider>( identifier: String, builder: () -> T ) -> T } /// - Note: for internal use only. :nodoc: @MainActor final class DefaultThomasStateStorage: ThomasStateStorage { private var store: any LayoutDataStorage private var providers: [String: any ThomasStateProvider] = [:] private var cancellables: [String: AnyCancellable] = [:] init(store: any LayoutDataStorage) { self.store = store } func store(_ provider: any ThomasStateProvider, identifier: String) { removeStored(forKey: identifier) encodeAndSave(provider.persistentStateSnapshot(), identifier: identifier) let subscription = monitorUpdates(provider, identifier: identifier) cancellables[identifier] = subscription } func retrieve<T>( identifier: String, builder: () -> T ) -> T where T : ThomasStateProvider { //check if we have a cached value if let cached = providers[identifier] { if let result = cached as? T { return result } else { removeStored(forKey: identifier) } } let result = builder() if let stored = store.retrieve(identifier), let state = decodeState(T.SnapshotType.self, data: stored) { result.restorePersistentState(state) } store(result, identifier: identifier) return result } private func monitorUpdates(_ provider: any ThomasStateProvider, identifier: String) -> AnyCancellable { self.providers[identifier] = provider return provider.updates .sink { [weak self] snapshot in self?.encodeAndSave(snapshot, identifier: identifier) } } private func removeStored(forKey key: String) { cancellables.removeValue(forKey: key)?.cancel() providers.removeValue(forKey: key) } private func encodeAndSave(_ snapshot: any Codable, identifier: String) { guard let data = try? JSONEncoder().encode(snapshot) else { AirshipLogger.warn("Failed to encode state snapshot: \(snapshot)") return } self.store.store(data, key: identifier) } private func decodeState<T: Codable>(_ type: T.Type, data: Data) -> T? { do { return try JSONDecoder().decode(type, from: data) } catch { AirshipLogger.warn("Failed to restore state for type \(type): \(error)") return nil } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasStateTrigger.swift ================================================ import Foundation struct ThomasStateTriggers: ThomasSerializable { var id: String var triggerWhenStateMatches: JSONPredicate var resetWhenStateMatches: JSONPredicate? var onTrigger: TriggerActions struct TriggerActions: ThomasSerializable { var stateActions: [ThomasStateAction]? enum CodingKeys: String, CodingKey { case stateActions = "state_actions" } } enum CodingKeys: String, CodingKey { case id = "identifier" case triggerWhenStateMatches = "trigger_when_state_matches" case resetWhenStateMatches = "reset_when_state_matches" case onTrigger = "on_trigger" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasTextAppearance.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct ThomasTextAppearance: ThomasSerializable { var color: ThomasColor var fontSize: Double var alignment: TextAlignement? var styles: [TextStyle]? var fontFamilies: [String]? var placeHolderColor: ThomasColor? var lineHeightMultiplier: Double? var kerning: Double? var fontWeight: Double? enum TextStyle: String, ThomasSerializable { case bold case italic case underlined } enum TextAlignement: String, ThomasSerializable { case start case end case center } enum CodingKeys: String, CodingKey { case color case fontSize = "font_size" case alignment case styles case fontFamilies = "font_families" case placeHolderColor = "place_holder_color" case lineHeightMultiplier = "line_height_multiplier" case fontWeight = "font_weight" case kerning } } extension ThomasTextAppearance { /// Resolves the SwiftUI Font using the AirshipFont system @MainActor var font: Font { return AirshipFont.resolveFont( size: self.fontSize, families: self.fontFamilies, weight: self.fontWeight, isItalic: self.styles?.contains(.italic) ?? false, isBold: self.styles?.contains(.bold) ?? false ) } /// Resolves the Native Font (UIFont/NSFont) using the AirshipFont system @MainActor var nativeFont: AirshipNativeFont { return AirshipFont.resolveNativeFont( size: self.fontSize, families: self.fontFamilies, weight: self.fontWeight, isItalic: self.styles?.contains(.italic) ?? false, isBold: self.styles?.contains(.bold) ?? false ) } /// Returns the scaled font size based on platform logic var scaledFontSize: Double { return AirshipFont.scaledSize(self.fontSize) } /// Helper to determine if a specific text style is present func hasStyle(_ style: ThomasTextAppearance.TextStyle) -> Bool { return self.styles?.contains(style) ?? false } } ================================================ FILE: Airship/AirshipCore/Source/ThomasToggleStyleInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasToggleStyleInfo: ThomasSerializable { case switchStyle(Switch) case checkboxStyle(Checkbox) private enum CodingKeys: String, CodingKey { case type = "type" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(StyleType.self, forKey: .type) self = switch type { case .switch: .switchStyle(try Switch(from: decoder)) case .checkbox: .checkboxStyle(try Checkbox(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .switchStyle(let style): try style.encode(to: encoder) case .checkboxStyle(let style): try style.encode(to: encoder) } } enum StyleType: String, ThomasSerializable { case `switch` case checkbox } struct Switch: ThomasSerializable { let type: StyleType = .switch let colors: ToggleColors private enum CodingKeys: String, CodingKey { case colors = "toggle_colors" case type } struct ToggleColors: ThomasSerializable { var on: ThomasColor var off: ThomasColor } } struct Checkbox: ThomasSerializable { let type: StyleType = .checkbox let bindings: Bindings private enum CodingKeys: String, CodingKey { case bindings case type } struct Bindings: ThomasSerializable { let selected: Binding let unselected: Binding } struct Binding: ThomasSerializable { let shapes: [ThomasShapeInfo]? let icon: ThomasIconInfo? } } } ================================================ FILE: Airship/AirshipCore/Source/ThomasValidationInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasValidationInfo: ThomasSerializable { var isRequired: Bool? var onError: ErrorInfo? var onEdit: EditInfo? var onValid: ValidInfo? struct ErrorInfo: ThomasSerializable { var stateActions: [ThomasStateAction]? enum CodingKeys: String, CodingKey { case stateActions = "state_actions" } } struct EditInfo: ThomasSerializable { var stateActions: [ThomasStateAction]? enum CodingKeys: String, CodingKey { case stateActions = "state_actions" } } struct ValidInfo: ThomasSerializable { var stateActions: [ThomasStateAction]? enum CodingKeys: String, CodingKey { case stateActions = "state_actions" } } enum CodingKeys: String, CodingKey { case isRequired = "required" case onError = "on_error" case onEdit = "on_edit" case onValid = "on_valid" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasViewController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine #if !os(watchOS) && !os(macOS) class ThomasViewController<Content> : UIHostingController<Content> where Content : View { var options: ThomasViewControllerOptions var onDismiss: (() -> Void)? private var scrollViewsUpdated: Bool = false init(rootView: Content, options: ThomasViewControllerOptions = ThomasViewControllerOptions()) { self.options = options super.init(rootView: rootView) self.view.backgroundColor = .clear } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.onDismiss?() } #if !os(tvOS) override var supportedInterfaceOrientations: UIInterfaceOrientationMask { guard let orientation = options.orientation else { return .all } switch orientation { case .portrait: return .portrait case .landscape: return .landscape } } override var shouldAutorotate: Bool { return self.options.orientation == nil } #endif override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if !scrollViewsUpdated { updateScrollViews(view: self.view) scrollViewsUpdated = true } } override func accessibilityPerformEscape() -> Bool { self.onDismiss?() return true } func updateScrollViews(view: UIView) { view.subviews.forEach { subView in if let subView = subView as? UIScrollView { if (subView.bounces) { subView.bounces = false #if os(tvOS) subView.panGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] #endif } } updateScrollViews(view: subView) } } } class ThomasBannerViewController: ThomasViewController<BannerView> { private var centerXConstraint: NSLayoutConstraint? private var topConstraint: NSLayoutConstraint? private var bottomConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint? private var widthConstraint: NSLayoutConstraint? private let thomasBannerConstraints: ThomasBannerConstraints private let position: ThomasPresentationInfo.Banner.Position? private var subscription: AnyCancellable? init( rootView: BannerView, position: ThomasPresentationInfo.Banner.Position, options: ThomasViewControllerOptions, constraints: ThomasBannerConstraints ) { self.thomasBannerConstraints = constraints self.position = position super.init(rootView: rootView, options: options) } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) createBannerConstraints() if UIAccessibility.isVoiceOverRunning { DispatchQueue.main.asyncAfter(deadline: .now() + BannerView.animationInOutDuration) { UIAccessibility.post(notification: .screenChanged, argument: self) } } subscription = thomasBannerConstraints.$contentPlacement.sink { [weak self] contentPlacement in if let contentPlacement { self?.handleBannerConstraints(contentPlacement: contentPlacement) } } } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() self.thomasBannerConstraints.updateWindowSize(self.view.window?.frame.size) } override func viewWillDisappear(_ animated: Bool) { subscription?.cancel() super.viewWillDisappear(animated) } func createBannerConstraints() { self.view.translatesAutoresizingMaskIntoConstraints = false if let window = self.view.window { centerXConstraint = self.view.centerXAnchor.constraint(equalTo: window.centerXAnchor) topConstraint = self.view.topAnchor.constraint(equalTo: window.topAnchor) bottomConstraint = self.view.bottomAnchor.constraint(equalTo: window.bottomAnchor) heightConstraint = self.view.heightAnchor.constraint( equalToConstant: thomasBannerConstraints.windowSize.height ) widthConstraint = self.view.widthAnchor.constraint( equalToConstant: thomasBannerConstraints.windowSize.width ) } } private func handleBannerConstraints(contentPlacement: ContentPlacement) { // Ensure view is still in window hierarchy before updating constraints guard let window = self.view.window else { return } // Use content size directly - margins will be handled by positioning self.heightConstraint?.isActive = true self.widthConstraint?.isActive = true self.widthConstraint?.constant = contentPlacement.width self.heightConstraint?.constant = contentPlacement.height // Deactivate old constraints before creating new ones self.centerXConstraint?.isActive = false self.topConstraint?.isActive = false self.bottomConstraint?.isActive = false let edgeInsets = contentPlacement.additionalEdgeInsets // Shift horizontal constraint by start/end margins // Positive leading margin shifts right, positive trailing margin shifts left let horizontalOffset = edgeInsets.leading - edgeInsets.trailing self.centerXConstraint = self.view.centerXAnchor.constraint( equalTo: window.centerXAnchor, constant: horizontalOffset ) self.centerXConstraint?.isActive = true if contentPlacement.ignoreSafeArea { // Anchor to window edges when ignoring safe area, shifted by margins if contentPlacement.isTop { self.topConstraint = self.view.topAnchor.constraint( equalTo: window.topAnchor, constant: edgeInsets.top ) } else { self.bottomConstraint = self.view.bottomAnchor.constraint( equalTo: window.bottomAnchor, constant: -edgeInsets.bottom ) } } else { // Anchor to safe area layout guide when respecting safe area, shifted by margins if contentPlacement.isTop { self.topConstraint = self.view.topAnchor.constraint( equalTo: window.safeAreaLayoutGuide.topAnchor, constant: edgeInsets.top ) } else { self.bottomConstraint = self.view.bottomAnchor.constraint( equalTo: window.safeAreaLayoutGuide.bottomAnchor, constant: -edgeInsets.bottom ) } } switch self.position { case .top: self.topConstraint?.isActive = true self.bottomConstraint?.isActive = false default: self.topConstraint?.isActive = false self.bottomConstraint?.isActive = true } self.view.layoutIfNeeded() } } class ThomasModalViewController : ThomasViewController<ModalView> { override init(rootView: ModalView, options: ThomasViewControllerOptions) { super.init(rootView: rootView, options: options) self.modalPresentationStyle = .currentContext } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } #elseif os(macOS) @available(iOS 13.0.0, tvOS 13.0, *) class ThomasViewController<Content> : NSHostingController<Content> where Content : View { var options: ThomasViewControllerOptions var onDismiss: (() -> Void)? private var scrollViewsUpdated: Bool = false init(rootView: Content, options: ThomasViewControllerOptions = ThomasViewControllerOptions()) { self.options = options super.init(rootView: rootView) self.view.layer?.backgroundColor = NSColor.clear.cgColor } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewWillDisappear() { super.viewWillDisappear() self.onDismiss?() } } class ThomasBannerViewController: ThomasViewController<BannerView> { private var centerXConstraint: NSLayoutConstraint? private var topConstraint: NSLayoutConstraint? private var bottomConstraint: NSLayoutConstraint? private var heightConstraint: NSLayoutConstraint? private var widthConstraint: NSLayoutConstraint? private let thomasBannerConstraints: ThomasBannerConstraints private let position: ThomasPresentationInfo.Banner.Position? private var subscription: AnyCancellable? init(rootView: BannerView, position: ThomasPresentationInfo.Banner.Position, options: ThomasViewControllerOptions, constraints: ThomasBannerConstraints ) { self.thomasBannerConstraints = constraints self.position = position super.init(rootView: rootView, options: options) } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidAppear() { super.viewDidAppear() createBannerConstraints() handleBannerConstraints(size: self.thomasBannerConstraints.windowSize) let isVoiceOverRunning = AXIsProcessTrusted() if isVoiceOverRunning { DispatchQueue.main.asyncAfter(deadline: .now() + BannerView.animationInOutDuration) { NSAccessibility.post(element: self, notification: .layoutChanged) } } subscription = thomasBannerConstraints.$windowSize.sink { [weak self] size in self?.handleBannerConstraints(size: size) } } override func viewWillDisappear() { subscription?.cancel() super.viewWillDisappear() } func createBannerConstraints() { self.view.translatesAutoresizingMaskIntoConstraints = false if let contentView = self.view.window?.contentView { centerXConstraint = self.view.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) topConstraint = self.view.topAnchor.constraint(equalTo: contentView.topAnchor) bottomConstraint = self.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) heightConstraint = self.view.heightAnchor.constraint(equalToConstant: self.thomasBannerConstraints.windowSize.height) widthConstraint = self.view.widthAnchor.constraint(equalToConstant: self.thomasBannerConstraints.windowSize.width) } } func handleBannerConstraints(size: CGSize) { // Ensure view is still in window hierarchy before updating constraints guard self.view.window != nil else { return } self.centerXConstraint?.isActive = true self.heightConstraint?.isActive = true self.widthConstraint?.isActive = true self.widthConstraint?.constant = size.width switch self.position { case .top: self.topConstraint?.isActive = true self.bottomConstraint?.isActive = false self.heightConstraint?.constant = size.height + self.view.safeAreaInsets.top default: self.topConstraint?.isActive = false self.bottomConstraint?.isActive = true self.heightConstraint?.constant = size.height + self.view.safeAreaInsets.bottom } self.view.layoutSubtreeIfNeeded() } } class ThomasModalViewController : ThomasViewController<ModalView> { override init(rootView: ModalView, options: ThomasViewControllerOptions) { super.init(rootView: rootView, options: options) } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } #endif class ThomasViewControllerOptions { var orientation: ThomasOrientation? var bannerPlacement: ThomasPresentationInfo.Banner.Placement? } @MainActor class ThomasBannerConstraints: ObservableObject { @Published fileprivate var contentPlacement: ContentPlacement? @Published private(set) var windowSize: CGSize init(windowSize: CGSize) { self.windowSize = windowSize } func updateContentSize( _ size: CGSize, constraints: ViewConstraints, placement: ThomasPresentationInfo.Banner.Placement ) { let width = if let width = constraints.width { width } else { size.width } let height = if let height = constraints.height { height } else { size.height } let additionalEdgeInsets = EdgeInsets( top: placement.margin?.top ?? 0, leading: placement.margin?.start ?? 0, bottom: placement.margin?.bottom ?? 0, trailing: placement.margin?.end ?? 0 ) let contentPlacement = ContentPlacement( isTop: placement.position == .top, additionalEdgeInsets: additionalEdgeInsets, width: width, height: height, ignoreSafeArea: placement.ignoreSafeArea == true ) if self.contentPlacement != contentPlacement { self.contentPlacement = contentPlacement } } func updateWindowSize(_ size: CGSize?) { if self.windowSize != size, let size { self.windowSize = size } } } fileprivate struct ContentPlacement: Sendable, Equatable { let isTop: Bool let additionalEdgeInsets: EdgeInsets let width: Double let height: Double let ignoreSafeArea: Bool } ================================================ FILE: Airship/AirshipCore/Source/ThomasViewInfo.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Foundation indirect enum ThomasViewInfo: ThomasSerializable { case container(Container) case linearLayout(LinearLayout) #if !os(tvOS) && !os(watchOS) case webView(WebView) #endif case customView(CustomView) case scrollLayout(ScrollLayout) case media(Media) case label(Label) case labelButton(LabelButton) case imageButton(ImageButton) case stackImageButton(StackImageButton) case emptyView(EmptyView) case pager(Pager) case pagerIndicator(PagerIndicator) case storyIndicator(StoryIndicator) case pagerController(PagerController) case formController(FormController) case checkbox(Checkbox) case checkboxController(CheckboxController) case radioInput(RadioInput) case radioInputController(RadioInputController) case textInput(TextInput) case score(Score) case npsController(NPSController) case toggle(Toggle) case stateController(StateController) case buttonLayout(ButtonLayout) case basicToggleLayout(BasicToggleLayout) case checkboxToggleLayout(CheckboxToggleLayout) case radioInputToggleLayout(RadioInputToggleLayout) case iconView(IconView) case scoreController(ScoreController) case scoreToggleLayout(ScoreToggleLayout) case videoController(VideoController) enum ViewType: String, Codable { case container case linearLayout = "linear_layout" case webView = "web_view" case customView = "custom_view" case scrollLayout = "scroll_layout" case media case label case labelButton = "label_button" case imageButton = "image_button" case stackImageButton = "stack_image_button" case buttonLayout = "button_layout" case emptyView = "empty_view" case pager case pagerIndicator = "pager_indicator" case storyIndicator = "story_indicator" case pagerController = "pager_controller" case formController = "form_controller" case checkbox case checkboxController = "checkbox_controller" case radioInput = "radio_input" case radioInputController = "radio_input_controller" case textInput = "text_input" case score case npsController = "nps_form_controller" case toggle case basicToggleLayout = "basic_toggle_layout" case checkboxToggleLayout = "checkbox_toggle_layout" case radioInputToggleLayout = "radio_input_toggle_layout" case stateController = "state_controller" case iconView = "icon_view" case scoreController = "score_controller" case scoreToggleLayout = "score_toggle_layout" case videoController = "video_controller" } protocol BaseInfo: ThomasSerializable { var commonProperties: CommonViewProperties { get } var commonOverrides: CommonViewOverrides? { get } } private enum CodingKeys: String, CodingKey { case type } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(ViewType.self, forKey: .type) self = try Self.decodeViewType(type, from: decoder) } // Separate decoding into smaller focused methods to reduce type-checking overhead @inline(never) private static func decodeViewType(_ type: ViewType, from decoder: any Decoder) throws -> ThomasViewInfo { return switch type { case .container: .container(try Container(from: decoder)) case .linearLayout: .linearLayout(try LinearLayout(from: decoder)) case .webView: #if os(tvOS) || os(watchOS) throw AirshipErrors.error("Webview not available on tvOS and watchOS") #else .webView(try WebView(from: decoder)) #endif case .scrollLayout: .scrollLayout(try ScrollLayout(from: decoder)) case .media: .media(try Media(from: decoder)) case .label: .label(try Label(from: decoder)) case .labelButton: .labelButton(try LabelButton(from: decoder)) case .imageButton: .imageButton(try ImageButton(from: decoder)) case .stackImageButton: .stackImageButton(try StackImageButton(from: decoder)) case .emptyView: .emptyView(try EmptyView(from: decoder)) case .pager: .pager(try Pager(from: decoder)) case .pagerIndicator: .pagerIndicator(try PagerIndicator(from: decoder)) case .storyIndicator: .storyIndicator(try StoryIndicator(from: decoder)) case .pagerController: .pagerController(try PagerController(from: decoder)) case .formController: .formController(try FormController(from: decoder)) case .checkbox: .checkbox(try Checkbox(from: decoder)) case .checkboxController: .checkboxController(try CheckboxController(from: decoder)) case .radioInput: .radioInput(try RadioInput(from: decoder)) case .radioInputController: .radioInputController(try RadioInputController(from: decoder)) case .textInput: .textInput(try TextInput(from: decoder)) case .score: .score(try Score(from: decoder)) case .npsController: .npsController(try NPSController(from: decoder)) case .toggle: .toggle(try Toggle(from: decoder)) case .stateController: .stateController(try StateController(from: decoder)) case .customView: .customView(try CustomView(from: decoder)) case .buttonLayout: .buttonLayout(try ButtonLayout(from: decoder)) case .basicToggleLayout: .basicToggleLayout(try BasicToggleLayout(from: decoder)) case .checkboxToggleLayout: .checkboxToggleLayout(try CheckboxToggleLayout(from: decoder)) case .radioInputToggleLayout: .radioInputToggleLayout(try RadioInputToggleLayout(from: decoder)) case .iconView: .iconView(try IconView(from: decoder)) case .scoreController: .scoreController(try ScoreController(from: decoder)) case .scoreToggleLayout: .scoreToggleLayout(try ScoreToggleLayout(from: decoder)) case .videoController: .videoController(try VideoController(from: decoder)) } } func encode(to encoder: any Encoder) throws { try Self.encodeViewInfo(self, to: encoder) } // Separate encoding into smaller focused methods to reduce type-checking overhead @inline(never) private static func encodeViewInfo(_ viewInfo: ThomasViewInfo, to encoder: any Encoder) throws { switch viewInfo { case .container(let info): try info.encode(to: encoder) case .linearLayout(let info): try info.encode(to: encoder) #if !os(tvOS) && !os(watchOS) case .webView(let info): try info.encode(to: encoder) #endif case .customView(let info): try info.encode(to: encoder) case .scrollLayout(let info): try info.encode(to: encoder) case .media(let info): try info.encode(to: encoder) case .label(let info): try info.encode(to: encoder) case .labelButton(let info): try info.encode(to: encoder) case .imageButton(let info): try info.encode(to: encoder) case .stackImageButton(let info): try info.encode(to: encoder) case .emptyView(let info): try info.encode(to: encoder) case .pager(let info): try info.encode(to: encoder) case .pagerIndicator(let info): try info.encode(to: encoder) case .storyIndicator(let info): try info.encode(to: encoder) case .pagerController(let info): try info.encode(to: encoder) case .formController(let info): try info.encode(to: encoder) case .checkbox(let info): try info.encode(to: encoder) case .checkboxController(let info): try info.encode(to: encoder) case .radioInput(let info): try info.encode(to: encoder) case .radioInputController(let info): try info.encode(to: encoder) case .textInput(let info): try info.encode(to: encoder) case .score(let info): try info.encode(to: encoder) case .npsController(let info): try info.encode(to: encoder) case .toggle(let info): try info.encode(to: encoder) case .stateController(let info): try info.encode(to: encoder) case .buttonLayout(let info): try info.encode(to: encoder) case .basicToggleLayout(let info): try info.encode(to: encoder) case .checkboxToggleLayout(let info): try info.encode(to: encoder) case .radioInputToggleLayout(let info): try info.encode(to: encoder) case .iconView(let info): try info.encode(to: encoder) case .scoreController(let info): try info.encode(to: encoder) case .scoreToggleLayout(let info): try info.encode(to: encoder) case .videoController(let info): try info.encode(to: encoder) } } struct Container: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .container let items: [Item] private enum CodingKeys: String, CodingKey { case type case items } } struct Item: ThomasSerializable { var position: ThomasPosition var margin: ThomasMargin? var size: ThomasSize var view: ThomasViewInfo var ignoreSafeArea: Bool? private enum CodingKeys: String, CodingKey { case position case margin case size case view case ignoreSafeArea = "ignore_safe_area" } } } struct LinearLayout: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .linearLayout var direction: ThomasDirection var randomizeChildren: Bool? var items: [Item] private enum CodingKeys: String, CodingKey { case type case direction case randomizeChildren = "randomize_children" case items } } struct Item: ThomasSerializable { var size: ThomasSize var margin: ThomasMargin? var view: ThomasViewInfo var position: ThomasPosition? } } struct ScrollLayout: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .scrollLayout var direction: ThomasDirection var view: ThomasViewInfo private enum CodingKeys: String, CodingKey { case type case direction case view } } } struct WebView: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .webView var url: String private enum CodingKeys: String, CodingKey { case url case type } } } struct CustomView: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .customView let name: String let properties: AirshipJSON? private enum CodingKeys: String, CodingKey { case type case name case properties } } } struct Label: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo var overrides: Overrides? func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides, overrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.overrides = try decoder.decodeOverrides() } struct Overrides: ThomasSerializable { var text: [ThomasPropertyOverride<String>]? var ref: [ThomasPropertyOverride<String>]? var refs: [ThomasPropertyOverride<[String]>]? var iconStart: [ThomasPropertyOverride<LabelIcon>]? var iconEnd: [ThomasPropertyOverride<LabelIcon>]? var textAppearance: [ThomasPropertyOverride<ThomasTextAppearance>]? private enum CodingKeys: String, CodingKey { case text case ref case refs case iconStart = "icon_start" case iconEnd = "icon_end" case textAppearance = "text_appearance" } } enum IconType: String, Codable { case type = "floating" } struct LabelIcon: ThomasSerializable { var type: IconType var icon: ThomasIconInfo var space: Double } struct LabelAssociation: ThomasSerializable { enum LabelAssociationTypes: String, ThomasSerializable { case labels case describes } var viewID: String var type: LabelAssociationTypes var viewType: ViewType enum CodingKeys: String, CodingKey { case viewID = "view_id" case type case viewType = "view_type" } } struct Properties: ThomasSerializable { let type: ViewType = .label var text: String var ref: String? var refs: [String]? var textAppearance: ThomasTextAppearance var markdown: ThomasMarkDownOptions? var accessibilityRole: AccessibilityRole? var iconStart: LabelIcon? var iconEnd: LabelIcon? var labels: LabelAssociation? var isAccessibilityAlert: Bool? private enum CodingKeys: String, CodingKey { case type case text case ref case refs case textAppearance = "text_appearance" case markdown case accessibilityRole = "accessibility_role" case iconStart = "icon_start" case iconEnd = "icon_end" case labels case isAccessibilityAlert = "is_accessibility_alert" } } enum AccessibilityRole: Codable, Equatable, Sendable { case heading(level: Int) fileprivate enum AccessibilityRoleType: String, Codable { case heading } private enum CodingKeys: String, CodingKey { case type case level } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .heading(let level): try container.encode(AccessibilityRoleType.heading, forKey: .type) try container.encode(level, forKey: .level) } } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(AccessibilityRoleType.self, forKey: .type) switch type { case .heading: self = .heading(level: try container.decode(Int.self, forKey: .level)) } } } } struct Media: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } enum MediaType: String, ThomasSerializable { case image case video case youtube case vimeo } struct Video: ThomasSerializable { var aspectRatio: Double? var showControls: Bool? var autoplay: Bool? var muted: Bool? var loop: Bool? var autoResetPosition: Bool? enum CodingKeys: String, CodingKey { case aspectRatio = "aspect_ratio" case showControls = "show_controls" case autoplay case muted case loop case autoResetPosition = "auto_reset_position" } } struct Properties: ThomasSerializable { let type: ViewType = .media var url: String var mediaType: MediaType var mediaFit: ThomasMediaFit var video: Video? var cropPosition: ThomasPosition? var identifier: String? private enum CodingKeys: String, CodingKey { case mediaType = "media_type" case url case mediaFit = "media_fit" case video case cropPosition = "position" case type case identifier } } } struct LabelButton: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .labelButton var identifier: String var clickBehaviors: [ThomasButtonClickBehavior]? var actions: ThomasActionsPayload? var label: ThomasViewInfo.Label var reportingMetadata: AirshipJSON? var tapEffect: ThomasButtonTapEffect? private enum CodingKeys: String, CodingKey { case identifier case clickBehaviors = "button_click" case actions case label case type case tapEffect = "tap_effect" case reportingMetadata = "reporting_metadata" } } } struct ButtonLayout: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .buttonLayout var identifier: String var clickBehaviors: [ThomasButtonClickBehavior]? var actions: ThomasActionsPayload? var reportingMetadata: AirshipJSON? var tapEffect: ThomasButtonTapEffect? var accessibilityRole: AccessibilityRole? var view: ThomasViewInfo private enum CodingKeys: String, CodingKey { case identifier case clickBehaviors = "button_click" case actions case type case tapEffect = "tap_effect" case view case accessibilityRole = "accessibility_role" case reportingMetadata = "reporting_metadata" } } fileprivate enum AccessibilityRoleType: String, Codable { case button case container } enum AccessibilityRole: Codable, Equatable, Sendable { case container case button private enum CodingKeys: String, CodingKey { case type } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .button: try container.encode(AccessibilityRoleType.button, forKey: .type) case .container: try container.encode(AccessibilityRoleType.container, forKey: .type) } } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(AccessibilityRoleType.self, forKey: .type) self = switch type { case .button: .button case .container: .container } } } } struct StackImageButton: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo var overrides: Overrides? func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides, overrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.overrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .imageButton var identifier: String var clickBehaviors: [ThomasButtonClickBehavior]? var actions: ThomasActionsPayload? var reportingMetadata: AirshipJSON? var tapEffect: ThomasButtonTapEffect? var items: [Item] private enum CodingKeys: String, CodingKey { case identifier case clickBehaviors = "button_click" case actions case type case tapEffect = "tap_effect" case items case reportingMetadata = "reporting_metadata" } } struct Overrides: ThomasSerializable { var items: [ThomasPropertyOverride<[Item]>]? var contentDescription: [ThomasPropertyOverride<String>]? var localizedContentDescription: [ThomasPropertyOverride<ThomasAccessibleInfo.Localized>]? enum CodingKeys: String, CodingKey { case items case contentDescription = "content_description" case localizedContentDescription = "localized_content_description" } } enum ItemType: String, Codable, Equatable, Sendable { case icon case shape case imageURL = "image_url" } enum Item: Codable, Equatable, Sendable { case imageURL(ImageURLItem) case icon(IconItem) case shape(ShapeItem) private enum CodingKeys: String, CodingKey { case type = "type" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(ItemType.self, forKey: .type) self = switch type { case .imageURL: .imageURL(try ImageURLItem(from: decoder)) case .shape: .shape(try ShapeItem(from: decoder)) case .icon: .icon(try IconItem(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .icon(let info): try info.encode(to: encoder) case .imageURL(let info): try info.encode(to: encoder) case .shape(let info): try info.encode(to: encoder) } } struct ImageURLItem: ThomasSerializable { let type: ItemType = .imageURL var url: String var mediaFit: ThomasMediaFit var cropPosition: ThomasPosition? enum CodingKeys: String, CodingKey { case url case type case cropPosition = "position" case mediaFit = "media_fit" } } struct ShapeItem: ThomasSerializable { let type: ItemType = .shape var shape: ThomasShapeInfo enum CodingKeys: String, CodingKey { case type case shape } } struct IconItem: ThomasSerializable { let type: ItemType = .icon var icon: ThomasIconInfo enum CodingKeys: String, CodingKey { case type case icon } } } } struct ImageButton: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .imageButton var identifier: String var clickBehaviors: [ThomasButtonClickBehavior]? var actions: ThomasActionsPayload? var reportingMetadata: AirshipJSON? var tapEffect: ThomasButtonTapEffect? var image: ButtonImage private enum CodingKeys: String, CodingKey { case identifier case clickBehaviors = "button_click" case actions case type case tapEffect = "tap_effect" case image case reportingMetadata = "reporting_metadata" } } enum ButtonImageType: String, Codable, Equatable, Sendable { case url case icon } enum ButtonImage: Codable, Equatable, Sendable { case url(ImageURL) case icon(ThomasIconInfo) private enum CodingKeys: String, CodingKey { case type = "type" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(ButtonImageType.self, forKey: .type) self = switch type { case .url: .url(try ImageURL(from: decoder)) case .icon: .icon(try ThomasIconInfo(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .icon(let info): try info.encode(to: encoder) case .url(let info): try info.encode(to: encoder) } } struct ImageURL: ThomasSerializable { let type: ButtonImageType = .url var url: String var mediaFit: ThomasMediaFit? var cropPosition: ThomasPosition? enum CodingKeys: String, CodingKey { case url case type case cropPosition = "position" case mediaFit = "media_fit" } } } } struct NubInfo: ThomasSerializable { var size: ThomasSize var margin: ThomasMargin? var color: ThomasColor } struct CornerRadiusInfo: ThomasSerializable { var topLeft: Double? var topRight: Double? var bottomLeft: Double? var bottomRight: Double? private enum CodingKeys: String, CodingKey { case topLeft = "top_left" case topRight = "top_right" case bottomLeft = "bottom_left" case bottomRight = "bottom_right" } } struct EmptyView: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(commonProperties: CommonViewProperties, commonOverrides: CommonViewOverrides? = nil, properties: Properties) { self.commonProperties = commonProperties self.commonOverrides = commonOverrides self.properties = properties } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .emptyView private enum CodingKeys: String, CodingKey { case type } } } struct Pager: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .pager let disableSwipe: Bool? let items: [Item] let gestures: [Gesture]? let disableSwipePredicate: [DisableSwipeSelector]? enum CodingKeys: String, CodingKey { case items = "items" case disableSwipe = "disable_swipe" case gestures = "gestures" case type case disableSwipePredicate = "disable_swipe_when" } } struct Item: ThomasSerializable, Identifiable { let identifier: String let view: ThomasViewInfo let displayActions: ThomasActionsPayload? let automatedActions: [ThomasAutomatedAction]? let accessibilityActions: [ThomasAccessibilityAction]? let stateActions: [ThomasStateAction]? let branching: ThomasPageBranching? enum CodingKeys: String, CodingKey { case identifier = "identifier" case view = "view" case displayActions = "display_actions" case automatedActions = "automated_actions" case accessibilityActions = "accessibility_actions" case stateActions = "state_actions" case branching } var id: String { return identifier } } struct DisableSwipeSelector: ThomasSerializable { let predicate: JSONPredicate? let direction: Direction enum CodingKeys: String, CodingKey { case predicate = "when_state_matches" case direction = "directions" } private enum DirectionCodingKeys: String, CodingKey { case type } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) predicate = try container.decodeIfPresent(JSONPredicate.self, forKey: .predicate) let directionContainer = try container.nestedContainer(keyedBy: DirectionCodingKeys.self, forKey: .direction) direction = try directionContainer.decode(Direction.self, forKey: .type) } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(predicate, forKey: .predicate) var nested = container.nestedContainer(keyedBy: DirectionCodingKeys.self, forKey: .direction) try nested.encode(direction, forKey: .type) } } enum Direction: String, ThomasSerializable { case horizontal = "horizontal" } indirect enum Gesture: ThomasSerializable { case swipeGesture(Swipe) case tapGesture(Tap) case holdGesture(Hold) private enum CodingKeys: String, CodingKey { case type } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(GestureType.self, forKey: .type) self = switch type { case .tap: .tapGesture(try Tap(from: decoder)) case .swipe: .swipeGesture(try Swipe(from: decoder)) case .hold: .holdGesture(try Hold(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .swipeGesture(let gesture): try gesture.encode(to: encoder) case .tapGesture(let gesture): try gesture.encode(to: encoder) case .holdGesture(let gesture): try gesture.encode(to: encoder) } } enum GestureLocation: String, Codable, Equatable, Sendable { case top case bottom case start case end case left case right case any } enum GestureDirection: String, ThomasSerializable { case up case down } enum GestureType: String, Codable, Equatable, Sendable { case tap case swipe case hold } protocol Info: ThomasSerializable { var reportingMetadata: AirshipJSON? { get } var type: GestureType { get } var identifier: String { get } } struct GestureBehavior: ThomasSerializable { var actions: [ThomasActionsPayload]? var behaviors: [ThomasButtonClickBehavior]? } struct Swipe: Info { let type: GestureType = .swipe var identifier: String var reportingMetadata: AirshipJSON? var direction: GestureDirection var behavior: GestureBehavior enum CodingKeys: String, CodingKey { case identifier case reportingMetadata = "reporting_metadata" case direction case behavior case type } } struct Tap: Info { let type: GestureType = .tap var identifier: String var reportingMetadata: AirshipJSON? var location: GestureLocation var behavior: GestureBehavior enum CodingKeys: String, CodingKey { case identifier case location case behavior case type } } struct Hold: Info { let type: GestureType = .hold var identifier: String var reportingMetadata: AirshipJSON? var pressBehavior: GestureBehavior var releaseBehavior: GestureBehavior enum CodingKeys: String, CodingKey { case identifier = "identifier" case pressBehavior = "press_behavior" case releaseBehavior = "release_behavior" case type } } } } enum ProgressType: String, Decodable { case linear = "linear" } struct Progress: Decodable, Equatable, Sendable { var type: ProgressType var color: ThomasColor enum CodingKeys: String, CodingKey { case type = "type" case color = "color" } } struct PagerIndicator: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .pagerIndicator var bindings: Bindings var spacing: Double var automatedAccessibilityActions: [ThomasAutomatedAccessibilityAction]? enum CodingKeys: String, CodingKey { case bindings = "bindings" case spacing = "spacing" case type case automatedAccessibilityActions = "automated_accessibility_actions" } struct Bindings: Codable, Equatable, Sendable { var selected: Binding var unselected: Binding } struct Binding: Codable, Equatable, Sendable { var shapes: [ThomasShapeInfo]? var icon: ThomasIconInfo? } } } struct StoryIndicator: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .storyIndicator var source: Source var style: Style var automatedAccessibilityActions: [ThomasAutomatedAccessibilityAction]? enum CodingKeys: String, CodingKey { case source = "source" case style = "style" case type case automatedAccessibilityActions = "automated_accessibility_actions" } } struct Source: ThomasSerializable { let type: IndicatorType enum IndicatorType: String, ThomasSerializable { case pager = "pager" case currentPage = "current_page" } } enum Style: ThomasSerializable { case linearProgress(LinearProgress) enum StyleType: String, Codable, Equatable, Sendable { case linearProgress = "linear_progress" } private enum CodingKeys: String, CodingKey { case type } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(StyleType.self, forKey: .type) self = switch type { case .linearProgress: .linearProgress(try LinearProgress(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .linearProgress(let style): try style.encode(to: encoder) } } enum LayoutDirection: String, ThomasSerializable { case vertical = "vertical" case horizontal = "horizontal" } enum ProgressSizingType: String, Codable, Equatable, Sendable { case equal case pageDuration = "page_duration" } struct LinearProgress: ThomasSerializable { let type: StyleType = .linearProgress var direction: LayoutDirection var sizing: ProgressSizingType? var spacing: Double? var progressColor: ThomasColor var trackColor: ThomasColor var inactiveSegmentScaler: Double? private enum CodingKeys: String, CodingKey { case type case direction case sizing case spacing case progressColor = "progress_color" case trackColor = "track_color" case inactiveSegmentScaler = "inactive_segment_scaler" } } } } struct PagerController: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .pagerController var view: ThomasViewInfo var identifier: String let branching: ThomasPagerControllerBranching? enum CodingKeys: String, CodingKey { case view = "view" case identifier = "identifier" case type case branching } } } struct VideoController: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct GroupInfo: ThomasSerializable { var identifier: String } struct Properties: ThomasSerializable { let type: ViewType = .videoController var view: ThomasViewInfo var identifier: String var videoScope: [String]? var muteGroup: GroupInfo? var playGroup: GroupInfo? enum CodingKeys: String, CodingKey { case view case identifier case type case videoScope = "video_scope" case muteGroup = "mute_group" case playGroup = "play_group" } } } struct FormController: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .formController var identifier: String var submit: ThomasFormSubmitBehavior? var view: ThomasViewInfo var responseType: String? var formEnableBehaviors: [ThomasEnableBehavior]? var validationMode: ThomasFormValidationMode? enum CodingKeys: String, CodingKey { case identifier = "identifier" case submit = "submit" case view = "view" case responseType = "response_type" case formEnableBehaviors = "form_enabled" case type case validationMode = "validation_mode" } } } struct StateController: BaseInfo { static let defaultIdentifier: String = "default" var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .stateController var view: ThomasViewInfo var initialState: AirshipJSON? var identifier: String? enum CodingKeys: String, CodingKey { case view case type case initialState = "initial_state" case identifier } } var identifier: String { return properties.identifier ?? Self.defaultIdentifier } } struct NPSController: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .npsController var identifier: String var submit: ThomasFormSubmitBehavior? var npsIdentifier: String var view: ThomasViewInfo var responseType: String? var formEnableBehaviors: [ThomasEnableBehavior]? var validationMode: ThomasFormValidationMode? enum CodingKeys: String, CodingKey { case identifier case submit case view case npsIdentifier = "nps_identifier" case responseType = "response_type" case formEnableBehaviors = "form_enabled" case type case validationMode = "validation_mode" } } } struct CheckboxController: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .checkboxController var identifier: String var view: ThomasViewInfo var minSelection: Int? var maxSelection: Int? enum CodingKeys: String, CodingKey { case identifier case view case type case minSelection = "min_selection" case maxSelection = "max_selection" } } } struct RadioInputController: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .radioInputController var identifier: String var view: ThomasViewInfo var attributeName: ThomasAttributeName? enum CodingKeys: String, CodingKey { case identifier case view case attributeName = "attribute_name" case type } } } struct ScoreController: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .radioInputController var identifier: String var view: ThomasViewInfo var attributeName: ThomasAttributeName? enum CodingKeys: String, CodingKey { case identifier case view case attributeName = "attribute_name" case type } } } struct ScoreToggleLayout: BaseInfo { let properties: Properties var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.properties = try decoder.decodeProperties() self.commonProperties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() } struct Properties: ThomasSerializable { let type: ViewType = .scoreToggleLayout var identifier: String var attributeValue: ThomasAttributeValue? var onToggleOn: ToggleActions var onToggleOff: ToggleActions var view: ThomasViewInfo var reportingValue: AirshipJSON private enum CodingKeys: String, CodingKey { case identifier case attributeValue = "attribute_value" case onToggleOn = "on_toggle_on" case onToggleOff = "on_toggle_off" case view case reportingValue = "reporting_value" case type } } } struct TextInput: BaseInfo { var commonProperties: CommonViewProperties var properties: Properties var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo var commonOverrides: CommonViewOverrides? var overrides: Overrides? func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides, overrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.overrides = try decoder.decodeOverrides() } enum IconEndType: String, Codable { case floating = "floating" } struct IconEndInfo: ThomasSerializable { var type: IconEndType = .floating var icon: ThomasIconInfo } struct Properties: ThomasSerializable { let type: ViewType = .textInput var identifier: String var attributeName: ThomasAttributeName? var placeholder: String? var textAppearance: ThomasTextAppearance var inputType: TextInputType var iconEnd: IconEndInfo? var emailRegistration: ThomasEmailRegistrationOption? var smsLocales: [ThomasSMSLocale]? enum CodingKeys: String, CodingKey { case attributeName = "attribute_name" case textAppearance = "text_appearance" case identifier case placeholder = "place_holder" case inputType = "input_type" case type case iconEnd = "icon_end" case emailRegistration = "email_registration" case smsLocales = "locales" } } struct Overrides: ThomasSerializable { var iconEnd: [ThomasPropertyOverride<IconEndInfo>]? enum CodingKeys: String, CodingKey { case iconEnd = "icon_end" } } enum TextInputType: String, ThomasSerializable { case email case number case text case textMultiline = "text_multiline" case sms } } struct Toggle: BaseInfo { var commonProperties: CommonViewProperties var properties: Properties var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo var commonOverrides: CommonViewOverrides? func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .toggle var identifier: String var style: ThomasToggleStyleInfo var attributeName: ThomasAttributeName? var attributeValue: ThomasAttributeValue? enum CodingKeys: String, CodingKey { case style case identifier case attributeName = "attribute_name" case attributeValue = "attribute_value" case type } } } struct ToggleActions: ThomasSerializable { var stateActions: [ThomasStateAction]? enum CodingKeys: String, CodingKey { case stateActions = "state_actions" } } struct BasicToggleLayout: BaseInfo { let properties: Properties var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.properties = try decoder.decodeProperties() self.commonProperties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() } struct Properties: ThomasSerializable { let type: ViewType = .basicToggleLayout var identifier: String var attributeName: ThomasAttributeName? var attributeValue: ThomasAttributeValue? var onToggleOn: ToggleActions var onToggleOff: ToggleActions var view: ThomasViewInfo private enum CodingKeys: String, CodingKey { case identifier case attributeName = "attribute_name" case attributeValue = "attribute_value" case onToggleOn = "on_toggle_on" case onToggleOff = "on_toggle_off" case view case type } } } struct CheckboxToggleLayout: BaseInfo { let properties: Properties var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.properties = try decoder.decodeProperties() self.commonProperties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() } struct Properties: ThomasSerializable { let type: ViewType = .checkboxToggleLayout var identifier: String var onToggleOn: ToggleActions var onToggleOff: ToggleActions var view: ThomasViewInfo var reportingValue: AirshipJSON private enum CodingKeys: String, CodingKey { case identifier case onToggleOn = "on_toggle_on" case onToggleOff = "on_toggle_off" case view case reportingValue = "reporting_value" case type } } } struct RadioInputToggleLayout: BaseInfo { let properties: Properties var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.properties = try decoder.decodeProperties() self.commonProperties = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() } struct Properties: ThomasSerializable { let type: ViewType = .radioInputToggleLayout var identifier: String var attributeValue: ThomasAttributeValue? var onToggleOn: ToggleActions var onToggleOff: ToggleActions var view: ThomasViewInfo var reportingValue: AirshipJSON private enum CodingKeys: String, CodingKey { case identifier case attributeValue = "attribute_value" case onToggleOn = "on_toggle_on" case onToggleOff = "on_toggle_off" case view case reportingValue = "reporting_value" case type } } } struct Checkbox: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .checkbox var reportingValue: AirshipJSON var style: ThomasToggleStyleInfo var identifier: String? // Added later so its treated as optional. enum CodingKeys: String, CodingKey { case style case reportingValue = "reporting_value" case type case identifier } } } struct RadioInput: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .radioInput var reportingValue: AirshipJSON var style: ThomasToggleStyleInfo var attributeValue: ThomasAttributeValue? var identifier: String? // Added later so its treated as optional. enum CodingKeys: String, CodingKey { case style case identifier case reportingValue = "reporting_value" case attributeValue = "attribute_value" case type } } } struct Score: BaseInfo { var commonProperties: CommonViewProperties var commonOverrides: CommonViewOverrides? var properties: Properties var accessible: ThomasAccessibleInfo var validation: ThomasValidationInfo func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, validation, overrides: commonOverrides ) } init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.validation = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() } struct Properties: ThomasSerializable { let type: ViewType = .score var identifier: String var style: ScoreStyle var attributeName: ThomasAttributeName? private enum CodingKeys: String, CodingKey { case identifier case style case attributeName = "attribute_name" case type } } enum ScoreStyle: ThomasSerializable { case numberRange(NumberRange) enum ScoreStyleType: String, ThomasSerializable { case numberRange = "number_range" } private enum CodingKeys: String, CodingKey { case type } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(ScoreStyleType.self, forKey: .type) self = switch type { case .numberRange: .numberRange(try NumberRange(from: decoder)) } } func encode(to encoder: any Encoder) throws { switch self { case .numberRange(let info): try info.encode(to: encoder) } } struct NumberRange: ThomasSerializable { let type: ScoreStyleType = .numberRange var spacing: Double? var bindings: Bindings var start: Int var end: Int let wrapping: Wrapping? struct Wrapping: ThomasSerializable { let lineSpacing: Double? let maxItemsPerLine: Int? enum CodingKeys: String, CodingKey { case lineSpacing = "line_spacing" case maxItemsPerLine = "max_items_per_line" } } enum CodingKeys: String, CodingKey { case spacing case bindings case start case end case type case wrapping } struct Bindings: ThomasSerializable { var selected: Binding var unselected: Binding } struct Binding: ThomasSerializable { var shapes: [ThomasShapeInfo]? var textAppearance: ThomasTextAppearance? private enum CodingKeys: String, CodingKey { case shapes case textAppearance = "text_appearance" } } } } } struct IconView: BaseInfo { var properties: Properties var accessible: ThomasAccessibleInfo var commonProperties: ThomasViewInfo.CommonViewProperties var commonOverrides: ThomasViewInfo.CommonViewOverrides? var overrides: Overrides? init(from decoder: any Decoder) throws { self.commonProperties = try decoder.decodeProperties() self.properties = try decoder.decodeProperties() self.accessible = try decoder.decodeProperties() self.commonOverrides = try decoder.decodeOverrides() self.overrides = try decoder.decodeOverrides() } func encode(to encoder: any Encoder) throws { try encoder.encode( properties: commonProperties, properties, accessible, overrides: commonOverrides, overrides ) } struct Properties: ThomasSerializable { let type: ViewType = .iconView var icon: ThomasIconInfo private enum CodingKeys: String, CodingKey { case type case icon } } struct Overrides: ThomasSerializable { var icon: [ThomasPropertyOverride<ThomasIconInfo>]? } } struct CommonViewOverrides: ThomasSerializable { var border: [ThomasPropertyOverride<ThomasBorder>]? var backgroundColor: [ThomasPropertyOverride<ThomasColor>]? enum CodingKeys: String, CodingKey { case border case backgroundColor = "background_color" } } struct CommonViewProperties: ThomasSerializable { var border: ThomasBorder? var backgroundColor: ThomasColor? var visibility: ThomasVisibilityInfo? var eventHandlers: [ThomasEventHandler]? var enabled: [ThomasEnableBehavior]? var stateTriggers: [ThomasStateTriggers]? enum CodingKeys: String, CodingKey { case border case backgroundColor = "background_color" case visibility case eventHandlers = "event_handlers" case enabled case stateTriggers = "state_triggers" } } } fileprivate extension Encoder { func encode(properties: (any Encodable)?..., overrides: (any Encodable)?...) throws { try properties.forEach { codable in try codable?.encode(to: self) } let overrides = overrides.compactMap { $0 } if !overrides.isEmpty { try ViewOverridesEncodable(overrides: overrides).encode(to: self) } } } fileprivate extension Decoder { func decodeProperties<T: Decodable>() throws -> T { return try T(from: self) } func decodeOverrides<T: Decodable>() throws -> T? { return try ViewOverridesDecodable<T>(from: self).overrides } } fileprivate struct ViewOverridesEncodable: Encodable { private let wrapper: Wrapper? init(overrides: [any Encodable]) { self.wrapper = Wrapper(overrides: overrides) } enum CodingKeys: String, CodingKey { case wrapper = "view_overrides" } struct Wrapper: Encodable { var overrides: [any Encodable] func encode(to encoder: any Encoder) throws { try overrides.forEach { try $0.encode(to: encoder) } } } } fileprivate struct ViewOverridesDecodable<T: Decodable>: Decodable { var overrides: T? enum CodingKeys: String, CodingKey { case overrides = "view_overrides" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasViewedPageInfo.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /// - Note: for internal use only. :nodoc: public struct ThomasViewedPageInfo: Encodable, Sendable, Equatable, Hashable { public var identifier: String public var index: Int public var displayTime: TimeInterval public init(identifier: String, index: Int, displayTime: TimeInterval) { self.identifier = identifier self.index = index self.displayTime = displayTime } enum CodingKeys: String, CodingKey { case identifier = "page_identifier" case index = "page_index" case displayTime = "display_time" } public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(self.identifier, forKey: .identifier) try container.encode(self.index, forKey: .index) try container.encode( String(format: "%.2f", displayTime), forKey: .displayTime ) } } ================================================ FILE: Airship/AirshipCore/Source/ThomasVisibilityInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation struct ThomasVisibilityInfo: ThomasSerializable { let invertWhenStateMatches: JSONPredicate let defaultVisibility: Bool private enum CodingKeys: String, CodingKey { case invertWhenStateMatches = "invert_when_state_matches" case defaultVisibility = "default" } } ================================================ FILE: Airship/AirshipCore/Source/ThomasWindowSize.swift ================================================ /* Copyright Airship and Contributors */ import Foundation enum ThomasWindowSize: String, ThomasSerializable { case small case medium case large } ================================================ FILE: Airship/AirshipCore/Source/ToggleLayout.swift ================================================ import Foundation import SwiftUI @MainActor struct ToggleLayout<Content> : View where Content : View { @EnvironmentObject private var thomasState: ThomasState @Binding private var isOn: Bool private let onToggleOn: ThomasViewInfo.ToggleActions private let onToggleOff: ThomasViewInfo.ToggleActions private let content: () -> Content init( isOn: Binding<Bool>, onToggleOn: ThomasViewInfo.ToggleActions, onToggleOff: ThomasViewInfo.ToggleActions, @ViewBuilder content: @escaping () -> Content ) { self._isOn = isOn self.onToggleOn = onToggleOn self.onToggleOff = onToggleOff self.content = content } var body: some View { Toggle(isOn: $isOn.animation()) { content().background(Color.airshipTappableClear) } .airshipOnChangeOf(self.isOn) { isOn in self.handleStateActions(isOn) } .toggleStyle(PlainButtonToggleStyle()) .accessibilityRemoveTraits(.isSelected) } private func handleStateActions(_ isOn: Bool) { let actions: ThomasViewInfo.ToggleActions = isOn ? onToggleOn : onToggleOff guard let stateActions = actions.stateActions else { return } thomasState.processStateActions(stateActions) } } fileprivate struct PlainButtonToggleStyle: ToggleStyle { func makeBody(configuration: Self.Configuration) -> some View { Button { configuration.isOn.toggle() } label: { configuration.label } #if os(tvOS) .buttonStyle(TVButtonStyle()) #else .buttonStyle(.plain) #endif } } ================================================ FILE: Airship/AirshipCore/Source/TouchViewModifier.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if !os(tvOS) fileprivate struct TouchViewModifier: ViewModifier { @GestureState var isPressed: Bool = false let onChange: (Bool) -> Void func body(content: Content) -> some View { content .simultaneousGesture( TapGesture() ) .gesture( LongPressGesture(minimumDuration: 0.1) .sequenced(before: LongPressGesture(minimumDuration: .infinity)) .updating($isPressed) { value, state, transaction in switch value { case .second(true, nil): state = true default: break } } ) .airshipOnChangeOf(self.isPressed) { value in onChange(value) } } } extension View { func onTouch(onChange: @escaping (Bool) -> Void) -> some View { self.modifier(TouchViewModifier(onChange: onChange)) } } #endif ================================================ FILE: Airship/AirshipCore/Source/UAAppIntegrationDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import UserNotifications #if canImport(UIKit) import UIKit #endif #if canImport(WatchKit) import WatchKit #endif /// Delegate for Airship auto-integration events. protocol AppIntegrationDelegate: AnyObject, Sendable { @MainActor func didRegisterForRemoteNotifications(deviceToken: Data) @MainActor func didFailToRegisterForRemoteNotifications(error: any Error) @MainActor func onBackgroundAppRefresh() @MainActor func presentationOptions(for notification: UNNotification, completionHandler: @Sendable @escaping (UNNotificationPresentationOptions) -> Void) @MainActor func willPresentNotification(notification: UNNotification, presentationOptions: UNNotificationPresentationOptions, completionHandler: @Sendable @escaping () -> Void) #if !os(tvOS) @MainActor func didReceiveNotificationResponse(response: UNNotificationResponse, completionHandler: @Sendable @escaping () -> Void) #endif #if os(watchOS) @MainActor func didReceiveRemoteNotification(userInfo: [AnyHashable: Any], isForeground: Bool, completionHandler: @Sendable @escaping (WKBackgroundFetchResult) -> Void) #elseif os(macOS) @MainActor func didReceiveRemoteNotification(userInfo: [AnyHashable: Any], isForeground: Bool) #else @MainActor func didReceiveRemoteNotification(userInfo: [AnyHashable: Any], isForeground: Bool, completionHandler: @Sendable @escaping (UIBackgroundFetchResult) -> Void) #endif } ================================================ FILE: Airship/AirshipCore/Source/UACoreData.swift ================================================ /* Copyright Airship and Contributors */ public import CoreData #if canImport(UIKit) import UIKit #endif /// - Note: For internal use only. :nodoc: public actor UACoreData { private static let managedContextStoreDirectory: String = "com.urbanairship.no-backup" private let name: String private let modelURL: URL private let storeNames: [String] public nonisolated let inMemory: Bool private var shouldPrepareCoreData: Bool = false private var coreDataPrepared: Bool = false private var prepareCoreDataTask: Task<Void, any Error>? private var _container: NSPersistentContainer? private var container: NSPersistentContainer { get async throws { try await prepareCoreData() guard let container = _container else { throw AirshipErrors.error("Failed to get container") } return container } } private var _context: NSManagedObjectContext? private var context: NSManagedObjectContext { get async throws { if let context = _context { return context } let context = try await container.newBackgroundContext() _context = context return context } } public init( name: String, modelURL: URL, inMemory: Bool = false, stores: [String] ) { self.name = name self.modelURL = modelURL self.inMemory = inMemory self.storeNames = stores #if !os(watchOS) && !os(macOS) Task { @MainActor [weak self] in if (UIApplication.shared.isProtectedDataAvailable) { await self?.protectedDataAvailable() } else { guard let self else { return } NotificationCenter.default.addObserver(forName: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil, queue: nil, using: { _ in Task { [weak self] in await self?.protectedDataAvailable() } }) } } #endif } public func perform( skipIfStoreNotCreated: Bool = false, _ block: @Sendable @escaping (NSManagedObjectContext) throws -> Void ) async throws { if (skipIfStoreNotCreated) { guard self.inMemory || self.storesExistOnDisk() else { return } } let context = try await self.context try await withCheckedThrowingContinuation { continuation in context.perform { do { try block(context) try context.saveIfChanged() continuation.resume() } catch { continuation.resume(throwing: error) } } } } public func performWithNullableResult<T: Sendable>( skipIfStoreNotCreated: Bool = false, _ block: @Sendable @escaping (NSManagedObjectContext) throws -> T ) async throws -> T? { if (skipIfStoreNotCreated) { guard self.inMemory || self.storesExistOnDisk() else { return nil } } return try await performWithResult(block) } public func performWithResult<T: Sendable>( _ block: @Sendable @escaping (NSManagedObjectContext) throws -> T ) async throws -> T { let context = try await self.context return try await withCheckedThrowingContinuation { continuation in context.perform { do { let result = try block(context) try context.saveIfChanged() continuation.resume(returning: result) } catch { continuation.resume(throwing: error) } } } } public func deleteStoresOnDisk() throws { for name in self.storeNames { guard let storeURL = self.storeURL(name) else { continue } if FileManager.default.fileExists(atPath: storeURL.path) { try FileManager.default.removeItem(atPath: storeURL.path) } } } private func protectedDataAvailable() { Task { if self.shouldPrepareCoreData { _ = try? await self.prepareCoreData() } } } private func prepareCoreData() async throws { if (coreDataPrepared) { return } try? await prepareCoreDataTask?.value let task = Task { let container = try (_container ?? makeContainer()) if (_container == nil) { _container = container } if !coreDataPrepared { try await prepareStore() try await loadStores(container: container) coreDataPrepared = true } } prepareCoreDataTask = task try await task.value } private func prepareStore() async throws { if !inMemory { guard let storeDirectory = self.storeSQLDirectory() else { throw AirshipErrors.error("Unable to get store directory.") } let fileManager = FileManager.default if !fileManager.fileExists(atPath: storeDirectory.path) { do { try fileManager.createDirectory( at: storeDirectory, withIntermediateDirectories: true, attributes: nil ) } catch { throw AirshipErrors.error( "Failed to create airship SQL directory. \(error)" ) } } for name in self.storeNames { if let storeURL = self.storeURL(name) { correctFilePermissions(url: storeURL) } } } } private func loadStores(container: NSPersistentContainer) async throws { let remaining = AirshipAtomicValue(container.persistentStoreDescriptions.count) let errorMessage = AirshipAtomicValue<String?>(nil) try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) -> Void in container.loadPersistentStores { description, error in if let error { AirshipLogger.error( "Failed to create store \(description): \(error)" ) errorMessage.update { msg in if let msg { return "\(msg), \(error.localizedDescription)" } else { return error.localizedDescription } } } remaining.update { current in current - 1 } if (remaining.value == 0) { if let msg = errorMessage.value { continuation.resume(throwing: AirshipErrors.error(msg)) } else { continuation.resume() } } } } } private func storeSQLDirectory() -> URL? { let fileManager = FileManager.default #if os(tvOS) let baseDirectory = fileManager.urls( for: .cachesDirectory, in: .userDomainMask ) .last #else let baseDirectory = fileManager.urls( for: .libraryDirectory, in: .userDomainMask ) .last #endif return baseDirectory?.appendingPathComponent( Self.managedContextStoreDirectory ) } private func storeURL(_ storeName: String?) -> URL? { return storeSQLDirectory()?.appendingPathComponent(storeName ?? "") } private func storesExistOnDisk() -> Bool { for name in self.storeNames { let storeURL = self.storeURL(name) if storeURL != nil && FileManager.default.fileExists(atPath: storeURL?.path ?? "") { return true } } return false } private func makeContainer() throws -> NSPersistentContainer { guard let mom = NSManagedObjectModel(contentsOf: self.modelURL) else { throw AirshipErrors.error("Failed to create managed object model \(self.modelURL)") } let container = NSPersistentContainer(name: self.name, managedObjectModel: mom) if inMemory { let description = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null")) description.type = NSInMemoryStoreType container.persistentStoreDescriptions = [description] } else { container.persistentStoreDescriptions = self.storeNames.compactMap { store in guard let storeURL = self.storeURL(store) else { return nil } let description = NSPersistentStoreDescription(url: storeURL) description.type = NSSQLiteStoreType description.shouldAddStoreAsynchronously = true description.shouldMigrateStoreAutomatically = true description.shouldInferMappingModelAutomatically = true return description } } return container } private func correctFilePermissions(url: URL) { do { guard (FileManager.default.fileExists(atPath: url.path)) else { return } let attributes = [FileAttributeKey.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication] try FileManager.default.setAttributes(attributes, ofItemAtPath: url.path) } catch(let exception) { AirshipLogger.error("Failed to set file attribute \(exception)") } } } fileprivate extension NSManagedObjectContext { func saveIfChanged() throws { if hasChanges { try save() } } } ================================================ FILE: Airship/AirshipCore/Source/UARemoteDataMapping.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import CoreData @objc(UARemoteDataMappingV3toV4) class UARemoteDataMappingV3toV4: NSEntityMigrationPolicy { override func createDestinationInstances( forSource source: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager ) throws { // data -> JSON data guard source.entity.name == RemoteDataStore.remoteDataEntity else { return } let type = source.value(forKey: "type") as? String let timestamp = source.value(forKey: "timestamp") as? Date let data = source.value(forKey: "data") as? [AnyHashable: Any] let remoteDataInfo = source.value(forKey: "remoteDataInfo") as? Data let newRemoteDataEntity = NSEntityDescription.insertNewObject( forEntityName: RemoteDataStore.remoteDataEntity, into: manager.destinationContext ) newRemoteDataEntity.setValue(type, forKey: "type") newRemoteDataEntity.setValue(timestamp, forKey: "timestamp") newRemoteDataEntity.setValue(AirshipJSONUtils.toData(data), forKey: "data") newRemoteDataEntity.setValue(remoteDataInfo, forKey: "remoteDataInfo") } } @objc(UARemoteDataMappingV2toV4) class UARemoteDataMappingV2toV4: NSEntityMigrationPolicy { override func createDestinationInstances( forSource source: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager ) throws { // data -> JSON data // metadata -> drop guard source.entity.name == RemoteDataStore.remoteDataEntity else { return } let type = source.value(forKey: "type") as? String let timestamp = source.value(forKey: "timestamp") as? Date let data = source.value(forKey: "data") as? [AnyHashable: Any] let newRemoteDataEntity = NSEntityDescription.insertNewObject( forEntityName: RemoteDataStore.remoteDataEntity, into: manager.destinationContext ) newRemoteDataEntity.setValue(type, forKey: "type") newRemoteDataEntity.setValue(timestamp, forKey: "timestamp") newRemoteDataEntity.setValue(AirshipJSONUtils.toData(data), forKey: "data") } } @objc(UARemoteDataMappingV1toV4) class UARemoteDataMappingV1toV4: NSEntityMigrationPolicy { override func createDestinationInstances( forSource source: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager ) throws { // data -> JSON data guard source.entity.name == RemoteDataStore.remoteDataEntity else { return } let type = source.value(forKey: "type") as? String let timestamp = source.value(forKey: "timestamp") as? Date let data = source.value(forKey: "data") as? [AnyHashable: Any] let newRemoteDataEntity = NSEntityDescription.insertNewObject( forEntityName: RemoteDataStore.remoteDataEntity, into: manager.destinationContext ) newRemoteDataEntity.setValue(type, forKey: "type") newRemoteDataEntity.setValue(timestamp, forKey: "timestamp") newRemoteDataEntity.setValue(AirshipJSONUtils.toData(data), forKey: "data") } } ================================================ FILE: Airship/AirshipCore/Source/UNNotificationRegistrar.swift ================================================ // Copyright Airship and Contributors import Foundation @preconcurrency import UserNotifications /// UNNotificationCenter notification registrar struct UNNotificationRegistrar: NotificationRegistrar { #if !os(tvOS) @MainActor func setCategories(_ categories: Set<UNNotificationCategory>) { UNUserNotificationCenter.current().setNotificationCategories(categories) } #endif @MainActor func checkStatus() async -> (UNAuthorizationStatus, AirshipAuthorizedNotificationSettings) { let settings = await UNUserNotificationCenter.current().notificationSettings() return (settings.authorizationStatus, AirshipAuthorizedNotificationSettings.from(settings: settings)) } func updateRegistration( options: UNAuthorizationOptions, skipIfEphemeral: Bool ) async -> Void { let requestOptions = options let (status, settings) = await checkStatus() // Skip registration if no options are enable and we are requesting no options if settings == [] && requestOptions == [] { return } #if !os(tvOS) && !os(watchOS) && !os(macOS) // Skip registration for ephemeral if skipRegistrationIfEphemeral if status == .ephemeral && skipIfEphemeral { return } #endif var granted = false // Request do { granted = try await UNUserNotificationCenter.current().requestAuthorization(options: options) } catch { AirshipLogger.error( "requestAuthorizationWithOptions failed with error: \(error)" ) } AirshipLogger.debug( "requestAuthorizationWithOptions \(granted)" ) } } ================================================ FILE: Airship/AirshipCore/Source/URLAllowList.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(UIKit) import UIKit #endif /// Delegate protocol for accepting and rejecting URLs. public protocol URLAllowListDelegate: Sendable { /** * Called when a URL has been allowed by the SDK, but before the URL is fetched. * * - Parameters: * - url: The URL allowed by the SDK. * - scope: The scope of the desired match. * * - Returns: `true` to accept this URL, `false` to reject this URL. */ func allowURL(_ url: URL, scope: URLAllowListScope) -> Bool } /// Class for accepting and verifying webview URLs. /// /// URL allow list entries are written as URL patterns with optional wildcard matching: /// /// ~~~ /// <scheme> := <any char combination, '*' are treated as wildcards> /// /// <host> := '*' | '*.'<any char combination except '/' and '*'> | <any char combination except '/' and '*'> /// /// <path> := <any char combination, '*' are treated as wildcards> /// /// <pattern> := '*' | <scheme>://<host>/<path> | <scheme>://<host> | <scheme>:/<path> | <scheme>:///<path> /// ~~~ /// /// A single wildcard will match any URI. /// Wildcards in the scheme pattern will match any characters, and a single wildcard in the scheme will match any scheme. /// The wildcard in a host pattern `"*.mydomain.com"` will match anything within the mydomain.com domain. /// Wildcards in the path pattern will match any characters, including subdirectories. /// /// Note that NSURL does not support internationalized domains containing non-ASCII characters. /// All URL allow list entries for internationalized domains must be in ASCII IDNA format as /// specified in https://tools.ietf.org/html/rfc3490 public protocol AirshipURLAllowList: Sendable { /// The URL allow list delegate. /// /// - note: The delegate is not retained. @MainActor var delegate: (any URLAllowListDelegate)? { get set } /// If set, this block will be called when a URL is checked to override if it should be allowed or not. /// This will be called in place of the `URLAllowListDelegate` delegate. @MainActor var onAllowURL: (@MainActor @Sendable (URL, URLAllowListScope) -> Bool)? { get set } /// Determines whether a given URL is allowed, with the implicit scope `URLAllowListScope.all`. /// /// - Parameters: /// - url: The URL under consideration. /// /// - Returns: `true` if the URL is allowed, `false` otherwise. @MainActor func isAllowed(_ url: URL?) -> Bool /// Determines whether a given URL is allowed. /// /// - Parameters: /// - url: The URL under consideration. /// - scope: The scope of the desired match. /// /// - Returns: `true` if the URL is allowed, `false` otherwise. @MainActor func isAllowed(_ url: URL?, scope: URLAllowListScope) -> Bool /// Add an entry to the URL allow list. /// /// - Parameters: /// - patternString: A URL allow list pattern string. /// - scope: The scope of the pattern. /// /// - Returns: `true` if the URL allow list pattern was validated and added, `false` otherwise. @discardableResult @MainActor func addEntry(_ patternString: String, scope: URLAllowListScope) -> Bool /// Add an entry to the URL allow list, with the implicit scope `URLAllowListScope.all`. /// /// - Parameter patternString: A URL allow list pattern string. /// /// - Returns: `true` if the URL allow list pattern was validated and added, `false` otherwise. @discardableResult @MainActor func addEntry(_ patternString: String) -> Bool } @MainActor final class DefaultAirshipURLAllowList: AirshipURLAllowList { /// `<scheme> := <any chars (no spaces), '*' will match 0 or more characters>` private static let schemeRegex: String = "^([^\\s]*)$" /// `<host> := '*' | *.<valid host characters> | <valid host characters>` private static let hostRegex: String = "^((\\*)|(\\*\\.[^/\\*]+)|([^/\\*]+))$" /// `<path> | <scheme> := <any chars (no spaces), '*' will match 0 or more characters>` private static let pathRegex: String = "^([^\\s]*)$" /// Regular expression to escape from a pattern private static let escapeRegexCharacters: [String] = [ "\\", ".", "[", "]", "{", "}", "(", ")", "^", "$", "?", "+", "|", "*", ] private let schemePatternValidator: NSRegularExpression = try! NSRegularExpression( pattern: schemeRegex, options: .useUnicodeWordBoundaries ) private let hostPatternValidator: NSRegularExpression = try! NSRegularExpression( pattern: hostRegex, options: .useUnicodeWordBoundaries ) private let pathPatternValidator: NSRegularExpression = try! NSRegularExpression( pattern: pathRegex, options: .useUnicodeWordBoundaries ) @MainActor private var entries: Set<AllowListEntry> = [] init(airshipConfig: AirshipConfig) { addEntry("https://*.urbanairship.com") addEntry("https://*.asnapieu.com") if let initialConfigURL = airshipConfig.initialConfigURL { addEntry(initialConfigURL) } // Open only addEntry("mailto:", scope: .openURL) addEntry("sms:", scope: .openURL) addEntry("tel:", scope: .openURL) if (airshipConfig.urlAllowList == nil && airshipConfig.urlAllowListScopeOpenURL == nil) { addEntry("*", scope: .openURL) } #if os(macOS) // Allow-list the System Settings root and specific panes addEntry( "x-apple.systempreferences:", scope: .openURL ) #elseif !os(watchOS) // iOS, tvOS, visionOS addEntry( UIApplication.openSettingsURLString, scope: .openURL ) #endif airshipConfig.urlAllowList?.forEach { addEntry($0) } airshipConfig.urlAllowListScopeOpenURL?.forEach { addEntry($0, scope: .openURL) } airshipConfig.urlAllowListScopeJavaScriptInterface?.forEach { addEntry($0, scope: .javaScriptInterface) } } init() { } @MainActor var delegate: (any URLAllowListDelegate)? = nil @MainActor var onAllowURL: (@MainActor @Sendable (URL, URLAllowListScope) -> Bool)? = nil @discardableResult @MainActor func addEntry(_ patternString: String) -> Bool { return addEntry(patternString, scope: .all) } @discardableResult @MainActor func addEntry( _ patternString: String, scope: URLAllowListScope ) -> Bool { if patternString.isEmpty { AirshipLogger.error( "Invalid URL allow list pattern: \(patternString)" ) return false } let escapedPattern = Self.escapeSchemeWildcard(patternString) if patternString == "*" { let entry = AllowListEntry.entryWithMatcher( matcherForScheme("", host: "", path: ""), scope: scope, pattern: patternString ) entries.insert(entry) return true } guard let url = URL(string: escapedPattern) else { AirshipLogger.error( "Unable to parse URL for pattern: \(patternString)" ) return false } // Scheme WILDCARD -> * let scheme = url.scheme?.replacingOccurrences(of: "WILDCARD", with: "*") ?? "" if scheme.isEmpty || !Self.validatePattern( scheme, expression: schemePatternValidator ) { AirshipLogger.error( "Invalid scheme '\(scheme)' in URL allow list pattern: \(patternString)" ) return false } let host = url.host ?? "" if !host.isEmpty && !Self.validatePattern( host, expression: hostPatternValidator ) { AirshipLogger.error( "Invalid host '\(host)' in URL allow list pattern: \(patternString)" ) return false } let path = Self.pathForUrl(url) ?? "" if !path.isEmpty && !Self.validatePattern( path, expression: pathPatternValidator ) { AirshipLogger.error( "Invalid path '\(path)' in URL allow list pattern: \(patternString)" ) return false } let entry = AllowListEntry.entryWithMatcher( matcherForScheme(scheme, host: host, path: path), scope: scope, pattern: patternString ) entries.insert(entry) return true } @MainActor func isAllowed(_ url: URL?) -> Bool { return isAllowed(url, scope: .all) } @MainActor func isAllowed(_ url: URL?, scope: URLAllowListScope) -> Bool { guard let url = url else { return false } var match = false var matchedScope: URLAllowListScope = [] for entry in entries { if entry.matcher(url) { matchedScope.formUnion(entry.scope) } } match = matchedScope.contains(scope) // If the url is allowed, allow the delegate or block to reject the url if match { match = if let onAllowURL { onAllowURL(url, scope) } else if let delegate { delegate.allowURL(url, scope: scope) } else { match } } return match } // MARK: - Internal types / helpers /// Escapes URL allow list pattern strings so that they don't contain unanticipated regex characters. private static func escapeRegexString( _ input: String, escapingWildcards: Bool ) -> String { var input = input // Prefix all special characters with a backslash for char in escapeRegexCharacters { input = input.replacingOccurrences( of: char, with: "\\".appending(char) ) } // If wildcards are intended, transform them in to the appropriate regex pattern. if !escapingWildcards { input = input.replacingOccurrences(of: "\\*", with: ".*") } return input } private static func validatePattern( _ pattern: String, expression: NSRegularExpression ) -> Bool { let matches = expression.numberOfMatches( in: pattern, options: [], range: NSMakeRange(0, pattern.count) ) return matches > 0 } private static func escapeSchemeWildcard(_ pattern: String) -> String { var components = pattern.components(separatedBy: ":") guard components.count > 1 else { return pattern } let schemeComponent = components.removeFirst() .replacingOccurrences( of: "*", with: "WILDCARD" ) var array = [schemeComponent] array.append(contentsOf: components) return array.joined(separator: ":") } private static func compilePattern(_ pattern: String) -> NSRegularExpression? { var pattern = pattern if !pattern.hasPrefix("^") { pattern = "^".appending(pattern) } if !pattern.hasSuffix("$") { pattern = pattern.appending("$") } return try? NSRegularExpression(pattern: pattern, options: []) } private static func pathForUrl(_ url: URL) -> String? { // URL path using CoreFoundation, which preserves trailing slashes guard let path = CFURLCopyPath(url as CFURL) as String? else { // If the path is nil then it's nonstandard, use the resource specifier as path return (url as NSURL).resourceSpecifier } return path } private func matcherForScheme(_ scheme: String, host: String, path: String) -> AllowListMatcher { let schemeRegex: NSRegularExpression? if scheme.isEmpty || scheme == "*" { schemeRegex = nil } else { schemeRegex = Self.compilePattern( Self.escapeRegexString(scheme, escapingWildcards: false) ) } let hostRegex: NSRegularExpression? if host.isEmpty || host == "*" { hostRegex = nil } else if host.hasPrefix("*.") { let substring = host[host.index(host.startIndex, offsetBy: 2)...] hostRegex = Self.compilePattern( "(.*\\.)?" .appending( Self.escapeRegexString( String(substring), escapingWildcards: true ) ) ) } else { hostRegex = Self.compilePattern( "(.*\\.)?" .appending( Self.escapeRegexString( host, escapingWildcards: true ) ) ) } let pathRegex: NSRegularExpression? if path.isEmpty || path == "/*" || path == "*" { pathRegex = nil } else { pathRegex = Self.compilePattern( Self.escapeRegexString(path, escapingWildcards: false) ) } return { @MainActor (url: URL) -> Bool in let scheme = url.scheme ?? "" if let expression = schemeRegex, scheme.isEmpty || !Self.validatePattern( scheme, expression: expression ) { return false } let host = url.host ?? "" if let expression = hostRegex, host.isEmpty || !Self.validatePattern( host, expression: expression ) { return false } let path = Self.pathForUrl(url) ?? "" if let expression = pathRegex, path.isEmpty || !Self.validatePattern( path, expression: expression ) { return false } return true } } /// Block mapping URLs to allow list status. private typealias AllowListMatcher = @MainActor @Sendable (URL) -> Bool private struct AllowListEntry: Hashable, Sendable { let matcher: AllowListMatcher let scope: URLAllowListScope // Pattern is only used for hashing private let pattern: String static func entryWithMatcher( _ matcher: @escaping AllowListMatcher, scope: URLAllowListScope, pattern: String ) -> AllowListEntry { return AllowListEntry( matcher: matcher, scope: scope, pattern: pattern ) } static func == ( lhs: DefaultAirshipURLAllowList.AllowListEntry, rhs: DefaultAirshipURLAllowList.AllowListEntry ) -> Bool { lhs.scope == rhs.scope && lhs.pattern == rhs.pattern } func hash(into hasher: inout Hasher) { hasher.combine(scope.rawValue) hasher.combine(pattern) } } } // Scope options for URL allow list matching public struct URLAllowListScope: OptionSet, Sendable, Equatable { public let rawValue: Int // Scope that is checked before loading the JavaScript Native Bridge into // a WebView when using the `NativeBridge` public static let javaScriptInterface: URLAllowListScope = URLAllowListScope(rawValue: 1 << 0) // Scope that is checked before opening a URL. public static let openURL: URLAllowListScope = URLAllowListScope(rawValue: 1 << 1) // All scopes public static let all: URLAllowListScope = [.javaScriptInterface, .openURL] public init(rawValue: Int) { self.rawValue = rawValue } } ================================================ FILE: Airship/AirshipCore/Source/UrlInfo.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Url Info /// - Note: for internal use only. :nodoc: public enum URLInfo: Sendable, Equatable { case web(url: String, requireNetwork: Bool = true) case video(url: String, requireNetwork: Bool = true) case image(url: String, prefetch: Bool = true) } extension AirshipLayout { public var urlInfos: [URLInfo] { let urls: [[URLInfo]?] = extract { info in switch info { case .media(let info): return switch info.properties.mediaType { case .image: [.image(url: info.properties.url)] case .youtube: [.video(url: info.properties.url)] case .vimeo: [.video(url: info.properties.url)] case .video: [.video(url: info.properties.url)] } #if !os(tvOS) && !os(watchOS) case .webView(let info): return [.web(url: info.properties.url)] #endif case .imageButton(let info): return switch info.properties.image { case .url(let imageModel): [.image(url: imageModel.url)] case .icon: nil } case .stackImageButton(let info): var images: [URLInfo] = [] for item in info.properties.items { switch item { case .imageURL(let info): images.append(.image(url: info.url)) case .icon, .shape: break } } if let overrides = info.overrides?.items { for override in overrides { guard let item = override.value else { continue } for value in item { switch value { case .imageURL(let info): images.append(.image(url: info.url)) case .icon, .shape: break } } } } return images default: return nil } } return urls.compactMap { $0 }.reduce(into: []) { result, urlArray in result.append(contentsOf: urlArray) } } } ================================================ FILE: Airship/AirshipCore/Source/ValidatableHelper.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine @MainActor final class ValidatableHelper : ObservableObject { enum ValidationAction: Equatable, Hashable { case edit case error case valid case none } private var subscriptionState: [String: State] = [:] private final class State { var lastValue: (any Equatable)? var isInitialValue: Bool var subscription: AnyCancellable? var lastAction: ValidationAction? init(isInitialValue: Bool, lastValue: (any Equatable)? = nil, subscription: AnyCancellable? = nil) { self.isInitialValue = isInitialValue self.lastValue = lastValue self.subscription = subscription } } func subscribe<T: Equatable>( forIdentifier identifier: String, formState: ThomasFormState, initialValue: T?, valueUpdates: Published<T>.Publisher, validatables: ThomasValidationInfo, onStateActions: @escaping @MainActor ([ThomasStateAction]) -> Void ) { let state: State = subscriptionState[identifier] ?? State( isInitialValue: true, lastValue: initialValue ) subscriptionState[identifier] = state state.subscription?.cancel() state.subscription = Publishers.CombineLatest( formState.$status, valueUpdates ) .receive(on: RunLoop.main) .map { (status, value) -> ValidationAction in let fieldStatus = formState.lastFieldStatus( identifier: identifier ) var didEdit = false if value != state.lastValue as? Published<T>.Publisher.Output { state.lastValue = value didEdit = true state.isInitialValue = false } guard let fieldStatus else { return didEdit ? .edit : .none } switch (status) { case .valid, .error, .invalid: switch(fieldStatus) { case .valid: return .valid case .invalid: return if !state.isInitialValue || formState.validationMode == .onDemand { .error } else { .none } case .pending: return .edit case .error: return .none } default: return didEdit ? .edit : .none } } .filter { $0 != state.lastAction } .sink { action in state.lastAction = action let actions: [ThomasStateAction]? = switch(action) { case .edit: validatables.onEdit?.stateActions case .error: validatables.onError?.stateActions case .valid: validatables.onValid?.stateActions case .none: nil } guard let actions else { return } onStateActions(actions) } } } ================================================ FILE: Airship/AirshipCore/Source/VideoController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Controller view for managing video playback state. @MainActor struct VideoController: View { let info: ThomasViewInfo.VideoController let constraints: ViewConstraints @EnvironmentObject var environment: ThomasEnvironment @EnvironmentObject var state: ThomasState @EnvironmentObject var parentVideoState: VideoState init( info: ThomasViewInfo.VideoController, constraints: ViewConstraints ) { self.info = info self.constraints = constraints } var body: some View { Content( info: self.info, constraints: constraints, environment: environment, parentState: state, parentVideoState: parentVideoState ) } @MainActor struct Content: View { let info: ThomasViewInfo.VideoController let constraints: ViewConstraints @Environment(\.layoutState) var layoutState @StateObject var videoState: VideoState @StateObject var state: ThomasState init( info: ThomasViewInfo.VideoController, constraints: ViewConstraints, environment: ThomasEnvironment, parentState: ThomasState, parentVideoState: VideoState ) { self.info = info self.constraints = constraints let videoGroups = parentVideoState.videoGroups.copy() let muteGroup = if let id = info.properties.muteGroup?.identifier { videoGroups.muteGroup(for: id) } else { VideoGroupState() } let playGroup = if let id = info.properties.playGroup?.identifier { videoGroups.playGroup(for: id) } else { VideoGroupState() } let videoState = VideoState( identifier: info.properties.identifier, videoScope: info.properties.videoScope, videoGroups: videoGroups, muteGroup: muteGroup, playGroup: playGroup ) self._videoState = StateObject(wrappedValue: videoState) self._state = StateObject( wrappedValue: parentState.with(videoState: videoState) ) } var body: some View { ViewFactory.createView(self.info.properties.view, constraints: constraints) .constraints(constraints) .thomasCommon(self.info) .environmentObject(self.videoState) .environmentObject(self.state) .accessibilityElement(children: .contain) } } } ================================================ FILE: Airship/AirshipCore/Source/VideoGroupState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine @MainActor class VideoGroupState: ObservableObject { @Published var isMuted: Bool = false @Published var isPlaying: Bool = false private(set) var isMutedInitialized: Bool = false private(set) var isPlayingInitialized: Bool = false func initializeMuted(_ muted: Bool) { guard !isMutedInitialized else { return } isMutedInitialized = true isMuted = muted } func initializePlaying(_ playing: Bool) { guard !isPlayingInitialized else { return } isPlayingInitialized = true isPlaying = playing } } @MainActor class VideoGroups { private var muteGroups: [String: VideoGroupState] private var playGroups: [String: VideoGroupState] init( muteGroups: [String: VideoGroupState] = [:], playGroups: [String: VideoGroupState] = [:] ) { self.muteGroups = muteGroups self.playGroups = playGroups } func muteGroup(for id: String) -> VideoGroupState { if let existing = muteGroups[id] { return existing } let group = VideoGroupState() muteGroups[id] = group return group } func playGroup(for id: String) -> VideoGroupState { if let existing = playGroups[id] { return existing } let group = VideoGroupState() playGroups[id] = group return group } func copy() -> VideoGroups { VideoGroups(muteGroups: muteGroups, playGroups: playGroups) } } ================================================ FILE: Airship/AirshipCore/Source/VideoMediaNativeView.swift ================================================ /* Copyright Airship and Contributors */ #if !os(watchOS) && !os(macOS) import SwiftUI import AVFoundation import AVKit import Combine @MainActor private class VideoControlsObserver: ObservableObject { var timeObserver: Any? var endTimeObserver: (any NSObjectProtocol)? var statusObserver: NSKeyValueObservation? weak var player: AVPlayer? var isCancelled: Bool = false /// Called by wrapper internal func cleanup() { if let timeObserver = timeObserver, let player = player { player.removeTimeObserver(timeObserver) } timeObserver = nil if let observer = endTimeObserver { NotificationCenter.default.removeObserver(observer) endTimeObserver = nil } statusObserver?.invalidate() statusObserver = nil player = nil } } @MainActor struct VideoMediaNativeView: View { let info: ThomasViewInfo.Media let videoIdentifier: String? let constraints: ViewConstraints let videoAspectRatio: CGFloat let onMediaReady: @MainActor () -> Void @State private var hasError: Bool = false @State private var player: AVPlayer? @State private var isPlaying: Bool = false @State private var currentTime: Double = 0 @State private var duration: Double = 1.0 @State private var isControlsVisible: Bool = true @State private var controlsTimer: Timer? @EnvironmentObject var videoState: VideoState @Environment(\.isVisible) var isVisible private var showControls: Bool { info.properties.video?.showControls ?? true } private var shouldLoop: Bool { info.properties.video?.loop ?? false } var body: some View { NativeVideoPlayer( info: info, videoIdentifier: videoIdentifier, onMediaReady: onMediaReady, hasError: $hasError, player: $player ) .airshipApplyIf(self.constraints.width == nil || self.constraints.height == nil) { $0.aspectRatio(videoAspectRatio, contentMode: ContentMode.fill) } .fitVideo( mediaFit: self.info.properties.mediaFit, cropPosition: self.info.properties.cropPosition, constraints: constraints, videoAspectRatio: videoAspectRatio ) .modifier( VideoControls( hasError: hasError, showControls: showControls, shouldLoop: shouldLoop, player: player, isPlaying: $isPlaying, currentTime: $currentTime, duration: $duration, isControlsVisible: $isControlsVisible, controlsTimer: $controlsTimer ) ) } } internal struct VideoControls: ViewModifier { let hasError: Bool let showControls: Bool let shouldLoop: Bool let player: AVPlayer? @Binding var isPlaying: Bool @Binding var currentTime: Double @Binding var duration: Double @Binding var isControlsVisible: Bool @Binding var controlsTimer: Timer? @StateObject private var observer = VideoControlsObserver() @State private var isDraggingSlider: Bool = false func body(content: Content) -> some View { content .overlay( GeometryReader { geometry in ZStack { if hasError { VideoErrorView() .frame(width: geometry.size.width, height: geometry.size.height) } else if showControls && isControlsVisible && player?.currentItem?.status == .readyToPlay { VideoControlsView( player: player, isPlaying: $isPlaying, currentTime: $currentTime, duration: $duration, isDraggingSlider: $isDraggingSlider, size: geometry.size, onInteraction: resetHideTimer ) .frame(width: geometry.size.width, height: geometry.size.height) .transition(.opacity) } } } .allowsHitTesting(hasError || (showControls && isControlsVisible)) ) .onTapGesture { if showControls && !hasError { toggleControlsVisibility() } } .onAppear { setupPlayerObservers() if showControls { startHideTimer() } } .onDisappear { cleanup() /// Do final observer cleanup observer.cleanup() } .airshipOnChangeOf(player) { _ in cleanup() setupPlayerObservers() if showControls { startHideTimer() } } } private func toggleControlsVisibility() { withAnimation(.easeInOut(duration: 0.3)) { isControlsVisible.toggle() } if isControlsVisible { startHideTimer() } else { controlsTimer?.invalidate() } } private func resetHideTimer() { if isControlsVisible { startHideTimer() } } private func startHideTimer() { controlsTimer?.invalidate() let visibilityBinding = _isControlsVisible let observer = observer controlsTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { _ in Task { @MainActor in guard !observer.isCancelled else { return } withAnimation(.easeInOut(duration: 0.3)) { visibilityBinding.wrappedValue = false } } } } private func setupPlayerObservers() { guard let player = player else { return } observer.cleanup() observer.isCancelled = false observer.player = player let isPlayingBinding = _isPlaying let currentTimeBinding = _currentTime let durationBinding = _duration let isDraggingBinding = _isDraggingSlider if !shouldLoop { observer.endTimeObserver = NotificationCenter.default.addObserver( forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main ) { _ in Task { @MainActor in isPlayingBinding.wrappedValue = false player.seek(to: .zero) } } } let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) observer.timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main ) { time in if !isDraggingBinding.wrappedValue { currentTimeBinding.wrappedValue = time.seconds } isPlayingBinding.wrappedValue = player.rate > 0 if let currentItem = player.currentItem { let duration = currentItem.duration if duration.isNumeric && !duration.isIndefinite { durationBinding.wrappedValue = duration.seconds } } } isPlaying = player.rate > 0 if let currentItem = player.currentItem { let duration = currentItem.duration if duration.isNumeric && !duration.isIndefinite { self.duration = duration.seconds } } if let currentItem = player.currentItem { let currentTime = currentItem.currentTime() if currentTime.isNumeric { self.currentTime = currentTime.seconds } } observer.statusObserver = player.currentItem?.observe(\.status, options: [.new, .initial]) { item, _ in Task { @MainActor in if item.status == .readyToPlay { let duration = item.duration if duration.isNumeric && !duration.isIndefinite { durationBinding.wrappedValue = duration.seconds } let currentTime = item.currentTime() if currentTime.isNumeric { currentTimeBinding.wrappedValue = currentTime.seconds } isPlayingBinding.wrappedValue = player.rate > 0 } } } } private func cleanup() { controlsTimer?.invalidate() controlsTimer = nil observer.isCancelled = true observer.cleanup() } } private struct VideoErrorView: View { var body: some View { Color.black .overlay( Image(systemName: "play.slash.fill") .font(.system(size: 60)) .foregroundColor(.white.opacity(0.5)) ) } } private struct VideoControlsView: View { let player: AVPlayer? @Binding var isPlaying: Bool @Binding var currentTime: Double @Binding var duration: Double @Binding var isDraggingSlider: Bool let size: CGSize let onInteraction: () -> Void @State private var isMuted: Bool = false @State private var wasPlayingBeforeDrag: Bool = false private var scaleFactor: CGFloat { let baseControlsWidth: CGFloat = 320 let baseControlsHeight: CGFloat = 180 let widthScale = size.width / baseControlsWidth let heightScale = size.height / baseControlsHeight let scale = min(widthScale, heightScale, 1.0) return max(scale, 0.2) } private var centerControlSize: CGFloat { 50 * scaleFactor } private var skipButtonSize: CGFloat { 30 * scaleFactor } private var controlSpacing: CGFloat { 60 * scaleFactor } private var controlPadding: CGFloat { 40 * scaleFactor } private var fontSize: CGFloat { 14 * scaleFactor } private var iconSize: CGFloat { 18 * scaleFactor } private var bottomBarPadding: CGFloat { 12 * scaleFactor } private var progressBarSpacing: CGFloat { 10 * scaleFactor } private var cornerRadius: CGFloat { 20 * scaleFactor } var body: some View { ZStack { Color.clear .contentShape(Rectangle()) VStack(spacing: 0) { Spacer() HStack(spacing: controlSpacing) { Button(action: { skipBackward() onInteraction() }) { Image(systemName: "gobackward.15") .font(.system(size: skipButtonSize)) .foregroundColor(.white) } Button(action: { togglePlayPause() onInteraction() }) { Image(systemName: isPlaying ? "pause.fill" : "play.fill") .font(.system(size: centerControlSize)) .foregroundColor(.white) } Button(action: { skipForward() onInteraction() }) { Image(systemName: "goforward.15") .font(.system(size: skipButtonSize)) .foregroundColor(.white) } } .padding(controlPadding) .background(Color.black.opacity(0.3)) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) Spacer() VStack(spacing: progressBarSpacing) { GeometryReader { geometry in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 2 * scaleFactor) .fill(Color.white.opacity(0.3)) .frame(height: 4 * scaleFactor) .frame(maxHeight: .infinity) RoundedRectangle(cornerRadius: 2 * scaleFactor) .fill(Color.white) .frame(width: max(0, (duration > 0 ? CGFloat(currentTime / duration) : 0) * geometry.size.width), height: 4 * scaleFactor) .frame(maxHeight: .infinity) Circle() .fill(Color.white) .frame(width: 12 * scaleFactor, height: 12 * scaleFactor) .position( x: max(6 * scaleFactor, min(geometry.size.width - 6 * scaleFactor, (duration > 0 ? CGFloat(currentTime / duration) : 0) * geometry.size.width)), y: geometry.size.height / 2 ) } .contentShape(Rectangle()) #if os(tvOS) .focusable() .onMoveCommand { direction in let stepSize: Double = 5.0 // 5 second steps switch direction { case .left: let newTime = max(0, currentTime - stepSize) currentTime = newTime seek(to: newTime) onInteraction() case .right: let newTime = min(duration, currentTime + stepSize) currentTime = newTime seek(to: newTime) onInteraction() default: break } } #else .gesture( DragGesture() .onChanged { value in if !isDraggingSlider { wasPlayingBeforeDrag = isPlaying } isDraggingSlider = true let progress = max(0, min(1, value.location.x / geometry.size.width)) currentTime = progress * duration player?.pause() onInteraction() } .onEnded { _ in isDraggingSlider = false seek(to: currentTime) if wasPlayingBeforeDrag { player?.play() } onInteraction() } ) .gesture( DragGesture(minimumDistance: 0) .onEnded { value in let progress = max(0, min(1, value.location.x / geometry.size.width)) currentTime = progress * duration seek(to: currentTime) onInteraction() } ) #endif } .frame(height: max(20 * scaleFactor, 20)) HStack { Text(formatTime(currentTime)) .font(.system(size: fontSize)) .foregroundColor(.white) .monospacedDigit() .lineLimit(1) Spacer() Text("-\(formatTime(max(0, duration - currentTime)))") .font(.system(size: fontSize)) .foregroundColor(.white) .monospacedDigit() .lineLimit(1) Button(action: { toggleMute() onInteraction() }) { Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") .font(.system(size: iconSize)) .foregroundColor(.white) .frame(width: iconSize * 1.5, height: iconSize * 1.5) } } } .padding(bottomBarPadding) .background( LinearGradient( gradient: Gradient(colors: [Color.black.opacity(0), Color.black.opacity(0.8)]), startPoint: .top, endPoint: .bottom ) ) } } .onAppear { if let player = player { isMuted = player.isMuted } } } private func togglePlayPause() { guard let player = player else { return } if isPlaying { player.pause() } else { player.play() } isPlaying = player.rate > 0 } private func seek(to time: Double) { guard let player = player else { return } let targetTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero) } private func skipBackward() { let newTime = max(currentTime - 15, 0) seek(to: newTime) } private func skipForward() { let newTime = min(currentTime + 15, duration) seek(to: newTime) } private func toggleMute() { guard let player = player else { return } player.isMuted.toggle() isMuted = player.isMuted } private static let hourFormatter: DateComponentsFormatter = { let f = DateComponentsFormatter() f.unitsStyle = .positional f.zeroFormattingBehavior = .pad f.allowedUnits = [.hour, .minute, .second] return f }() private static let minuteFormatter: DateComponentsFormatter = { let f = DateComponentsFormatter() f.unitsStyle = .positional f.zeroFormattingBehavior = .pad f.allowedUnits = [.minute, .second] return f }() private func formatTime(_ interval: TimeInterval, locale: Locale = Locale.current) -> String { if interval.isNaN || interval.isInfinite { return "--:--" } let effectiveInterval = max(0, interval) let showsHours = effectiveInterval >= 3600.0 let formatter = showsHours ? Self.hourFormatter : Self.minuteFormatter var calendar = Calendar.current calendar.locale = locale formatter.calendar = calendar return formatter.string(from: effectiveInterval) ?? (showsHours ? "00:00:00" : "00:00") } } #endif ================================================ FILE: Airship/AirshipCore/Source/VideoMediaWebView.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Foundation import SwiftUI import WebKit @MainActor private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { weak var delegate: (any WKScriptMessageHandler)? init(_ delegate: any WKScriptMessageHandler) { self.delegate = delegate super.init() } func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { delegate?.userContentController(userContentController, didReceive: message) } } struct VideoMediaWebView: AirshipNativeViewRepresentable { #if os(macOS) typealias NSViewType = WKWebView func makeNSView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateNSView(_ nsView: WKWebView, context: Context) { updateView(nsView, context: context) } static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) { coordinator.teardown() } #else typealias UIViewType = WKWebView func makeUIView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateUIView(_ uiView: WKWebView, context: Context) { updateView(uiView, context: context) } static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { coordinator.teardown() } #endif let info: ThomasViewInfo.Media let videoIdentifier: String? let onMediaReady: @MainActor () -> Void @Environment(\.isVisible) var isVisible @State private var isLoaded: Bool = false @EnvironmentObject var pagerState: PagerState @EnvironmentObject var videoState: VideoState @EnvironmentObject var thomasEnvironment: ThomasEnvironment @Environment(\.layoutDirection) var layoutDirection private var url: String { self.info.properties.url } private var styleForVideo: String { switch(self.info.properties.mediaFit) { case .centerInside: return "object-fit: contain" case .center: return "object-fit: none" case .fitCrop: guard let position = self.info.properties.cropPosition else { return "object-fit: cover" } let horizontal = switch(position.horizontal) { case .center: "center" case .start: if layoutDirection == .leftToRight { "left" } else { "right" } case .end: if layoutDirection == .leftToRight { "right" } else { "left" } } let vertical = switch(position.vertical) { case .center: "center" case .top: "top" case .bottom: "bottom" } return "width: 100vw; height: 100vh; object-fit: cover; object-position: \(horizontal) \(vertical)" case .centerCrop: return "object-fit: cover" } } private var baseURL: URL? { let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.airship.sdk" return URL(string: "https://\(bundleIdentifier)") } @MainActor func makeWebView(context: Context) -> WKWebView { let contentController = WKUserContentController() contentController.add(WeakScriptMessageHandler(context.coordinator), name: "callback") let config = WKWebViewConfiguration() config.userContentController = contentController #if os(macOS) let webView = WKWebView(frame: .zero, configuration: config) webView.setAccessibilityElement(true) webView.setAccessibilityLabel(self.info.accessible.contentDescription) webView.layer?.backgroundColor = .clear webView.setValue(false, forKey: "drawsBackground") // For transparency #else config.allowsInlineMediaPlayback = true config.mediaTypesRequiringUserActionForPlayback = [] config.allowsPictureInPictureMediaPlayback = true let webView = WKWebView(frame: .zero, configuration: config) webView.isAccessibilityElement = true webView.accessibilityLabel = self.info.accessible.contentDescription webView.scrollView.isScrollEnabled = false webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear webView.scrollView.contentInsetAdjustmentBehavior = .never #endif webView.navigationDelegate = context.coordinator context.coordinator.configure(webView: webView) if #available(iOS 16.4, *) { webView.isInspectable = Airship.isFlying && Airship.config.airshipConfig.isWebViewInspectionEnabled } loadMediaContent(webView: webView) return webView } @MainActor private func updateView(_ view: WKWebView, context: Context) { let isVisible = isVisible let isLoaded = isLoaded let inProgress = pagerState.inProgress let isDismissed = thomasEnvironment.isDismissed Task { @MainActor [weak coordinator = context.coordinator] in coordinator?.update( isVisible: isVisible, isLoaded: isLoaded, inProgress: inProgress, isDismissed: isDismissed ) } } @MainActor private func loadMediaContent(webView: WKWebView) { let video = self.info.properties.video switch(info.properties.mediaType) { case .image: return case .video: let html = String( format: """ <body style="margin:0; background-color:transparent;"> <video id="video" playsinline %@ %@ %@ %@ height="100%%" width="100%%" src="%@" style="%@"></video> <script> let videoElement = document.getElementById("video"); videoElement.addEventListener("canplay", (event) => { webkit.messageHandlers.callback.postMessage('mediaReady'); webkit.messageHandlers.callback.postMessage(videoElement.muted ? 'muted' : 'unmuted'); }); videoElement.addEventListener("play", () => { webkit.messageHandlers.callback.postMessage('playing'); }); videoElement.addEventListener("pause", () => { webkit.messageHandlers.callback.postMessage('paused'); }); videoElement.addEventListener("ended", () => { webkit.messageHandlers.callback.postMessage('ended'); }); </script> </body> """, video?.showControls ?? true ? "controls" : "", video?.autoplay ?? false ? "autoplay" : "", video?.muted ?? false ? "muted" : "", video?.loop ?? false ? "loop" : "", url, styleForVideo ) webView.loadHTMLString(html, baseURL: baseURL) case .youtube: if let videoID = Self.retrieveYoutubeVideoID(url: url) { let html = String( format: """ <head> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <style> body { margin:0; background-color:transparent; overflow:hidden; } #player { width:100vw; height:100vh; } </style> </head> <body> <div id="player"></div> <script> var tag = document.createElement('script'); tag.src = "https://www.youtube.com/iframe_api"; var firstScriptTag = document.getElementsByTagName('script')[0]; firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); var player; function onYouTubeIframeAPIReady() { player = new YT.Player('player', { height: '100%%', width: '100%%', videoId: '%@', playerVars: { 'playsinline': 1, 'modestbranding': 1, 'controls': %@, 'autoplay': %@, 'mute': %@, 'loop': %@ }, events: { 'onReady': onPlayerReady, 'onStateChange': function(event) { if (event.data === YT.PlayerState.PLAYING) { webkit.messageHandlers.callback.postMessage('playing'); } else if (event.data === YT.PlayerState.PAUSED) { webkit.messageHandlers.callback.postMessage('paused'); } else if (event.data === YT.PlayerState.ENDED) { webkit.messageHandlers.callback.postMessage('ended'); } } } }); } function onPlayerReady(event) { webkit.messageHandlers.callback.postMessage('mediaReady'); webkit.messageHandlers.callback.postMessage(player.isMuted() ? 'muted' : 'unmuted'); } </script> </body> """, videoID, video?.showControls ?? true ? "1" : "0", video?.autoplay ?? false ? "1" : "0", video?.muted ?? false ? "1" : "0", video?.loop ?? false ? "1, \'playlist\': \'\(videoID)\'" : "0" ) webView.loadHTMLString(html, baseURL: baseURL) } else { let suffix = url.contains("?") ? "&playsinline=1" : "?playsinline=1" if let videoUrl = URL(string: "\(url)\(suffix)") { webView.load(URLRequest(url: videoUrl)) } } case .vimeo: let html = String( format: """ <head><meta name="viewport" content="initial-scale=1,maximum-scale=1"></head> <body style="margin:0; background-color:transparent;"> <iframe id="vimeoIframe" src="%@&playsinline=1" width="100%%" height="100%%" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen> </iframe> <script src="https://player.vimeo.com/api/player.js"></script> <script> const vimeoIframe = document.querySelector('#vimeoIframe'); const vimeoPlayer = new Vimeo.Player(vimeoIframe); vimeoPlayer.ready().then(function() { webkit.messageHandlers.callback.postMessage('mediaReady'); vimeoPlayer.on('play', function() { webkit.messageHandlers.callback.postMessage('playing'); }); vimeoPlayer.on('pause', function() { webkit.messageHandlers.callback.postMessage('paused'); }); vimeoPlayer.on('ended', function() { webkit.messageHandlers.callback.postMessage('ended'); }); return vimeoPlayer.getMuted(); }).then(function(muted) { webkit.messageHandlers.callback.postMessage(muted ? 'muted' : 'unmuted'); }); </script> </body> """, url ) webView.loadHTMLString(html, baseURL: baseURL) } } static func retrieveYoutubeVideoID(url: String) -> String? { do { let regex = try NSRegularExpression(pattern: "embed/([a-zA-Z0-9_-]+)") guard let match = regex.firstMatch(in: url, range: NSRange(url.startIndex..., in: url)), let range = Range(match.range(at: 1), in: url) else { return nil } return String(url[range]) } catch { return nil } } private var video: ThomasViewInfo.Media.Video? { self.info.properties.video } func makeCoordinator() -> Coordinator { let video = self.info.properties.video return Coordinator( isLoaded: $isLoaded, videoIdentifier: videoIdentifier, videoState: videoState, mediaType: info.properties.mediaType, isAutoplay: video?.autoplay ?? false, showControls: video?.showControls ?? true, autoResetPosition: video?.autoResetPosition ?? ((video?.autoplay ?? false) && !(video?.showControls ?? true)), isLooping: video?.loop ?? false, isMuted: video?.muted ?? false, onMediaReady: onMediaReady ) } // MARK: - Coordinator class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { private var isLoaded: Binding<Bool> private var videoIdentifier: String? private var videoState: VideoState private var onMediaReady: @MainActor () -> Void private let challengeResolver: ChallengeResolver private weak var webView: WKWebView? private var lastIsVisible: Bool = false private var lastIsLoaded: Bool = false private var lastInProgress: Bool = true /// Tracks whether the system (visibility change, pager, backgrounding) initiated a pause. /// When true, incoming "paused" JS callbacks won't clear `localIsPlaying`. private var isSystemPausing: Bool = false /// Tracks playing intent from JS callbacks. `nil` = initial (autoplay should trigger), /// `true` = playing/was playing, `false` = user explicitly paused. /// Guarded by `isSystemPausing` so system pauses don't clear user intent. private var localIsPlaying: Bool? = nil private let mediaType: ThomasViewInfo.Media.MediaType private let isAutoplay: Bool private let showControls: Bool private let autoResetPosition: Bool private let isLooping: Bool private let isMuted: Bool private var appStateTask: Task<Void, Never>? init( isLoaded: Binding<Bool>, videoIdentifier: String?, videoState: VideoState, mediaType: ThomasViewInfo.Media.MediaType, isAutoplay: Bool, showControls: Bool, autoResetPosition: Bool, isLooping: Bool, isMuted: Bool, resolver: ChallengeResolver = .shared, onMediaReady: @escaping @MainActor () -> Void ) { self.isLoaded = isLoaded self.videoIdentifier = videoIdentifier self.videoState = videoState self.mediaType = mediaType self.isAutoplay = isAutoplay self.showControls = showControls self.autoResetPosition = autoResetPosition self.isLooping = isLooping self.isMuted = isMuted self.onMediaReady = onMediaReady self.challengeResolver = resolver super.init() AirshipLogger.trace("VideoMediaWebView Coordinator init, mediaType: \(mediaType)") appStateTask = Task { @MainActor [weak self] in for await state in AppStateTracker.shared.stateUpdates { guard !Task.isCancelled else { return } if state == .active { self?.handleForeground() } else { self?.systemPause() } } } } deinit { appStateTask?.cancel() AirshipLogger.trace("VideoMediaWebView Coordinator deinit") } @MainActor func configure(webView: WKWebView) { self.webView = webView } @MainActor func teardown() { appStateTask?.cancel() appStateTask = nil if let videoIdentifier { videoState.unregister(videoIdentifier: videoIdentifier) } self.webView?.stopLoading() self.webView?.navigationDelegate = nil self.webView?.configuration.userContentController.removeAllScriptMessageHandlers() self.webView?.pauseAllMediaPlayback() #if !os(macOS) if #unavailable(iOS 26.3) { if self.webView?.superview != nil { self.webView?.removeFromSuperview() } } #endif self.webView = nil } // MARK: - Playback Control @MainActor private func play() { guard let webView else { return } switch mediaType { case .video: webView.evaluateJavaScript("videoElement.play();") case .youtube: webView.evaluateJavaScript("player.playVideo();") case .vimeo: webView.evaluateJavaScript("vimeoPlayer.play();") case .image: break } } @MainActor private func pause() { guard let webView else { return } switch mediaType { case .video: webView.evaluateJavaScript("videoElement.pause();") case .youtube: webView.evaluateJavaScript("player.pauseVideo();") case .vimeo: webView.evaluateJavaScript("vimeoPlayer.pause();") case .image: break } } @MainActor private func reset() { guard autoResetPosition, let webView else { return } switch mediaType { case .video: webView.evaluateJavaScript("videoElement.currentTime = 0;") case .youtube: webView.evaluateJavaScript("player.seekTo(0);") case .vimeo: webView.evaluateJavaScript("vimeoPlayer.setCurrentTime(0);") case .image: break } } @MainActor private func mute() { guard let webView else { return } switch mediaType { case .video: webView.evaluateJavaScript("videoElement.muted = true;") case .youtube: webView.evaluateJavaScript("player.mute();") case .vimeo: webView.evaluateJavaScript("vimeoPlayer.setMuted(true);") case .image: break } } @MainActor private func unmute() { guard let webView else { return } switch mediaType { case .video: webView.evaluateJavaScript("videoElement.muted = false;") case .youtube: webView.evaluateJavaScript("player.unMute();") case .vimeo: webView.evaluateJavaScript("vimeoPlayer.setMuted(false);") case .image: break } } // MARK: - State Management @MainActor func update(isVisible: Bool, isLoaded: Bool, inProgress: Bool, isDismissed: Bool) { guard !isDismissed else { teardown() return } let didChange = lastIsVisible != isVisible || lastIsLoaded != isLoaded || lastInProgress != inProgress lastIsVisible = isVisible lastIsLoaded = isLoaded lastInProgress = inProgress guard didChange else { return } if inProgress, isVisible, isLoaded { handleResume() } else { if !isVisible { self.reset() } systemPause() } } @MainActor private func systemPause() { isSystemPausing = true pause() } @MainActor private func handleForeground() { guard lastIsVisible, lastIsLoaded, lastInProgress else { return } handleResume() } @MainActor private func handleResume() { let shouldPlay: Bool if videoState.shouldControl(videoIdentifier: videoIdentifier) { shouldPlay = videoState.isPlaying } else if isAutoplay { shouldPlay = localIsPlaying != false } else { shouldPlay = localIsPlaying == true } isSystemPausing = false if shouldPlay { localIsPlaying = true play() } } // MARK: - Video State Registration @MainActor private func registerWithVideoState() { guard let videoId = videoIdentifier, videoState.shouldControl(videoIdentifier: videoId) else { return } videoState.register( videoIdentifier: videoId, play: { [weak self] in self?.play() }, pause: { [weak self] in self?.pause() }, mute: { [weak self] in self?.mute() }, unmute: { [weak self] in self?.unmute() } ) videoState.playGroup.initializePlaying(isAutoplay) videoState.muteGroup.initializeMuted(isMuted) } // MARK: - WKNavigationDelegate func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { Task { @MainActor [weak self] in guard let self else { return } self.isLoaded.wrappedValue = true self.registerWithVideoState() } } func webView(_ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await challengeResolver.resolve(challenge) } // MARK: - WKScriptMessageHandler @MainActor func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { guard let response = message.body as? String else { return } let canControlVideo = lastIsVisible && videoState.shouldControl(videoIdentifier: videoIdentifier) switch response { case "mediaReady": onMediaReady() if canControlVideo { if videoState.isMuted { mute() } else { unmute() } } case "playing": if canControlVideo { videoState.updatePlayingState(true) } else { localIsPlaying = true } case "paused": if canControlVideo && !isSystemPausing { videoState.updatePlayingState(false) } else if !isSystemPausing { localIsPlaying = false } case "ended" where !isLooping: if canControlVideo && !isSystemPausing { videoState.updatePlayingState(false) } else if !isSystemPausing { localIsPlaying = false } case "muted" where canControlVideo && showControls: videoState.updateMutedState(true) case "unmuted" where canControlVideo && showControls: videoState.updateMutedState(false) default: break } } } } #endif ================================================ FILE: Airship/AirshipCore/Source/VideoState.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine /// State class managing video playback state within a VideoController scope. @MainActor class VideoState: ObservableObject { /// The unique identifier for this video controller let identifier: String /// Optional array of video identifiers to control. If nil, controls all videos. let videoScope: [String]? /// Shared group container that flows through the VideoState chain. let videoGroups: VideoGroups let muteGroup: VideoGroupState let playGroup: VideoGroupState var isPlaying: Bool { playGroup.isPlaying } var isMuted: Bool { muteGroup.isMuted } var isPlayingPublisher: AnyPublisher<Bool, Never> { playGroup.$isPlaying.eraseToAnyPublisher() } var isMutedPublisher: AnyPublisher<Bool, Never> { muteGroup.$isMuted.eraseToAnyPublisher() } /// Registry of video callbacks private var registeredVideos: [String: VideoRegistration] = [:] private var subscriptions: Set<AnyCancellable> = Set() struct VideoRegistration { let play: @MainActor () -> Void let pause: @MainActor () -> Void let mute: @MainActor () -> Void let unmute: @MainActor () -> Void } init( identifier: String, videoScope: [String]? = nil, videoGroups: VideoGroups = VideoGroups(), muteGroup: VideoGroupState = VideoGroupState(), playGroup: VideoGroupState = VideoGroupState() ) { self.identifier = identifier self.videoScope = videoScope self.videoGroups = videoGroups self.muteGroup = muteGroup self.playGroup = playGroup setupGroupSync() } /// False when this is the default fallback state injected at the root with no real VideoController ancestor. private var hasController: Bool { guard !identifier.isEmpty else { return false } return true } /// Determines if this controller should control a video with the given identifier func shouldControl(videoIdentifier: String?) -> Bool { guard hasController else { return false } guard let scope = videoScope else { return true } guard let videoId = videoIdentifier else { return false } return scope.contains(videoId) } /// Register a video with its control callbacks func register( videoIdentifier: String, play: @escaping @MainActor () -> Void, pause: @escaping @MainActor () -> Void, mute: @escaping @MainActor () -> Void, unmute: @escaping @MainActor () -> Void ) { guard shouldControl(videoIdentifier: videoIdentifier) else { return } guard hasController else { return } registeredVideos[videoIdentifier] = VideoRegistration( play: play, pause: pause, mute: mute, unmute: unmute ) } /// Unregister a video func unregister(videoIdentifier: String) { registeredVideos.removeValue(forKey: videoIdentifier) } // MARK: - Playback Control func play() { guard hasController else { return } registeredVideos.values.forEach { $0.play() } playGroup.isPlaying = true } func pause() { guard hasController else { return } registeredVideos.values.forEach { $0.pause() } playGroup.isPlaying = false } func togglePlay() { guard hasController else { return } if isPlaying { pause() } else { play() } } // MARK: - Mute Control func mute() { guard hasController else { return } registeredVideos.values.forEach { $0.mute() } muteGroup.isMuted = true } func unmute() { guard hasController else { return } registeredVideos.values.forEach { $0.unmute() } muteGroup.isMuted = false } func toggleMute() { guard hasController else { return } if isMuted { unmute() } else { mute() } } // MARK: - State Updates from Videos func updatePlayingState(_ playing: Bool) { if !playGroup.isPlayingInitialized { playGroup.initializePlaying(playing) } else if isPlaying != playing { playGroup.isPlaying = playing } } func updateMutedState(_ muted: Bool) { if !muteGroup.isMutedInitialized { muteGroup.initializeMuted(muted) } else if isMuted != muted { muteGroup.isMuted = muted } } // MARK: - Group Sync private func setupGroupSync() { muteGroup.objectWillChange.sink { [weak self] _ in Task { @MainActor in guard let self else { return } self.objectWillChange.send() let muted = self.muteGroup.isMuted if muted { self.registeredVideos.values.forEach { $0.mute() } } else { self.registeredVideos.values.forEach { $0.unmute() } } } }.store(in: &subscriptions) playGroup.objectWillChange.sink { [weak self] _ in Task { @MainActor in guard let self else { return } self.objectWillChange.send() let playing = self.playGroup.isPlaying if playing { self.registeredVideos.values.forEach { $0.play() } } else { self.registeredVideos.values.forEach { $0.pause() } } } }.store(in: &subscriptions) } } ================================================ FILE: Airship/AirshipCore/Source/ViewConstraints.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct ViewConstraints: Equatable { enum SafeAreaInsetsMode { // Insets will be passed on to children case ignore // Insets will be consumed case consume // Insets will be consumed and applied as margins if the size is percent case consumeMargin } static let emptyEdgeSet = EdgeInsets( top: 0, leading: 0, bottom: 0, trailing: 0 ) var maxWidth: CGFloat? var maxHeight: CGFloat? var width: CGFloat? var height: CGFloat? var safeAreaInsets: EdgeInsets var isHorizontalFixedSize: Bool var isVerticalFixedSize: Bool init( width: CGFloat? = nil, height: CGFloat? = nil, maxWidth: CGFloat? = nil, maxHeight: CGFloat? = nil, isHorizontalFixedSize: Bool = false, isVerticalFixedSize: Bool = false, safeAreaInsets: EdgeInsets = emptyEdgeSet ) { self.width = width self.height = height self.maxWidth = maxWidth self.maxHeight = maxHeight self.safeAreaInsets = safeAreaInsets self.isHorizontalFixedSize = isHorizontalFixedSize self.isVerticalFixedSize = isVerticalFixedSize } init(size: CGSize, safeAreaInsets: EdgeInsets) { self.init( width: size.width + safeAreaInsets.trailing + safeAreaInsets.leading, height: size.height + safeAreaInsets.top + safeAreaInsets.bottom, isHorizontalFixedSize: true, isVerticalFixedSize: true, safeAreaInsets: safeAreaInsets ) } func contentConstraints( _ constrainedSize: ThomasConstrainedSize, contentSize: CGSize?, margin: ThomasMargin? ) -> ViewConstraints { let verticalMargins: CGFloat = margin?.verticalMargins ?? 0.0 let horizontalMargins: CGFloat = margin?.horiztonalMargins ?? 0.0 let parentWidth: CGFloat? = self.width?.subtract(horizontalMargins) let parentHeight: CGFloat? = self.height?.subtract(verticalMargins) let childMinWidth: CGFloat? = constrainedSize.minWidth?.calculateSize( parentWidth ) let childMaxWidth: CGFloat? = constrainedSize.maxWidth?.calculateSize( parentWidth ) var childWidth: CGFloat? = constrainedSize.width.calculateSize( parentWidth ) childWidth = childWidth?.bound( minValue: childMinWidth, maxValue: childMaxWidth ) let childMinHeight: CGFloat? = constrainedSize.minHeight?.calculateSize( parentHeight ) let childMaxHeight: CGFloat? = constrainedSize.maxHeight?.calculateSize( parentHeight ) var childHeight: CGFloat? = constrainedSize.height.calculateSize( parentHeight ) childHeight = childHeight?.bound( minValue: childMinHeight, maxValue: childMaxHeight ) let isVerticalFixedSize: Bool = constrainedSize.height.isFixedSize( self.isVerticalFixedSize ) let isHorizontalFixedSize: Bool = constrainedSize.width.isFixedSize( self.isHorizontalFixedSize ) if let contentSize = contentSize { if let maxWidth = childMaxWidth, contentSize.width >= maxWidth { childWidth = maxWidth } else if let minWidth = childMinWidth, contentSize.width <= minWidth { childWidth = minWidth } if let maxHeight = childMaxHeight, contentSize.height >= maxHeight { childHeight = maxHeight } else if let minHeight = childMinHeight, contentSize.height <= minHeight { childHeight = minHeight } } return ViewConstraints( width: childWidth, height: childHeight, maxWidth: childWidth ?? parentWidth, maxHeight: childHeight ?? parentHeight, isHorizontalFixedSize: isHorizontalFixedSize, isVerticalFixedSize: isVerticalFixedSize, safeAreaInsets: self.safeAreaInsets ) } func childConstraints( _ size: ThomasSize, margin: ThomasMargin?, padding: Double = 0, safeAreaInsetsMode: SafeAreaInsetsMode = .ignore ) -> ViewConstraints { let parentWidth: CGFloat? = self.width?.subtract(padding * 2) let parentHeight: CGFloat? = self.height?.subtract(padding * 2) var horizontalMargins: CGFloat = margin?.horiztonalMargins ?? 0.0 var verticalMargins: CGFloat = margin?.verticalMargins ?? 0.0 var safeAreaInsets: EdgeInsets = self.safeAreaInsets switch safeAreaInsetsMode { case .ignore: break case .consume: safeAreaInsets = ViewConstraints.emptyEdgeSet case .consumeMargin: horizontalMargins = horizontalMargins + self.safeAreaInsets.leading + self.safeAreaInsets.trailing verticalMargins = verticalMargins + self.safeAreaInsets.top + self.safeAreaInsets.bottom safeAreaInsets = ViewConstraints.emptyEdgeSet } var childWidth: CGFloat? = size.width.calculateSize(parentWidth) var childHeight: CGFloat? = size.height.calculateSize(parentHeight) if size.width.isPercent, let width = childWidth, let parentWidth = parentWidth { childWidth = max(0, min(width, parentWidth.subtract(horizontalMargins))) } if size.height.isPercent, let height = childHeight, let parentHeight = parentHeight { childHeight = max(0, min(height, parentHeight.subtract(verticalMargins))) } let isVerticalFixedSize: Bool = size.height.isFixedSize( self.isVerticalFixedSize ) let isHorizontalFixedSize: Bool = size.width.isFixedSize( self.isHorizontalFixedSize ) let maxWidth = (parentWidth ?? self.maxWidth?.subtract(padding * 2))?.subtract(horizontalMargins) let maxHeight = (parentHeight ?? self.maxHeight?.subtract(padding * 2))?.subtract(verticalMargins) return ViewConstraints( width: childWidth, height: childHeight, maxWidth: childWidth ?? maxWidth, maxHeight: childHeight ?? maxHeight, isHorizontalFixedSize: isHorizontalFixedSize, isVerticalFixedSize: isVerticalFixedSize, safeAreaInsets: safeAreaInsets ) } } extension ThomasSizeConstraint { func calculateSize(_ parentSize: CGFloat?) -> CGFloat? { switch self { case .points(let points): return points case .percent(let percent): guard let parentSize = parentSize else { return nil } return percent / 100.0 * parentSize case .auto: return nil } } func isFixedSize(_ isParentFixed: Bool) -> Bool { switch self { case .points(_): return true case .percent(_): return isParentFixed case .auto: return false } } var isAuto: Bool { switch self { case .points(_): return false case .percent(_): return false case .auto: return true } } var isPercent: Bool { switch self { case .points(_): return false case .percent(_): return true case .auto: return false } } } extension ThomasMargin { var verticalMargins: CGFloat { return (self.bottom ?? 0.0) + (self.top ?? 0.0) } var horiztonalMargins: CGFloat { return (self.start ?? 0.0) + (self.end ?? 0.0) } } extension CGFloat { func subtract(_ value: CGFloat) -> CGFloat { return self - value } func bound(minValue: CGFloat? = nil, maxValue: CGFloat? = nil) -> CGFloat { var value = self if let minValue = minValue { value = CGFloat.maximum(value, minValue) } if let maxValue = maxValue { value = CGFloat.minimum(value, maxValue) } return value } /// Returns self if finite, otherwise returns nil. /// Guards against NaN and infinity crashing SwiftUI frame/offset/position modifiers. var safeValue: CGFloat? { self.isFinite ? self : nil } } ================================================ FILE: Airship/AirshipCore/Source/ViewExtensions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI extension View { @ViewBuilder func foreground(_ color: ThomasColor?, colorScheme: ColorScheme) -> some View { if let color = color { self.foregroundColor(color.toColor(colorScheme)) } else { self } } @ViewBuilder internal func applyMargin(edge: Edge.Set, margin: CGFloat?) -> some View { if let margin = margin { self.padding(edge, margin) } else { self } } @ViewBuilder func margin(_ margin: ThomasMargin?) -> some View { if let margin = margin { self.applyMargin(edge: .leading, margin: margin.start) .applyMargin(edge: .top, margin: margin.top) .applyMargin(edge: .trailing, margin: margin.end) .applyMargin(edge: .bottom, margin: margin.bottom) } else { self } } @ViewBuilder func constraints( _ constraints: ViewConstraints, alignment: Alignment? = nil, fixedSize: Bool = false ) -> some View { self.frame( idealWidth: constraints.width, maxWidth: constraints.width, idealHeight: constraints.height, maxHeight: constraints.height, alignment: alignment ?? .center ) .airshipApplyIf(fixedSize) { view in view.fixedSize( horizontal: constraints.isHorizontalFixedSize && constraints.width != nil, vertical: constraints.isVerticalFixedSize && constraints.height != nil ) } } @ViewBuilder internal func thomasToggleStyle( _ style: ThomasToggleStyleInfo, constraints: ViewConstraints ) -> some View { switch style { case .checkboxStyle(let style): self.toggleStyle( AirshipCheckboxToggleStyle( viewConstraints: constraints, info: style ) ) case .switchStyle(let style): self.toggleStyle( AirshipSwitchToggleStyle( info: style ) ) } } @ViewBuilder public func airshipApplyIf<Content: View>( _ predicate: @autoclosure () -> Bool, @ViewBuilder transform: (Self) -> Content ) -> some View { if predicate() { transform(self) } else { self } } @ViewBuilder public func airshipGeometryGroupCompat() -> some View { if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { self.geometryGroup() } else { self.transformEffect(.identity) } } @ViewBuilder internal func addTapGesture(action: @escaping () -> Void) -> some View { self.onTapGesture(perform: action) .accessibilityAction(.default, action) } @ViewBuilder internal func accessible( _ accessible: ThomasAccessibleInfo?, associatedLabel: String?, fallbackContentDescription: String? = nil, hideIfDescriptionIsMissing: Bool ) -> some View { let contentDescription = accessible?.resolveContentDescription ?? fallbackContentDescription if accessible?.accessibilityHidden == true { self.accessibilityHidden(true) } else if let contentDescription, let associatedLabel { self.accessibilityLabel(associatedLabel) .accessibilityHint(contentDescription) } else if let contentDescription { self.accessibilityLabel(contentDescription) } else if let associatedLabel { self.accessibilityLabel(associatedLabel) }else if hideIfDescriptionIsMissing { self.accessibilityHidden(true) } else { self } } } internal struct ThomasCommonScope: OptionSet { let rawValue: UInt public static let background = ThomasCommonScope(rawValue: 1 << 0) public static let stateTriggers = ThomasCommonScope(rawValue: 1 << 1) public static let eventHandlers = ThomasCommonScope(rawValue: 1 << 2) public static let enableBehaviors = ThomasCommonScope(rawValue: 1 << 3) public static let visibility = ThomasCommonScope(rawValue: 1 << 4) static let all: ThomasCommonScope = [.background, .stateTriggers, .eventHandlers, .enableBehaviors, .visibility] } fileprivate extension ThomasViewInfo.BaseInfo { var hasBackground: Bool { return commonProperties.border != nil || commonProperties.backgroundColor != nil || (commonOverrides?.border?.isEmpty == false) || (commonOverrides?.backgroundColor?.isEmpty == false) } } extension View { @ViewBuilder internal func thomasCommon( _ info: any ThomasViewInfo.BaseInfo, formInputID: String? = nil, scope: ThomasCommonScope = .all ) -> some View { let commonOverrides = info.commonOverrides let commonProperties = info.commonProperties self.viewModifiers { if scope.contains(.background), info.hasBackground { BackgroundViewModifier( backgroundColor: commonProperties.backgroundColor, backgroundColorOverrides: commonOverrides?.backgroundColor, border: commonProperties.border, borderOverrides: commonOverrides?.border, shadow: nil ) } if scope.contains(.stateTriggers), let triggers = commonProperties.stateTriggers, !triggers.isEmpty { StateTriggerModifier( triggers: triggers ) } if scope.contains(.eventHandlers), let handlers = commonProperties.eventHandlers, !handlers.isEmpty { EventHandlerViewModifier( eventHandlers: handlers, formInputID: formInputID ) } if scope.contains(.enableBehaviors), let behaviors = commonProperties.enabled, !behaviors.isEmpty { if behaviors.contains(.formValidation) { ValidFormButtonEnableBehavior(onApply: nil) } if behaviors.contains(.pagerNext) { PagerNextButtonEnableBehavior(onApply: nil) } if behaviors.contains(.pagerPrevious) { PagerPreviousButtonEnableBehavior(onApply: nil) } if behaviors.contains(.formSubmission) { FormSubmissionEnableBehavior(onApply: nil) } } if scope.contains(.visibility), let visibilityInfo = commonProperties.visibility { VisibilityViewModifier(visibilityInfo: visibilityInfo) } } } internal func viewModifiers<Modifiers: ViewModifier>( @AirshipViewModifierBuilder modifiers: () -> Modifiers ) -> some View { self.modifier(modifiers()) } internal func overlayView<T: View>( alignment: Alignment = .center, @ViewBuilder content: () -> T ) -> some View { overlay( Group(content: content), alignment: alignment ) } } @resultBuilder struct AirshipViewModifierBuilder { static func buildBlock() -> EmptyModifier { EmptyModifier() } @MainActor static func buildOptional<VM0: ViewModifier>(_ vm0: VM0?) -> some ViewModifier { return Optional(viewModifier: vm0) } static func buildBlock<VM0: ViewModifier>(_ vm0: VM0) -> some ViewModifier { return vm0 } static func buildBlock<VM0: ViewModifier, VM1: ViewModifier>( _ vm0: VM0, _ vm1: VM1 ) -> some ViewModifier { return vm0.concat(vm1) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2) -> some ViewModifier { return vm0.concat(vm1).concat(vm2) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier, VM3: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2, _ vm3: VM3) -> some ViewModifier { return vm0.concat(vm1).concat(vm2).concat(vm3) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier, VM3: ViewModifier, VM4: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2, _ vm3: VM3, _ vm4: VM4) -> some ViewModifier { return vm0.concat(vm1).concat(vm2).concat(vm3).concat(vm4) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier, VM3: ViewModifier, VM4: ViewModifier, VM5: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2, _ vm3: VM3, _ vm4: VM4, _ vm5: VM5) -> some ViewModifier { return vm0.concat(vm1).concat(vm2).concat(vm3).concat(vm4).concat(vm5) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier, VM3: ViewModifier, VM4: ViewModifier, VM5: ViewModifier, VM6: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2, _ vm3: VM3, _ vm4: VM4, _ vm5: VM5, _ vm6: VM6) -> some ViewModifier { return vm0.concat(vm1).concat(vm2).concat(vm3).concat(vm4).concat(vm5).concat(vm6) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier, VM3: ViewModifier, VM4: ViewModifier, VM5: ViewModifier, VM6: ViewModifier, VM7: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2, _ vm3: VM3, _ vm4: VM4, _ vm5: VM5, _ vm6: VM6, _ vm7: VM7) -> some ViewModifier { return vm0.concat(vm1).concat(vm2).concat(vm3).concat(vm4).concat(vm5).concat(vm6).concat(vm7) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier, VM3: ViewModifier, VM4: ViewModifier, VM5: ViewModifier, VM6: ViewModifier, VM7: ViewModifier, VM8: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2, _ vm3: VM3, _ vm4: VM4, _ vm5: VM5, _ vm6: VM6, _ vm7: VM7, _ vm8: VM8) -> some ViewModifier { return vm0.concat(vm1).concat(vm2).concat(vm3).concat(vm4).concat(vm5).concat(vm6).concat(vm7).concat(vm8) } static func buildBlock< VM0: ViewModifier, VM1: ViewModifier, VM2: ViewModifier, VM3: ViewModifier, VM4: ViewModifier, VM5: ViewModifier, VM6: ViewModifier, VM7: ViewModifier, VM8: ViewModifier, VM9: ViewModifier >(_ vm0: VM0, _ vm1: VM1, _ vm2: VM2, _ vm3: VM3, _ vm4: VM4, _ vm5: VM5, _ vm6: VM6, _ vm7: VM7, _ vm8: VM8, _ vm9: VM9) -> some ViewModifier { return vm0.concat(vm1).concat(vm2).concat(vm3).concat(vm4).concat(vm5).concat(vm6).concat(vm7).concat(vm8).concat(vm9) } private struct Optional<Modifier: ViewModifier>: ViewModifier { let viewModifier: Modifier? func body(content: Content) -> some View { if let viewModifier = viewModifier { content.modifier(viewModifier) } else { content } } } } ================================================ FILE: Airship/AirshipCore/Source/ViewFactory.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// View factory. Inflates views based on type. struct ViewFactory { @MainActor @ViewBuilder static func createView( _ viewInfo: ThomasViewInfo, constraints: ViewConstraints ) -> some View { switch viewInfo { case .container(let info): Container(info: info, constraints: constraints) case .linearLayout(let info): LinearLayout(info: info, constraints: constraints) case .scrollLayout(let info): ScrollLayout(info: info, constraints: constraints) case .label(let info): Label(info: info, constraints: constraints) case .media(let info): Media(info: info, constraints: constraints) case .labelButton(let info): LabelButton(info: info, constraints: constraints) case .emptyView(let info): AirshipEmptyView(info: info, constraints: constraints) case .formController(let info): FormController(info: .form(info), constraints: constraints) case .npsController(let info): FormController(info: .nps(info), constraints: constraints) case .textInput(let info): TextInput(info: info, constraints: constraints) case .pagerController(let info): PagerController(info: info, constraints: constraints) case .pagerIndicator(let info): PagerIndicator(info: info, constraints: constraints) case .storyIndicator(let info): StoryIndicator(info: info, constraints: constraints) case .pager(let info): Pager(info: info, constraints: constraints) #if !os(tvOS) && !os(watchOS) case .webView(let info): AirshipWebView(info: info, constraints: constraints) #endif case .imageButton(let info): ImageButton(info: info, constraints: constraints) case .stackImageButton(let info): StackImageButton(info: info, constraints: constraints) case .checkbox(let info): Checkbox(info: info, constraints: constraints) case .checkboxController(let info): CheckboxController(info: info, constraints: constraints) case .toggle(let info): AirshipToggle(info: info, constraints: constraints) case .radioInputController(let info): RadioInputController(info: info, constraints: constraints) case .radioInput(let info): RadioInput(info: info, constraints: constraints) case .score(let info): Score(info: info, constraints: constraints) case .stateController(let info): StateController(info: info, constraints: constraints) case .customView(let info): CustomView(info: info, constraints: constraints) case .buttonLayout(let info): ButtonLayout(info: info, constraints: constraints) case .basicToggleLayout(let info): BasicToggleLayout(info: info, constraints: constraints) case .checkboxToggleLayout(let info): CheckboxToggleLayout(info: info, constraints: constraints) case .radioInputToggleLayout(let info): RadioInputToggleLayout(info: info, constraints: constraints) case .iconView(let info): IconView(info: info, constraints: constraints) case .scoreController(let info): ScoreController(info: info, constraints: constraints) case .scoreToggleLayout(let info): ScoreToggleLayout(info: info, constraints: constraints) case .videoController(let info): VideoController(info: info, constraints: constraints) } } } ================================================ FILE: Airship/AirshipCore/Source/VisibilityViewModifier.swift ================================================ import Foundation import SwiftUI internal struct VisibilityViewModifier: ViewModifier { let visibilityInfo: ThomasVisibilityInfo @EnvironmentObject var thomasState: ThomasState @ViewBuilder func body(content: Content) -> some View { if isVisible() { content } } func isVisible() -> Bool { let predicate = visibilityInfo.invertWhenStateMatches guard predicate.evaluate(json: thomasState.state) else { return visibilityInfo.defaultVisibility } return !visibilityInfo.defaultVisibility } } ================================================ FILE: Airship/AirshipCore/Source/WorkBackgroundTasks.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) import UIKit #endif protocol WorkBackgroundTasksProtocol: Sendable { @MainActor func beginTask( _ name: String, expirationHandler: (@Sendable () -> Void)? ) throws -> any AirshipCancellable } final class WorkBackgroundTasks: WorkBackgroundTasksProtocol, Sendable { #if !os(watchOS) && !os(macOS) private let requestMap: AirshipMainActorValue<[UInt: UIBackgroundTaskIdentifier]> = AirshipMainActorValue([:]) private let nextRequestID: AirshipMainActorValue<UInt> = AirshipMainActorValue(0) #endif @MainActor func beginTask( _ name: String, expirationHandler: (@Sendable () -> Void)? = nil ) throws -> any AirshipCancellable { #if os(watchOS) || os(macOS) let cancellable: CancellableValueHolder<UInt> = CancellableValueHolder(value: 0) { _ in } return cancellable #else AirshipLogger.trace("Requesting task: \(name)") let requestID = nextRequestID.value nextRequestID.update { $0 += 1} let cancellable: CancellableValueHolder<UInt> = CancellableValueHolder(value: requestID) { requestID in Task { @MainActor in self.cancel(requestID: requestID) } } let application = UIApplication.shared let bgTask = application.beginBackgroundTask(withName: name) { AirshipLogger.trace("Task expired: \(name)") self.cancel(requestID: requestID) expirationHandler?() } self.requestMap.update { $0[requestID] = bgTask } guard let task = self.requestMap.value[requestID], task != UIBackgroundTaskIdentifier.invalid else { throw AirshipErrors.error("Unable to request background time.") } AirshipLogger.trace("Task granted: \(name)") return cancellable #endif } @MainActor private func cancel(requestID: UInt) { #if !os(watchOS) && !os(macOS) let taskID = self.requestMap.value[requestID] self.requestMap.update { $0.removeValue(forKey: requestID) } guard let taskID = taskID, taskID != UIBackgroundTaskIdentifier.invalid else { return } UIApplication.shared.endBackgroundTask(taskID) #endif } } ================================================ FILE: Airship/AirshipCore/Source/WorkConditionsMonitor.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation struct WorkConditionsMonitor: @unchecked Sendable { private let cancellable: AnyCancellable private let conditionsSubject: PassthroughSubject<Void, Never> = PassthroughSubject<Void, Never>() private let networkMonitor: any AirshipNetworkCheckerProtocol init( networkMonitor: AirshipNetworkChecker = AirshipNetworkChecker() ) { self.networkMonitor = networkMonitor self.cancellable = Publishers.CombineLatest( NotificationCenter.default.publisher( for: AppStateTracker.didBecomeActiveNotification ), NotificationCenter.default.publisher( for: AppStateTracker.didEnterBackgroundNotification ) ) .receive(on: RunLoop.main) .sink { [conditionsSubject] _ in conditionsSubject.send() } Task { @MainActor [conditionsSubject] in for await _ in networkMonitor.connectionUpdates { conditionsSubject.send() } } } @MainActor func checkConditions(workRequest: AirshipWorkRequest) -> Bool { if workRequest.requiresNetwork == true { return networkMonitor.isConnected } return true } @MainActor private func waitConditionsUpdate() async { return await withCheckedContinuation { continuation in var cancellable: AnyCancellable? = nil cancellable = self.conditionsSubject .first() .sink { _ in continuation.resume() cancellable?.cancel() } } } @MainActor func awaitConditions(workRequest: AirshipWorkRequest) async { while checkConditions(workRequest: workRequest) == false { await waitConditionsUpdate() } } } ================================================ FILE: Airship/AirshipCore/Source/WorkRateLimiterActor.swift ================================================ /* Copyright Airship and Contributors */ import Foundation actor WorkRateLimiter { private struct RateLimiterState: Sendable { let rate: Int let timeInterval: TimeInterval var hits: [Date] = [] mutating func prune(now: Date) { let cutoff = now.addingTimeInterval(-timeInterval) hits.removeAll { $0 <= cutoff } if hits.count > rate { hits.removeFirst(hits.count - rate) } } } enum Status: Sendable { case overLimit(TimeInterval) case withinLimit(Int) } private var limiters: [String: RateLimiterState] = [:] private let date: any AirshipDateProtocol init(date: any AirshipDateProtocol = AirshipDate()) { self.date = date } func set(_ key: String, rate: Int, timeInterval: TimeInterval) throws { guard rate > 0, timeInterval > 0 else { throw AirshipErrors.error("Rate and time interval must be greater than 0") } var newState = RateLimiterState( rate: rate, timeInterval: timeInterval, hits: [] ) // Reserve rate + 1 capacity. We prune after adding a value so it should only ever grow by 1 more than the rate. newState.hits.reserveCapacity(rate + 1) self.limiters[key] = newState } func nextAvailable(_ keys: Set<String>) -> TimeInterval { keys.reduce(0.0) { maxDelay, key in guard case let .overLimit(delay)? = status(key) else { return maxDelay } return max(maxDelay, delay) } } func trackIfWithinLimit(_ keys: Set<String>) -> Bool { guard !keys.isEmpty else { return true } // Check first for key in keys { if case .overLimit? = status(key) { return false } } let now = date.now keys.forEach { track($0, now: now) } return true } private func status(_ key: String) -> Status? { guard var limiter = self.limiters[key] else { AirshipLogger.debug("No rule for key \(key)") return nil } let now = date.now // Save the struct back with pruned hits limiter.prune(now: now) self.limiters[key] = limiter let count = limiter.hits.count guard count >= limiter.rate else { return .withinLimit(limiter.rate - count) } let oldestHitIndex = count - limiter.rate guard oldestHitIndex >= 0, oldestHitIndex < limiter.hits.count else { AirshipLogger.error("Rate limiter index check failed for key \(key). Count: \(count), Rate: \(limiter.rate)") return .overLimit(limiter.timeInterval) } let gate = limiter.hits[oldestHitIndex] let wait = limiter.timeInterval - now.timeIntervalSince(gate) return .overLimit(max(wait, 0)) } private func track(_ key: String, now: Date) { guard var limiter = self.limiters[key] else { AirshipLogger.debug("No rule for key \(key)") return } // Append and then prune the state limiter.hits.append(now) limiter.prune(now: now) // Save the updated struct back self.limiters[key] = limiter } } ================================================ FILE: Airship/AirshipCore/Source/Worker.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation /// Worker that handles queuing tasks and performing the actual work actor Worker { private let workContinuation: AsyncStream<PendingRequest>.Continuation private let workStream: AsyncStream<PendingRequest> private var pending: [PendingRequest] = [] private var inProgress: Set<PendingRequest> = Set() private var tasks: Set<Task<Void, any Error>> = Set() private var nextPendingID: Int = 0 private static let initialBackOff: TimeInterval = 30.0 private static let maxBackOff: TimeInterval = 120.0 let workID: String private let conditionsMonitor: WorkConditionsMonitor private let rateLimiter: WorkRateLimiter private let backgroundTasks: any WorkBackgroundTasksProtocol private let workHandler: (AirshipWorkRequest) async throws -> AirshipWorkResult private let notificationCenter: NotificationCenter = NotificationCenter.default init( workID: String, conditionsMonitor: WorkConditionsMonitor, rateLimiter: WorkRateLimiter, backgroundTasks: any WorkBackgroundTasksProtocol, workHandler: @escaping (AirshipWorkRequest) async throws -> AirshipWorkResult ) { self.workID = workID self.conditionsMonitor = conditionsMonitor self.rateLimiter = rateLimiter self.backgroundTasks = backgroundTasks self.workHandler = workHandler (self.workStream, self.workContinuation) = AsyncStream<PendingRequest>.airshipMakeStreamWithContinuation() } deinit { workContinuation.finish() tasks.forEach { $0.cancel() } tasks.removeAll() } func addWork(request: AirshipWorkRequest) { guard request.workID == self.workID else { AirshipLogger.error("Invalid request: \(request.workID)") return } var queueWork = false switch request.conflictPolicy { case .append: queueWork = true case .replace: pending.removeAll() tasks.forEach { $0.cancel() } tasks.removeAll() queueWork = true case .keepIfNotStarted: queueWork = Set(pending).subtracting(inProgress).isEmpty } if queueWork { let pendingID = nextPendingID nextPendingID += 1 let pendingRequest = PendingRequest(id: pendingID, request: request) pending.append(pendingRequest) workContinuation.yield(pendingRequest) } } func run() async { for await next in self.workStream { let task: Task<Void, any Error> = Task { [weak self] in var attempt = 1 while await self?.isValidRequest(next) == true { let cancellableValueHolder: CancellableValueHolder<Task<Void, any Error>> = CancellableValueHolder { task in task.cancel() } await withTaskCancellationHandler { [attempt] in let task = Task { [weak self] in try Task.checkCancellation() try await self?.process( pendingRequest: next, attempt: attempt ) { cancellableValueHolder.cancel() } } cancellableValueHolder.value = task try? await task.result.get() } onCancel: { cancellableValueHolder.cancel() } attempt += 1 } } tasks.insert(task) try? await task.result.get() tasks.remove(task) } } private func isValidRequest(_ pendingRequest: PendingRequest) -> Bool { return self.pending.contains(pendingRequest) } private func removeRequest(_ pendingRequest: PendingRequest) { self.pending.removeAll { request in request.id == pendingRequest.id } } private func process( pendingRequest: PendingRequest, attempt: Int, onCancel: @escaping @Sendable () -> Void ) async throws { let canonicalID = "\(workID)(\(pendingRequest.id))" try await prepare(pendingRequest: pendingRequest) try Task.checkCancellation() let backgroundTask = try await backgroundTasks.beginTask("Airship: \(canonicalID)") { onCancel() } var result: AirshipWorkResult = .failure inProgress.insert(pendingRequest) do { result = try await self.workHandler(pendingRequest.request) } catch { AirshipLogger.debug("Failed to execute work \(canonicalID): \(error)") } inProgress.remove(pendingRequest) if result == .success { // Success AirshipLogger.trace("Work \(canonicalID) finished") self.removeRequest(pendingRequest) backgroundTask.cancel() } else { AirshipLogger.trace("Work \(canonicalID) failed") backgroundTask.cancel() try Task.checkCancellation() let backOff = min( Worker.maxBackOff, Double(attempt) * Worker.initialBackOff ) AirshipLogger.trace("Work \(canonicalID) backing off for \(backOff) seconds.") let cancellable = self.notificationCenter .publisher( for: AppStateTracker.didEnterBackgroundNotification ) .first() .sink { _ in onCancel() } try await Self.sleep(backOff) cancellable.cancel() } } func calculateBackgroundWaitTime( maxTime: TimeInterval ) async -> TimeInterval { guard let pending = self.pending.first else { return 0.0 } return await calculateBackgroundWaitTime( workRequest: pending.request, maxTime: maxTime ) } private func calculateBackgroundWaitTime( workRequest: AirshipWorkRequest, maxTime: TimeInterval ) async -> TimeInterval { guard await self.conditionsMonitor.checkConditions( workRequest: workRequest ), let rateLimitIDs = workRequest.rateLimitIDs, !rateLimitIDs.isEmpty else { return 0.0 } let wait = await self.rateLimiter.nextAvailable(rateLimitIDs) if wait > maxTime { return 0.0 } return wait } private func prepare(pendingRequest: PendingRequest) async throws { let workRequest = pendingRequest.request if workRequest.initialDelay > 0 { let timeSinceRequest = Date().timeIntervalSince(pendingRequest.date) if timeSinceRequest < workRequest.initialDelay { try await Self.sleep(workRequest.initialDelay - timeSinceRequest) } } guard let rateLimitIDs = workRequest.rateLimitIDs, !rateLimitIDs.isEmpty else { await self.conditionsMonitor.awaitConditions( workRequest: workRequest ) return } repeat { let rateLimit = await rateLimiter.nextAvailable( rateLimitIDs ) if rateLimit > 0 { try await Self.sleep(rateLimit) } await self.conditionsMonitor.awaitConditions( workRequest: workRequest ) } while await !rateLimiter.trackIfWithinLimit(rateLimitIDs) } private static func sleep(_ time: TimeInterval) async throws { guard time > 0 else { return } let sleep = UInt64(time * 1_000_000_000) try await Task.sleep(nanoseconds: sleep) } fileprivate struct PendingRequest: Equatable, Sendable, Hashable { let id: Int let request: AirshipWorkRequest let date: Date = Date() } } ================================================ FILE: Airship/AirshipCore/Source/WrappingLayout.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI /// Wrapping layout will attempt to wrap items with a specified max items per line when parent width /// is constrained. Display can break when parent height is exceeded - especially in landscape or when excessive /// item padding is specified. internal struct WrappingLayout: Layout { /// View constraints to apply var viewConstraints: ViewConstraints /// Minimum number of lines to display var minLines: Int private static let defaultMinLines: Int = 1 /// Spacing applied around each item var itemSpacing: CGFloat private static let defaultItemSpacing: CGFloat = 0 /// Spacing applied for each wrapped line var lineSpacing: CGFloat private static let defaultLineSpacing: CGFloat = 0 /// Maximum number of items to display per line var maxItemsPerLine: Int private static let defaultMaxItemsPerLine: Int = 11 init( viewConstraints: ViewConstraints, minLines: Int? = Self.defaultMinLines, itemSpacing: CGFloat? = Self.defaultItemSpacing, lineSpacing: CGFloat? = Self.defaultLineSpacing, maxItemsPerLine: Int? = Self.defaultMaxItemsPerLine ) { self.viewConstraints = viewConstraints self.minLines = minLines ?? Self.defaultMinLines self.itemSpacing = itemSpacing ?? Self.defaultItemSpacing self.lineSpacing = lineSpacing ?? Self.defaultLineSpacing self.maxItemsPerLine = maxItemsPerLine ?? Self.defaultMaxItemsPerLine } func sizeThatFits( proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) -> CGSize { guard !subviews.isEmpty else { AirshipLogger.debug("WrappingLayout - subviews are empty. Returning zero size.") return .zero } // Get the maximum width and height of the subviews let itemSizes = subviews.map { $0.sizeThatFits(.unspecified) } let itemWidth = itemSizes.map { $0.width }.max() ?? 0 let itemHeight = itemSizes.map { $0.height }.max() ?? 0 let totalItems = subviews.count // Determine the maximum width from viewConstraints or proposal let maxWidth = viewConstraints.width ?? proposal.width ?? viewConstraints.maxWidth ?? .infinity // Calculate the number of items per line let itemsInLine = calculateItemsInLine( totalItems: totalItems, maxItemsPerLine: maxItemsPerLine, itemWidth: itemWidth, itemSpacing: itemSpacing, maxWidth: maxWidth ) let linesNeeded = calculateLinesNeeded(totalItems: totalItems, itemsInLine: itemsInLine) // Calculate total height let totalHeight = calculateTotalHeight(linesNeeded: linesNeeded, itemHeight: itemHeight, lineSpacing: lineSpacing) // Apply viewConstraints maxHeight if available let finalHeight = min(totalHeight, viewConstraints.maxHeight ?? totalHeight) let finalWidth = min(maxWidth, viewConstraints.maxWidth ?? maxWidth) return CGSize(width: finalWidth, height: finalHeight) } func placeSubviews( in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout () ) { guard !subviews.isEmpty else { AirshipLogger.debug("WrappingLayout - subviews are empty.") return } // Get the maximum width and height of the subviews let itemSizes = subviews.map { $0.sizeThatFits(.unspecified) } let itemWidth = itemSizes.map { $0.width }.max() ?? 0 let itemHeight = itemSizes.map { $0.height }.max() ?? 0 let totalItems = subviews.count // Use bounds width and height directly let availableWidth = bounds.width let availableHeight = bounds.height // Calculate the number of items per line let itemsInLine = calculateItemsInLine( totalItems: totalItems, maxItemsPerLine: maxItemsPerLine, itemWidth: itemWidth, itemSpacing: itemSpacing, maxWidth: availableWidth ) let linesNeeded = calculateLinesNeeded(totalItems: totalItems, itemsInLine: itemsInLine) // Calculate total content height let totalContentHeight = calculateTotalHeight(linesNeeded: linesNeeded, itemHeight: itemHeight, lineSpacing: lineSpacing) // Adjust yPosition to center content vertically var yPosition = (bounds.minY + (availableHeight - totalContentHeight) / 2.0).safeValue ?? bounds.minY var currentIndex = 0 for _ in 0..<linesNeeded { let itemsInThisLine = min(itemsInLine, totalItems - currentIndex) let totalLineWidth = CGFloat(itemsInThisLine) * itemWidth + CGFloat(itemsInThisLine - 1) * itemSpacing // Center the line within the available width var xPosition = (bounds.minX + (availableWidth - totalLineWidth) / 2.0).safeValue ?? bounds.minX for _ in 0..<itemsInThisLine { if currentIndex >= subviews.count { break } let subview = subviews[currentIndex] let subviewProposal = ProposedViewSize(width: itemWidth, height: itemHeight) subview.place( at: CGPoint(x: xPosition, y: yPosition), anchor: .topLeading, proposal: subviewProposal ) xPosition += itemWidth + itemSpacing currentIndex += 1 } yPosition += itemHeight + lineSpacing } } // MARK: - Utilities private func calculateLinesNeeded( totalItems: Int, itemsInLine: Int ) -> Int { let safeItemsInLine = itemsInLine > 0 ? itemsInLine : 1 return max(minLines, Int(ceil(Double(totalItems) / Double(safeItemsInLine)))) } private func calculateTotalHeight( linesNeeded: Int, itemHeight: CGFloat, lineSpacing: CGFloat ) -> CGFloat { return CGFloat(linesNeeded) * itemHeight + CGFloat(linesNeeded - 1) * lineSpacing } private func calculateItemsInLine( totalItems: Int, maxItemsPerLine: Int, itemWidth: CGFloat, itemSpacing: CGFloat, maxWidth: CGFloat ) -> Int { var itemsInLine = min(totalItems, maxItemsPerLine) while (CGFloat(itemsInLine) * itemWidth + CGFloat(itemsInLine - 1) * itemSpacing) > maxWidth && itemsInLine > 1 { itemsInLine -= 1 } return itemsInLine } } ================================================ FILE: Airship/AirshipCore/Tests/APNSEnvironmentTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class APNSEnvironmentTest: XCTestCase { func testProductionProfileParsing() throws { let profilePath = Bundle(for: self.classForCoder).path( forResource: "production-embedded", ofType: "mobileprovision" ) let isProduction = try APNSEnvironment.isProduction(profilePath) XCTAssertTrue(isProduction) } func testDevelopmentProfileParsing() throws { let profilePath = Bundle(for: self.classForCoder).path( forResource: "development-embedded", ofType: "mobileprovision" ) let isProduction = try APNSEnvironment.isProduction(profilePath) XCTAssertFalse(isProduction) } func testMissingEmbeddedProfile() { do { _ = try APNSEnvironment.isProduction(nil) XCTFail() } catch {} } func testInvalidEmbeddedProfilePath() { do { _ = try APNSEnvironment.isProduction("Neat") XCTFail() } catch {} } } ================================================ FILE: Airship/AirshipCore/Tests/AccountEventTemplateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AccountEventTemplateTest: XCTestCase { func testRegistered() { let event = CustomEvent(accountTemplate: .registered) XCTAssertEqual("registered_account", event.eventName) XCTAssertEqual("account", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testLoggedIn() { let event = CustomEvent(accountTemplate: .loggedIn) XCTAssertEqual("logged_in", event.eventName) XCTAssertEqual("account", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testLoggedOut() { let event = CustomEvent(accountTemplate: .loggedOut) XCTAssertEqual("logged_out", event.eventName) XCTAssertEqual("account", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testProperties() { let properties = CustomEvent.AccountProperties( category: "some category", type: "some type", isLTV: true, userID: "some user" ) let event = CustomEvent(accountTemplate: .loggedOut, properties: properties) let expectedProperties: [String: AirshipJSON] = [ "user_id": "some user", "category": "some category", "type": "some type", "ltv": true ] XCTAssertEqual(expectedProperties, event.properties) } } ================================================ FILE: Airship/AirshipCore/Tests/ActionArgumentsTest.swift ================================================ ///* Copyright Airship and Contributors */ // //import XCTest // //@testable import AirshipCore // //final class ActionArgumentTest: XCTestCase { // // override func setUpWithError() throws { // try super.setUpWithError() // } // // /* // * Test the argumentsWithValue:withSituation factory method sets the values correctly // */ // func testArgumentsWithValue() { // var args = ActionArguments(value: "some-value", situation: .backgroundPush) // XCTAssertEqual("some-value", args.value as! String) // XCTAssertEqual(.backgroundPush, args.situation) // // args = ActionArguments(value: "whatever", situation: .manualInvocation) // XCTAssertEqual(.manualInvocation, args.situation) // // args = ActionArguments(value: "whatever", situation: .foregroundPush) // XCTAssertEqual(.foregroundPush, args.situation) // // args = ActionArguments(value: "whatever", situation: .launchedFromPush) // XCTAssertEqual(.launchedFromPush, args.situation) // // args = ActionArguments(value: "whatever", situation: .webViewInvocation) // XCTAssertEqual(.webViewInvocation, args.situation) // // args = ActionArguments(value: "whatever", situation: .foregroundInteractiveButton) // XCTAssertEqual(.foregroundInteractiveButton, args.situation) // // args = ActionArguments(value: "whatever", situation: .backgroundInteractiveButton) // XCTAssertEqual(.backgroundInteractiveButton, args.situation) // // args = ActionArguments(value: "whatever", situation: .automation) // XCTAssertEqual(.automation, args.situation) // } // // /* // * Test the override of the description method // */ // /* // func testDescription() { // let args = ActionArguments(value: "foo", situation: .manualInvocation) // let expectedDescription = "UAActionArgument with situation: Manual Invocation, value: \(args.value!)" // XCTAssertEqual(args.description, expectedDescription) // } // */ //} ================================================ FILE: Airship/AirshipCore/Tests/ActionRegistryTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class ActionRegistryTest: AirshipBaseTest { private var registry: DefaultAirshipActionRegistry! @MainActor override func setUpWithError() throws { try super.setUpWithError() self.registry = DefaultAirshipActionRegistry() } @MainActor func testRegisterAction() async { let action = EmptyAction() registry.registerEntry( names: ["name", "alias", "another-name"], entry: ActionEntry(action: action) ) await validateIsRegistered( action: action, names: ["name", "alias", "another-name"] ) } @MainActor func testRegisterActionClosure() async { let action = EmptyAction() var called = 0 registry.registerEntry( names: ["name", "alias", "another-name"] ) { called += 1 return ActionEntry(action: action) } await validateIsRegistered( action: action, names: ["name", "alias", "another-name"] ) XCTAssertEqual(called, 1) } @MainActor func testRegisterActionNameConflict() async { let action = EmptyAction() let anotherAction = EmptyAction() registry.registerEntry( names: ["name", "alias", "another-name"], entry: ActionEntry(action: action) ) await validateIsRegistered( action: action, names: ["name", "alias", "another-name"] ) registry.registerEntry( names: ["name", "what"], entry: ActionEntry(action: anotherAction) ) await validateIsRegistered( action: anotherAction, names: ["name", "what"] ) // First entry should still be registered under 'alias' and 'another-name' await validateIsRegistered( action: action, names: ["alias", "another-name"] ) } @MainActor func testUpdateAction() async { let action = EmptyAction() let other = EmptyAction() registry.registerEntry( names: ["name", "alias", "another-name"], entry: ActionEntry(action: action) ) registry.updateEntry(name: "alias", action: other) await validateIsRegistered( action: other, names: ["name", "alias", "another-name"] ) } @MainActor func testUpdateActionForSituation() async { let action = EmptyAction() let other = EmptyAction() registry.registerEntry( names: ["name", "alias", "another-name"], entry: ActionEntry(action: action) ) registry.updateEntry(name: "alias", situation: .manualInvocation, action: other) await validateIsRegistered( action: action, names: ["name", "alias", "another-name"] ) let entry = registry.entry(name: "name")! XCTAssertTrue(other === entry.action(situation: .manualInvocation)) } func validateIsRegistered ( action: AirshipAction, names: [String] ) async { for name in names { let entry = await self.registry.entry(name: name) XCTAssertTrue(entry?.action === action) } } } ================================================ FILE: Airship/AirshipCore/Tests/AddCustomEventActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AddCustomEventActionTest: AirshipBaseTest { private let analytics = TestAnalytics() private var airship: TestAirshipInstance! private let action = AddCustomEventAction() @MainActor override func setUpWithError() throws { airship = TestAirshipInstance() self.airship.components = [analytics] self.airship.makeShared() } // Test custom event action accepts all the situations. func testAcceptsArgumentsAllSituations() async throws { let dict = ["event_name": "event name"] await verifyAcceptsArguments(withValue: try AirshipJSON.wrap(dict), shouldAccept: true) } func testAcceptsNewEventNameAndValueAllSituations() async throws { let dict = ["name": "name"] await verifyAcceptsArguments(withValue: try AirshipJSON.wrap(dict), shouldAccept: true) } // Test that it rejects invalid argument values. func testAcceptsArgumentsNo() async throws { let invalidDict = ["invalid_key": "event name"] await verifyAcceptsArguments(withValue: try AirshipJSON.wrap(invalidDict), shouldAccept: false) await verifyAcceptsArguments(withValue: AirshipJSON.null, shouldAccept: false) await verifyAcceptsArguments(withValue: try AirshipJSON.wrap("not a dictionary"), shouldAccept: false) await verifyAcceptsArguments(withValue: AirshipJSON.object([:]), shouldAccept: false) await verifyAcceptsArguments(withValue: AirshipJSON.array([]), shouldAccept: false) } // Test performing the action actually creates and adds the event from a NSNumber event value. func testPerformNSNumber() async throws { let dict: [String: Any] = [ "event_name": "event name", "transaction_id": "transaction ID", "event_value": 123.45, "interaction_type": "interaction type", "interaction_id": "interaction ID" ] let args = ActionArguments( value: try AirshipJSON.wrap(dict), situation: .manualInvocation ) try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTAssertEqual(1, self.analytics.customEvents.count); let event = try XCTUnwrap(self.analytics.customEvents.first) XCTAssertEqual("event name", event.eventName); XCTAssertEqual("transaction ID", event.transactionID); XCTAssertEqual("interaction type", event.interactionType); XCTAssertEqual("interaction ID", event.interactionID); XCTAssertEqual(123.45, event.eventValue); } // Test performing the action actually creates and adds the event from a string // event value. func testPerformString() async throws { let dict: [String : Any] = [ "event_name": "event name", "transaction_id": "transaction ID", "event_value": "123.45", "interaction_type": "interaction type", "interaction_id": "interaction ID" ] let args = ActionArguments( value: try AirshipJSON.wrap(dict), situation: .manualInvocation ) try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTAssertEqual(1, self.analytics.customEvents.count); let event = try XCTUnwrap(self.analytics.customEvents.first) XCTAssertEqual("event name", event.eventName); XCTAssertEqual("transaction ID", event.transactionID); XCTAssertEqual("interaction type", event.interactionType); XCTAssertEqual("interaction ID", event.interactionID); XCTAssertEqual(123.45, event.eventValue); } func testPerformPrefersNewNames() async throws { let dict: [String : Any] = [ "name": "new event name", "event_name": "event name", "transaction_id": "transaction ID", "event_value": "123.45", "value": "321.21", "interaction_type": "interaction type", "interaction_id": "interaction ID" ] let args = ActionArguments( value: try AirshipJSON.wrap(dict), situation: .manualInvocation ) try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTAssertEqual(1, self.analytics.customEvents.count); let event = try XCTUnwrap(self.analytics.customEvents.first) XCTAssertEqual("new event name", event.eventName); XCTAssertEqual("transaction ID", event.transactionID); XCTAssertEqual("interaction type", event.interactionType); XCTAssertEqual("interaction ID", event.interactionID); XCTAssertEqual(321.21, event.eventValue); } // Test perform with invalid event name should result in error. func testPerformInvalidCustomEventName() async throws { let dict: [String: Any] = [ "event_name": "", "transaction_id": "transaction ID", "event_value": "123.45", "interaction_type": "interaction type", "interaction_id": "interaction ID" ] let args = ActionArguments( value: try AirshipJSON.wrap(dict), situation: .manualInvocation ) do { try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTFail("Should throw") } catch { XCTAssertNotNil(error) } } // Test auto filling in the interaction ID and type from an mcrap when left // empty. func testInteractionEmptyMCRAP() async throws { let eventPayload = [ "event_name": "event name", "transaction_id": "transaction ID", "event_value": "123.45" ] let args = ActionArguments( value: try AirshipJSON.wrap(eventPayload), situation: .manualInvocation, metadata: [ActionArguments.inboxMessageIDMetadataKey: "message ID"] ) try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTAssertEqual(1, self.analytics.customEvents.count); let event = try XCTUnwrap(self.analytics.customEvents.first) XCTAssertEqual("event name", event.eventName); XCTAssertEqual("transaction ID", event.transactionID); XCTAssertEqual("ua_mcrap", event.interactionType); XCTAssertEqual("message ID", event.interactionID); XCTAssertEqual(123.45, event.eventValue); } // Test not modifying the interaction ID and type when it is set and triggered // from an mcrap. func testInteractionSetMCRAP() async throws { let eventPayload = [ "event_name": "event name", "transaction_id": "transaction ID", "event_value": "123.45", "interaction_type": "interaction type", "interaction_id": "interaction ID" ] let args = ActionArguments( value: try AirshipJSON.wrap(eventPayload), situation: .manualInvocation, metadata: [ActionArguments.inboxMessageIDMetadataKey: "message ID"] ) try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTAssertEqual(1, self.analytics.customEvents.count); let event = try XCTUnwrap(self.analytics.customEvents.first) XCTAssertEqual("event name", event.eventName); XCTAssertEqual("transaction ID", event.transactionID); XCTAssertEqual("interaction type", event.interactionType); XCTAssertEqual("interaction ID", event.interactionID); XCTAssertEqual(123.45, event.eventValue); } // Test setting the conversion send ID on the event if the action arguments has // a push payload meta data. func testSetConversionSendIdFromPush() async throws { let dict: [String: String] = [ "event_name": "event name", "transaction_id": "transaction ID", "event_value": "123.45", "interaction_type": "interaction type", "interaction_id": "interaction ID" ] let notification: [String: Any] = [ "_": "send ID", "com.urbanairship.metadata": "send metadata", "apns": [ "alert": "oh hi" ] ] let args = ActionArguments( value: try AirshipJSON.wrap(dict), situation: .manualInvocation, metadata: [ActionArguments.pushPayloadJSONMetadataKey: try AirshipJSON.wrap(notification)] ) try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTAssertEqual(1, self.analytics.customEvents.count); let event = try XCTUnwrap(self.analytics.customEvents.first) XCTAssertEqual("event name", event.eventName); XCTAssertEqual("transaction ID", event.transactionID); XCTAssertEqual("interaction type", event.interactionType); XCTAssertEqual("interaction ID", event.interactionID); XCTAssertEqual("send ID", event.data["conversion_send_id"] as! String); XCTAssertEqual("send metadata", event.data["conversion_metadata"] as! String); XCTAssertEqual(123.45, event.eventValue); } // Test settings properties on a custom event. // func testSetCustomProperties() async throws { let dict: [String : Any] = [ "event_name": "event name", "properties": [ "array": ["string", "another string"], "bool": true, "number": 123, "string": "string value" ] as [String : Any] ] let args = ActionArguments( value: try AirshipJSON.wrap(dict), situation: .manualInvocation ) try await verifyPerformWithArgs(args: args, expectedResult: nil) XCTAssertEqual(1, self.analytics.customEvents.count); let event = try XCTUnwrap(self.analytics.customEvents.first) XCTAssertEqual("event name", event.eventName); XCTAssertEqual(try! AirshipJSON.wrap(dict["properties"]), try! AirshipJSON.wrap(event.properties)); } // Helper method to verify accepts arguments. func verifyAcceptsArguments( withValue value: AirshipJSON, shouldAccept: Bool ) async { let situations = [ActionSituation.webViewInvocation, .foregroundPush, .backgroundPush, .launchedFromPush, .manualInvocation, .foregroundInteractiveButton, .backgroundInteractiveButton, .automation] for situation in situations { let args = ActionArguments(value: value, situation: situation) var accepts = false do { try await verifyPerformWithArgs(args: args) accepts = true } catch { accepts = false } if (shouldAccept) { XCTAssertTrue(accepts, "Add custom event action should accept value \(String(describing: value)) in situation \(situation)"); } else { XCTAssertFalse(accepts, "Add custom event action should not accept value \(String(describing: value)) in situation \(situation)"); } } } // Helper method to verify perform. func verifyPerformWithArgs(args: ActionArguments, expectedResult: AirshipJSON? = nil) async throws { let result = try await self.action.perform(arguments: args) XCTAssertEqual(result, expectedResult, "Result status should match expected result status."); } } ================================================ FILE: Airship/AirshipCore/Tests/AddTagsActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AddTagsActionTest: XCTestCase { private let simpleValue = ["tag", "another tag"] private let complexValue: [String: AnyHashable] = [ "channel": [ "channel_tag_group": ["channel_tag_1", "channel_tag_2"], "other_channel_tag_group": ["other_channel_tag_1"] ], "named_user": [ "named_user_tag_group": ["named_user_tag_1", "named_user_tag_2"], "other_named_user_tag_group": ["other_named_user_tag_1"] ], "device": [ "tag", "another_tag"] ] private let channel = TestChannel() private let contact = TestContact() private var action: AddTagsAction! override func setUp() async throws { action = AddTagsAction( channel: { [channel] in return channel }, contact: { [contact] in return contact } ) } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ActionSituation.backgroundInteractiveButton, ] let rejectedSituations = [ ActionSituation.backgroundPush ] for situation in validSituations { let args = ActionArguments(value: try! AirshipJSON.wrap(simpleValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in validSituations { let args = ActionArguments(value: try! AirshipJSON.wrap(complexValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(value: try! AirshipJSON.wrap(simpleValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } func testPerformSimple() async throws { self.channel.tags = ["foo", "bar"] let updates = await self.action.tagMutations _ = try await self.action.perform(arguments: ActionArguments( value: try! AirshipJSON.wrap(simpleValue), situation: .manualInvocation ) ) var iterator = updates.makeAsyncIterator() let tagsAction = await iterator.next() XCTAssertEqual(TagActionMutation.channelTags(simpleValue), tagsAction) XCTAssertEqual( ["foo", "bar", "tag", "another tag"], channel.tags ) } func testPerformComplex() async throws { self.channel.tags = ["foo", "bar"] let tagGroupsSet = self.expectation(description: "tagGroupsSet") tagGroupsSet.expectedFulfillmentCount = 2 self.channel.tagGroupEditor = TagGroupsEditor { updates in let expected = [ TagGroupUpdate( group: "channel_tag_group", tags: ["channel_tag_1", "channel_tag_2"], type: .add ), TagGroupUpdate( group: "other_channel_tag_group", tags: ["other_channel_tag_1"], type: .add ) ] XCTAssertEqual(Set(expected), Set(updates)) tagGroupsSet.fulfill() } self.contact.tagGroupEditor = TagGroupsEditor { updates in let expected = [ TagGroupUpdate( group: "named_user_tag_group", tags: ["named_user_tag_1", "named_user_tag_2"], type: .add ), TagGroupUpdate( group: "other_named_user_tag_group", tags: ["other_named_user_tag_1"], type: .add ) ] XCTAssertEqual(Set(expected), Set(updates)) tagGroupsSet.fulfill() } let updates = await self.action.tagMutations _ = try await self.action.perform(arguments: ActionArguments( value: try! AirshipJSON.wrap(complexValue), situation: .manualInvocation ) ) var expectedActions: [TagActionMutation] = [ .channelTagGroups(["channel_tag_group": ["channel_tag_1", "channel_tag_2"], "other_channel_tag_group": ["other_channel_tag_1"]]), .contactTagGroups(["named_user_tag_group": ["named_user_tag_1", "named_user_tag_2"], "other_named_user_tag_group": ["other_named_user_tag_1"]]), .channelTags(["tag", "another_tag"]), ] for await item in updates { XCTAssertEqual(item, expectedActions.removeFirst()) if (expectedActions.isEmpty) { break } } XCTAssertEqual( ["foo", "bar", "tag", "another_tag"], channel.tags ) await fulfillment(of: [tagGroupsSet]) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipAnalyticFeedTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipAnalyticFeedTest: XCTestCase { private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var privacyManager: TestPrivacyManager! override func setUp() async throws { self.privacyManager = TestPrivacyManager( dataStore: dataStore, config: .testConfig(), defaultEnabledFeatures: .all ) } func testFeed() async throws { let feed = makeFeed() var updates = await feed.updates.makeAsyncIterator() let result = await feed.notifyEvent(.screen(screen: "foo")) XCTAssertTrue(result) let next = await updates.next() XCTAssertEqual(next, .screen(screen: "foo")) } func testFeedAnalyticsDisabled() async throws { let feed = makeFeed() privacyManager.disableFeatures(.analytics) var updates = await feed.updates.makeAsyncIterator() var result = await feed.notifyEvent(.screen(screen: "foo")) XCTAssertFalse(result) privacyManager.enableFeatures(.analytics) result = await feed.notifyEvent(.screen(screen: "bar")) XCTAssertTrue(result) let next = await updates.next() XCTAssertEqual(next, .screen(screen: "bar")) } func testFeedDisabled() async throws { let feed = makeFeed(enabled: false) let result = await feed.notifyEvent(.screen(screen: "foo")) XCTAssertFalse(result) } private func makeFeed(enabled: Bool = true) -> AirshipAnalyticsFeed { return AirshipAnalyticsFeed(privacyManager: privacyManager, isAnalyticsEnabled: enabled) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipAsyncChannelTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipAsyncChannelTest: XCTestCase { private let channel = AirshipAsyncChannel<Int>() func testSingleListener() async throws { var stream = await channel.makeStream().makeAsyncIterator() var sent: [Int] = [] for i in 0...5 { sent.append(i) await channel.send(i) } var received: [Int] = [] for _ in 0...5 { received.append(await stream.next()!) } XCTAssertEqual(sent, received) } func testMultipleListeners() async throws { let streams = [ await channel.makeStream().makeAsyncIterator(), await channel.makeStream().makeAsyncIterator(), await channel.makeStream().makeAsyncIterator() ] var sent: [Int] = [] for i in 0...5 { sent.append(i) await channel.send(i) } for var stream in streams { var received: [Int] = [] for _ in 0...5 { received.append(await stream.next()!) } XCTAssertEqual(sent, received) } } func testNonIsolatedDedupingStreamMapped() async throws { var updates = channel.makeNonIsolatedDedupingStream( initialValue: { "1" }, transform: { int in "\(int)" } ).makeAsyncIterator() // Wait for first so we know the task is setup to listen for changes let first = await updates.next() XCTAssertEqual(first, "1") await channel.send(2) await channel.send(2) await channel.send(2) await channel.send(3) await channel.send(3) await channel.send(4) var received: [String] = [] for _ in 0...2 { received.append(await updates.next()!) } XCTAssertEqual(["2", "3", "4"], received) } func testNonIsolatedDedupingStream() async throws { var updates = channel.makeNonIsolatedDedupingStream( initialValue: { 1 } ).makeAsyncIterator() await channel.send(1) // Wait for first so we know the task is setup to listen for changes let first = await updates.next() XCTAssertEqual(first, 1) await channel.send(1) await channel.send(1) await channel.send(2) await channel.send(2) await channel.send(3) var received: [Int] = [] for _ in 0...1 { received.append(await updates.next()!) } XCTAssertEqual([2, 3], received) } func testNonIsolatedStreamMapped() async throws { var updates = channel.makeNonIsolatedStream( initialValue: { "1" }, transform: { int in "\(int)" } ).makeAsyncIterator() // Wait for first so we know the task is setup to listen for changes let first = await updates.next() XCTAssertEqual(first, "1") await channel.send(1) await channel.send(2) await channel.send(2) await channel.send(3) await channel.send(4) var received: [String] = [] for _ in 0...4 { received.append(await updates.next()!) } XCTAssertEqual(["1", "2", "2", "3", "4"], received) } func testNonIsolatedStream() async throws { var updates = channel.makeNonIsolatedStream( initialValue: { 1 } ).makeAsyncIterator() // Wait for first so we know the task is setup to listen for changes let first = await updates.next() XCTAssertEqual(first, 1) await channel.send(1) await channel.send(1) await channel.send(1) await channel.send(2) await channel.send(2) await channel.send(3) var received: [Int] = [] for _ in 0...5 { received.append(await updates.next()!) } XCTAssertEqual([1, 1, 1, 2, 2, 3], received) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipBase64Test.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class AirshipBase64Test: XCTestCase { // Examples from Wikipedia page on base64 encoding // http://en.wikipedia.org/wiki/Base64 // These test strings were encoded/decoded with Python 2.7.2 base64 lib to check for errors // Note the period (.), it is part of the encoding, as well as the '=' sign, it is used // for padding. //>>> one = base64.b64encode('pleasure.') //>>> print(one) //cGxlYXN1cmUu //>>> one == 'cGxlYXN1cmUu' //True //>>> one = base64.b64encode('leasure.') //>>> one == 'bGVhc3VyZS4=' //True //>>> one = base64.b64encode('easure.') //>>> one == 'ZWFzdXJlLg==' //True //>>> let pleasure = "pleasure." let pleasure64 = "cGxlYXN1cmUu" let leasure = "leasure." let leasure64 = "bGVhc3VyZS4=" let easure = "easure." let easure64 = "ZWFzdXJlLg==" let easure64PartiallyPadded = "ZWFzdXJlLg=" let easure64Unpadded = "ZWFzdXJlLg" let easure64Newline = "ZWFzdXJlLg\n" let easure64InterstitialNewline = "ZWFzdXJlLg=\n=" func testBase64Encode() { let dataToEncode = pleasure.data(using: .ascii)! let encoded = AirshipBase64.string(from: dataToEncode) XCTAssertTrue(encoded == pleasure64) let dataToEncode2 = leasure.data(using: .ascii)! let encoded2 = AirshipBase64.string(from: dataToEncode2) XCTAssertTrue(encoded2 == leasure64) let dataToEncode3 = easure.data(using: .ascii)! let encoded3 = AirshipBase64.string(from: dataToEncode3) XCTAssertTrue(encoded3 == easure64) } func testBase64Decode() { var decodedData = AirshipBase64.data(from: pleasure64)! var decodedString = String(data: decodedData, encoding: .ascii)! XCTAssertTrue(decodedString == pleasure) decodedData = AirshipBase64.data(from: leasure64)! decodedString = String(data: decodedData, encoding: .ascii)! XCTAssertTrue(decodedString == leasure) decodedData = AirshipBase64.data(from: easure64)! decodedString = String(data: decodedData, encoding: .ascii)! XCTAssertTrue(decodedString == easure) decodedData = AirshipBase64.data(from: easure64PartiallyPadded)! decodedString = String(data: decodedData, encoding: .ascii)! XCTAssertTrue(decodedString == easure) decodedData = AirshipBase64.data(from: easure64Unpadded)! decodedString = String(data: decodedData, encoding: .ascii)! XCTAssertTrue(decodedString == easure) decodedData = AirshipBase64.data(from: easure64Newline)! decodedString = String(data: decodedData, encoding: .ascii)! XCTAssertTrue(decodedString == easure) decodedData = AirshipBase64.data(from: easure64InterstitialNewline)! decodedString = String(data: decodedData, encoding: .ascii)! XCTAssertTrue(decodedString == easure) } func testBase64DecodeInvalidString() { XCTAssertNoThrow(AirshipBase64.data(from: ".")) XCTAssertNoThrow(AirshipBase64.data(from: " ")) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipBaseTest.swift ================================================ ///* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore public let testExpectationTimeOut = 10.0 class AirshipBaseTest: XCTestCase { /** * A preference data store unique to this test. The dataStore is created * lazily when first used. */ lazy var dataStore: PreferenceDataStore = { return PreferenceDataStore(appKey: UUID().uuidString) }() /** * A preference airship with unique appkey/secret. A runtime config is created * lazily when first used. */ lazy var config: RuntimeConfig = RuntimeConfig.testConfig() } ================================================ FILE: Airship/AirshipCore/Tests/AirshipCacheTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipCacheTest: XCTestCase { private let date = UATestDate(offset: 0, dateOverride: Date()) private let coreData: UACoreData = CoreDataAirshipCache.makeCoreData( appKey: UUID().uuidString, inMemory: true )! private var cache: CoreDataAirshipCache! override func setUpWithError() throws { self.cache = CoreDataAirshipCache( coreData: coreData, appVersion: "some-app-version", sdkVersion: "some-sdk-version", date: self.date ) } func testCacheTTL() async throws { await self.cache.setCachedValue("cache value", key: "some key", ttl: 10.0) var value: String? = await self.cache.getCachedValue(key: "some key") XCTAssertEqual("cache value", value) date.offset += 9.9 value = await self.cache.getCachedValue(key: "some key") XCTAssertEqual("cache value", value) date.offset += 0.1 value = await self.cache.getCachedValue(key: "some key") XCTAssertNil(value) } func testCacheNil() async throws { await self.cache.setCachedValue("cache value", key: "some key", ttl: 10.0) var value: String? = await self.cache.getCachedValue(key: "some key") XCTAssertEqual("cache value", value) value = nil await self.cache.setCachedValue(value, key: "some key", ttl: 10.0) XCTAssertNil(value) } func testOverwriteCache() async throws { await self.cache.setCachedValue("cache value", key: "some key", ttl: 10.0) await self.cache.setCachedValue("some other cache value", key: "some key", ttl: 10.0) let value: String? = await self.cache.getCachedValue(key: "some key") XCTAssertEqual("some other cache value", value) } func testCache() async throws { await self.cache.setCachedValue("some value", key: "some key", ttl: 10.0) await self.cache.setCachedValue("some other value", key: "some other key", ttl: 10.0) var value: String? = await self.cache.getCachedValue(key: "some key") XCTAssertEqual("some value", value) value = await self.cache.getCachedValue(key: "some other key") XCTAssertEqual("some other value", value) value = await self.cache.getCachedValue(key: "some null key") XCTAssertNil(value) } func testOverwriteCacheClearedSDKVersionChange() async throws { await self.cache.setCachedValue("cache value", key: "some key", ttl: 10.0) self.cache = CoreDataAirshipCache( coreData: coreData, appVersion: "some-app-version", sdkVersion: "some-other-sdk-version", date: self.date ) let value: String? = await self.cache.getCachedValue(key: "some key") XCTAssertNil(value) } func testOverwriteCacheClearedAppVersionChange() async throws { await self.cache.setCachedValue("cache value", key: "some key", ttl: 10.0) self.cache = CoreDataAirshipCache( coreData: coreData, appVersion: "some-other-app-version", sdkVersion: "some-sdk-version", date: self.date ) let value: String? = await self.cache.getCachedValue(key: "some key") XCTAssertNil(value) } } public enum TestAirshipCoreDataCache { static func makeCache(date: AirshipDateProtocol) -> AirshipCache { return CoreDataAirshipCache( coreData: CoreDataAirshipCache.makeCoreData(appKey: UUID().uuidString)!, appVersion: "version", sdkVersion: "sdk", date: date ) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipColorTests.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore import SwiftUI @Suite struct AirshipColorTests { @Test func testResolveNativeColorAARRGGBB() throws { let color = try #require(AirshipColor.resolveNativeColor("#FFFF0000")) var red: CGFloat = 0.0 var green: CGFloat = 0.0 var blue: CGFloat = 0.0 var alpha: CGFloat = 0.0 color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) #expect(red == 1.0) #expect(green == 0) #expect(blue == 0) #expect(alpha == 1.0) #expect(try AirshipColor.hexString(color) == "#FFFF0000") // Lowercase and no hash let color2 = try #require(AirshipColor.resolveNativeColor("8000ff00")) color2.getRed(&red, green: &green, blue: &blue, alpha: &alpha) #expect(red == 0) #expect(green == 1.0) #expect(blue == 0) #expect(Double(alpha).isApproximately(0.5, within: 0.01)) #expect(try AirshipColor.hexString(color2) == "#8000FF00") } @Test func testResolveNativeColorRRGGBB() throws { let color = try #require(AirshipColor.resolveNativeColor("FF0000")) var red: CGFloat = 0.0 var green: CGFloat = 0.0 var blue: CGFloat = 0.0 var alpha: CGFloat = 0.0 color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) #expect(red == 1.0) #expect(green == 0) #expect(blue == 0) #expect(alpha == 1.0) // Lowercase and no hash let color2 = try #require(AirshipColor.resolveNativeColor("00ff80")) color2.getRed(&red, green: &green, blue: &blue, alpha: &alpha) #expect(red == 0) #expect(green == 1.0) #expect(Double(blue).isApproximately(0.5, within: 0.01)) #expect(alpha == 1.0) } @Test func testResolveNativeColorInvalid() { #expect(AirshipColor.resolveNativeColor("Not a color") == nil) #expect(AirshipColor.resolveNativeColor("#FF00") == nil) // Too short #expect(AirshipColor.resolveNativeColor("#FFFF00FF00") == nil) // Too long } @Test func testStringToColorExtension() { let color = "#FFFF0000".airshipToColor() // verifying it returns a valid View/Color #expect(String(describing: color).count > 0) } } extension Double { func isApproximately(_ other: Double, within tolerance: Double) -> Bool { return abs(self - other) <= tolerance } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipConfigTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipConfigTest: XCTestCase { func testEmptyConfig() { let config = AirshipConfig() verifyDefaultConfig(config) } func testConfigFromEmptyJSON() throws { let config: AirshipConfig = try AirshipJSON.wrap([:]).decode() verifyDefaultConfig(config) } func testOldPlistFormat() throws { let path = Bundle(for: self.classForCoder).path( forResource: "AirshipConfig-Valid-Legacy", ofType: "plist" ) let config = try AirshipConfig(fromPlist: path!) XCTAssertEqual(config.productionAppKey, "0A00000000000000000000") XCTAssertEqual(config.productionAppSecret, "0A00000000000000000000") XCTAssertEqual(config.developmentAppKey, "0A00000000000000000000") XCTAssertEqual(config.developmentAppSecret, "0A00000000000000000000") XCTAssertEqual(config.developmentLogLevel, .verbose) XCTAssertEqual(config.inProduction, true) } func testPlistParsing() throws { let path = Bundle(for: self.classForCoder).path( forResource: "AirshipConfig-Valid", ofType: "plist" ) let config = try AirshipConfig(fromPlist: path!) XCTAssertEqual(config.productionAppKey, "0A00000000000000000000") XCTAssertEqual(config.productionAppSecret, "0A00000000000000000000") XCTAssertEqual(config.developmentAppKey, "0A00000000000000000000") XCTAssertEqual(config.developmentAppSecret, "0A00000000000000000000") XCTAssertEqual(config.developmentLogLevel, .error) XCTAssertEqual(config.developmentLogPrivacyLevel, .private) XCTAssertEqual(config.productionLogLevel, .verbose) XCTAssertEqual(config.productionLogPrivacyLevel, .public) XCTAssertTrue(config.isChannelCreationDelayEnabled) XCTAssertTrue(config.isExtendedBroadcastsEnabled) XCTAssertEqual(config.inProduction, true) XCTAssertEqual(config.enabledFeatures, [.inAppAutomation, .push]) XCTAssertTrue(config.resetEnabledFeatures) XCTAssertEqual(config.messageCenterStyleConfig, "ValidUAMessageCenterDefaultStyle") } private func verifyDefaultConfig( _ config: AirshipConfig, file: StaticString = #filePath, line: UInt = #line ) { XCTAssertNil(config.developmentAppKey) XCTAssertNil(config.developmentAppSecret) XCTAssertNil(config.productionAppKey) XCTAssertNil(config.productionAppSecret) XCTAssertNil(config.defaultAppKey) XCTAssertNil(config.defaultAppSecret) XCTAssertNil(config.logHandler) XCTAssertEqual(config.site, .us) XCTAssertEqual(config.developmentLogLevel, .debug) XCTAssertEqual(config.developmentLogPrivacyLevel, .private) XCTAssertEqual(config.productionLogLevel, .error) XCTAssertEqual(config.productionLogPrivacyLevel, .private) XCTAssertNil(config.inProduction) XCTAssertTrue(config.isAutomaticSetupEnabled) XCTAssertTrue(config.isAnalyticsEnabled) XCTAssertFalse(config.clearUserOnAppRestore) XCTAssertNil(config.urlAllowList) XCTAssertNil(config.urlAllowListScopeJavaScriptInterface) XCTAssertNil(config.urlAllowListScopeOpenURL) XCTAssertFalse(config.clearNamedUserOnAppRestore) XCTAssertTrue(config.isChannelCaptureEnabled) XCTAssertFalse(config.isChannelCreationDelayEnabled) XCTAssertFalse(config.isExtendedBroadcastsEnabled) XCTAssertTrue(config.requestAuthorizationToUseNotifications) XCTAssertTrue(config.requireInitialRemoteConfigEnabled) XCTAssertFalse(config.autoPauseInAppAutomationOnLaunch) XCTAssertFalse(config.resetEnabledFeatures) XCTAssertFalse(config.isWebViewInspectionEnabled) XCTAssertNil(config.connectionChallengeResolver) XCTAssertNil(config.restoreChannelID) XCTAssertNil(config.itunesID) XCTAssertNil(config.messageCenterStyleConfig) XCTAssertEqual(config.enabledFeatures, .all) XCTAssertNil(config.initialConfigURL) XCTAssertFalse(config.useUserPreferredLocale) XCTAssertTrue(config.restoreMessageCenterOnReinstall) } func testValidation() throws { var config = AirshipConfig() // Not set verifyThrows { try config.validateCredentials(inProduction: true) } verifyThrows { try config.validateCredentials(inProduction: false) } // App key & secret match config.developmentAppKey = "0A00000000000000000000" config.developmentAppSecret = "0A00000000000000000000" verifyThrows { try config.validateCredentials(inProduction: false) } // Should not throw config.developmentAppSecret = "0B00000000000000000000" try config.validateCredentials(inProduction: false) // Production still not set verifyThrows { try config.validateCredentials(inProduction: true) } // Invalid key config.productionAppKey = "NOT VALID" config.productionAppSecret = "0A00000000000000000000" verifyThrows { try config.validateCredentials(inProduction: true) } // Invalid secret config.productionAppKey = "0A00000000000000000000" config.productionAppSecret = "NOT VALID" verifyThrows { try config.validateCredentials(inProduction: true) } // Both invalid config.productionAppKey = "NOT VALID KEY" config.productionAppSecret = "NOT VALID" verifyThrows { try config.validateCredentials(inProduction: true) } // Both valid config.productionAppKey = "0A00000000000000000000" config.productionAppSecret = "0B00000000000000000000" try config.validateCredentials(inProduction: true) } private func verifyThrows( block: () throws -> Void, file: StaticString = #filePath, line: UInt = #line ) { do { try block() XCTFail() } catch {} } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipContactTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore import Combine import Foundation 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() ) private let date: UATestDate = UATestDate(offset: 0, dateOverride: Date()) private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private let audienceOverridesProvider: DefaultAudienceOverridesProvider = DefaultAudienceOverridesProvider() private let contactManager: TestContactManager = TestContactManager() private var contactQueue: AirshipAsyncSerialQueue! private var contact: DefaultAirshipContact! private var privacyManager: DefaultAirshipPrivacyManager! private var config: RuntimeConfig = RuntimeConfig.testConfig() private var subscriptionProvider: SubscriptionListProviderProtocol! override func setUp() async throws { self.privacyManager = await DefaultAirshipPrivacyManager( dataStore: self.dataStore, config: self.config, defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) self.subscriptionProvider = SubscriptionListProvider( audienceOverrides: self.audienceOverridesProvider, apiClient: self.apiClient, date: self.date, privacyManager: self.privacyManager) self.channel.identifier = "channel id" await setupContact() self.contact.airshipReady() await self.waitOnContactQueue() // waits for the initial setup task } @MainActor func setupContact() { contactQueue = AirshipAsyncSerialQueue(priority: .high) self.contact = DefaultAirshipContact( dataStore: self.dataStore, config: config, channel: self.channel, privacyManager: self.privacyManager, contactChannelsProvider: self.contactChannelsProvider, subscriptionListProvider: subscriptionProvider, date: self.date, notificationCenter: self.notificationCenter, audienceOverridesProvider: self.audienceOverridesProvider, contactManager: self.contactManager, serialQueue: contactQueue ) } func testMigrateNamedUser() async throws { await self.verifyOperations([]) let attributeDate = AirshipDateFormatter.string(fromDate: self.date.now, format: .isoDelimitter) let attributePayload = [ "action": "remove", "key": "some-attribute", "timestamp": attributeDate ] let attributeMutation = AttributePendingMutations(mutationsPayload: [ attributePayload ]) let attributeData = try! NSKeyedArchiver.archivedData( withRootObject: [attributeMutation], requiringSecureCoding: true ) dataStore.setObject( attributeData, forKey: DefaultAirshipContact.legacyPendingAttributesKey ) let tagMutation = TagGroupsMutation( adds: ["some-group": Set(["tag"])], removes: nil, sets: nil ) let tagData = try! NSKeyedArchiver.archivedData( withRootObject: [tagMutation], requiringSecureCoding: true ) dataStore.setObject(tagData, forKey: DefaultAirshipContact.legacyPendingTagGroupsKey) self.dataStore.setObject( "named-user", forKey: DefaultAirshipContact.legacyNamedUserKey ) await setupContact() await verifyOperations( [ .identify("named-user"), .update( tagUpdates: [ TagGroupUpdate(group: "some-group", tags: ["tag"], type: .add) ], attributeUpdates: [ AttributeUpdate.remove(attribute: "some-attribute", date: AirshipDateFormatter.date(fromISOString: attributeDate)!) ], subscriptionListsUpdates: nil ) ] ) } /// Test skip calling identify on the legacy named user if we already have contact data func testSkipMigrateLegacyNamedUser() async throws { let tagMutation = TagGroupsMutation( adds: ["some-group": Set(["tag"])], removes: nil, sets: nil ) let tagData = try! NSKeyedArchiver.archivedData( withRootObject: [tagMutation], requiringSecureCoding: true ) dataStore.setObject(tagData, forKey: DefaultAirshipContact.legacyPendingTagGroupsKey) self.dataStore.setObject( "named-user", forKey: DefaultAirshipContact.legacyNamedUserKey ) await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some contact ID", isStable: false, namedUserID: nil) ) await setupContact() let _ = await contact.namedUserID await verifyOperations( [ .update( tagUpdates: [ TagGroupUpdate(group: "some-group", tags: ["tag"], type: .add) ], attributeUpdates: nil, subscriptionListsUpdates: nil ) ] ) } @MainActor func testChannelCreatedEnqueuesUpdateTask() async throws { notificationCenter.post( name: AirshipNotifications.ChannelCreated.name ) await verifyOperations([.resolve]) } func testStableVerifiedContactID() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) ) let contactManager = self.contactManager let channel = self.channel let date = self.date.now let payloadTaskStarted = self.expectation(description: "payload task started") let payloadTask = Task { payloadTaskStarted.fulfill() return await channel.channelPayload } await fulfillment(of: [payloadTaskStarted]) await contactManager.setCurrentContactIDInfo( ContactIDInfo( contactID: "some-other-contact-id", isStable: false, namedUserID: nil, resolveDate: date.advanced(by: -DefaultAirshipContact.defaultVerifiedContactIDAge) ) ) await contactManager.setCurrentContactIDInfo( ContactIDInfo( contactID: "some-stable-contact-id", isStable: true, namedUserID: nil, resolveDate: date.advanced(by: -DefaultAirshipContact.defaultVerifiedContactIDAge) ) ) await contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-stable-verified-contact-id", isStable: true, namedUserID: nil, resolveDate: date) ) let payload = await payloadTask.value XCTAssertEqual("some-stable-verified-contact-id", payload.channel.contactID) await verifyOperations([.verify(date)]) } func testStableVerifiedContactIDAlreadyUpToDate() async throws { let date = self.date.now await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil, resolveDate: date) ) let channel = self.channel let payload = await channel.channelPayload XCTAssertEqual("some-contact-id", payload.channel.contactID) await verifyOperations([]) } @MainActor func testMaxAgeStableVerifiedContactID() async throws { self.config.updateRemoteConfig( RemoteConfig( contactConfig: .init( foregroundIntervalMilliseconds: nil, channelRegistrationMaxResolveAgeMilliseconds: 1000 ) ) ) let date = self.date.now // Ensure stale age > 1 s max-age to avoid race let staleDate = date.advanced(by: -2) await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil, resolveDate: staleDate) ) let contactManager = self.contactManager let channel = self.channel let payloadTaskStarted = self.expectation(description: "payload task started") let payloadTask = Task { @MainActor in payloadTaskStarted.fulfill() return await channel.channelPayload } await fulfillment(of: [payloadTaskStarted]) await contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-stable-verified-contact-id", isStable: true, namedUserID: nil, resolveDate: date) ) let payload = await payloadTask.value XCTAssertEqual("some-stable-verified-contact-id", payload.channel.contactID) await verifyOperations([.verify(date)]) } func testExtendRegistrationPaylaodOnChannelCreate() async throws { self.channel.identifier = nil await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) ) XCTAssertEqual(1, self.channel.extenders.count) let payload = await self.channel.channelPayload XCTAssertEqual("some-contact-id", payload.channel.contactID) } func testExtendRegistrationPayloadGeneratesContactID() async throws { self.channel.identifier = nil await self.contactManager.clearGenerateDefaultContactIDCalledFlag() _ = await self.channel.channelPayload let generated = await self.contactManager.generateDefaultContactIDCalled XCTAssertTrue(generated) } func testForegroundResolves() async throws { notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) await verifyOperations([.resolve]) } func testRefreshContactChannelsOnActive() async throws { notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) XCTAssertTrue(contactChannelsProvider.refreshedCalled) } @MainActor func testRefreshContactChannelsOnPush() async throws { _ = await self.contact.receivedRemoteNotification( try! AirshipJSON.wrap( [ "com.urbanairship.contact.update": NSNumber(value: true) ] ) ) XCTAssertTrue(contactChannelsProvider.refreshedCalled) } func testForegroundSkipsResolves() async throws { notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) await verifyOperations([.resolve]) // Default is 60 seconds self.date.offset += DefaultAirshipContact.defaultForegroundResolveInterval - 1.0 notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) await verifyOperations([.resolve]) self.date.offset += 1 notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) await verifyOperations([.resolve, .resolve]) } func testForegroundSkipsResolvesConfigValue() async throws { await self.config.updateRemoteConfig( RemoteConfig( contactConfig: .init( foregroundIntervalMilliseconds: 1000, channelRegistrationMaxResolveAgeMilliseconds: nil ) ) ) notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) await verifyOperations([.resolve]) self.date.offset += 0.5 notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) await verifyOperations([.resolve]) self.date.offset += 0.5 notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification ) await verifyOperations([.resolve, .resolve]) } func testIdentify() async throws { self.contact.identify("cool user 1") await self.verifyOperations([.identify("cool user 1")]) } func testReset() async throws { self.contact.reset() await self.verifyOperations([.reset]) } func testRegisterEmail() async throws { let options = EmailRegistrationOptions.options( transactionalOptedIn: Date(), properties: ["interests": "newsletter"], doubleOptIn: true ) self.contact.registerEmail( "ua@airship.com", options: options ) await self.verifyOperations([.registerEmail(address: "ua@airship.com", options: options)]) } func testRegisterSMS() async throws { let options = SMSRegistrationOptions.optIn(senderID: "28855") self.contact.registerSMS( "15035556789", options: options ) await self.verifyOperations([.registerSMS(msisdn: "15035556789", options: options)]) } func testRegisterOpen() async throws { let options = OpenRegistrationOptions.optIn( platformName: "my_platform", identifiers: ["model": "4"] ) self.contact.registerOpen( "open_address", options: options ) await self.verifyOperations([.registerOpen(address: "open_address", options: options)]) } func testAssociateChannel() async throws { self.contact.associateChannel( "some-channel-id", type: .email ) await self.verifyOperations([.associateChannel( channelID: "some-channel-id", channelType: .email )]) } func testEdits() async throws { self.contact.editTagGroups() { editor in editor.add(["neat"], group: "cool") } self.contact.editAttributes() { editor in editor.set(int: 1, attribute: "one") } self.contact.editSubscriptionLists() { editor in editor.subscribe("some id", scope: .app) } } @MainActor func testResolveSkippedContactsDisabled() async throws { self.privacyManager.disableFeatures(.contacts) notificationCenter.post(name: AirshipNotifications.ChannelCreated.name) await self.verifyOperations([.reset]) } @MainActor func testTagsAndAttributesSkippedContactsDisabled() async throws { self.privacyManager.disableFeatures(.contacts) self.contact.editTagGroups() { editor in editor.add(["neat"], group: "cool") } self.contact.editAttributes() { editor in editor.set(int: 1, attribute: "one") } self.contact.editSubscriptionLists() { editor in editor.subscribe("some id", scope: .app) } await self.verifyOperations([.reset]) } @MainActor func testIdentifySkippedContactsDisabled() async throws { self.privacyManager.disableFeatures(.contacts) await self.verifyOperations([.reset]) self.contact.identify("cat") await self.verifyOperations([.reset]) } @MainActor func testResetOnDisableContacts() async throws { self.privacyManager.disableFeatures(.contacts) await self.verifyOperations([.reset]) } func testFetchSubscriptionLists() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) let apiResult: [String: [ChannelScope]] = ["neat": [.web]] let expected = apiResult self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-contact-id", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } let lists:[String: [ChannelScope]] = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) } func testFetchSubscriptionListsCached() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) var apiResult: [String: [ChannelScope]] = ["neat": [.web]] var expected = apiResult self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-contact-id", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } // Populate cache var lists: [String: [ChannelScope]] = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) apiResult = ["something else": [.web]] lists = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) self.date.offset += 599 // 1 second before cache should invalidate lists = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) self.date.offset += 1 // From api expected = apiResult lists = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) } func testFetchSubscriptionListsReset() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) var apiResult: [String: [ChannelScope]] = ["neat": [.web]] var expected = apiResult self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-contact-id", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } // Populate cache var lists: [String: [ChannelScope]] = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) apiResult = ["something else": [.web]] lists = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) await subscriptionProvider.refresh() lists = try await self.contact.fetchSubscriptionLists() expected = apiResult XCTAssertEqual(expected, lists) } @MainActor func testFetchSubscriptionListsCachedDifferentContactID() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) var apiResult: [String: [ChannelScope]] = ["neat": [ChannelScope.web]] var expected = apiResult self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-contact-id", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } // Populate cache var lists:[String: [ChannelScope]] = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) apiResult = ["something else": [.web]] // From cache lists = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) // Resolve a new contact ID await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-other-contact-id", isStable: true, namedUserID: nil) ) self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-other-contact-id", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } // From api expected = apiResult lists = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) } func testFetchWaitsForStableContactID() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) ) let apiResult: [String: [ChannelScope]] = ["neat": [.web]] let expected = apiResult self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-stable-contact-id", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } let contactManager = self.contactManager DispatchQueue.main.async { Task { await contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-other-contact-id", isStable: false, namedUserID: nil) ) await contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-stable-contact-id", isStable: true, namedUserID: nil) ) } } let lists:[String: [ChannelScope]] = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(expected, lists) } func testNotifyRemoteLogin() async throws { self.contact.notifyRemoteLogin() await verifyOperations([.verify(self.date.now, required: true)]) } func testFetchSubscriptionListsOverrides() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) let apiResult: [String: [ChannelScope]] = ["neat": [.web, .app]] self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-contact-id", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } /// Local history await self.audienceOverridesProvider.contactUpdated( contactID: "some-contact-id", tags: nil, attributes: nil, subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "neat", type: .unsubscribe, scope: .web, date: self.date.now) ], channels: [] ) // Pending await self.contactManager.setPendingAudienceOverrides( ContactAudienceOverrides( subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "neat", type: .subscribe, scope: .sms, date: self.date.now) ] )) let lists:[String: [ChannelScope]] = try await self.contact.fetchSubscriptionLists() XCTAssertEqual(["neat": [.app, .sms]], lists) } func testFetchSubscriptionListsFails() async throws { await self.contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) ) self.apiClient.fetchSubscriptionListsCallback = { identifier in XCTAssertEqual("some-contact-id", identifier) return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } do { let _ = try await self.contact.fetchSubscriptionLists() XCTFail("Should throw") } catch {} } func testAudienceOverrides() async throws { let update = ContactAudienceUpdate( contactID: "some-contact-id", tags: [ TagGroupUpdate(group: "some group", tags: ["tag"], type: .add) ], attributes: [ AttributeUpdate(attribute: "some attribute", type: .set, jsonValue: "cool", date: self.date.now) ], subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "some list", type: .unsubscribe, scope: .app, date: self.date.now) ], contactChannels: [] ) let pending = ContactAudienceOverrides( tags: [ TagGroupUpdate(group: "some other group", tags: ["tag"], type: .add) ], attributes: [ AttributeUpdate(attribute: "some other attribute", type: .set, jsonValue: "cool", date: self.date.now) ], subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "some other list", type: .unsubscribe, scope: .app, date: self.date.now) ] ) await self.contactManager.setPendingAudienceOverrides(pending) await self.contactManager.dispatchAudienceUpdate(update) let overrides = await self.audienceOverridesProvider.contactOverrides(contactID: "some-contact-id") XCTAssertEqual(overrides.tags, update.tags! + pending.tags) XCTAssertEqual(overrides.attributes, update.attributes! + pending.attributes) XCTAssertEqual(overrides.subscriptionLists, update.subscriptionLists! + pending.subscriptionLists) } func testAudienceOverridesStableID() async throws { let updateFoo = ContactAudienceUpdate( contactID: "foo", tags: [ TagGroupUpdate(group: "some group", tags: ["tag"], type: .add) ], attributes: [ AttributeUpdate(attribute: "some attribute", type: .set, jsonValue: "cool", date: self.date.now) ], subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "some list", type: .unsubscribe, scope: .app, date: self.date.now) ], contactChannels: [] ) let updateBar = ContactAudienceUpdate( contactID: "bar", tags: [ TagGroupUpdate(group: "some other group", tags: ["tag"], type: .add) ], attributes: [ AttributeUpdate(attribute: "some other attribute", type: .set, jsonValue: "cool", date: self.date.now) ], subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "some other list", type: .unsubscribe, scope: .app, date: self.date.now) ], contactChannels: [] ) await self.contactManager.dispatchAudienceUpdate(updateFoo) await self.contactManager.dispatchAudienceUpdate(updateBar) let contactManager = self.contactManager Task.detached(priority: .high) { await contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "foo", isStable: false, namedUserID: nil) ) await contactManager.setCurrentContactIDInfo( ContactIDInfo(contactID: "bar", isStable: true, namedUserID: nil) ) } let overrides = await self.audienceOverridesProvider.contactOverrides() XCTAssertEqual(overrides.tags, updateBar.tags) XCTAssertEqual(overrides.attributes, updateBar.attributes) XCTAssertEqual(overrides.subscriptionLists, updateBar.subscriptionLists) } @MainActor func testGenerateDefaultContactInfo() async throws { // Should be called on migrate if no named user ID var isCalled = await self.contactManager.generateDefaultContactIDCalled XCTAssertTrue(isCalled) // Clear it await self.contactManager.clearGenerateDefaultContactIDCalledFlag() // Trigger it to be called when privacy manager enables contacts self.privacyManager.disableFeatures(.all) self.privacyManager.enableFeatures(.contacts) await self.waitOnContactQueue() isCalled = await self.contactManager.generateDefaultContactIDCalled XCTAssertTrue(isCalled) } func testNamedUserID() async throws { await self.contactManager.setCurrentNamedUserID("some named user") let namedUser = await self.contact.namedUserID XCTAssertEqual("some named user", namedUser) } @MainActor func testConflictEvents() async throws { let event = ContactConflictEvent( tags: [:], attributes: [:], associatedChannels: [], subscriptionLists: [:], conflictingNamedUserID: "neat" ) let expectation = XCTestExpectation() let subscription = self.contact.conflictEventPublisher.sink { conflict in XCTAssertEqual(event, conflict) expectation.fulfill() } self.contactManager.contactUpdatesContinuation.yield(.conflict(event)) await fulfillment(of: [expectation]) subscription.cancel() } @MainActor func testConflictEventNotificationCenter() async throws { let event = ContactConflictEvent( tags: [:], attributes: [:], associatedChannels: [], subscriptionLists: [:], conflictingNamedUserID: "neat" ) let expectation = XCTestExpectation() self.notificationCenter.addObserver(forName: AirshipNotifications.ContactConflict.name, object: nil, queue: nil) { notification in XCTAssertEqual(event, notification.userInfo?[AirshipNotifications.ContactConflict.eventKey] as? ContactConflictEvent) expectation.fulfill() } self.contactManager.contactUpdatesContinuation.yield(.conflict(event)) await fulfillment(of: [expectation]) } private func verifyOperations(_ operations: [ContactOperation], file: StaticString = #filePath, line: UInt = #line) async { let expectation = XCTestExpectation() let contactManager = self.contactManager let file = file let line = line self.contactQueue.enqueue { let contactOperations = await contactManager.operations XCTAssertEqual(operations, contactOperations, file: file, line: line) expectation.fulfill() } await fulfillment(of: [expectation], timeout: 10.0) } private func waitOnContactQueue() async { let expectation = XCTestExpectation() self.contactQueue.enqueue { expectation.fulfill() } await fulfillment(of: [expectation], timeout: 10.0) } } fileprivate actor TestContactManager: ContactManagerProtocol { private var _currentNamedUserID: String? = nil private var _currentContactIDInfo: ContactIDInfo? = nil private var _pendingAudienceOverrides = ContactAudienceOverrides() private var _onAudienceUpdatedCallback: (@Sendable (ContactAudienceUpdate) async -> Void)? let contactUpdates: AsyncStream<ContactUpdate> let contactUpdatesContinuation: AsyncStream<ContactUpdate>.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 init() { ( self.contactUpdates, self.contactUpdatesContinuation ) = AsyncStream<ContactUpdate>.airshipMakeStreamWithContinuation() ( self.channelUpdates, self.channelUpdatesContinuation ) = AsyncStream<[ContactChannel]>.airshipMakeStreamWithContinuation() } func onAudienceUpdated( onAudienceUpdatedCallback: (@Sendable (AirshipCore.ContactAudienceUpdate) async -> Void)? ) { self._onAudienceUpdatedCallback = onAudienceUpdatedCallback } func dispatchAudienceUpdate(_ update: ContactAudienceUpdate) async { await self._onAudienceUpdatedCallback!(update) } func addOperation(_ operation: ContactOperation) { operations.append(operation) } func clearGenerateDefaultContactIDCalledFlag() { self.generateDefaultContactIDCalled = false } func generateDefaultContactIDIfNotSet() { generateDefaultContactIDCalled = true } func setCurrentNamedUserID(_ namedUserID: String) { self._currentNamedUserID = namedUserID self.contactUpdatesContinuation.yield(.namedUserUpdate(namedUserID)) } func currentNamedUserID() -> String? { return self._currentNamedUserID } func setEnabled(enabled: Bool) { } func setCurrentContactIDInfo(_ contactIDInfo: ContactIDInfo) { self._currentContactIDInfo = contactIDInfo self.contactUpdatesContinuation.yield(.contactIDUpdate(contactIDInfo)) } func currentContactIDInfo() -> ContactIDInfo? { return _currentContactIDInfo } func setPendingAudienceOverrides(_ overrides: ContactAudienceOverrides) { self._pendingAudienceOverrides = overrides } func pendingAudienceOverrides(contactID: String) -> ContactAudienceOverrides { return self._pendingAudienceOverrides } func resolveAuth(identifier: String) async throws -> String { return "" } func authTokenExpired(token: String) async { } func resetIfNeeded() { addOperation(.reset) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipDateFormatterTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class AirshipDateFormatterTest: XCTestCase { private var gregorianUTC: Calendar = { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! return calendar }() func components(for date: Date) -> DateComponents { return gregorianUTC.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) } func validateDateFormatter(_ format: AirshipDateFormatter.Format, withFormatString formatString: String) { guard let date = AirshipDateFormatter.date(fromISOString: formatString) else { XCTFail("Failed to parse date from format string") return } let components = self.components(for: date) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 45) XCTAssertEqual(components.second, 22) XCTAssertEqual(formatString, AirshipDateFormatter.string(fromDate: date, format: format)) } func testISODateFormatterUTC() { validateDateFormatter(.iso, withFormatString: "2020-12-15 11:45:22") } func testISODateFormatterUTCWithDelimiter() { validateDateFormatter(.isoDelimitter, withFormatString: "2020-12-15T11:45:22") } func testParseISO8601FromTimeStamp() { // yyyy var date = AirshipDateFormatter.date(fromISOString: "2020")! var components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 1) XCTAssertEqual(components.day, 1) XCTAssertEqual(components.hour, 0) XCTAssertEqual(components.minute, 0) XCTAssertEqual(components.second, 0) // yyyy-MM date = AirshipDateFormatter.date(fromISOString: "2020-12")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 1) XCTAssertEqual(components.hour, 0) XCTAssertEqual(components.minute, 0) XCTAssertEqual(components.second, 0) // yyyy-MM-dd date = AirshipDateFormatter.date(fromISOString: "2020-12-15")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 0) XCTAssertEqual(components.minute, 0) XCTAssertEqual(components.second, 0) // yyyy-MM-dd'T'hh date = AirshipDateFormatter.date(fromISOString: "2020-12-15T11")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 0) XCTAssertEqual(components.second, 0) // yyyy-MM-dd hh date = AirshipDateFormatter.date(fromISOString: "2020-12-15 11")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 0) XCTAssertEqual(components.second, 0) // yyyy-MM-dd'T'hh:mm date = AirshipDateFormatter.date(fromISOString: "2020-12-15T11:45")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 45) XCTAssertEqual(components.second, 0) // yyyy-MM-dd hh:mm date = AirshipDateFormatter.date(fromISOString: "2020-12-15 11:45")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 45) XCTAssertEqual(components.second, 0) // yyyy-MM-dd'T'hh:mm:ss date = AirshipDateFormatter.date(fromISOString: "2020-12-15T11:45:22")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 45) XCTAssertEqual(components.second, 22) // yyyy-MM-dd hh:mm:ss date = AirshipDateFormatter.date(fromISOString: "2020-12-15T11:45:22")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 45) XCTAssertEqual(components.second, 22) let dateWithoutSubseconds = date // yyyy-MM-ddThh:mm:ss.SSS date = AirshipDateFormatter.date(fromISOString: "2020-12-15T11:45:22.123")! components = self.components(for: date) XCTAssertNotNil(components) XCTAssertEqual(components.year, 2020) XCTAssertEqual(components.month, 12) XCTAssertEqual(components.day, 15) XCTAssertEqual(components.hour, 11) XCTAssertEqual(components.minute, 45) XCTAssertEqual(components.second, 22) let seconds = date.timeIntervalSince(dateWithoutSubseconds) XCTAssertEqual(seconds, 0.123, accuracy: 0.0001) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipDeviceIDTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipDeviceIDTest: XCTestCase { private let appKey: String = UUID().uuidString private let keychain: TestKeyChainAccess = TestKeyChainAccess() private var deviceID: AirshipDeviceID! override func setUp() async throws { self.deviceID = AirshipDeviceID(appKey: self.appKey, keychain: keychain) } func testGenerateDeviceID() async { let id = await deviceID.value XCTAssertNotNil(id) let fromStore = await self.keychain.readCredentails(identifier: "com.urbanairship.deviceID", appKey: appKey) XCTAssertEqual(fromStore?.password, id) } func testRestoreFromKeychain() async { let first = await deviceID.value XCTAssertNotNil(first) self.deviceID = AirshipDeviceID(appKey: self.appKey, keychain: keychain) let second = await deviceID.value XCTAssertEqual(first, second) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipEventsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import Combine @testable import AirshipCore class AirshipEventsTest: XCTestCase { @MainActor func testForegroundAppInitEvent() throws { let sessionEvent = SessionEvent( type: .foregroundInit, date: Date(), sessionState: SessionState( conversionSendID: UUID().uuidString, conversionMetadata: UUID().uuidString ) ) let expectedBody = """ { "notification_types": [], "notification_authorization": "not_determined", "time_zone": \(TimeZone.current.secondsFromGMT()), "daylight_savings": "\(TimeZone.current.isDaylightSavingTime().toString())", "package_version": "\(AirshipUtils.bundleShortVersionString()!)", "foreground": "true", "os_version": "\(UIDevice.current.systemVersion)", "lib_version": "\(AirshipVersion.version)", "push_id": "\(sessionEvent.sessionState.conversionSendID!)", "metadata": "\(sessionEvent.sessionState.conversionMetadata!)" } """ let event = AirshipEvents.sessionEvent( sessionEvent: sessionEvent, push: EventTestPush() ) XCTAssertEqual(event.eventType.reportingName, "app_init") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @MainActor func testBackgroundAppInitEvent() throws { let sessionEvent = SessionEvent( type: .backgroundInit, date: Date(), sessionState: SessionState( conversionSendID: UUID().uuidString, conversionMetadata: UUID().uuidString ) ) let expectedBody = """ { "notification_types": [], "notification_authorization": "not_determined", "time_zone": \(TimeZone.current.secondsFromGMT()), "daylight_savings": "\(TimeZone.current.isDaylightSavingTime().toString())", "package_version": "\(AirshipUtils.bundleShortVersionString()!)", "foreground": "false", "os_version": "\(UIDevice.current.systemVersion)", "lib_version": "\(AirshipVersion.version)", "push_id": "\(sessionEvent.sessionState.conversionSendID!)", "metadata": "\(sessionEvent.sessionState.conversionMetadata!)" } """ let event = AirshipEvents.sessionEvent( sessionEvent: sessionEvent, push: EventTestPush() ) XCTAssertEqual(event.eventType.reportingName, "app_init") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @MainActor func testAppForegroundEvent() throws { let sessionEvent = SessionEvent( type: .foreground, date: Date(), sessionState: SessionState( conversionSendID: UUID().uuidString, conversionMetadata: UUID().uuidString ) ) let expectedBody = """ { "notification_types": [], "notification_authorization": "not_determined", "time_zone": \(TimeZone.current.secondsFromGMT()), "daylight_savings": "\(TimeZone.current.isDaylightSavingTime().toString())", "package_version": "\(AirshipUtils.bundleShortVersionString()!)", "os_version": "\(UIDevice.current.systemVersion)", "lib_version": "\(AirshipVersion.version)", "push_id": "\(sessionEvent.sessionState.conversionSendID!)", "metadata": "\(sessionEvent.sessionState.conversionMetadata!)" } """ let event = AirshipEvents.sessionEvent( sessionEvent: sessionEvent, push: EventTestPush() ) XCTAssertEqual(event.eventType.reportingName, "app_foreground") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } @MainActor func testAppBackgroundEvent() throws { let sessionEvent = SessionEvent( type: .background, date: Date(), sessionState: SessionState( conversionSendID: UUID().uuidString, conversionMetadata: UUID().uuidString ) ) let expectedBody = """ { "push_id": "\(sessionEvent.sessionState.conversionSendID!)", "metadata": "\(sessionEvent.sessionState.conversionMetadata!)" } """ let event = AirshipEvents.sessionEvent( sessionEvent: sessionEvent, push: EventTestPush() ) XCTAssertEqual(event.eventType.reportingName, "app_background") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } func testScreenTracking() throws { let expectedBody = """ { "screen": "test_screen", "previous_screen": "previous_screen", "duration": "1.000", "exited_time": "1.000", "entered_time": "0.000" } """ let event = try AirshipEvents.screenTrackingEvent( screen: "test_screen", previousScreen: "previous_screen", startDate: Date(timeIntervalSince1970: 0), duration: 1 ) XCTAssertEqual(event.eventType.reportingName, "screen_tracking") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } func testScreenValidation() throws { var screenName = "" .padding( toLength: 255, withPad: "test_screen_name", startingAt: 0 ) _ = try AirshipEvents.screenTrackingEvent( screen: screenName, previousScreen: nil, startDate: Date(), duration: 1 ) screenName = "" .padding( toLength: 256, withPad: "test_screen_name", startingAt: 0 ) do { _ = try AirshipEvents.screenTrackingEvent( screen: screenName, previousScreen: nil, startDate: Date(), duration: 1 ) XCTFail() } catch {} do { _ = try AirshipEvents.screenTrackingEvent( screen: "", previousScreen: nil, startDate: Date(), duration: 1 ) XCTFail() } catch {} } func testInstallAttributeTest() throws { let expectedBody = """ { "app_store_purchase_date": "100.000", "app_store_ad_impression_date": "99.000", } """ let event = AirshipEvents.installAttirbutionEvent( appPurchaseDate: Date(timeIntervalSince1970: 100.0), iAdImpressionDate: Date(timeIntervalSince1970: 99.0) ) XCTAssertEqual(event.eventType.reportingName, "install_attribution") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } func testInstallAttributeNoDatesTest() throws { let expectedBody = """ { } """ let event = AirshipEvents.installAttirbutionEvent() XCTAssertEqual(event.eventType.reportingName, "install_attribution") XCTAssertEqual(event.priority, .normal) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } func testInteractiveNotificationEventTest() throws { let expectedBody = """ { "foreground": "true", "button_id": "action_identifier", "button_description": "action_title", "button_group": "category_id", "send_id": "send ID", "user_input": "some response text" } """ let event = AirshipEvents.interactiveNotificationEvent( action: UNNotificationAction( identifier: "action_identifier", title: "action_title", options: .foreground ), category: "category_id", notification: [ "_": "send ID", "aps": [ "alert": "sample alert!", "badge": 2, "sound": "cat", "category": "category_id" ] ], responseText: "some response text" ) XCTAssertEqual(event.eventType.reportingName, "interactive_notification_action") XCTAssertEqual(event.priority, .high) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } } private final class EventTestPush: AirshipPush, @unchecked Sendable { var onAPNSRegistrationFinished: (@MainActor @Sendable (AirshipCore.APNSRegistrationResult) -> Void)? var onNotificationRegistrationFinished: (@MainActor @Sendable (AirshipCore.NotificationRegistrationResult) -> Void)? var onNotificationAuthorizedSettingsDidChange: (@MainActor @Sendable (AirshipCore.AirshipAuthorizedNotificationSettings) -> Void)? var quietTime: QuietTimeSettings? func enableUserPushNotifications() async -> Bool { return true } func enableUserPushNotifications(fallback: PromptPermissionFallback) async -> Bool { return true } func setBadgeNumber(_ newBadgeNumber: Int) async { } func resetBadge() async { } var autobadgeEnabled: Bool = false var timeZone: NSTimeZone? var quietTimeEnabled: Bool = false func setQuietTimeStartHour(_ startHour: Int, startMinute: Int, endHour: Int, endMinute: Int) { } var notificationStatusPublisher: AnyPublisher<AirshipCore.AirshipNotificationStatus, Never> { fatalError("not implemented") } var notificationStatus: AirshipCore.AirshipNotificationStatus { fatalError("not implemented") } let notificationStatusUpdates: AsyncStream<AirshipNotificationStatus> let statusUpdateContinuation: AsyncStream<AirshipNotificationStatus>.Continuation var isPushNotificationsOptedIn: Bool = false var deviceToken: String? var combinedCategories: Set<UNNotificationCategory> = [] var backgroundPushNotificationsEnabled = true var userPushNotificationsEnabled = true var extendedPushNotificationPermissionEnabled = false var requestExplicitPermissionWhenEphemeral = false var notificationOptions: UNAuthorizationOptions = [.alert, .sound, .badge] var customCategories: Set<UNNotificationCategory> = [] var accengageCategories: Set<UNNotificationCategory> = [] var requireAuthorizationForDefaultCategories = false var pushNotificationDelegate: PushNotificationDelegate? var registrationDelegate: RegistrationDelegate? var launchNotificationResponse: UNNotificationResponse? var authorizedNotificationSettings: AirshipAuthorizedNotificationSettings = [] var authorizationStatus: UNAuthorizationStatus = .notDetermined var userPromptedForNotifications = false var defaultPresentationOptions: UNNotificationPresentationOptions = [ .list, .sound, .badge, ] var badgeNumber: Int = 0 // Notification callbacks var onForegroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> Void)? #if !os(watchOS) var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> UIBackgroundFetchResult)? #else var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> WKBackgroundFetchResult)? #endif #if !os(tvOS) var onNotificationResponseReceived: (@MainActor @Sendable (UNNotificationResponse) async -> Void)? #endif var onExtendPresentationOptions: (@MainActor @Sendable (UNNotificationPresentationOptions, UNNotification) async -> UNNotificationPresentationOptions)? init() { (self.notificationStatusUpdates, self.statusUpdateContinuation) = AsyncStream<AirshipNotificationStatus>.airshipMakeStreamWithContinuation() } } private final class InternalPush: InternalAirshipPush { var deviceToken: String? = "a12312ad" func dispatchUpdateAuthorizedNotificationTypes() {} func didRegisterForRemoteNotifications(_ deviceToken: Data) {} func didFailToRegisterForRemoteNotifications(_ error: Error) {} func didReceiveRemoteNotification( _ userInfo: [AnyHashable: Any], isForeground: Bool ) async -> UABackgroundFetchResult { return .noData } func presentationOptionsForNotification( _ notification: UNNotification ) async -> UNNotificationPresentationOptions { return [] } func didReceiveNotificationResponse( _ response: UNNotificationResponse ) async {} var combinedCategories: Set<UNNotificationCategory> = Set() } fileprivate extension TimeInterval { func toString() -> String { String( format: "%0.3f", self ) } } fileprivate extension Bool { func toString() -> String { return self ? "true" : "false" } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipHTTPResponseTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable public import AirshipCore public extension AirshipHTTPResponse { static func make(result: T?, statusCode: Int, headers: [String: String]) -> AirshipHTTPResponse<T> { return .init(result: result, statusCode: statusCode, headers: headers) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipIvyVersionMatcherTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipIvyVersionMatcherTest: XCTestCase { func testValidVersions() { XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "[1.22.6.189,)")) XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "[1.22.6.189,)")) XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "[1.22.6.189,2.2.3.4]")) XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "[1.22.6.189, 2.2.3.4]")) XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "[1.22.6.189-junk, 2.2.3.4-junk]")) XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "1.2.3.4")) XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "1.2.3.4.+")) XCTAssertNotNil(try? AirshipIvyVersionMatcher(versionConstraint: "1.2.3-junk")) } func testRangeLongVersion() throws { let matcher = try AirshipIvyVersionMatcher(versionConstraint: "[1.22.6.189,)") XCTAssertTrue(matcher.evaluate(version: "1.22.6")) XCTAssertTrue(matcher.evaluate(version: "1.22.6.189")) XCTAssertTrue(matcher.evaluate(version: "1.22.6.188")) XCTAssertTrue(matcher.evaluate(version: "1.22.7")) XCTAssertFalse(matcher.evaluate(version: "1.22.5")) } func testRangeWithWhiteSpace() throws { let matcher = try AirshipIvyVersionMatcher(versionConstraint: "[ 1.2 , 2.0 ]") XCTAssertTrue(matcher.evaluate(version: "1.2")) XCTAssertTrue(matcher.evaluate(version: "1.2.0")) XCTAssertTrue(matcher.evaluate(version: "1.2.1")) XCTAssertTrue(matcher.evaluate(version: "2.0")) XCTAssertTrue(matcher.evaluate(version: "2.0.0")) XCTAssertFalse(matcher.evaluate(version: "1.1")) XCTAssertFalse(matcher.evaluate(version: "1.1.0")) XCTAssertFalse(matcher.evaluate(version: "2.0.1")) XCTAssertFalse(matcher.evaluate(version: "2.1")) } func testExactVersionMatcher() throws { let matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0") XCTAssertTrue(matcher.evaluate(version: "1.0")) XCTAssertTrue(matcher.evaluate(version: " 1.0")) XCTAssertTrue(matcher.evaluate(version: "1.0 ")) XCTAssertTrue(matcher.evaluate(version: " 1.0 ")) XCTAssertFalse(matcher.evaluate(version: " 0.9")) XCTAssertFalse(matcher.evaluate(version: "1.1 ")) XCTAssertFalse(matcher.evaluate(version: " 2.0")) XCTAssertFalse(matcher.evaluate(version: " 2.0 ")) let matcher2 = try AirshipIvyVersionMatcher(versionConstraint: " 1.0") XCTAssertTrue(matcher2.evaluate(version: "1.0")) XCTAssertTrue(matcher2.evaluate(version: " 1.0")) XCTAssertTrue(matcher2.evaluate(version: "1.0 ")) XCTAssertTrue(matcher2.evaluate(version: " 1.0 ")) XCTAssertFalse(matcher2.evaluate(version: " 0.9")) XCTAssertFalse(matcher2.evaluate(version: "1.1 ")) XCTAssertFalse(matcher2.evaluate(version: " 2.0")) XCTAssertFalse(matcher2.evaluate(version: " 2.0 ")) let matcher3 = try AirshipIvyVersionMatcher(versionConstraint: "1.0 ") XCTAssertTrue(matcher3.evaluate(version: "1.0")) XCTAssertTrue(matcher3.evaluate(version: " 1.0")) XCTAssertTrue(matcher3.evaluate(version: "1.0 ")) XCTAssertTrue(matcher3.evaluate(version: " 1.0 ")) XCTAssertFalse(matcher3.evaluate(version: " 0.9")) XCTAssertFalse(matcher3.evaluate(version: "1.1 ")) XCTAssertFalse(matcher3.evaluate(version: " 2.0")) XCTAssertFalse(matcher3.evaluate(version: " 2.0 ")) let matcher4 = try AirshipIvyVersionMatcher(versionConstraint: " 1.0 ") XCTAssertTrue(matcher4.evaluate(version: "1.0")) XCTAssertTrue(matcher4.evaluate(version: " 1.0")) XCTAssertTrue(matcher4.evaluate(version: "1.0 ")) XCTAssertTrue(matcher4.evaluate(version: " 1.0 ")) XCTAssertFalse(matcher4.evaluate(version: " 0.9")) XCTAssertFalse(matcher4.evaluate(version: "1.1 ")) XCTAssertFalse(matcher4.evaluate(version: " 2.0")) XCTAssertFalse(matcher4.evaluate(version: " 2.0 ")) } func testSubVersionMatcher() throws { let matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0.+") XCTAssertTrue(matcher.evaluate(version: "1.0.1")) XCTAssertTrue(matcher.evaluate(version: "1.0.5")) XCTAssertTrue(matcher.evaluate(version: "1.0.a")) XCTAssertFalse(matcher.evaluate(version: "1.0")) XCTAssertFalse(matcher.evaluate(version: "1")) XCTAssertFalse(matcher.evaluate(version: "1.01")) XCTAssertFalse(matcher.evaluate(version: "1.11")) XCTAssertFalse(matcher.evaluate(version: "2")) let matcher2 = try AirshipIvyVersionMatcher(versionConstraint: "1.0+") XCTAssertNotNil(matcher2) XCTAssertTrue(matcher2.evaluate(version: "1.0")) XCTAssertTrue(matcher2.evaluate(version: "1.0.1")) XCTAssertTrue(matcher2.evaluate(version: "1.00")) XCTAssertTrue(matcher2.evaluate(version: "1.01")) XCTAssertFalse(matcher2.evaluate(version: "1")) XCTAssertFalse(matcher2.evaluate(version: "1.11")) XCTAssertFalse(matcher2.evaluate(version: "2")) } func testVersionRangeMatcher() throws { let matcher = try AirshipIvyVersionMatcher(versionConstraint: "[1.0, 2.0]") XCTAssertNotNil(matcher) XCTAssertTrue(matcher.evaluate(version: "1.0")) XCTAssertTrue(matcher.evaluate(version: "1.0.1")) XCTAssertTrue(matcher.evaluate(version: "1.5")) XCTAssertTrue(matcher.evaluate(version: "1.9.9")) XCTAssertTrue(matcher.evaluate(version: "2.0")) XCTAssertFalse(matcher.evaluate(version: "0.0")) XCTAssertFalse(matcher.evaluate(version: "0.9.9")) XCTAssertFalse(matcher.evaluate(version: "2.0.1")) XCTAssertFalse(matcher.evaluate(version: "3.0")) } func testSubVersionIgnoresVersionQualifiers() throws { let matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0-rc1+") XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.00")); XCTAssertTrue(matcher.evaluate(version: "1.01")); XCTAssertTrue(matcher.evaluate(version: "1.01-beta")); XCTAssertFalse(matcher.evaluate(version: "1")); XCTAssertFalse(matcher.evaluate(version: "1.11")); XCTAssertFalse(matcher.evaluate(version: "2")); XCTAssertFalse(matcher.evaluate(version: "2-SNAPSHOT")); } func testExactVersion() throws { var matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-SNAPSHOT")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "1.0-beta")); XCTAssertTrue(matcher.evaluate(version: "1.0-rc")); XCTAssertTrue(matcher.evaluate(version: "1.0-rc1")); XCTAssertTrue(matcher.evaluate(version: " 1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0 ")); XCTAssertTrue(matcher.evaluate(version: " 1.0 ")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "1"); XCTAssertTrue(matcher.evaluate(version: "1")); XCTAssertTrue(matcher.evaluate(version: "1-SNAPSHOT")); XCTAssertFalse(matcher.evaluate(version: " 0.9")); XCTAssertFalse(matcher.evaluate(version: "1.1 ")); XCTAssertFalse(matcher.evaluate(version: " 2.0")); XCTAssertFalse(matcher.evaluate(version: " 2.0 ")); matcher = try AirshipIvyVersionMatcher(versionConstraint: " 1.0"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: " 1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0 ")); XCTAssertTrue(matcher.evaluate(version: " 1.0 ")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha ")); XCTAssertTrue(matcher.evaluate(version: " 1.0-beta")); XCTAssertFalse(matcher.evaluate(version: " 0.9")); XCTAssertFalse(matcher.evaluate(version: "1.1 ")); XCTAssertFalse(matcher.evaluate(version: " 2.0")); XCTAssertFalse(matcher.evaluate(version: " 2.0 ")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0 "); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: " 1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0 ")); XCTAssertTrue(matcher.evaluate(version: " 1.0 ")); XCTAssertTrue(matcher.evaluate(version: " 1.0-rc01 ")); XCTAssertFalse(matcher.evaluate(version: " 0.9")); XCTAssertFalse(matcher.evaluate(version: "1.1 ")); XCTAssertFalse(matcher.evaluate(version: " 2.0")); XCTAssertFalse(matcher.evaluate(version: " 2.0 ")); matcher = try AirshipIvyVersionMatcher(versionConstraint: " 1.0 "); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: " 1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0 ")); XCTAssertTrue(matcher.evaluate(version: " 1.0 ")); XCTAssertTrue(matcher.evaluate(version: " 1.0-SNAPSHOT")); XCTAssertFalse(matcher.evaluate(version: " 0.9")); XCTAssertFalse(matcher.evaluate(version: "1.1 ")); XCTAssertFalse(matcher.evaluate(version: " 2.0")); XCTAssertFalse(matcher.evaluate(version: " 2.0 ")); } func testSubVersion() throws { var matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0.+"); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.0.5")); XCTAssertTrue(matcher.evaluate(version: "1.0.a")); XCTAssertTrue(matcher.evaluate(version: "1.0.0-SNAPSHOT")); XCTAssertFalse(matcher.evaluate(version: "1")); XCTAssertFalse(matcher.evaluate(version: "1.0")); XCTAssertFalse(matcher.evaluate(version: "1.01")); XCTAssertFalse(matcher.evaluate(version: "1.11")); XCTAssertFalse(matcher.evaluate(version: "2")); XCTAssertFalse(matcher.evaluate(version: "1.0-SNAPSHOT")); XCTAssertFalse(matcher.evaluate(version: "1.1-beta")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0+"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.00")); XCTAssertTrue(matcher.evaluate(version: "1.01")); XCTAssertTrue(matcher.evaluate(version: "1.01-beta")); XCTAssertFalse(matcher.evaluate(version: "1")); XCTAssertFalse(matcher.evaluate(version: "1.11")); XCTAssertFalse(matcher.evaluate(version: "2")); XCTAssertFalse(matcher.evaluate(version: "2-SNAPSHOT")); matcher = try AirshipIvyVersionMatcher(versionConstraint: " 1.0+"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.00")); XCTAssertTrue(matcher.evaluate(version: "1.01")); XCTAssertTrue(matcher.evaluate(version: "1.01-beta")); XCTAssertFalse(matcher.evaluate(version: "1")); XCTAssertFalse(matcher.evaluate(version: "1.11")); XCTAssertFalse(matcher.evaluate(version: "2")); XCTAssertFalse(matcher.evaluate(version: "2-SNAPSHOT")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0+ "); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.00")); XCTAssertTrue(matcher.evaluate(version: "1.01")); XCTAssertTrue(matcher.evaluate(version: "1.01-beta")); XCTAssertFalse(matcher.evaluate(version: "1")); XCTAssertFalse(matcher.evaluate(version: "1.11")); XCTAssertFalse(matcher.evaluate(version: "2")); XCTAssertFalse(matcher.evaluate(version: "2-SNAPSHOT")); matcher = try AirshipIvyVersionMatcher(versionConstraint: " 1.0+ "); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.00")); XCTAssertTrue(matcher.evaluate(version: "1.01")); XCTAssertTrue(matcher.evaluate(version: "1.01-beta")); XCTAssertFalse(matcher.evaluate(version: "1")); XCTAssertFalse(matcher.evaluate(version: "1.11")); XCTAssertFalse(matcher.evaluate(version: "2")); XCTAssertFalse(matcher.evaluate(version: "2-SNAPSHOT")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "+"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.00")); XCTAssertTrue(matcher.evaluate(version: "1.01")); XCTAssertTrue(matcher.evaluate(version: "1")); XCTAssertTrue(matcher.evaluate(version: "1.11")); XCTAssertTrue(matcher.evaluate(version: "2")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "2.2.2-beta")); XCTAssertTrue(matcher.evaluate(version: "2-SNAPSHOT")); } func testVersionRange() throws { var matcher = try AirshipIvyVersionMatcher(versionConstraint: "[1.0, 2.0]"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "2.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-SNAPSHOT")); XCTAssertTrue(matcher.evaluate(version: "1.9.9-rc1")); XCTAssertTrue(matcher.evaluate(version: "2.0-beta")); XCTAssertFalse(matcher.evaluate(version: "0.0")); XCTAssertFalse(matcher.evaluate(version: "0.9.9")); XCTAssertFalse(matcher.evaluate(version: "2.0.1")); XCTAssertFalse(matcher.evaluate(version: "3.0")); XCTAssertFalse(matcher.evaluate(version: "3.0-alpha")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "[1.0 ,2.0["); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "1.0-SNAPSHOT")); XCTAssertTrue(matcher.evaluate(version: "1.9.9-rc1")); XCTAssertFalse(matcher.evaluate(version: "0.0")); XCTAssertFalse(matcher.evaluate(version: "0.9.9")); XCTAssertFalse(matcher.evaluate(version: "2.0")); XCTAssertFalse(matcher.evaluate(version: "2.0.1")); XCTAssertFalse(matcher.evaluate(version: "3.0")); XCTAssertFalse(matcher.evaluate(version: "2.0-beta")); XCTAssertFalse(matcher.evaluate(version: "3.0-alpha")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "]1.0 , 2.0]"); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "2.0")); XCTAssertTrue(matcher.evaluate(version: "1.0.1-beta")); XCTAssertTrue(matcher.evaluate(version: "2.0-beta")); XCTAssertFalse(matcher.evaluate(version: "0.0")); XCTAssertFalse(matcher.evaluate(version: "0.9.9")); XCTAssertFalse(matcher.evaluate(version: "1.0")); XCTAssertFalse(matcher.evaluate(version: "2.0.1")); XCTAssertFalse(matcher.evaluate(version: "3.0")); XCTAssertFalse(matcher.evaluate(version: "1.0-SNAPSHOT")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "] 1.0,2.0["); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "1.0.1-beta")); XCTAssertFalse(matcher.evaluate(version: "0.0")); XCTAssertFalse(matcher.evaluate(version: "0.9.9")); XCTAssertFalse(matcher.evaluate(version: "1.0")); XCTAssertFalse(matcher.evaluate(version: "2.0")); XCTAssertFalse(matcher.evaluate(version: "2.0.1")); XCTAssertFalse(matcher.evaluate(version: "3.0")); XCTAssertFalse(matcher.evaluate(version: "2.0-beta")); XCTAssertFalse(matcher.evaluate(version: "3.0-SNAPSHOT")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "[1.0, )"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "2.0")); XCTAssertTrue(matcher.evaluate(version: "2.0.1")); XCTAssertTrue(matcher.evaluate(version: "3.0")); XCTAssertTrue(matcher.evaluate(version: "999.999.999")); XCTAssertTrue(matcher.evaluate(version: "3.0-SNAPSHOT")); XCTAssertFalse(matcher.evaluate(version: "0.0")); XCTAssertFalse(matcher.evaluate(version: "0.9.9")); XCTAssertFalse(matcher.evaluate(version: "0.1-rc3")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "]1.0,) "); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "2.0")); XCTAssertTrue(matcher.evaluate(version: "2.0.1")); XCTAssertTrue(matcher.evaluate(version: "3.0")); XCTAssertTrue(matcher.evaluate(version: "999.999.999")); XCTAssertTrue(matcher.evaluate(version: "2.0-alpha01")); XCTAssertFalse(matcher.evaluate(version: "0.0")); XCTAssertFalse(matcher.evaluate(version: "0.9.9")); XCTAssertFalse(matcher.evaluate(version: "1.0")); XCTAssertFalse(matcher.evaluate(version: "1.0-alpha01")); matcher = try AirshipIvyVersionMatcher(versionConstraint: " (,2.0]"); XCTAssertTrue(matcher.evaluate(version: "0.0")); XCTAssertTrue(matcher.evaluate(version: "0.9.9")); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "2.0")); XCTAssertTrue(matcher.evaluate(version: "2.0-beta3")); XCTAssertFalse(matcher.evaluate(version: "2.0.1")); XCTAssertFalse(matcher.evaluate(version: "3.0")); XCTAssertFalse(matcher.evaluate(version: "999.999.999")); XCTAssertFalse(matcher.evaluate(version: "3.0-alpha01")); matcher = try AirshipIvyVersionMatcher(versionConstraint: " ( , 2.0 [ "); XCTAssertTrue(matcher.evaluate(version: "0.0")); XCTAssertTrue(matcher.evaluate(version: "0.9.9")); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "1.1-rc1")); XCTAssertFalse(matcher.evaluate(version: "2.0")); XCTAssertFalse(matcher.evaluate(version: "2.0.1")); XCTAssertFalse(matcher.evaluate(version: "3.0")); XCTAssertFalse(matcher.evaluate(version: "999.999.999")); XCTAssertFalse(matcher.evaluate(version: "3.0-beta33")); } func testExactConstraintIgnoresVersionQualifiers() throws { let matcher = try AirshipIvyVersionMatcher(versionConstraint: "1.0-beta"); XCTAssertTrue(matcher.evaluate(version: "1.0-SNAPSHOT")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "1.0-alpha01")); XCTAssertTrue(matcher.evaluate(version: "1.0-beta")); XCTAssertTrue(matcher.evaluate(version: "1.0-beta01")); XCTAssertTrue(matcher.evaluate(version: "1.0-rc")); XCTAssertTrue(matcher.evaluate(version: "1.0-rc1")); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertFalse(matcher.evaluate(version: "1.0.0-SNAPSHOT")); XCTAssertFalse(matcher.evaluate(version: "1.0.0-alpha")); XCTAssertFalse(matcher.evaluate(version: "1.0.0-alpha01")); XCTAssertFalse(matcher.evaluate(version: "1.0.0-beta")); XCTAssertFalse(matcher.evaluate(version: "1.0.0-beta01")); XCTAssertFalse(matcher.evaluate(version: "1.0.0-rc")); XCTAssertFalse(matcher.evaluate(version: "1.0.0-rc1")); XCTAssertFalse(matcher.evaluate(version: "1.0.0")); } func testVersionRangeIgnoresVersionQualifiers() throws { var matcher = try AirshipIvyVersionMatcher(versionConstraint: "[1.0-alpha, 2.0-alpha01]"); XCTAssertTrue(matcher.evaluate(version: "1.0")); XCTAssertTrue(matcher.evaluate(version: "1.0-SNAPSHOT")); XCTAssertTrue(matcher.evaluate(version: "1.0.1")); XCTAssertTrue(matcher.evaluate(version: "1.5")); XCTAssertTrue(matcher.evaluate(version: "1.9.9")); XCTAssertTrue(matcher.evaluate(version: "1.9.9-rc1")); XCTAssertTrue(matcher.evaluate(version: "2.0-beta")); XCTAssertTrue(matcher.evaluate(version: "2.0")); XCTAssertFalse(matcher.evaluate(version: "0.0")); XCTAssertFalse(matcher.evaluate(version: "0.9.9")); XCTAssertFalse(matcher.evaluate(version: "2.0.1")); XCTAssertFalse(matcher.evaluate(version: "3.0")); XCTAssertFalse(matcher.evaluate(version: "3.0-alpha")); matcher = try AirshipIvyVersionMatcher(versionConstraint: "]17.0.0-beta,)"); XCTAssertFalse(matcher.evaluate(version: "17.0.0")); XCTAssertFalse(matcher.evaluate(version: "17.0.0-SNAPSHOT")); XCTAssertFalse(matcher.evaluate(version: "17.0.0-alpha")); XCTAssertFalse(matcher.evaluate(version: "17.0.0-beta")); XCTAssertFalse(matcher.evaluate(version: "17.0.0-rc")); XCTAssertTrue(matcher.evaluate(version: "17.0.1")); XCTAssertTrue(matcher.evaluate(version: "17.0.1-SNAPSHOT")); XCTAssertTrue(matcher.evaluate(version: "17.0.1-alpha")); XCTAssertTrue(matcher.evaluate(version: "17.0.1-beta")); XCTAssertTrue(matcher.evaluate(version: "17.0.1-rc")); XCTAssertTrue(matcher.evaluate(version: "18.0.0")); XCTAssertTrue(matcher.evaluate(version: "18.0.0-SNAPSHOT")); XCTAssertTrue(matcher.evaluate(version: "18.0.0-alpha")); XCTAssertTrue(matcher.evaluate(version: "18.0.0-beta")); XCTAssertTrue(matcher.evaluate(version: "999.999.999")); XCTAssertTrue(matcher.evaluate(version: "999.999.999-rc")); } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipJSONTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipJSONTest: XCTestCase { func testWrapPrimitives() throws { XCTAssertEqual(.number(100.0), try AirshipJSON.wrap(100.0)) XCTAssertEqual(.number(99.0), try AirshipJSON.wrap(99)) XCTAssertEqual(.number(33.0), try AirshipJSON.wrap(UInt(33))) XCTAssertEqual(.number(1), try AirshipJSON.wrap(1)) XCTAssertEqual(.number(0), try AirshipJSON.wrap(0)) XCTAssertEqual(.string("hello"), try AirshipJSON.wrap("hello")) XCTAssertEqual(.bool(true), try AirshipJSON.wrap(true)) XCTAssertEqual(.bool(false), try AirshipJSON.wrap(false)) XCTAssertEqual(.null, try AirshipJSON.wrap(nil)) } func testWrapNSNumber() throws { XCTAssertEqual(.number(100.0), try AirshipJSON.wrap(NSNumber(100))) XCTAssertEqual(.number(99.0), try AirshipJSON.wrap(NSNumber(99.0))) XCTAssertEqual(.number(33.0), try AirshipJSON.wrap(NSNumber(33.0))) XCTAssertEqual(.number(1), try AirshipJSON.wrap(NSNumber(1))) XCTAssertEqual(.number(0), try AirshipJSON.wrap(NSNumber(0))) XCTAssertEqual(.bool(true), try AirshipJSON.wrap(NSNumber(true))) XCTAssertEqual(.bool(false), try AirshipJSON.wrap(NSNumber(false))) } func testWrapArray() throws { let array: [Any?] = [ "hello", 100, [ "foo", ["cool": "story"], ] as [Any], ["neat": "object"], nil, true, ] let expected: [AirshipJSON] = [ "hello", 100.0, ["foo", ["cool": "story"], ], ["neat": "object"], nil, true, ] XCTAssertEqual(.array(expected), try AirshipJSON.wrap(array)) } func testWrapObject() throws { let object: [String: Any?] = [ "string": "hello", "number": 100.0, "array": ["cool", "story"], "null": nil, "boolean": true, "object": ["neat": "object"], ] let expected: [String: AirshipJSON] = [ "string": "hello", "number": 100.0, "array": ["cool", "story"], "null": nil, "boolean": true, "object": ["neat": "object"], ] XCTAssertEqual(.object(expected), try AirshipJSON.wrap(object)) } func testWrapInvalid() throws { XCTAssertThrowsError(try AirshipJSON.wrap(InvalidJSON())) } fileprivate struct InvalidJSON { } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipJSONUtilsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class JSONUtilsTest: XCTestCase { func testInvalidJSON() { do { _ = try AirshipJSONUtils.data(NSObject(), options: .prettyPrinted) XCTFail() } catch {} } func testValidJSON() throws { let _ = try AirshipJSONUtils.data(["Valid JSON object": true], options: .prettyPrinted) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipLocaleManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipLocaleManagerTest: XCTestCase { private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter( notificationCenter: NotificationCenter() ) private func makeLocaleManager( useUserPreferredLocale: Bool = false ) -> DefaultAirshipLocaleManager { return DefaultAirshipLocaleManager( dataStore: PreferenceDataStore( appKey: UUID().uuidString ), config: .testConfig(useUserPreferredLocale: useUserPreferredLocale), notificationCenter: notificationCenter ) } func testLocale() throws { let localeManager = makeLocaleManager() XCTAssertEqual(localeManager.currentLocale, Locale.autoupdatingCurrent) let french = Locale(identifier: "fr") localeManager.currentLocale = french XCTAssertEqual(localeManager.currentLocale, french) let english = Locale(identifier: "en") localeManager.currentLocale = english XCTAssertEqual(localeManager.currentLocale, english) localeManager.clearLocale() XCTAssertEqual(localeManager.currentLocale, Locale.autoupdatingCurrent) } func testLocaleWithUseUserPreferredLocale() throws { let localeManager = makeLocaleManager(useUserPreferredLocale: true) let preferredLocale = Locale(identifier: Locale.preferredLanguages[0]) XCTAssertEqual(localeManager.currentLocale, preferredLocale) let french = Locale(identifier: "fr") localeManager.currentLocale = french XCTAssertEqual(localeManager.currentLocale, french) localeManager.clearLocale() XCTAssertEqual(localeManager.currentLocale, preferredLocale) } func testNotificationWhenOverrideChanges() { let localeManager = makeLocaleManager() let expectation = self.expectation(description: "update called") self.notificationCenter.addObserver( forName: AirshipNotifications.LocaleUpdated.name ) { _ in expectation.fulfill() } localeManager.currentLocale = Locale(identifier: "fr") self.waitForExpectations(timeout: 10.0) } func testNotificationWhenOverrideClears() { let localeManager = makeLocaleManager() localeManager.currentLocale = Locale(identifier: "fr") let expectation = self.expectation(description: "update called") self.notificationCenter.addObserver( forName: AirshipNotifications.LocaleUpdated.name ) { _ in expectation.fulfill() } localeManager.clearLocale() self.waitForExpectations(timeout: 10.0) } func testNotificationWhenAutoUpdateChanges() { let localeManager = makeLocaleManager() let expectation = self.expectation(description: "update called") self.notificationCenter.addObserver( forName: AirshipNotifications.LocaleUpdated.name ) { _ in expectation.fulfill() } self.notificationCenter.post(name: NSLocale.currentLocaleDidChangeNotification) self.waitForExpectations(timeout: 10.0) _ = localeManager } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipLocalizationUtilsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class AirshipLocalizationUtilsTest: XCTestCase { func testLocalization() { let localizedString = AirshipLocalizationUtils.localizedString( "ua_notification_button_yes", withTable: "UrbanAirship", moduleBundle: AirshipCoreResources.bundle ) XCTAssertEqual(localizedString, "Yes") let badKeyString = AirshipLocalizationUtils.localizedString( "not_a_key", withTable: "UrbanAirship", moduleBundle: AirshipCoreResources.bundle ) XCTAssertNil(badKeyString) let badTableString = AirshipLocalizationUtils.localizedString( "ua_notification_button_yes", withTable: "NotATable", moduleBundle: AirshipCoreResources.bundle ) XCTAssertNil(badTableString) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipMeteredUsageTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipMeteredUsageTest: XCTestCase { private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private let channel: TestChannel = TestChannel() private let contact: TestContact = TestContact() private var privacyManager: TestPrivacyManager! private let apiClient: MeteredUsageAPIClientProtocol = MeteredTestApiClient() private let storage = MeteredUsageStore(appKey: "test.app.key", inMemory: true) private let workManager = TestWorkManager() private var config: RuntimeConfig = RuntimeConfig.testConfig() private var target: DefaultAirshipMeteredUsage! @MainActor override func setUp() async throws { self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config:self.config, defaultEnabledFeatures:[] ) self.target = DefaultAirshipMeteredUsage( config: config, dataStore: dataStore, channel: channel, contact: contact, privacyManager: privacyManager, client: apiClient, store: storage, workManager: workManager ) } func testInit() { let worker = workManager.workers.first XCTAssertNotNil(worker) XCTAssertEqual("MeteredUsage.upload", worker?.workID) // should set a default rate limit from the config XCTAssertEqual(1, workManager.rateLimits.count) XCTAssertEqual(0, workManager.workRequests.count) } func testUpdateConfig() async { var newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: nil, initialDelayMilliseconds: nil, intervalMilliseconds: nil) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) XCTAssertEqual(0, workManager.workRequests.count) var limit = workManager.rateLimits["MeteredUsage.rateLimit"] XCTAssertNotNil(limit) XCTAssertEqual(30, limit?.timeInterval) XCTAssertEqual(1, limit?.rate) newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: nil, initialDelayMilliseconds: nil, intervalMilliseconds: 2000) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) XCTAssertEqual(0, workManager.workRequests.count) limit = workManager.rateLimits["MeteredUsage.rateLimit"] XCTAssertNotNil(limit) XCTAssertEqual(2, limit?.timeInterval) XCTAssertEqual(1, limit?.rate) newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: false, initialDelayMilliseconds: 1000, intervalMilliseconds: 2000) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) XCTAssertEqual(0, workManager.workRequests.count) limit = workManager.rateLimits["MeteredUsage.rateLimit"] XCTAssertNotNil(limit) XCTAssertEqual(2, limit?.timeInterval) XCTAssertEqual(1, limit?.rate) newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: true, initialDelayMilliseconds: 1000, intervalMilliseconds: 2000) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) var workRequest = workManager.workRequests.last XCTAssertNotNil(workRequest) XCTAssertEqual(1, workRequest?.initialDelay) limit = workManager.rateLimits["MeteredUsage.rateLimit"] XCTAssertNotNil(limit) XCTAssertEqual(2, limit?.timeInterval) XCTAssertEqual(1, limit?.rate) workManager.workRequests.removeAll() newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: false, initialDelayMilliseconds: 1000, intervalMilliseconds: 2000) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) XCTAssertEqual(0, workManager.workRequests.count) limit = workManager.rateLimits["MeteredUsage.rateLimit"] XCTAssertNotNil(limit) XCTAssertEqual(2, limit?.timeInterval) XCTAssertEqual(1, limit?.rate) newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: true, initialDelayMilliseconds: nil, intervalMilliseconds: 2000) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) workRequest = workManager.workRequests.last XCTAssertNotNil(workRequest) XCTAssertEqual(15, workRequest?.initialDelay) limit = workManager.rateLimits["MeteredUsage.rateLimit"] XCTAssertNotNil(limit) XCTAssertEqual(2, limit?.timeInterval) XCTAssertEqual(1, limit?.rate) } func testManagerUploadsDataOnBackground() { XCTAssertEqual(1, workManager.backgroundWorkRequests.count) let work = workManager.backgroundWorkRequests.last XCTAssertNotNil(work) XCTAssertEqual("MeteredUsage.upload", work?.workID) XCTAssertEqual(0, work?.initialDelay) } func testEventStoreTheEventAndSendsData() async throws { privacyManager.enabledFeatures = [.analytics] let newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: true, initialDelayMilliseconds: 1, intervalMilliseconds: nil) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) workManager.workRequests.removeAll() let event = AirshipMeteredUsageEvent( eventID: "test.id", entityID: "story.id", usageType: .inAppExperienceImpression, product: "Story", reportingContext: try! AirshipJSON.wrap("context"), timestamp: Date(), contactID: "test-contact-id" ) XCTAssertEqual(0, workManager.workRequests.count) let storedEvents = try await storage.getEvents() XCTAssertEqual(0, storedEvents.count) let expectation = XCTestExpectation(description: "adding new event") workManager.onNewWorkRequestAdded = { _ in expectation.fulfill() } try await self.target.addEvent(event) await fulfillment(of: [expectation], timeout: 30) XCTAssertEqual(1, workManager.workRequests.count) let storedEvent = try await storage.getEvents().first XCTAssertEqual(event, storedEvent) } func testAddEventConfigDisabled() async throws { let newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: false, initialDelayMilliseconds: 1, intervalMilliseconds: nil) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) workManager.workRequests.removeAll() let event = AirshipMeteredUsageEvent( eventID: "test.id", entityID: "story.id", usageType: .inAppExperienceImpression, product: "Story", reportingContext: try! AirshipJSON.wrap("context"), timestamp: Date(), contactID: "test-contact-id" ) try await self.target.addEvent(event) XCTAssertEqual(0, workManager.workRequests.count) let events = try await storage.getEvents() XCTAssertTrue(events.isEmpty) } func testEventStoreStripsDataIfAnalyticsDisabled() async throws { let newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: true, initialDelayMilliseconds: 1, intervalMilliseconds: nil) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) workManager.workRequests.removeAll() let event = AirshipMeteredUsageEvent( eventID: "test.id", entityID: "story.id", usageType: .inAppExperienceImpression, product: "Story", reportingContext: try! AirshipJSON.wrap("context"), timestamp: Date(), contactID: "test-contact-id" ) XCTAssertEqual(0, workManager.workRequests.count) let storedEvents = try await storage.getEvents() XCTAssertEqual(0, storedEvents.count) let expectation = XCTestExpectation(description: "adding new event") workManager.onNewWorkRequestAdded = { _ in expectation.fulfill() } try await self.target.addEvent(event) await fulfillment(of: [expectation], timeout: 30) XCTAssertEqual(1, workManager.workRequests.count) let storedEvent = try await storage.getEvents().first XCTAssertNotNil(storedEvent) XCTAssertNotEqual(storedEvent, event) XCTAssertEqual(storedEvent, event.withDisabledAnalytics()) } func testContactIDAddedIfNotSet() async throws { self.contact.contactID = "from-contact" privacyManager.enableFeatures(.analytics) let newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: true, initialDelayMilliseconds: 1, intervalMilliseconds: nil) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) try await self.target.addEvent( AirshipMeteredUsageEvent( eventID: "test.id", entityID: "story.id", usageType: .inAppExperienceImpression, product: "Story", reportingContext: try! AirshipJSON.wrap("context"), timestamp: Date(), contactID: "test-contact-id" ) ) var fromStore = try await storage.getEvents().first! XCTAssertEqual(fromStore.contactID, "test-contact-id") try await self.target.addEvent( AirshipMeteredUsageEvent( eventID: "test.id", entityID: "story.id", usageType: .inAppExperienceImpression, product: "Story", reportingContext: try! AirshipJSON.wrap("context"), timestamp: Date(), contactID: nil ) ) fromStore = try await storage.getEvents().first! XCTAssertEqual(fromStore.contactID, "from-contact") } func testEventStripDataOnDisabledAnalytics() { let timeStamp = Date() let event = AirshipMeteredUsageEvent( eventID: "test.id", entityID: "story.id", usageType: .inAppExperienceImpression, product: "Story", reportingContext: try! AirshipJSON.wrap("context"), timestamp: timeStamp, contactID: "test-contact-id" ) .withDisabledAnalytics() XCTAssertEqual(event.eventID, "test.id") XCTAssertEqual(event.usageType, .inAppExperienceImpression) XCTAssertEqual(event.product, "Story") XCTAssertNil(event.entityID) XCTAssertNil(event.reportingContext) XCTAssertNil(event.timestamp) XCTAssertNil(event.contactID) } func testScheduleWorkRespectsConfig() async { XCTAssertEqual(0, workManager.workRequests.count) target.scheduleWork() XCTAssertEqual(0, workManager.workRequests.count) let newConfig = RemoteConfig.MeteredUsageConfig(isEnabled: true, initialDelayMilliseconds: 1, intervalMilliseconds: 2000) await config.updateRemoteConfig(RemoteConfig(meteredUsageConfig: newConfig)) workManager.workRequests.removeAll() target.scheduleWork() var lastWork = workManager.workRequests.last XCTAssertNotNil(lastWork) XCTAssertEqual("MeteredUsage.upload", lastWork?.workID) XCTAssertEqual(0, lastWork?.initialDelay) XCTAssertEqual(true, lastWork?.requiresNetwork) XCTAssertEqual(AirshipWorkRequestConflictPolicy.keepIfNotStarted, lastWork?.conflictPolicy) workManager.workRequests.removeAll() target.scheduleWork(initialDelay: 2) lastWork = workManager.workRequests.last XCTAssertNotNil(lastWork) XCTAssertEqual("MeteredUsage.upload", lastWork?.workID) XCTAssertEqual(2, lastWork?.initialDelay) XCTAssertEqual(true, lastWork?.requiresNetwork) XCTAssertEqual(AirshipWorkRequestConflictPolicy.keepIfNotStarted, lastWork?.conflictPolicy) workManager.workRequests.removeAll() target.scheduleWork(initialDelay: 2) lastWork = workManager.workRequests.last XCTAssertNotNil(lastWork) XCTAssertEqual("MeteredUsage.upload", lastWork?.workID) XCTAssertEqual(2, lastWork?.initialDelay) XCTAssertEqual(true, lastWork?.requiresNetwork) XCTAssertEqual(AirshipWorkRequestConflictPolicy.keepIfNotStarted, lastWork?.conflictPolicy) } } final class MeteredTestApiClient: MeteredUsageAPIClientProtocol { func uploadEvents(_ events: [AirshipCore.AirshipMeteredUsageEvent], channelID: String?) async throws -> AirshipCore.AirshipHTTPResponse<Void> { return .init(result: nil, statusCode: 200, headers: [:]) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipPrivacyManagerTest.swift ================================================ import XCTest @testable import AirshipCore class DefaultAirshipPrivacyManagerTest: XCTestCase { private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) private var config: RuntimeConfig = RuntimeConfig.testConfig() private var privacyManager: DefaultAirshipPrivacyManager! override func setUp() async throws { self.privacyManager = await DefaultAirshipPrivacyManager( dataStore: dataStore, config: self.config, defaultEnabledFeatures: .all, notificationCenter: notificationCenter ) } func testDefaultFeatures() async { XCTAssertEqual(self.privacyManager.enabledFeatures, .all) self.privacyManager = await DefaultAirshipPrivacyManager( dataStore: dataStore, config: self.config, defaultEnabledFeatures: [], notificationCenter: notificationCenter ) XCTAssertEqual(self.privacyManager.enabledFeatures, []) } func testEnableFeatures() { self.privacyManager.disableFeatures(.all) XCTAssertEqual(self.privacyManager.enabledFeatures, []) self.privacyManager.enableFeatures(.push) XCTAssertEqual(self.privacyManager.enabledFeatures, [.push]) self.privacyManager.enableFeatures([.push, .contacts]) XCTAssertEqual(self.privacyManager.enabledFeatures, [.push, .contacts]) } func testDisableFeatures() { XCTAssertEqual(self.privacyManager.enabledFeatures, .all) self.privacyManager.disableFeatures(.push) XCTAssertNotEqual(self.privacyManager.enabledFeatures, .all) self.privacyManager.disableFeatures([.analytics, .messageCenter, .tagsAndAttributes]) XCTAssertEqual(self.privacyManager.enabledFeatures, [.inAppAutomation, .contacts, .featureFlags]) } func testIsEnabled() { self.privacyManager.disableFeatures(.all) XCTAssertFalse(self.privacyManager.isEnabled(.analytics)) self.privacyManager.enableFeatures(.contacts) XCTAssertTrue(self.privacyManager.isEnabled(.contacts)) self.privacyManager.enableFeatures(.analytics) XCTAssertTrue(self.privacyManager.isEnabled(.analytics)) self.privacyManager.enableFeatures(.all) XCTAssertTrue(self.privacyManager.isEnabled(.inAppAutomation)) } func testIsAnyEnabled() { XCTAssertTrue(self.privacyManager.isAnyFeatureEnabled(ignoringRemoteConfig: false)) self.privacyManager.disableFeatures([.push, .contacts]) XCTAssertTrue(self.privacyManager.isAnyFeatureEnabled(ignoringRemoteConfig: false)) self.privacyManager.disableFeatures(.all) XCTAssertFalse(self.privacyManager.isAnyFeatureEnabled(ignoringRemoteConfig: false)) } func testNoneEnabled() { self.privacyManager.enabledFeatures = [] XCTAssertFalse(self.privacyManager.isAnyFeatureEnabled(ignoringRemoteConfig: false)) self.privacyManager.enableFeatures([.push, .tagsAndAttributes]) XCTAssertTrue(self.privacyManager.isAnyFeatureEnabled(ignoringRemoteConfig: false)) self.privacyManager.enabledFeatures = [] XCTAssertFalse(self.privacyManager.isAnyFeatureEnabled(ignoringRemoteConfig: false)) } func testSetEnabled() { self.privacyManager.enabledFeatures = .contacts XCTAssertTrue(self.privacyManager.isEnabled(.contacts)) XCTAssertFalse(self.privacyManager.isEnabled(.analytics)) self.privacyManager.enabledFeatures = .analytics XCTAssertTrue(self.privacyManager.isEnabled(.analytics)) } func testRemoteConfigOverrides() async { XCTAssertEqual(AirshipFeature.all, self.privacyManager.enabledFeatures) await self.config.updateRemoteConfig( RemoteConfig(disabledFeatures: .push) ) XCTAssertEqual(AirshipFeature.all.subtracting(.push), self.privacyManager.enabledFeatures) await self.config.updateRemoteConfig( RemoteConfig(disabledFeatures: []) ) XCTAssertEqual(AirshipFeature.all, self.privacyManager.enabledFeatures) await self.config.updateRemoteConfig( RemoteConfig(disabledFeatures: .all) ) XCTAssertEqual([], self.privacyManager.enabledFeatures) } @MainActor func testNotifiedOnChange() { let counter = AirshipAtomicValue(0) let observer = notificationCenter.addObserver(forName: AirshipNotifications.PrivacyManagerUpdated.name, object: nil, queue: nil) { @Sendable _ in counter.value += 1 } self.privacyManager.enabledFeatures = .all self.privacyManager.disableFeatures([]) self.privacyManager.enableFeatures(.all) self.privacyManager.enableFeatures(.analytics) XCTAssertEqual(counter.value, 0) self.privacyManager.disableFeatures(.analytics) XCTAssertEqual(counter.value, 1) self.privacyManager.enableFeatures(.analytics) XCTAssertEqual(counter.value, 2) self.config.updateRemoteConfig( RemoteConfig(disabledFeatures: []) ) XCTAssertEqual(counter.value, 2) self.config.updateRemoteConfig( RemoteConfig(disabledFeatures: [.analytics]) ) XCTAssertEqual(counter.value, 3) notificationCenter.removeObserver(observer) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipPushTest.swift ================================================ // Copyright Airship and Contributors import XCTest @testable import AirshipCore import Combine class AirshipPushTest: XCTestCase { private static let validDeviceToken = "0123456789abcdef0123456789abcdef" private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private let channel = TestChannel() private let analtyics = TestAnalytics() private var permissionsManager: DefaultAirshipPermissionsManager! private let notificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) private let notificationRegistrar = TestNotificationRegistrar() private var apnsRegistrar: TestAPNSRegistrar! private let badger = TestBadger() private let registrationDelegate = TestRegistraitonDelegate() private var pushDelegate: TestPushNotificationDelegate! private var config = AirshipConfig() private var privacyManager: TestPrivacyManager! private var push: DefaultAirshipPush! private var serialQueue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue(priority: .high) override func setUp() async throws { self.pushDelegate = await TestPushNotificationDelegate() self.apnsRegistrar = await TestAPNSRegistrar() self.permissionsManager = await DefaultAirshipPermissionsManager() self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: .testConfig(), defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) self.push = await createPush() await self.serialQueue.waitForCurrentOperations() self.channel.updateRegistrationCalled = false } override func tearDown() async throws { self.serialQueue.stop() } @MainActor func createPush() -> DefaultAirshipPush { return DefaultAirshipPush( config: .testConfig(airshipConfig: self.config), dataStore: dataStore, channel: channel, analytics: analtyics, privacyManager: privacyManager, permissionsManager: permissionsManager, notificationCenter: notificationCenter, notificationRegistrar: notificationRegistrar, apnsRegistrar: apnsRegistrar, badger: badger, serialQueue: serialQueue ) } @MainActor func testBackgroundPushNotificationsEnabled() async throws { XCTAssertTrue(self.push.backgroundPushNotificationsEnabled) XCTAssertFalse(self.channel.updateRegistrationCalled) self.push.backgroundPushNotificationsEnabled = false await self.serialQueue.waitForCurrentOperations() XCTAssertTrue(self.channel.updateRegistrationCalled) } func testNotificationsPromptedAuthorizedStatus() async throws { XCTAssertFalse(self.push.userPromptedForNotifications) self.notificationRegistrar.onCheckStatus = { return (.authorized, []) } let completed = self.expectation(description: "Completed") let _ = await self.permissionsManager.requestPermission(.displayNotifications) completed.fulfill() await self.fulfillment(of: [completed], timeout: 10.0) XCTAssertTrue(self.push.userPromptedForNotifications) } func testNotificationsPromptedDeniedStatus() async throws { XCTAssertFalse(self.push.userPromptedForNotifications) self.notificationRegistrar.onCheckStatus = { return(.denied, []) } let completed = self.expectation(description: "Completed") let _ = await self.permissionsManager.requestPermission(.displayNotifications) completed.fulfill() await self.fulfillment(of: [completed], timeout: 10.0) XCTAssertTrue(self.push.userPromptedForNotifications) } func testNotificationsPromptedEphemeralStatus() async throws { XCTAssertFalse(self.push.userPromptedForNotifications) self.notificationRegistrar.onCheckStatus = { return(.ephemeral, []) } let completed = self.expectation(description: "Completed") let _ = await self.permissionsManager.requestPermission(.displayNotifications) completed.fulfill() await self.fulfillment(of: [completed], timeout: 10.0) XCTAssertFalse(self.push.userPromptedForNotifications) } func testNotificationsPromptedNotDeterminedStatus() async throws { XCTAssertFalse(self.push.userPromptedForNotifications) self.notificationRegistrar.onCheckStatus = { return(.notDetermined, []) } let completed = self.expectation(description: "Completed") let _ = await self.permissionsManager.requestPermission(.displayNotifications) completed.fulfill() await self.fulfillment(of: [completed], timeout: 10.0) XCTAssertFalse(self.push.userPromptedForNotifications) } @MainActor func testNotificationsStatusPropogation() async throws { XCTAssertFalse(self.push.userPromptedForNotifications) self.notificationRegistrar.onCheckStatus = { return (.authorized, [.badge]) } let completed = self.expectation(description: "Completed") let _ = await self.permissionsManager.requestPermission(.displayNotifications) let cancellable = self.push.notificationStatusPublisher.sink { status in XCTAssertEqual(true, status.areNotificationsAllowed) completed.fulfill() } let status = await self.push.notificationStatus XCTAssertEqual(true, status.areNotificationsAllowed) await self.fulfillment(of: [completed], timeout: 10.0) XCTAssertTrue(self.push.userPromptedForNotifications) cancellable.cancel() } /// Test that once prompted always prompted @MainActor func testNotificationsPromptedStaysPrompted() async throws { XCTAssertFalse(self.push.userPromptedForNotifications) self.notificationRegistrar.onCheckStatus = { return(.authorized, []) } let completed = self.expectation(description: "Completed") let _ = await self.permissionsManager.requestPermission(.displayNotifications) completed.fulfill() await self.fulfillment(of: [completed], timeout: 10.0) self.notificationRegistrar.onCheckStatus = { return(.notDetermined, []) } let completedAgain = self.expectation(description: "Completed Again") _ = await self.permissionsManager.requestPermission(.displayNotifications) completedAgain.fulfill() await self.fulfillment(of: [completedAgain], timeout: 10.0) XCTAssertTrue(self.push.userPromptedForNotifications) } func testUserPushNotificationsEnabled() async throws { self.push.notificationOptions = [.alert, .badge] self.push.requestExplicitPermissionWhenEphemeral = false await self.serialQueue.waitForCurrentOperations() // Make sure updates are called through permissions manager let permissionsManagerCalled = self.expectation( description: "Permissions manager called" ) self.permissionsManager.addRequestExtender( permission: .displayNotifications ) { _ in permissionsManagerCalled.fulfill() } let updated = self.expectation(description: "Registration updated") self.notificationRegistrar.onUpdateRegistration = { options, skipIfEphemeral in XCTAssertEqual([.alert, .badge], options) XCTAssertTrue(skipIfEphemeral) updated.fulfill() } self.push.userPushNotificationsEnabled = true await self.serialQueue.waitForCurrentOperations() await self.fulfillment(of: [permissionsManagerCalled, updated], timeout: 20.0) } func testUserPushNotificationsDisabled() async throws { let enabled = self.expectation(description: "Registration updated") self.notificationRegistrar.onUpdateRegistration = { options, skipIfEphemeral in XCTAssertEqual([.badge, .alert, .sound], options) XCTAssertTrue(skipIfEphemeral) enabled.fulfill() } self.push.userPushNotificationsEnabled = true await self.fulfillment(of: [enabled], timeout: 10.0) let disabled = self.expectation(description: "Registration updated") self.notificationRegistrar.onUpdateRegistration = { options, skipIfEphemeral in XCTAssertEqual([], options) XCTAssertTrue(skipIfEphemeral) disabled.fulfill() } self.push.userPushNotificationsEnabled = false await self.fulfillment(of: [disabled], timeout: 10.0) } /// Test that we always ephemeral when disabling notifications func testUserPushNotificationsSkipEphemeral() async throws { self.push.requestExplicitPermissionWhenEphemeral = false await self.serialQueue.waitForCurrentOperations() let enabled = self.expectation(description: "Registration updated") self.notificationRegistrar.onUpdateRegistration = { options, skipIfEphemeral in XCTAssertEqual([.badge, .alert, .sound], options) XCTAssertTrue(skipIfEphemeral) enabled.fulfill() } self.push.userPushNotificationsEnabled = true await self.serialQueue.waitForCurrentOperations() await self.fulfillment(of: [enabled], timeout: 10.0) let disabled = self.expectation(description: "Registration updated") self.notificationRegistrar.onUpdateRegistration = { options, skipIfEphemeral in XCTAssertEqual([], options) XCTAssertTrue(skipIfEphemeral) disabled.fulfill() } self.push.userPushNotificationsEnabled = false await self.serialQueue.waitForCurrentOperations() await self.fulfillment(of: [disabled], timeout: 10.0) } func testEnableUserNotificationsAppHandlingAuth() async throws { self.config.requestAuthorizationToUseNotifications = false self.push = await createPush() self.permissionsManager.addRequestExtender( permission: .displayNotifications ) { _ in XCTFail("Should be skipped") } self.notificationRegistrar.onCheckStatus = { return(.authorized, []) } let success = await self.push.enableUserPushNotifications() XCTAssertTrue(success) } func testEnableUserNotificationsAuthorized() async throws { // Make sure updates are called through permissions manager let permissionsManagerCalled = self.expectation( description: "Permissions manager called" ) self.permissionsManager.addRequestExtender( permission: .displayNotifications ) { _ in permissionsManagerCalled.fulfill() } self.push.notificationOptions = [.alert, .badge] self.push.requestExplicitPermissionWhenEphemeral = false await self.serialQueue.waitForCurrentOperations() self.notificationRegistrar.onCheckStatus = { return(.authorized, []) } let success = await self.push.enableUserPushNotifications() XCTAssertTrue(success) await self.fulfillment(of: [permissionsManagerCalled], timeout: 10.0) } func testEnableUserNotificationsDenied() async throws { self.notificationRegistrar.onCheckStatus = { return(.denied, []) } let enabled = self.expectation(description: "Enabled") let success = await self.push.enableUserPushNotifications() enabled.fulfill() XCTAssertFalse(success) await self.fulfillment(of: [enabled], timeout: 10.0) } func testSkipWhenEphemeralDisabled() async throws { let updated = self.expectation(description: "Registration updated") self.push.notificationOptions = [.alert, .badge] self.push.requestExplicitPermissionWhenEphemeral = true await self.serialQueue.waitForCurrentOperations() self.notificationRegistrar.onUpdateRegistration = { options, skipIfEphemeral in XCTAssertEqual([.alert, .badge], options) XCTAssertFalse(skipIfEphemeral) updated.fulfill() } self.push.userPushNotificationsEnabled = true await self.fulfillment(of: [updated], timeout: 10.0) } @MainActor func testDeviceToken() throws { push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) XCTAssertEqual(AirshipPushTest.validDeviceToken, self.push.deviceToken) } func testSetQuietTime() throws { self.push.setQuietTimeStartHour( 12, startMinute: 30, endHour: 14, endMinute: 58 ) XCTAssertEqual( "12:30", self.push.quietTime?.startString ) XCTAssertEqual( "14:58", self.push.quietTime?.endString ) XCTAssertTrue(self.channel.updateRegistrationCalled) let expected = try QuietTimeSettings(startHour: 12, startMinute: 30, endHour: 14, endMinute: 58) XCTAssertEqual(expected, self.push.quietTime) } func testSetQuietTimeInvalid() throws { XCTAssertNil(self.push.quietTime) self.push.setQuietTimeStartHour( 25, startMinute: 30, endHour: 14, endMinute: 58 ) XCTAssertNil(self.push.quietTime) self.push.setQuietTimeStartHour( 12, startMinute: 61, endHour: 14, endMinute: 58 ) XCTAssertNil(self.push.quietTime) } func testSetTimeZone() throws { self.push.timeZone = NSTimeZone(abbreviation: "HST") XCTAssertEqual("HST", self.push.timeZone?.abbreviation) self.push.timeZone = nil XCTAssertEqual(NSTimeZone.default as NSTimeZone, self.push.timeZone) } @MainActor func testChannelPayloadRegistered() async throws { self.push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) let payload = await self.channel.channelPayload XCTAssertEqual(AirshipPushTest.validDeviceToken, payload.channel.pushAddress) XCTAssertTrue( payload.channel.iOSChannelSettings?.isTimeSensitive == false ) XCTAssertTrue( payload.channel.iOSChannelSettings?.isScheduledSummary == false ) } func testChannelPayloadNotRegistered() async throws { let payload = await self.channel.channelPayload XCTAssertNil(payload.channel.pushAddress) XCTAssertFalse(payload.channel.isOptedIn) XCTAssertFalse(payload.channel.isBackgroundEnabled) XCTAssertNil(payload.channel.iOSChannelSettings?.quietTime) XCTAssertNil(payload.channel.iOSChannelSettings?.quietTimeTimeZone) XCTAssertTrue( payload.channel.iOSChannelSettings?.isTimeSensitive == false ) XCTAssertTrue( payload.channel.iOSChannelSettings?.isScheduledSummary == false ) XCTAssertNil(payload.channel.iOSChannelSettings?.badge) } @MainActor func testChannelPayloadNotificationsEnabled() async throws { self.push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) apnsRegistrar.isRegisteredForRemoteNotifications = true apnsRegistrar.isRemoteNotificationBackgroundModeEnabled = true apnsRegistrar.isBackgroundRefreshStatusAvailable = true self.notificationRegistrar.onCheckStatus = { return( .authorized, [.timeSensitive, .scheduledDelivery, .alert] ) } let enabled = self.expectation(description: "Registration updated") let status = await self.permissionsManager.requestPermission( .displayNotifications, enableAirshipUsageOnGrant: true ) enabled.fulfill() XCTAssertEqual(.granted, status) await self.fulfillment(of: [enabled], timeout: 10.0) let payload = await self.channel.channelPayload XCTAssertEqual(AirshipPushTest.validDeviceToken, payload.channel.pushAddress) XCTAssertTrue(payload.channel.isOptedIn) XCTAssertTrue(payload.channel.isBackgroundEnabled) XCTAssertTrue( payload.channel.iOSChannelSettings?.isTimeSensitive == true ) XCTAssertTrue( payload.channel.iOSChannelSettings?.isScheduledSummary == true ) } func testChannelPayloadQuietTime() async throws { self.push.quietTimeEnabled = true self.push.setQuietTimeStartHour( 1, startMinute: 30, endHour: 2, endMinute: 30 ) self.push.timeZone = NSTimeZone(abbreviation: "EDT") let payload = await self.channel.channelPayload XCTAssertEqual( "01:30", payload.channel.iOSChannelSettings?.quietTime?.start ) XCTAssertEqual( "02:30", payload.channel.iOSChannelSettings?.quietTime?.end ) XCTAssertEqual( "America/New_York", payload.channel.iOSChannelSettings?.quietTimeTimeZone ) } func testChannelPayloadQuietTimeDisabled() async throws { self.push.quietTimeEnabled = false self.push.setQuietTimeStartHour( 1, startMinute: 30, endHour: 2, endMinute: 30 ) self.push.timeZone = NSTimeZone(abbreviation: "EDT") let payload = await self.channel.channelPayload XCTAssertNil(payload.channel.iOSChannelSettings?.quietTime) XCTAssertNil(payload.channel.iOSChannelSettings?.quietTimeTimeZone) } @MainActor func testChannelPayloadAutoBadge() async throws { self.push.autobadgeEnabled = true try await self.push.setBadgeNumber(10) let payload = await self.channel.channelPayload XCTAssertEqual(10, payload.channel.iOSChannelSettings?.badge) } func testAnalyticsHeadersOptedOut() async throws { let expected = [ "X-UA-Channel-Opted-In": "false", "X-UA-Notification-Prompted": "false", "X-UA-Channel-Background-Enabled": "false", ] let headers = await self.analtyics.headers XCTAssertEqual(expected, headers) } @MainActor func testAnalyticsHeadersOptedIn() async throws { self.push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) apnsRegistrar.isRegisteredForRemoteNotifications = true apnsRegistrar.isRemoteNotificationBackgroundModeEnabled = true apnsRegistrar.isBackgroundRefreshStatusAvailable = true self.notificationRegistrar.onCheckStatus = { return( .authorized, [.timeSensitive, .scheduledDelivery, .alert] ) } let enabled = self.expectation(description: "Registration updated") let status = await self.permissionsManager.requestPermission( .displayNotifications, enableAirshipUsageOnGrant: true ) enabled.fulfill() XCTAssertEqual(.granted, status) await self.fulfillment(of: [enabled], timeout: 10.0) let expected = [ "X-UA-Channel-Opted-In": "true", "X-UA-Notification-Prompted": "true", "X-UA-Channel-Background-Enabled": "true", "X-UA-Push-Address": AirshipPushTest.validDeviceToken, ] let headers = await self.analtyics.headers XCTAssertEqual(expected, headers) } func testAnalyticsHeadersPushDisabled() async throws { await self.push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) self.privacyManager.disableFeatures(.push) let expected = [ "X-UA-Channel-Opted-In": "false", "X-UA-Channel-Background-Enabled": "false", ] let headers = await self.analtyics.headers XCTAssertEqual(expected, headers) } @MainActor func testDefaultNotificationCategories() throws { let defaultCategories = NotificationCategories.defaultCategories() XCTAssertEqual(defaultCategories, notificationRegistrar.categories) XCTAssertEqual(defaultCategories, self.push.combinedCategories) } @MainActor func testNotificationCategories() throws { let defaultCategories = NotificationCategories.defaultCategories() let customCategory = UNNotificationCategory( identifier: "something", actions: [], intentIdentifiers: ["intents"] ) let combined = Set(defaultCategories).union([customCategory]) self.push.customCategories = Set([customCategory]) XCTAssertEqual(combined, notificationRegistrar.categories) } @MainActor func testRequireAuthorizationForDefaultCategories() throws { self.push.requireAuthorizationForDefaultCategories = true let defaultCategories = NotificationCategories.defaultCategories( withRequireAuth: true ) XCTAssertEqual(defaultCategories, notificationRegistrar.categories) XCTAssertEqual(defaultCategories, self.push.combinedCategories) } @MainActor func testBadge() async throws { try await self.push.setBadgeNumber(100) XCTAssertEqual(100, self.badger.applicationIconBadgeNumber) } @MainActor func testAutoBadge() async throws { self.push.autobadgeEnabled = false try await self.push.setBadgeNumber(10) XCTAssertFalse(self.channel.updateRegistrationCalled) self.push.autobadgeEnabled = true XCTAssertTrue(self.channel.updateRegistrationCalled) self.channel.updateRegistrationCalled = false try await self.push.setBadgeNumber(1) XCTAssertTrue(self.channel.updateRegistrationCalled) self.channel.updateRegistrationCalled = false self.push.autobadgeEnabled = false XCTAssertTrue(self.channel.updateRegistrationCalled) } @MainActor func testResetBadge() async throws { try await self.push.setBadgeNumber(1000) XCTAssertEqual(1000, self.push.badgeNumber) try await self.push.resetBadge() XCTAssertEqual(0, self.push.badgeNumber) XCTAssertEqual(0, self.badger.applicationIconBadgeNumber) } func testActiveChecksRegistration() async { self.notificationRegistrar.onCheckStatus = { return (.authorized, [.alert]) } self.notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) await self.serialQueue.waitForCurrentOperations() XCTAssertEqual(.authorized, self.push.authorizationStatus) let settings = self.push.authorizedNotificationSettings XCTAssertEqual([.alert], settings) XCTAssertTrue(self.push.userPromptedForNotifications) } func testAuthorizedStatusUpdatesChannelRegistration() async { self.notificationRegistrar.onCheckStatus = { return(.authorized, [.alert]) } self.notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) await self.serialQueue.waitForCurrentOperations() XCTAssertTrue(self.channel.updateRegistrationCalled) } func testDefaultOptions() { XCTAssertEqual([.alert, .badge, .sound], self.push.notificationOptions) } func testDefaultOptionsProvisional() async { self.notificationRegistrar.onCheckStatus = { return(.provisional, []) } let completed = self.expectation(description: "Completed") let _ = await self.permissionsManager.requestPermission(.displayNotifications) completed.fulfill() self.push.userPushNotificationsEnabled = true await self.fulfillment(of: [completed], timeout: 10.0) XCTAssertEqual( [.alert, .badge, .sound, .provisional], self.push.notificationOptions ) } @MainActor func testCategoriesWhenAppIsHandlingAuthorization() { self.notificationRegistrar.categories = nil self.config.requestAuthorizationToUseNotifications = false self.push = createPush() let customCategory = UNNotificationCategory( identifier: "something", actions: [], intentIdentifiers: ["intents"] ) self.push.customCategories = Set([customCategory]) XCTAssertNil(self.notificationRegistrar.categories) } @MainActor func testPermissionsDelgateWhenAppIsHandlingAuthorization() { XCTAssertTrue( self.permissionsManager.configuredPermissions.contains( .displayNotifications ) ) self.permissionsManager.setDelegate( nil, permission: .displayNotifications ) XCTAssertFalse( self.permissionsManager.configuredPermissions.contains( .displayNotifications ) ) self.config.requestAuthorizationToUseNotifications = false self.push = createPush() XCTAssertTrue( self.permissionsManager.configuredPermissions.contains( .displayNotifications ) ) } @MainActor func testForwardNotificationRegistrationFinished() { self.push.registrationDelegate = self.registrationDelegate self.notificationRegistrar.onCheckStatus = { return(.provisional, [.badge]) } let called = self.expectation(description: "Delegate called") self.registrationDelegate.onNotificationRegistrationFinished = { settings, categories, status in XCTAssertEqual([.badge], settings) XCTAssertEqual(self.push.combinedCategories, categories) XCTAssertEqual(.provisional, status) called.fulfill() } self.push.userPushNotificationsEnabled = true self.wait(for: [called], timeout: 10.0) } @MainActor func testForwardAuthorizedSettingsChanges() { self.push.registrationDelegate = self.registrationDelegate self.notificationRegistrar.onCheckStatus = { return(.provisional, [.alert]) } let called = self.expectation(description: "Delegate called") self.registrationDelegate.onNotificationAuthorizedSettingsDidChange = { settings in XCTAssertEqual([.alert], settings) called.fulfill() } self.push.userPushNotificationsEnabled = true self.wait(for: [called], timeout: 10.0) } @MainActor func testForwardAuthorizedSettingsChangesForeground() { self.push.registrationDelegate = self.registrationDelegate self.notificationRegistrar.onCheckStatus = { return(.provisional, [.badge]) } let called = self.expectation(description: "Delegate called") self.registrationDelegate.onNotificationAuthorizedSettingsDidChange = { settings in XCTAssertEqual([.badge], settings) called.fulfill() } self.notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) self.wait(for: [called], timeout: 10.0) } @MainActor func testForwardAPNSRegistrationSucceeded() { let expectedToken = AirshipPushTest.validDeviceToken.hexData self.push.registrationDelegate = self.registrationDelegate let called = self.expectation(description: "Delegate called") self.registrationDelegate.onAPNSRegistrationSucceeded = { token in XCTAssertEqual(expectedToken, token) called.fulfill() } self.push.didRegisterForRemoteNotifications(expectedToken) self.wait(for: [called], timeout: 10.0) } @MainActor func testForwardAPNSRegistrationFailed() { let expectedError = AirshipErrors.error("something") self.push.registrationDelegate = self.registrationDelegate let called = self.expectation(description: "Delegate called") self.registrationDelegate.onAPNSRegistrationFailed = { error in XCTAssertEqual( expectedError.localizedDescription, error.localizedDescription ) called.fulfill() } self.push.didFailToRegisterForRemoteNotifications(expectedError) self.wait(for: [called], timeout: 10.0) } @MainActor func testReceivedForegroundNotification() async { let expected = ["cool": "payload"] let result = await self.push.didReceiveRemoteNotification( expected, isForeground: true ) XCTAssertEqual(UABackgroundFetchResult.noData, result) } @MainActor func testForwardReceivedForegroundNotification() async { let expected = ["cool": "payload"] self.push.pushNotificationDelegate = self.pushDelegate self.pushDelegate.onReceivedForegroundNotification = { notificaiton in XCTAssertEqual( expected as NSDictionary, notificaiton as NSDictionary ) } let result = await self.push.didReceiveRemoteNotification(expected, isForeground: true) XCTAssertEqual(UABackgroundFetchResult.noData, result) } @MainActor func testReceivedBackgroundNotification() async { let expected = ["cool": "payload"] let result = await self.push.didReceiveRemoteNotification( expected, isForeground: false ) XCTAssertEqual(UABackgroundFetchResult.noData, result) } @MainActor func testForwardReceivedBackgroundNotification() async { let expected = ["cool": "payload"] self.push.pushNotificationDelegate = self.pushDelegate self.pushDelegate.onReceivedBackgroundNotification = { notificaiton in XCTAssertEqual( expected as NSDictionary, notificaiton as NSDictionary ) return .newData } let result = await self.push.didReceiveRemoteNotification( expected, isForeground: false ) XCTAssertEqual(UABackgroundFetchResult.newData, result) } @MainActor func testOptionsPermissionDelegate() async { self.push.userPushNotificationsEnabled = false self.push.notificationOptions = .alert let updated = self.expectation(description: "Registration updated") self.notificationRegistrar.onUpdateRegistration = { options, skipIfEphemeral in XCTAssertTrue(skipIfEphemeral) if options == [.alert] { updated.fulfill() } } let completionHandlerCalled = self.expectation( description: "Completion handler called" ) let _ = await self.permissionsManager.requestPermission(.displayNotifications) completionHandlerCalled.fulfill() await self.fulfillment(of: [updated, completionHandlerCalled], timeout: 10) } @MainActor func testNotificationStatus() async { self.push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) self.push.userPushNotificationsEnabled = true self.notificationRegistrar.onCheckStatus = { return (.authorized, [.alert]) } self.apnsRegistrar.isRegisteredForRemoteNotifications = true self.privacyManager.enabledFeatures = .push let status = await self.push.notificationStatus XCTAssertEqual( AirshipNotificationStatus( isUserNotificationsEnabled: true, areNotificationsAllowed: true, isPushPrivacyFeatureEnabled: true, isPushTokenRegistered: true, displayNotificationStatus: .granted ), status ) } @MainActor func testNotificationStatusNoTokenRegistration() async { self.push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) var status = await self.push.notificationStatus XCTAssertEqual( AirshipNotificationStatus( isUserNotificationsEnabled: false, areNotificationsAllowed: false, isPushPrivacyFeatureEnabled: true, isPushTokenRegistered: false, displayNotificationStatus: .notDetermined ), status ) self.apnsRegistrar.isRegisteredForRemoteNotifications = true status = await self.push.notificationStatus XCTAssertEqual( AirshipNotificationStatus( isUserNotificationsEnabled: false, areNotificationsAllowed: false, isPushPrivacyFeatureEnabled: true, isPushTokenRegistered: true, displayNotificationStatus: .notDetermined ), status ) } @MainActor func testNotificationStatusAllowed() async { self.notificationRegistrar.onCheckStatus = { return (.notDetermined, [.alert]) } var status = await self.push.notificationStatus XCTAssertEqual( AirshipNotificationStatus( isUserNotificationsEnabled: false, areNotificationsAllowed: false, isPushPrivacyFeatureEnabled: true, isPushTokenRegistered: false, displayNotificationStatus: .notDetermined ), status ) self.notificationRegistrar.onCheckStatus = { return (.authorized, [.alert]) } status = await self.push.notificationStatus XCTAssertEqual( AirshipNotificationStatus( isUserNotificationsEnabled: false, areNotificationsAllowed: true, isPushPrivacyFeatureEnabled: true, isPushTokenRegistered: false, displayNotificationStatus: .granted ), status ) } @MainActor func testChannelRegistrationWaitsForToken() async { apnsRegistrar.isRegisteredForRemoteNotifications = true let startedCRATask = self.expectation(description: "Started CRA") Task { await fulfillment(of: [startedCRATask]) push.didRegisterForRemoteNotifications( AirshipPushTest.validDeviceToken.hexData ) } let payload = await Task { Task { @MainActor in try await Task.sleep(for: .milliseconds(100)) startedCRATask.fulfill() } return await self.channel.channelPayload }.value XCTAssertNotNil(payload.channel.pushAddress) } @MainActor func testAPNSRegistrationFinishedDelegateFallbackSuccess() async { let expectedToken = "some-token" let delegate = TestRegistraitonDelegate() let expectation = self.expectation(description: "Delegate called") delegate.onAPNSRegistrationSucceeded = { tokenData in XCTAssertEqual(expectedToken.hexData, tokenData) expectation.fulfill() } self.push.registrationDelegate = delegate self.push.onAPNSRegistrationFinished = nil self.push.didRegisterForRemoteNotifications(expectedToken.hexData) await self.fulfillment(of: [expectation], timeout: 10.0) } @MainActor func testAPNSRegistrationFinishedDelegateFallbackFailure() async { let expectedError = AirshipErrors.error("some error") let delegate = TestRegistraitonDelegate() let expectation = self.expectation(description: "Delegate called") delegate.onAPNSRegistrationFailed = { error in XCTAssertEqual(expectedError.localizedDescription, error.localizedDescription) expectation.fulfill() } self.push.registrationDelegate = delegate self.push.onAPNSRegistrationFinished = nil self.push.didFailToRegisterForRemoteNotifications(expectedError) await self.fulfillment(of: [expectation], timeout: 10.0) } @MainActor func testNotificationRegistrationFinishedCallback() async { let expectation = self.expectation(description: "Callback called") self.notificationRegistrar.onCheckStatus = { return (.authorized, [.alert]) } self.push.onNotificationRegistrationFinished = { result in XCTAssertEqual(.authorized, result.status) XCTAssertEqual([.alert], result.authorizedSettings) XCTAssertEqual(self.push.combinedCategories, result.categories) expectation.fulfill() } let _ = await self.permissionsManager.requestPermission(.displayNotifications) await self.fulfillment(of: [expectation], timeout: 10.0) } @MainActor func testNotificationRegistrationFinishedDelegateFallback() async { let delegate = TestRegistraitonDelegate() let expectation = self.expectation(description: "Delegate called") self.notificationRegistrar.onCheckStatus = { return (.authorized, [.alert]) } delegate.onNotificationRegistrationFinished = { settings, categories, status in XCTAssertEqual(.authorized, status) XCTAssertEqual([.alert], settings) XCTAssertEqual(self.push.combinedCategories, categories) expectation.fulfill() } self.push.registrationDelegate = delegate self.push.onNotificationRegistrationFinished = nil let _ = await self.permissionsManager.requestPermission(.displayNotifications) await self.fulfillment(of: [expectation], timeout: 10.0) } @MainActor func testAuthorizedSettingsDidChangeCallback() async { let expectation = self.expectation(description: "Callback called") self.notificationRegistrar.onCheckStatus = { return (.authorized, [.sound]) } self.push.onNotificationAuthorizedSettingsDidChange = { settings in XCTAssertEqual([.sound], settings) expectation.fulfill() } let _ = await self.permissionsManager.requestPermission(.displayNotifications) await self.fulfillment(of: [expectation], timeout: 10.0) } @MainActor func testAuthorizedSettingsDidChangeDelegateFallback() async { let delegate = TestRegistraitonDelegate() let expectation = self.expectation(description: "Delegate called") self.notificationRegistrar.onCheckStatus = { return (.authorized, [.sound]) } delegate.onNotificationAuthorizedSettingsDidChange = { settings in XCTAssertEqual([.sound], settings) expectation.fulfill() } self.push.registrationDelegate = delegate self.push.onNotificationAuthorizedSettingsDidChange = nil let _ = await self.permissionsManager.requestPermission(.displayNotifications) await self.fulfillment(of: [expectation], timeout: 10.0) } } extension String { var hexData: Data { let chars = Array(self) let bytes = stride(from: 0, to: chars.count, by: 2) .compactMap { UInt8("\(chars[$0])\(chars[$0 + 1])", radix: 16) } return Data(bytes) } } @MainActor class TestPushNotificationDelegate: PushNotificationDelegate { func extendPresentationOptions(_ options: UNNotificationPresentationOptions, notification: UNNotification) async -> UNNotificationPresentationOptions { return self.onExtend?(options, notification) ?? options } var onReceivedForegroundNotification: (([AnyHashable: Any]) -> Void)? var onReceivedBackgroundNotification: (([AnyHashable: Any]) -> UIBackgroundFetchResult)? var onReceivedNotificationResponse: ((UNNotificationResponse) -> Void)? var onExtend: ( (UNNotificationPresentationOptions, UNNotification) -> UNNotificationPresentationOptions )? func receivedForegroundNotification(_ userInfo: [AnyHashable: Any]) async { guard let block = onReceivedForegroundNotification else { return } block(userInfo) } func receivedBackgroundNotification( _ userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { guard let block = onReceivedBackgroundNotification else { return .noData } return block(userInfo) } func receivedNotificationResponse(_ notificationResponse: UNNotificationResponse) async { guard let block = onReceivedNotificationResponse else { return } block(notificationResponse) } } class TestRegistraitonDelegate: NSObject, RegistrationDelegate { func notificationRegistrationFinished(withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, status: UNAuthorizationStatus) {} var onNotificationRegistrationFinished: ( ( AirshipAuthorizedNotificationSettings, Set<UNNotificationCategory>, UNAuthorizationStatus ) -> Void )? var onNotificationAuthorizedSettingsDidChange: ((AirshipAuthorizedNotificationSettings) -> Void)? var onAPNSRegistrationSucceeded: ((Data) -> Void)? var onAPNSRegistrationFailed: ((Error) -> Void)? func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, categories: Set<UNNotificationCategory>, status: UNAuthorizationStatus ) { self.onNotificationRegistrationFinished?( authorizedSettings, categories, status ) } func notificationAuthorizedSettingsDidChange( _ authorizedSettings: AirshipAuthorizedNotificationSettings ) { self.onNotificationAuthorizedSettingsDidChange?(authorizedSettings) } func apnsRegistrationSucceeded(withDeviceToken deviceToken: Data) { self.onAPNSRegistrationSucceeded?(deviceToken) } func apnsRegistrationFailedWithError(_ error: Error) { self.onAPNSRegistrationFailed?(error) } } final class TestNotificationRegistrar: NotificationRegistrar, @unchecked Sendable { var categories: Set<UNNotificationCategory>? var onCheckStatus: (() -> (UNAuthorizationStatus, AirshipAuthorizedNotificationSettings))? var onUpdateRegistration: ((UNAuthorizationOptions, Bool) -> Void)? func setCategories(_ categories: Set<UNNotificationCategory>) { self.categories = categories } func checkStatus() async -> (UNAuthorizationStatus, AirshipAuthorizedNotificationSettings) { guard let callback = self.onCheckStatus else { return(.notDetermined, []) } return callback() } func updateRegistration( options: UNAuthorizationOptions, skipIfEphemeral: Bool ) async -> Void { guard let callback = self.onUpdateRegistration else { return } callback(options, skipIfEphemeral) } } final class TestAPNSRegistrar: APNSRegistrar, @unchecked Sendable { var isRegisteredForRemoteNotifications: Bool = false var isBackgroundRefreshStatusAvailable: Bool = false var isRemoteNotificationBackgroundModeEnabled: Bool = false var registerForRemoteNotificationsCalled: Bool? func registerForRemoteNotifications() { registerForRemoteNotificationsCalled = true } } final class TestBadger: BadgerProtocol, @unchecked Sendable { var applicationIconBadgeNumber: Int = 0 func setBadgeNumber(_ newBadgeNumber: Int) async throws { applicationIconBadgeNumber = newBadgeNumber } var badgeNumber: Int { return applicationIconBadgeNumber } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipTest.swift ================================================ import XCTest @testable import AirshipCore class UAirshipTest: XCTestCase { private var airshipInstance: TestAirshipInstance! private let deepLinkHandler: TestDeepLinkDelegateHandler = TestDeepLinkDelegateHandler() @MainActor override func setUp() { airshipInstance = TestAirshipInstance() self.airshipInstance.makeShared() } override class func tearDown() { TestAirshipInstance.clearShared() } @MainActor func testUAirshipDeepLinks() async { let component = TestAirshipComponent() component.onDeepLink = { _ in XCTFail() return false } let testOpener = (self.airshipInstance.urlOpener as! TestURLOpener) self.airshipInstance.components = [component] /// App settings var result = await Airship.processDeepLink(URL(string: "uairship://app_settings")!) XCTAssertTrue(result) XCTAssertTrue(testOpener.lastOpenSettingsCalled) testOpener.reset() // App Store deeplink result = await Airship.processDeepLink(URL(string: "uairship://app_store?itunesID=0123456789")!) XCTAssertTrue(result) XCTAssertEqual(testOpener.lastURL?.absoluteString, "itms-apps://itunes.apple.com/app/0123456789") } func testUAirshipComponentsDeepLinks() async { let component1 = TestAirshipComponent() component1.onDeepLink = { _ in return false } let component2 = TestAirshipComponent() component2.onDeepLink = { _ in return true } let component3 = TestAirshipComponent() component3.onDeepLink = { _ in XCTFail() return false } self.airshipInstance.components = [component1, component2, component3] self.airshipInstance.deepLinkDelegate = deepLinkHandler let deepLink = URL(string: "uairship://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) XCTAssertEqual(deepLink, component1.deepLink) XCTAssertEqual(deepLink, component2.deepLink) XCTAssertNil(component3.deepLink) XCTAssertNil(self.deepLinkHandler.deepLink) } func testUAirshipComponentsDeepLinksFallbackDelegate() async { let component1 = TestAirshipComponent() component1.onDeepLink = { _ in return false } let component2 = TestAirshipComponent() component2.onDeepLink = { _ in return false } let component3 = TestAirshipComponent() component3.onDeepLink = { _ in return false } self.airshipInstance.components = [component1, component2, component3] self.airshipInstance.deepLinkDelegate = deepLinkHandler let deepLink = URL(string: "uairship://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) XCTAssertEqual(deepLink, self.deepLinkHandler.deepLink) XCTAssertEqual(deepLink, component1.deepLink) XCTAssertEqual(deepLink, component2.deepLink) XCTAssertEqual(deepLink, component3.deepLink) } func testUAirshipComponentsDeepLinksAlwaysReturnsTrue() async { let component1 = TestAirshipComponent() component1.onDeepLink = { _ in return false } let component2 = TestAirshipComponent() component2.onDeepLink = { _ in return false } self.airshipInstance.components = [component1, component2] let deepLink = URL(string: "uairship://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) XCTAssertEqual(deepLink, component1.deepLink) XCTAssertEqual(deepLink, component2.deepLink) } func testDeepLink() async { let component = TestAirshipComponent() component.onDeepLink = { _ in XCTFail() return false } self.airshipInstance.components = [component] let deepLink = URL(string: "some-other://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertFalse(result) XCTAssertNil(component.deepLink) } func testDeepLinkDelegate() async { let component = TestAirshipComponent() component.onDeepLink = { _ in XCTFail() return false } self.airshipInstance.components = [component] self.airshipInstance.deepLinkDelegate = deepLinkHandler let deepLink = URL(string: "some-other://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) XCTAssertNil(component.deepLink) XCTAssertEqual(deepLink, deepLinkHandler.deepLink) } @MainActor func testDeepLinkHandlerReturnsTrue() async { let component = TestAirshipComponent() component.onDeepLink = { _ in XCTFail() return false } var handlerCalled = false self.airshipInstance.onDeepLink = { url in XCTAssertEqual(url.absoluteString, "some-other://some-deep-link") handlerCalled = true } self.airshipInstance.deepLinkDelegate = deepLinkHandler self.airshipInstance.components = [component] let deepLink = URL(string: "some-other://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) XCTAssertTrue(handlerCalled) XCTAssertNil(component.deepLink) XCTAssertNil(deepLinkHandler.deepLink) // Delegate should not be called } @MainActor func testDeepLinkHandlerPreventsDelegate() async { let component = TestAirshipComponent() component.onDeepLink = { _ in XCTFail() return false } var handlerCalled = false self.airshipInstance.onDeepLink = { url in XCTAssertEqual(url.absoluteString, "some-other://some-deep-link") handlerCalled = true } self.airshipInstance.deepLinkDelegate = deepLinkHandler self.airshipInstance.components = [component] let deepLink = URL(string: "some-other://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) XCTAssertTrue(handlerCalled) XCTAssertNil(component.deepLink) XCTAssertNil(deepLinkHandler.deepLink) // Delegate should NOT be called when handler is set } @MainActor func testDeepLinkHandlerWithNoDelegate() async { let component = TestAirshipComponent() component.onDeepLink = { _ in XCTFail() return false } var handlerCalled = false self.airshipInstance.onDeepLink = { url in XCTAssertEqual(url.absoluteString, "some-other://some-deep-link") handlerCalled = true } self.airshipInstance.components = [component] let deepLink = URL(string: "some-other://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) // Should return true since handler is set XCTAssertTrue(handlerCalled) XCTAssertNil(component.deepLink) } @MainActor func testUAirshipDeepLinkHandlerIntercepts() async { let component = TestAirshipComponent() component.onDeepLink = { _ in return false } var handlerCalled = false self.airshipInstance.onDeepLink = { url in XCTAssertEqual(url.absoluteString, "uairship://some-deep-link") handlerCalled = true } self.airshipInstance.deepLinkDelegate = deepLinkHandler self.airshipInstance.components = [component] let deepLink = URL(string: "uairship://some-deep-link")! let result = await Airship.processDeepLink(deepLink) XCTAssertTrue(result) XCTAssertTrue(handlerCalled) XCTAssertEqual(deepLink, component.deepLink) // Component still gets called for uairship:// URLs XCTAssertNil(deepLinkHandler.deepLink) // Delegate should NOT be called when handler is set } } fileprivate class TestAirshipComponent: AirshipComponent, @unchecked Sendable { var onDeepLink: ((URL) -> Bool)? var deepLink: URL? = nil func deepLink(_ deepLink: URL) -> Bool { self.deepLink = deepLink guard let onDeepLink = onDeepLink else { return false } return onDeepLink(deepLink) } } fileprivate class TestDeepLinkDelegateHandler: DeepLinkDelegate, @unchecked Sendable { var deepLink: URL? = nil func receivedDeepLink(_ deepLink: URL) async { self.deepLink = deepLink } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipURLAllowListTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @MainActor class AirshipURLAllowListTest: XCTestCase { private var allowList: DefaultAirshipURLAllowList = DefaultAirshipURLAllowList() private let scopes: [URLAllowListScope] = [.javaScriptInterface, .openURL, .all] func testDefaultURLAllowList() { var airshipConfig = AirshipConfig() airshipConfig.urlAllowListScopeOpenURL = [] allowList = DefaultAirshipURLAllowList(airshipConfig: airshipConfig) for scope in scopes { XCTAssertTrue(allowList.isAllowed(URL(string: "https://device-api.urbanairship.com/api/user/")!, scope: scope)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://dl.urbanairship.com/aaa/message_id")!, scope: scope)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://device-api.asnapieu.com/api/user/")!, scope: scope)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://dl.asnapieu.com/aaa/message_id")!, scope: scope)) } XCTAssertFalse(allowList.isAllowed(URL(string: "https://*.youtube.com")!, scope: .openURL)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://*.youtube.com")!, scope: .javaScriptInterface)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://*.youtube.com")!, scope: .all)) XCTAssertTrue(allowList.isAllowed(URL(string: "sms:+18675309?body=Hi%20you")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "sms:8675309")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "tel:+18675309")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "tel:867-5309")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "mailto:name@example.com?subject=The%20subject%20of%20the%20mail")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "mailto:name@example.com")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: UIApplication.openSettingsURLString)!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "app-settings:")!, scope: .openURL)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://some-random-url.com")!, scope: .openURL)) } @MainActor func testDefaultURLAllowListNoOpenScopeSet() { allowList = DefaultAirshipURLAllowList(airshipConfig: .init()) for scope in scopes { XCTAssertTrue(allowList.isAllowed(URL(string: "https://device-api.urbanairship.com/api/user/")!, scope: scope)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://dl.urbanairship.com/aaa/message_id")!, scope: scope)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://device-api.asnapieu.com/api/user/")!, scope: scope)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://dl.asnapieu.com/aaa/message_id")!, scope: scope)) } XCTAssertTrue(allowList.isAllowed(URL(string: "https://*.youtube.com")!, scope: .openURL)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://*.youtube.com")!, scope: .javaScriptInterface)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://*.youtube.com")!, scope: .all)) XCTAssertTrue(allowList.isAllowed(URL(string: "sms:+18675309?body=Hi%20you")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "sms:8675309")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "tel:+18675309")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "tel:867-5309")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "mailto:name@example.com?subject=The%20subject%20of%20the%20mail")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "mailto:name@example.com")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: UIApplication.openSettingsURLString)!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "app-settings:")!, scope: .openURL)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://some-random-url.com")!, scope: .openURL)) } func testInvalidPatterns() { // Not a URL XCTAssertFalse(allowList.addEntry("not a url")) // Missing schemes XCTAssertFalse(allowList.addEntry("www.urbanairship.com")) XCTAssertFalse(allowList.addEntry("://www.urbanairship.com")) // White space in scheme XCTAssertFalse(allowList.addEntry(" file://*")) // Invalid hosts XCTAssertFalse(allowList.addEntry("*://what*")) XCTAssertFalse(allowList.addEntry("*://*what")) } func testSchemeWildcard() { allowList.addEntry("*://www.urbanairship.com") XCTAssertTrue(allowList.addEntry("*://www.urbanairship.com")) XCTAssertTrue(allowList.addEntry("cool*story://rad")) // Reject XCTAssertFalse(allowList.isAllowed(URL(string: ""))) XCTAssertFalse(allowList.isAllowed(URL(string: "urbanairship.com")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "www.urbanairship.com")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "cool://rad")!)) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "https://www.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "http://www.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "file://www.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "valid://www.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "cool----story://rad")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "coolstory://rad")!)) } func testScheme() { allowList.addEntry("https://www.urbanairship.com") allowList.addEntry("file:///asset.html") // Reject XCTAssertFalse(allowList.isAllowed(URL(string: "http://www.urbanairship.com")!)) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "https://www.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "file:///asset.html")!)) } func testHost() { XCTAssertTrue(allowList.addEntry("http://www.urbanairship.com")) XCTAssertTrue(allowList.addEntry("http://oh.hi.marc")) // Reject XCTAssertFalse(allowList.isAllowed(URL(string: "http://oh.bye.marc")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "http://www.urbanairship.com.hackers.io")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "http://omg.www.urbanairship.com.hackers.io")!)) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "http://www.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "http://oh.hi.marc")!)) } func testHostWildcard() { XCTAssertTrue(allowList.addEntry("http://*")) XCTAssertTrue(allowList.addEntry("https://*.coolstory")) // * is only available at the beginning XCTAssertFalse(allowList.addEntry("https://*.coolstory.*")) // Reject XCTAssertFalse(allowList.isAllowed(URL(string: ""))) XCTAssertFalse(allowList.isAllowed(URL(string: "https://cool")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://story")!)) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "http://what.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "http:///android-asset/test.html")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "http://www.anything.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://coolstory")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://what.coolstory")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://what.what.coolstory")!)) } func testHostWildcardSubdomain() { XCTAssertTrue(allowList.addEntry("http://*.urbanairship.com")) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "http://what.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "http://hi.urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "http://urbanairship.com")!)) // Reject XCTAssertFalse(allowList.isAllowed(URL(string: "http://lololurbanairship.com")!)) } func testWildcardMatcher() { XCTAssertTrue(allowList.addEntry("*")) XCTAssertTrue(allowList.isAllowed(URL(string: "file:///what/oh/hi")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://hi.urbanairship.com/path")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "http://urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "cool.story://urbanairship.com")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "sms:+18664504185?body=Hi")!)) } func testFilePaths() { XCTAssertTrue(allowList.addEntry("file:///foo/index.html")) // Reject XCTAssertFalse(allowList.isAllowed(URL(string: "file:///foo/test.html")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "file:///foo/bar/index.html")!)) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "file:///foo/index.html")!)) } func testFilePathsWildCard() { XCTAssertTrue(allowList.addEntry("file:///foo/bar.html")) XCTAssertTrue(allowList.addEntry("file:///foo/*")) // Reject XCTAssertFalse(allowList.isAllowed(URL(string: "file:///foooooooo/bar.html")!)) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "file:///foo/test.html")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "file:///foo/bar/index.html")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "file:///foo/bar.html")!)) } func testURLPaths() { allowList.addEntry("*://*.urbanairship.com/accept.html") allowList.addEntry("*://*.urbanairship.com/anythingHTML/*.html") allowList.addEntry("https://urbanairship.com/what/index.html") allowList.addEntry("wild://cool/*") // Reject XCTAssertFalse(allowList.isAllowed(URL(string: "https://what.urbanairship.com/reject.html")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://what.urbanairship.com/anythingHTML/image.png")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://what.urbanairship.com/anythingHTML/image.png")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "wile:///whatever")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "wile:///cool")!)) // Accept XCTAssertTrue(allowList.isAllowed(URL(string: "https://what.urbanairship.com/anythingHTML/index.html")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://what.urbanairship.com/anythingHTML/test.html")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://what.urbanairship.com/anythingHTML/foo/bar/index.html")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/what/index.html")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "wild://cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "wild://cool/")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "wild://cool/path")!)) } func testScope() { allowList.addEntry("*://*.urbanairship.com/accept-js.html", scope: .javaScriptInterface) allowList.addEntry("*://*.urbanairship.com/accept-url.html", scope: .openURL) allowList.addEntry("*://*.urbanairship.com/accept-all.html", scope: .all) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-js.html")!, scope: .javaScriptInterface)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-js.html")!, scope: .openURL)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-js.html")!, scope: .all)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-url.html")!, scope: .openURL)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-url.html")!, scope: .javaScriptInterface)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-url.html")!, scope: .all)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-all.html")!, scope: .all)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-all.html")!, scope: .javaScriptInterface)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/accept-all.html")!, scope: .openURL)) } func testDisableOpenURLScopeAllowList() { XCTAssertFalse(allowList.isAllowed(URL(string: "https://someurl.com")!, scope: .openURL)) allowList.addEntry("*", scope: .openURL) XCTAssertTrue(allowList.isAllowed(URL(string: "https://someurl.com")!, scope: .openURL)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://someurl.com")!, scope: .javaScriptInterface)) XCTAssertFalse(allowList.isAllowed(URL(string: "https://someurl.com")!, scope: .all)) } func testAddAllScopesSeparately() { allowList.addEntry("*://*.urbanairship.com/all.html", scope: .openURL) allowList.addEntry("*://*.urbanairship.com/all.html", scope: .javaScriptInterface) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/all.html")!, scope: .all)) } func testAllScopeMatchesInnerScopes() { allowList.addEntry("*://*.urbanairship.com/all.html", scope: .all) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/all.html")!, scope: .javaScriptInterface)) XCTAssertTrue(allowList.isAllowed(URL(string: "https://urbanairship.com/all.html")!, scope: .openURL)) } func testDeepLinks() { // Test any path and undefined host XCTAssertTrue(allowList.addEntry("com.urbanairship.one:/*")) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.one://cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.one:cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.one:/cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.one:///cool")!)) // Test any host and undefined path XCTAssertTrue(allowList.addEntry("com.urbanairship.two://*")) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.two:cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.two://cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.two:/cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.two:///cool")!)) // Test any host and any path XCTAssertTrue(allowList.addEntry("com.urbanairship.three://*/*")) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.three:cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.three://cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.three:/cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.three:///cool")!)) // Test specific host and path XCTAssertTrue(allowList.addEntry("com.urbanairship.four://*.cool/whatever/*")) XCTAssertFalse(allowList.isAllowed(URL(string: "com.urbanairship.four:cool")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "com.urbanairship.four://cool")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "com.urbanairship.four:/cool")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "com.urbanairship.four:///cool")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.four://whatever.cool/whatever/")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.four://cool/whatever/indeed")!)) } func testRootPath() { XCTAssertTrue(allowList.addEntry("com.urbanairship.five:/")) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.five:/")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "com.urbanairship.five:///")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "com.urbanairship.five:/cool")!)) } func testDelegate() { // set up a simple URL allow list allowList.addEntry("https://*.urbanairship.com") allowList.addEntry("https://*.youtube.com", scope: .openURL) // Matching URL to be checked let matchingURLToReject = URL(string: "https://www.youtube.com/watch?v=sYd_-pAfbBw")! let matchingURLToAccept = URL(string: "https://device-api.urbanairship.com/api/user")! let nonMatchingURL = URL(string: "https://maps.google.com")! let scope: URLAllowListScope = .openURL // Allow listing when delegate is off XCTAssertTrue(allowList.isAllowed(matchingURLToReject, scope: scope)) XCTAssertTrue(allowList.isAllowed(matchingURLToAccept, scope: scope)) XCTAssertFalse(allowList.isAllowed(nonMatchingURL, scope: scope)) // Enable URL allow list delegate let delegate = TestDelegate() delegate.onAllow = { url, scope in if (url == matchingURLToAccept) { return true } if (url == matchingURLToReject) { return false } XCTFail() return false } allowList.delegate = delegate // rejected URL should now fail URL allow list test, others should be unchanged XCTAssertFalse(allowList.isAllowed(matchingURLToReject, scope: scope)) XCTAssertTrue(allowList.isAllowed(matchingURLToAccept, scope: scope)) XCTAssertFalse(allowList.isAllowed(nonMatchingURL, scope: scope)) // Disable URL allow list delegate allowList.delegate = nil // Should go back to original state when delegate was off XCTAssertTrue(allowList.isAllowed(matchingURLToReject, scope: scope)) XCTAssertTrue(allowList.isAllowed(matchingURLToAccept, scope: scope)) XCTAssertFalse(allowList.isAllowed(nonMatchingURL, scope: scope)) } func testOnAllowBlock() { // set up a simple URL allow list allowList.addEntry("https://*.urbanairship.com") allowList.addEntry("https://*.youtube.com", scope: .openURL) // Matching URL to be checked let matchingURLToReject = URL(string: "https://www.youtube.com/watch?v=sYd_-pAfbBw")! let matchingURLToAccept = URL(string: "https://device-api.urbanairship.com/api/user")! let nonMatchingURL = URL(string: "https://maps.google.com")! let scope: URLAllowListScope = .openURL // Allow listing when delegate is off XCTAssertTrue(allowList.isAllowed(matchingURLToReject, scope: scope)) XCTAssertTrue(allowList.isAllowed(matchingURLToAccept, scope: scope)) XCTAssertFalse(allowList.isAllowed(nonMatchingURL, scope: scope)) // Delegate should be ignored let delegate = TestDelegate() delegate.onAllow = { url, scope in XCTFail() return false } allowList.delegate = delegate allowList.onAllowURL = { url, scope in if (url == matchingURLToAccept) { return true } if (url == matchingURLToReject) { return false } XCTFail() return false } // rejected URL should now fail URL allow list test, others should be unchanged XCTAssertFalse(allowList.isAllowed(matchingURLToReject, scope: scope)) XCTAssertTrue(allowList.isAllowed(matchingURLToAccept, scope: scope)) XCTAssertFalse(allowList.isAllowed(nonMatchingURL, scope: scope)) // Disable URL allow list delegate allowList.delegate = nil allowList.onAllowURL = nil // Should go back to original state when delegate was off XCTAssertTrue(allowList.isAllowed(matchingURLToReject, scope: scope)) XCTAssertTrue(allowList.isAllowed(matchingURLToAccept, scope: scope)) XCTAssertFalse(allowList.isAllowed(nonMatchingURL, scope: scope)) } func testSMSPath() { XCTAssertTrue(allowList.addEntry("sms:86753*9*")) XCTAssertFalse(allowList.isAllowed(URL(string: "sms:86753")!)) XCTAssertFalse(allowList.isAllowed(URL(string: "sms:867530")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "sms:86753191")!)) XCTAssertTrue(allowList.isAllowed(URL(string: "sms:8675309")!)) } } fileprivate final class TestDelegate: URLAllowListDelegate, @unchecked Sendable { var allowURLCalled = false var onAllow: ((URL, URLAllowListScope) -> Bool)? func allowURL(_ url: URL, scope: URLAllowListScope) -> Bool { allowURLCalled = true return onAllow!(url, scope) } } ================================================ FILE: Airship/AirshipCore/Tests/AirshipUtilsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AirshipUtilsTest: XCTestCase { func testSignedToken() throws { XCTAssertEqual( "VWtkZq18HZM3GWzD/q27qPSVszysSyoQfQ6tDEAcAko=", try! AirshipUtils.generateSignedToken(secret: "appSecret", tokenParams: ["appKey", "some channel"]) ) XCTAssertEqual( "Npyqy5OZxMEVv4bt64S3aUE4NwUQVLX50vGrEegohFE=", try! AirshipUtils.generateSignedToken(secret: "test-app-secret", tokenParams: ["test-app-key", "channel ID"]) ) } func testIsSilentPush() { let emptyNotification: [String: Any] = [ "aps": [ "content-available": 1 ] ] let emptyAlert: [String: Any] = [ "aps": [ "alert": "" ] ] let emptyLocKey: [String: Any] = [ "aps": [ "alert": [ "loc-key": "" ] ] ] let emptyBody: [String: Any] = [ "aps": [ "alert": [ "body": "" ] ] ] XCTAssertTrue(AirshipUtils.isSilentPush(emptyNotification)) XCTAssertTrue(AirshipUtils.isSilentPush(emptyAlert)) XCTAssertTrue(AirshipUtils.isSilentPush(emptyLocKey)) XCTAssertTrue(AirshipUtils.isSilentPush(emptyBody)) } func testIsSilentPushNo() { let alertNotification: [String: Any] = [ "aps": [ "alert": "hello world" ] ] let badgeNotification: [String: Any] = [ "aps": [ "badge": 2 ] ] let soundNotification: [String: Any] = [ "aps": [ "sound": "cat" ] ] let notification: [String: Any] = [ "aps": [ "alert": "hello world", "badge": 2, "sound": "cat" ] ] let locKeyNotification: [String: Any] = [ "aps": [ "alert": [ "loc-key": "cool" ] ] ] let bodyNotification: [String: Any] = [ "aps": [ "alert": [ "body": "cool" ] ] ] XCTAssertFalse(AirshipUtils.isSilentPush(alertNotification)) XCTAssertFalse(AirshipUtils.isSilentPush(badgeNotification)) XCTAssertFalse(AirshipUtils.isSilentPush(soundNotification)) XCTAssertFalse(AirshipUtils.isSilentPush(notification)) XCTAssertFalse(AirshipUtils.isSilentPush(locKeyNotification)) XCTAssertFalse(AirshipUtils.isSilentPush(bodyNotification)) } func testIsAlertingPush() { let alertNotification: [String: Any] = [ "aps": [ "alert": "hello world" ] ] let notification: [String: Any] = [ "aps": [ "alert": "hello world", "badge": 2, "sound": "cat" ] ] let locKeyNotification: [String: Any] = [ "aps": [ "alert": [ "loc-key": "cool" ] ] ] let bodyNotification: [String: Any] = [ "aps": [ "alert": [ "body": "cool" ] ] ] XCTAssertTrue(AirshipUtils.isAlertingPush(alertNotification)) XCTAssertTrue(AirshipUtils.isAlertingPush(notification)) XCTAssertTrue(AirshipUtils.isAlertingPush(locKeyNotification)) XCTAssertTrue(AirshipUtils.isAlertingPush(bodyNotification)) } func testIsAlertingPushNo() { let emptyNotification: [String: Any] = [ "aps": [ "content-available": 1 ] ] let emptyAlert: [String: Any] = [ "aps": [ "alert": "" ] ] let emptyLocKey: [String: Any] = [ "aps": [ "alert": [ "loc-key": "" ] ] ] let emptyBody: [String: Any] = [ "aps": [ "alert": [ "body": "" ] ] ] let badgeNotification: [String: Any] = [ "aps": [ "badge": 2 ] ] let soundNotification: [String: Any] = [ "aps": [ "sound": "cat" ] ] XCTAssertFalse(AirshipUtils.isAlertingPush(emptyNotification)) XCTAssertFalse(AirshipUtils.isAlertingPush(emptyAlert)) XCTAssertFalse(AirshipUtils.isAlertingPush(emptyLocKey)) XCTAssertFalse(AirshipUtils.isAlertingPush(emptyBody)) XCTAssertFalse(AirshipUtils.isAlertingPush(badgeNotification)) XCTAssertFalse(AirshipUtils.isAlertingPush(soundNotification)) } func testParseURL() { var originalUrl = "https://advswift.com/api/v1?page=url+components" var url = AirshipUtils.parseURL(originalUrl) XCTAssertNotNil(url) XCTAssertEqual(originalUrl, url?.absoluteString) originalUrl = "rtlmost://szakaszó.com/main/típus/v1?page=azonosító" url = AirshipUtils.parseURL(originalUrl) XCTAssertNotNil(url) if #available(iOS 17.0, tvOS 17.0, *) { let encodedUrl = "rtlmost://xn--szakasz-r0a.com/main/t%C3%ADpus/v1?page=azonos%C3%ADt%C3%B3" XCTAssertEqual(encodedUrl, url?.absoluteString) } else { let encodedUrl = "rtlmost://szakasz%C3%B3.com/main/t%C3%ADpus/v1?page=azonos%C3%ADt%C3%B3" XCTAssertEqual(encodedUrl, url?.absoluteString) } } } ================================================ FILE: Airship/AirshipCore/Tests/AishipFontTests.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore import SwiftUI #if canImport(UIKit) import UIKit #elseif canImport(AppKit) import AppKit #endif @Suite struct AirshipFontTests { @Test func testResolveFontFamily() { // Serif -> Times New Roman #if canImport(UIKit) || canImport(AppKit) let serif = AirshipFont.resolveFontFamily(families: ["serif"]) #if os(macOS) // Times New Roman might vary by OS version but usually present // If not present, it might fail, but let's assume standard env if let serif = serif { #expect(serif == "Times New Roman") } #else #expect(serif == "Times New Roman") #endif // Sans-serif -> nil (system) let sans = AirshipFont.resolveFontFamily(families: ["sans-serif"]) #expect(sans == nil) // Existing font (Helvetica is standard on Apple platforms) let helvetica = AirshipFont.resolveFontFamily(families: ["Helvetica"]) #expect(helvetica == "Helvetica") // Fallback let fallback = AirshipFont.resolveFontFamily(families: ["NonExistentFont", "Helvetica"]) #expect(fallback == "Helvetica") // Non-existent let none = AirshipFont.resolveFontFamily(families: ["NonExistentFontSomething123"]) #expect(none == nil) // Empty/Nil #expect(AirshipFont.resolveFontFamily(families: []) == nil) #expect(AirshipFont.resolveFontFamily(families: nil) == nil) #endif } @Test @MainActor func testResolveNativeFont() { #if canImport(UIKit) let size = 20.0 let expectedScaledSize = CGFloat(AirshipFont.scaledSize(size)) // System Font let systemFont = AirshipFont.resolveNativeFont(size: size) // Note: scaledSize might make it larger or smaller than 20.0 depending on dynamic type settings #expect(abs(systemFont.pointSize - expectedScaledSize) <= 0.5) // Family let helvetica = AirshipFont.resolveNativeFont(size: size, families: ["Helvetica"]) #expect(helvetica.familyName == "Helvetica") // Italic let italic = AirshipFont.resolveNativeFont(size: size, isItalic: true) #expect(italic.fontDescriptor.symbolicTraits.contains(.traitItalic)) // Bold let bold = AirshipFont.resolveNativeFont(size: size, isBold: true) // .traitBold check #expect(bold.fontDescriptor.symbolicTraits.contains(.traitBold)) // Specific Weight let heavy = AirshipFont.resolveNativeFont(size: size, weight: 800) // Hard to check exact weight value easily cross-version, but we can verify it returns a font #expect(abs(heavy.pointSize - expectedScaledSize) <= 0.5) #elseif canImport(AppKit) let size = 20.0 // System Font let systemFont = AirshipFont.resolveNativeFont(size: size) #expect(systemFont.pointSize == size) // macOS usually doesn't scale by default like iOS dynamic type in this context unless specified? // Logic: scaledSize returns size on macOS // Family let helvetica = AirshipFont.resolveNativeFont(size: size, families: ["Helvetica"]) #expect(helvetica.familyName == "Helvetica") // Italic let italic = AirshipFont.resolveNativeFont(size: size, isItalic: true) #expect(italic.fontDescriptor.symbolicTraits.contains(.italic)) // Bold // Note: NSFont behavior with traits might vary, but basic system bold should work let bold = AirshipFont.resolveNativeFont(size: size, isBold: true) // Testing exact traits on macOS can be tricky with NSFontDescriptor, but let's try #expect(bold.fontDescriptor.symbolicTraits.contains(.bold)) // .bold is unavailable on some older OS versions? No, .bold is standard in NSFontDescriptor.SymbolicTraits #endif } @Test @MainActor func testResolveSwiftUIFont() { // Just verify it doesn't crash and returns a Font let font = AirshipFont.resolveFont(size: 16, families: ["serif"], weight: 400, isItalic: true, isBold: false) // Validating SwiftUI Font contents is not possible via public API, so existence is the test. _ = font } @Test func testScaledSize() { let size = 10.0 let scaled = AirshipFont.scaledSize(size) #if os(macOS) #expect(scaled == size) #else // iOS scales based on settings. Assuming default, it might be close to size or larger. // We just ensure it's not zero. #expect(scaled > 0) #endif } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutButtonTapEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutButtonTapEventTest { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.ButtonTapEvent( identifier: "button id", reportingMetadata: "reporting metadata" ) let event = ThomasLayoutButtonTapEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_button_tap") let expectedJSON = """ { "reporting_metadata":"reporting metadata", "button_identifier":"button id" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutDisplayEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutDisplayEventTest { @Test func testEvent() throws { let event = ThomasLayoutDisplayEvent() #expect(event.name.reportingName == "in_app_display") #expect(event.data == nil) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutEventTestUtils.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipCore extension ThomasLayoutEvent { var bodyJSON: AirshipJSON { get throws { guard let data = self.data else { return AirshipJSON.null } return try AirshipJSON.wrap(EventBody(data: data)) } } } fileprivate struct EventBody: Encodable { var data: (any Encodable&Sendable)? func encode(to encoder: Encoder) throws { try data?.encode(to: encoder) } } extension AirshipJSON { func log() { let string = try! self.toString() print("\(string)") } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutFormDisplayEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutFormDisplayEventTest { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.FormDisplayEvent( identifier: "form id", formType: "nps", responseType: "user feedback" ) let event = ThomasLayoutFormDisplayEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_form_display") let expectedJSON = """ { "form_identifier":"form id", "form_type":"nps", "form_response_type":"user feedback" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutFormResultEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutFormResultEventTest { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.FormResultEvent( forms: "form result" ) let event = ThomasLayoutFormResultEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_form_result") let expectedJSON = """ { "forms": "form result" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutGestureEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutGestureEventTest { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.GestureEvent( identifier: "gesture id", reportingMetadata: "reporting metadata" ) let event = ThomasLayoutGestureEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_gesture") let expectedJSON = """ { "reporting_metadata":"reporting metadata", "gesture_identifier":"gesture id" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutPageActionEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutPageActionEventTest { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.PageActionEvent( identifier: "action id", reportingMetadata: "reporting metadata" ) let event = ThomasLayoutPageActionEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_page_action") let expectedJSON = """ { "reporting_metadata":"reporting metadata", "action_identifier":"action id" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutPageSwipeEventAction.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutPageSwipeEventAction { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.PageSwipeEvent( identifier: "pager identifier", toPageIndex: 4, toPageIdentifier: "to page identifier", fromPageIndex: 3, fromPageIdentifier: "from page identifier" ) let event = ThomasLayoutPageSwipeEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_page_swipe") let expectedJSON = """ { "pager_identifier":"pager identifier", "from_page_index":3, "to_page_identifier":"to page identifier", "from_page_identifier":"from page identifier", "to_page_index":4 } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutPageViewEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutPageViewEventTest { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.PageViewEvent( identifier: "pager identifier", pageIdentifier: "page identifier", pageIndex: 3, pageViewCount: 31, pageCount: 12, completed: false ) let event = ThomasLayoutPageViewEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_page_view") let expectedJSON = """ { "page_identifier":"page identifier", "page_index":3, "viewed_count":31, "page_count":12, "pager_identifier":"pager identifier", "completed":false } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutPagerCompletedEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutPagerCompletedEventTest { @Test func testEvent() throws { let thomasEvent = ThomasReportingEvent.PagerCompletedEvent( identifier: "pager identifier", pageIndex: 3, pageCount: 12, pageIdentifier: "page identifier" ) let event = ThomasLayoutPagerCompletedEvent(data: thomasEvent) #expect(event.name.reportingName == "in_app_pager_completed") let expectedJSON = """ { "page_count":12, "pager_identifier":"pager identifier", "page_index":3, "page_identifier":"page identifier" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutPagerSummaryEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutPagerSummaryEventTest { func testEvent() throws { let event = ThomasLayoutPagerSummaryEvent( data: .init( identifier: "pager identifier", viewedPages: [ .init( identifier: "page 1", index: 0, displayTime: 10.4 ), .init( identifier: "page 2", index: 1, displayTime: 3.0 ), .init( identifier: "page 3", index: 2, displayTime: 4.0 ) ], pageCount: 12, completed: false ) ) #expect(event.name.reportingName == "in_app_pager_summary") let expectedJSON = """ { "viewed_pages":[ { "display_time":"10.40", "page_identifier":"page 1", "page_index":0 }, { "page_index":1, "display_time":"3.00", "page_identifier":"page 2" }, { "page_identifier":"page 3", "page_index":2, "display_time":"4.00" } ], "page_count":12, "completed":false, "pager_identifier":"pager identifier" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutPermissionResultEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutPermissionResultEventTest { @Test func testEvent() throws { let event = ThomasLayoutPermissionResultEvent( permission: .displayNotifications, startingStatus: .denied, endingStatus: .granted ) #expect(event.name.reportingName == "in_app_permission_result") let expectedJSON = """ { "permission":"display_notifications", "starting_permission_status":"denied", "ending_permission_status":"granted" } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/Events/ThomasLayoutResolutionEventTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ThomasLayoutResolutionEventTest { @Test func testButtonResolution() throws { let event = ThomasLayoutResolutionEvent.buttonTap( identifier: "button id", description: "button description", displayTime: 100.0 ) #expect(event.name.reportingName == "in_app_resolution") let expectedJSON = """ { "resolution": { "display_time":"100.00", "button_description":"button description", "type":"button_click", "button_id":"button id" } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } @Test func testMessageTap() throws { let event = ThomasLayoutResolutionEvent.messageTap(displayTime: 100.0) #expect(event.name.reportingName == "in_app_resolution") let expectedJSON = """ { "resolution": { "display_time":"100.00", "type":"message_click" } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } @Test func testUserDismissed() throws { let event = ThomasLayoutResolutionEvent.userDismissed(displayTime: 100.0) #expect(event.name.reportingName == "in_app_resolution") let expectedJSON = """ { "resolution": { "display_time":"100.00", "type":"user_dismissed" } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } @Test func testTimedOut() throws { let event = ThomasLayoutResolutionEvent.timedOut(displayTime: 100.0) #expect(event.name.reportingName == "in_app_resolution") let expectedJSON = """ { "resolution": { "display_time":"100.00", "type":"timed_out" } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } @Test func testControl() throws { let experimentResult = ExperimentResult( channelID: "channel id", contactID: "contact id", isMatch: true, reportingMetadata: [AirshipJSON.string("reporting")] ) let event = ThomasLayoutResolutionEvent.control(experimentResult: experimentResult) #expect(event.name.reportingName == "in_app_resolution") let expectedJSON = """ { "resolution": { "display_time":"0.00", "type":"control" }, "device": { "channel_id": "channel id", "contact_id": "contact id" } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } @Test func testAudienceExcluded() throws { let event = ThomasLayoutResolutionEvent.audienceExcluded() #expect(event.name.reportingName == "in_app_resolution") let expectedJSON = """ { "resolution": { "display_time":"0.00", "type":"audience_check_excluded" } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try event.bodyJSON #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/ThomasDisplayListenerTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore @MainActor struct ThomasDisplayListenerTest { private let analytics = TestThomasLayoutMessageAnalytics() private let listener: ThomasDisplayListener private let result: AirshipMainActorValue<ThomasDisplayListener.DisplayResult?> = AirshipMainActorValue(nil) private let layoutContext: ThomasLayoutContext = ThomasLayoutContext( pager: nil, button: .init(identifier: "button"), form: nil ) init() { self.listener = ThomasDisplayListener( analytics: analytics ) { [result] displayResult in result.set(displayResult) } } @Test func testDismiss() { listener.onDismissed(cancel: false) #expect(result.value == .finished) } @Test func testDismissAndCancel() { listener.onDismissed(cancel: true) #expect(result.value == .cancel) } @Test("Visibility changes emits display event") func testVisibilityChangesEmitsDisplayEvent() throws { listener.onVisibilityChanged(isVisible: true, isForegrounded: true) try verifyEvents([(ThomasLayoutDisplayEvent(), nil)]) listener.onVisibilityChanged(isVisible: false, isForegrounded: false) try verifyEvents([(ThomasLayoutDisplayEvent(), nil)]) listener.onVisibilityChanged(isVisible: true, isForegrounded: false) try verifyEvents([(ThomasLayoutDisplayEvent(), nil)]) listener.onVisibilityChanged(isVisible: false, isForegrounded: true) try verifyEvents([(ThomasLayoutDisplayEvent(), nil)]) listener.onVisibilityChanged(isVisible: true, isForegrounded: true) try verifyEvents([(ThomasLayoutDisplayEvent(), nil), (ThomasLayoutDisplayEvent(), nil)]) } @Test func testButtonTapEvent() throws { let thomasEvent = ThomasReportingEvent.ButtonTapEvent( identifier: "button id", reportingMetadata: "some metadata" ) listener.onReportingEvent(.buttonTap(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutButtonTapEvent( data: thomasEvent ), self.layoutContext ) ] ) } @Test func testFormDisplayedEvent() throws { let thomasEvent = ThomasReportingEvent.FormDisplayEvent( identifier: "form id", formType: "some type" ) listener.onReportingEvent(.formDisplay(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutFormDisplayEvent(data: thomasEvent), self.layoutContext ) ] ) } @Test func testFormResultEvent() throws { let thomasEvent = ThomasReportingEvent.FormResultEvent( forms: try! AirshipJSON.wrap(["form": "result"]) ) listener.onReportingEvent(.formResult(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutFormResultEvent(data: thomasEvent), self.layoutContext ) ] ) } @Test func testGestureEvent() throws { let thomasEvent = ThomasReportingEvent.GestureEvent( identifier: "gesture id", reportingMetadata: "some metadata" ) listener.onReportingEvent(.gesture(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutGestureEvent(data: thomasEvent), self.layoutContext ) ] ) } @Test func testPageActionEvent() throws { let thomasEvent = ThomasReportingEvent.PageActionEvent( identifier: "page id", reportingMetadata: "some metadata" ) listener.onReportingEvent(.pageAction(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutPageActionEvent(data: thomasEvent), self.layoutContext ) ] ) } @Test func testPagerCompletedEvent() throws { let thomasEvent = ThomasReportingEvent.PagerCompletedEvent( identifier: "pager id", pageIndex: 3, pageCount: 3, pageIdentifier: "page id" ) listener.onReportingEvent(.pagerCompleted(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutPagerCompletedEvent(data: thomasEvent), self.layoutContext ) ] ) } @Test func testPageSwipeEvent() throws { let thomasEvent = ThomasReportingEvent.PageSwipeEvent( identifier: "pager id", toPageIndex: 4, toPageIdentifier: "to page id", fromPageIndex: 3, fromPageIdentifier: "from page id" ) listener.onReportingEvent(.pageSwipe(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutPageSwipeEvent(data: thomasEvent), self.layoutContext ) ] ) } @Test func testPageViewEvent() throws { let thomasEvent = ThomasReportingEvent.PageViewEvent( identifier: "pager id", pageIdentifier: "page id", pageIndex: 1, pageViewCount: 1, pageCount: 3, completed: true ) listener.onReportingEvent(.pageView(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutPageViewEvent(data: thomasEvent), self.layoutContext ) ] ) } @Test func testPagerSummaryEvent() throws { let thomasEvent = ThomasReportingEvent.PagerSummaryEvent( identifier: "pager id", viewedPages: [ .init(identifier: "foo", index: 1, displayTime: 10), .init(identifier: "bar", index: 2, displayTime: 10), ], pageCount: 3, completed: true ) listener.onReportingEvent(.pagerSummary(thomasEvent, layoutContext)) try verifyEvents( [ ( ThomasLayoutPagerSummaryEvent(data: thomasEvent), self.layoutContext ) ] ) } @MainActor func testUserDismissedEvent() throws { listener.onReportingEvent( ThomasReportingEvent.dismiss(.userDismissed, 10, layoutContext) ) try verifyEvents( [(ThomasLayoutResolutionEvent.userDismissed(displayTime: 10), layoutContext)] ) } @MainActor func testTimedOUtEvent() throws { listener.onReportingEvent( ThomasReportingEvent.dismiss(.timedOut, 10, layoutContext) ) try verifyEvents( [(ThomasLayoutResolutionEvent.timedOut(displayTime: 10), layoutContext)] ) } @MainActor func testDismissedEvent() throws { listener.onReportingEvent( ThomasReportingEvent.dismiss( .buttonTapped( identifier: "button id", description: "button description" ), 10, layoutContext ) ) try verifyEvents( [ ( ThomasLayoutResolutionEvent.buttonTap( identifier: "button id", description: "button description", displayTime: 10), layoutContext ) ] ) } private func verifyEvents( _ expected: [(ThomasLayoutEvent, ThomasLayoutContext?)], sourceLocation: SourceLocation = #_sourceLocation ) throws { #expect(expected.count == self.analytics.events.count, sourceLocation: sourceLocation) try expected.indices.forEach { index in let expectedEvent = expected[index] let actual = analytics.events[index] #expect(actual.0.name == expectedEvent.0.name, sourceLocation: sourceLocation) let actualData = try AirshipJSON.wrap(actual.0.data) let expectedData = try AirshipJSON.wrap(expectedEvent.0.data) #expect(actualData == expectedData, sourceLocation: sourceLocation) #expect(actual.1 == expectedEvent.1, sourceLocation: sourceLocation) } } } final class TestThomasLayoutMessageAnalytics: ThomasLayoutMessageAnalyticsProtocol, @unchecked Sendable { var events: [(ThomasLayoutEvent, ThomasLayoutContext?)] = [] var impressionsRecored: UInt = 0 func recordEvent(_ event: ThomasLayoutEvent, layoutContext: ThomasLayoutContext?) { events.append((event, layoutContext)) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/ThomasLayoutEventContextTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore struct ThomasLayoutEventContextTest { private let campaigns = try! AirshipJSON.wrap( ["campaign1": "data1", "campaign2": "data2"] ) private let scheduleID = UUID().uuidString @Test func testJSON() async throws { let context = ThomasLayoutEventContext( pager: ThomasLayoutContext.Pager( identifier: "pager id", pageIdentifier: "page id", pageIndex: 1, completed: true, count: 2, pageHistory: [.init(identifier: "foo-0", index: 0, displayTime: 20)] ), button: ThomasLayoutContext.Button( identifier: "button id" ), form: ThomasLayoutContext.Form( identifier: "form id", submitted: true, type: "form type" ), display: .init( triggerSessionID: "trigger session id", isFirstDisplay: false, isFirstDisplayTriggerSessionID: true ), reportingContext: "reporting context", experimentsReportingData: [ "experiment result 1", "experiment result 2" ] ) let expectedJSON = """ { "reporting_context":"reporting context", "form":{ "type":"form type", "identifier":"form id", "submitted":true }, "button":{ "identifier":"button id" }, "pager":{ "page_identifier":"page id", "page_index":1, "identifier":"pager id", "completed":true, "count":2, "page_history": [ { "page_identifier": "foo-0", "page_index": 0, "display_time": "20.00" } ] }, "experiments":[ "experiment result 1", "experiment result 2" ], "display":{ "trigger_session_id":"trigger session id", "is_first_display":false, "is_first_display_trigger_session":true } } """ let string = try AirshipJSON.wrap(context).toString() AirshipLogger.error("\(string)") let expected = try AirshipJSON.from(json: expectedJSON) let actual = try AirshipJSON.wrap(context) #expect(actual == expected) } @Test func testMake() throws { let experimentResult = ExperimentResult( channelID: "some channel", contactID: "some contact", isMatch: true, reportingMetadata: [AirshipJSON.string("some reporting")] ) let reportingMetadata = AirshipJSON.string("reporting info") let thomasLayoutContext = ThomasLayoutContext( pager: ThomasLayoutContext.Pager( identifier: UUID().uuidString, pageIdentifier: UUID().uuidString, pageIndex: 1, completed: false, count: 2 ), button: ThomasLayoutContext.Button(identifier: UUID().uuidString), form: ThomasLayoutContext.Form( identifier: UUID().uuidString, submitted: false, type: UUID().uuidString, responseType: UUID().uuidString ) ) let displayContext = ThomasLayoutEventContext.Display( triggerSessionID: UUID().uuidString, isFirstDisplay: true, isFirstDisplayTriggerSessionID: false ) let context = ThomasLayoutEventContext.makeContext( reportingContext: reportingMetadata, experimentsResult: experimentResult, layoutContext: thomasLayoutContext, displayContext: displayContext ) let expected = ThomasLayoutEventContext( pager: thomasLayoutContext.pager, button: thomasLayoutContext.button, form: thomasLayoutContext.form, display: displayContext, reportingContext: reportingMetadata, experimentsReportingData: experimentResult.reportingMetadata ) #expect(context == expected) } @Test func testMakeEmpty() throws { let context = ThomasLayoutEventContext.makeContext( reportingContext: nil, experimentsResult: nil, layoutContext: nil, displayContext: nil ) #expect(context == nil) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/ThomasLayoutEventMessageIDTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore class ThomasLayoutEventMessageIDTest: XCTestCase { private let campaigns = try! AirshipJSON.wrap( ["campaign1": "data1", "campaign2": "data2"] ) private let scheduleID = UUID().uuidString func testLegacy() async throws { let messageID = ThomasLayoutEventMessageID.legacy(identifier: scheduleID) let expectedJSON = """ "\(scheduleID)" """ XCTAssertEqual(try AirshipJSON.wrap(messageID), try AirshipJSON.from(json: expectedJSON)) } func testAppDefined() async throws { let messageID = ThomasLayoutEventMessageID.appDefined(identifier: scheduleID) let expectedJSON = """ { "message_id": "\(scheduleID)" } """ XCTAssertEqual(try AirshipJSON.wrap(messageID), try AirshipJSON.from(json: expectedJSON)) } func testAirship() async throws { let messageID = ThomasLayoutEventMessageID.airship(identifier: scheduleID, campaigns: self.campaigns) let expectedJSON = """ { "message_id": "\(scheduleID)", "campaigns": \(try self.campaigns.toString()), } """ XCTAssertEqual(try AirshipJSON.wrap(messageID), try AirshipJSON.from(json: expectedJSON)) } func testAirshipNoCampaigns() async throws { let messageID = ThomasLayoutEventMessageID.airship(identifier: scheduleID, campaigns: nil) let expectedJSON = """ { "message_id": "\(scheduleID)" } """ XCTAssertEqual(try AirshipJSON.wrap(messageID), try AirshipJSON.from(json: expectedJSON)) } } ================================================ FILE: Airship/AirshipCore/Tests/Analytics/ThomasLayoutEventRecorderTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class ThomasLayoutEventRecorderTest: XCTestCase { private let airshipAnalytics: TestAnalytics = TestAnalytics() private let meteredUsage: TestMeteredUsage = TestMeteredUsage() private var eventRecorder: ThomasLayoutEventRecorder! private let campaigns = try! AirshipJSON.wrap( ["campaign1": "data1", "campaign2": "data2"] ) private let experimentResult = ExperimentResult( channelID: "some channel", contactID: "some contact", isMatch: true, reportingMetadata: [AirshipJSON.string("some reporting")] ) private let scheduleID = "5362C754-17A9-48B8-B101-60D9DC5688A2" private let reportingMetadata = AirshipJSON.string("reporting info") private let renderedLocale = try! AirshipJSON.wrap(["en-US"]) override func setUp() async throws { self.eventRecorder = ThomasLayoutEventRecorder( airshipAnalytics: airshipAnalytics, meteredUsage: meteredUsage ) } func testEventData() async throws { let inAppEvent = TestThomasLayoutEvent( name: .appInit, data: TestData(field: "something", anotherField: "something something") ) let data = ThomasLayoutEventData( event: inAppEvent, context: ThomasLayoutEventContext( reportingContext: self.reportingMetadata, experimentsReportingData: self.experimentResult.reportingMetadata ), source: .airship, messageID: .airship(identifier: self.scheduleID, campaigns: self.campaigns), renderedLocale: self.renderedLocale ) self.eventRecorder.recordEvent(inAppEventData: data) let expectedJSON = """ { "context":{ "reporting_context":"reporting info", "experiments":[ "some reporting" ] }, "source":"urban-airship", "rendered_locale":[ "en-US" ], "id":{ "campaigns":{ "campaign1":"data1", "campaign2":"data2" }, "message_id":"5362C754-17A9-48B8-B101-60D9DC5688A2" }, "field":"something", "anotherField":"something something" } """ let event = self.airshipAnalytics.events.first! XCTAssertEqual(event.eventType, inAppEvent.name) XCTAssertEqual(event.eventData, try AirshipJSON.from(json: expectedJSON)) } func testConversionIDs() async throws { let inAppEvent = TestThomasLayoutEvent( name: .featureFlagInteraction, data: TestData(field: "something", anotherField: "something something") ) self.airshipAnalytics.conversionSendID = UUID().uuidString self.airshipAnalytics.conversionPushMetadata = UUID().uuidString let data = ThomasLayoutEventData( event: inAppEvent, context: ThomasLayoutEventContext( reportingContext: self.reportingMetadata, experimentsReportingData: self.experimentResult.reportingMetadata ), source: .airship, messageID: .airship(identifier: self.scheduleID, campaigns: self.campaigns), renderedLocale: self.renderedLocale ) self.eventRecorder.recordEvent(inAppEventData: data) let expectedJSON = """ { "context":{ "reporting_context":"reporting info", "experiments":[ "some reporting" ] }, "source":"urban-airship", "rendered_locale":[ "en-US" ], "id":{ "campaigns":{ "campaign1":"data1", "campaign2":"data2" }, "message_id":"5362C754-17A9-48B8-B101-60D9DC5688A2" }, "field":"something", "anotherField":"something something", "conversion_send_id": "\(self.airshipAnalytics.conversionSendID!)", "conversion_metadata": "\(self.airshipAnalytics.conversionPushMetadata!)" } """ let event = self.airshipAnalytics.events.first! XCTAssertEqual(event.eventType, inAppEvent.name) XCTAssertEqual(event.eventData, try AirshipJSON.from(json: expectedJSON)) } func testEventDataError() async throws { let inAppEvent = TestThomasLayoutEvent( name: .appForeground, data: ErrorData(field: "something", anotherField: "something something") ) let data = ThomasLayoutEventData( event: inAppEvent, context: ThomasLayoutEventContext( reportingContext: self.reportingMetadata, experimentsReportingData: self.experimentResult.reportingMetadata ), source: .airship, messageID: .airship(identifier: self.scheduleID, campaigns: self.campaigns), renderedLocale: self.renderedLocale ) self.eventRecorder.recordEvent(inAppEventData: data) XCTAssertTrue(self.airshipAnalytics.events.isEmpty) } } fileprivate struct TestData: Encodable, Sendable { var field: String var anotherField: String } fileprivate struct ErrorData: Encodable, Sendable { var field: String var anotherField: String enum CodingKeys: CodingKey { case field case anotherField } func encode(to encoder: Encoder) throws { throw AirshipErrors.error("Failed") } } actor TestMeteredUsage: AirshipMeteredUsage { var events: [AirshipMeteredUsageEvent] = [] func addEvent(_ event: AirshipCore.AirshipMeteredUsageEvent) async throws { events.append(event) } } ================================================ FILE: Airship/AirshipCore/Tests/AnalyticsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import Combine @testable import AirshipCore class AnalyticsTest: XCTestCase { private let appStateTracker = TestAppStateTracker() private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private let config = RuntimeConfig.testConfig() private let channel = TestChannel() private let locale = TestLocaleManager() private var permissionsManager: DefaultAirshipPermissionsManager! private let notificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) private let date = UATestDate() private let eventManager = TestEventManager() private let sessionEventFactory = TestSessionEventFactory() private let sessionTracker = TestSessionTracker() private var privacyManager: TestPrivacyManager! private var analytics: DefaultAirshipAnalytics! private var testAirship: TestAirshipInstance! @MainActor override func setUp() async throws { testAirship = TestAirshipInstance() self.permissionsManager = DefaultAirshipPermissionsManager() self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: RuntimeConfig.testConfig(), defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) self.analytics = makeAnalytics() } @MainActor func makeAnalytics() -> DefaultAirshipAnalytics { return DefaultAirshipAnalytics( config: config, dataStore: dataStore, channel: channel, notificationCenter: notificationCenter, date: date, localeManager: locale, privacyManager: privacyManager, permissionsManager: permissionsManager, eventManager: eventManager, sessionTracker: sessionTracker, sessionEventFactory: sessionEventFactory ) } override class func tearDown() { TestAirshipInstance.clearShared() } @MainActor func testScreenTrackingBackground() async throws { let notificationCenter = self.notificationCenter // Foreground notificationCenter.post(name: AppStateTracker.willEnterForegroundNotification) self.analytics.trackScreen("test_screen") let events = try await self.produceEvents(count: 1) { @MainActor in notificationCenter.post( name: AppStateTracker.didEnterBackgroundNotification, object: nil ) } XCTAssertEqual("screen_tracking", events[0].type.reportingName) } @MainActor func testScreenTrackingTerminate() async throws { let notificationCenter = self.notificationCenter // Foreground notificationCenter.post(name: AppStateTracker.willEnterForegroundNotification) // Track the screen self.analytics.trackScreen("test_screen") self.analytics.trackScreen("test_screen") let events = try await self.produceEvents(count: 1) { @MainActor in notificationCenter.post(name: AppStateTracker.didEnterBackgroundNotification) } XCTAssertEqual("screen_tracking", events[0].type.reportingName) } func testScreenTracking() async throws { let date = self.date let analytics = self.analytics let currentTime = Date().timeIntervalSince1970 let timeOffset = 3.0 let events = try await self.produceEvents(count: 1) { @MainActor in analytics?.trackScreen("test_screen") date.offset = timeOffset analytics?.trackScreen("another_screen") } let body: AirshipJSON = events[0].body XCTAssertEqual("screen_tracking", events[0].type.reportingName) XCTAssertEqual("test_screen", body.object?["screen"]?.string) XCTAssertEqual("3.000", body.object?["duration"]?.string) compareTimestamps(value: body.object?["entered_time"]?.string, expectedValue: currentTime) compareTimestamps(value: body.object?["exited_time"]?.string, expectedValue: currentTime + timeOffset) } private func compareTimestamps(value: String?, expectedValue: TimeInterval) { if let value = value, let actualValue = Double(value) { XCTAssertEqual(actualValue, expectedValue, accuracy: 1) } else { XCTFail("Not a double") } } @MainActor func testDisablingAnalytics() throws { self.channel.identifier = "test channel" self.analytics.airshipReady() XCTAssertTrue(self.eventManager.uploadsEnabled) let expectation = XCTestExpectation() self.eventManager.deleteEventsCallback = { expectation.fulfill() } self.privacyManager.disableFeatures(.analytics) wait(for: [expectation], timeout: 5.0) XCTAssertFalse(self.eventManager.uploadsEnabled) } @MainActor func testEnableAnalytics() throws { self.channel.identifier = "test channel" self.analytics.airshipReady() XCTAssertTrue(self.eventManager.uploadsEnabled) self.privacyManager.disableFeatures(.analytics) XCTAssertFalse(self.eventManager.uploadsEnabled) let expectation = XCTestExpectation() self.eventManager.scheduleUploadCallback = { priority in XCTAssertEqual(AirshipEventPriority.normal, priority) expectation.fulfill() } self.privacyManager.enableFeatures(.analytics) XCTAssertTrue(self.eventManager.uploadsEnabled) wait(for: [expectation], timeout: 5.0) } @MainActor func testCurrentScreen() throws { self.analytics.trackScreen("foo") XCTAssertEqual("foo", self.analytics.currentScreen) self.analytics.trackScreen("bar") XCTAssertEqual("bar", self.analytics.currentScreen) self.analytics.trackScreen(nil) XCTAssertEqual(nil, self.analytics.currentScreen) } @MainActor func testScreenUpdates() async throws { let expectation = expectation(description: "updates received") let screenUpdates = analytics.screenUpdates Task { var updates: [String?] = [] for await update in screenUpdates { updates.append(update) if (updates.count == 4) { break } } XCTAssertEqual([nil, "foo", "bar", nil], updates) expectation.fulfill() } self.analytics.trackScreen("foo") XCTAssertEqual("foo", self.analytics.currentScreen) self.analytics.trackScreen("bar") XCTAssertEqual("bar", self.analytics.currentScreen) self.analytics.trackScreen("bar") self.analytics.trackScreen("bar") self.analytics.trackScreen(nil) XCTAssertEqual(nil, self.analytics.currentScreen) await self.fulfillment(of: [expectation]) } @MainActor func testRegions() async throws { var updates = self.analytics.regionUpdates.makeAsyncIterator() var update = await updates.next() XCTAssertEqual(Set(), update) self.analytics.recordRegionEvent( RegionEvent(regionID: "foo", source: "source", boundaryEvent: .enter)! ) update = await updates.next() XCTAssertEqual(Set(["foo"]), update) XCTAssertEqual(Set(["foo"]), self.analytics.currentRegions) self.analytics.recordRegionEvent( RegionEvent(regionID: "bar", source: "source", boundaryEvent: .enter)! ) update = await updates.next() XCTAssertEqual(Set(["foo", "bar"]), update) XCTAssertEqual(Set(["foo", "bar"]), self.analytics.currentRegions) self.analytics.recordRegionEvent( RegionEvent(regionID: "bar", source: "source", boundaryEvent: .exit)! ) update = await updates.next() XCTAssertEqual(Set(["foo"]), update) XCTAssertEqual(Set(["foo"]), self.analytics.currentRegions) self.analytics.recordRegionEvent( RegionEvent(regionID: "baz", source: "source", boundaryEvent: .exit)! ) update = await updates.next() XCTAssertEqual(Set(["foo"]), update) XCTAssertEqual(Set(["foo"]), self.analytics.currentRegions) self.analytics.recordRegionEvent( RegionEvent(regionID: "foo", source: "source", boundaryEvent: .exit)! ) update = await updates.next() XCTAssertEqual(Set(), update) XCTAssertEqual(Set(), self.analytics.currentRegions) } func testAddEvent() throws { let expectation = XCTestExpectation() self.eventManager.addEventCallabck = { event in XCTAssertEqual("app_background", event.type.reportingName) expectation.fulfill() } self.analytics.recordEvent(AirshipEvent(priority: .normal, eventType: .appBackground, eventData: "body")) wait(for: [expectation], timeout: 5.0) } func testAssociateDeviceIdentifiers() async throws { let analytics = self.analytics let events = try await self.produceEvents(count: 1) { let ids = AssociatedIdentifiers(identifiers: ["neat": "id"]) analytics?.associateDeviceIdentifiers(ids) } let expectedData = [ "neat": "id", ] XCTAssertEqual("associate_identifiers", events[0].type.reportingName) XCTAssertEqual( try AirshipJSON.wrap(expectedData), events[0].body ) } @MainActor func testMissingSendID() throws { let notification = ["aps": ["alert": "neat"]] self.analytics.launched(fromNotification: notification) XCTAssertEqual("MISSING_SEND_ID", self.analytics.conversionSendID) XCTAssertNil(self.analytics.conversionPushMetadata) } @MainActor func testConversionSendID() throws { let notification: [String: AnyHashable] = [ "aps": ["alert": "neat"], "_": "some conversionSendID", ] self.analytics.launched(fromNotification: notification) XCTAssertEqual("some conversionSendID", self.analytics.conversionSendID) } @MainActor func testConversationMetadata() throws { let notification: [String: AnyHashable] = [ "aps": ["alert": "neat"], "_": "some conversionSendID", "com.urbanairship.metadata": "some metadata", ] self.analytics.launched(fromNotification: notification) XCTAssertEqual("some metadata", self.analytics.conversionPushMetadata) } @MainActor func testLaunchedFromSilentPush() throws { let notification: [String: AnyHashable] = [ "aps": ["neat": "neat"], "_": "some conversionSendID", "com.urbanairship.metadata": "some metadata", ] self.analytics.launched(fromNotification: notification) XCTAssertNil(self.analytics.conversionPushMetadata) XCTAssertNil(self.analytics.conversionSendID) } func testScreenEventFeed() async throws { var feed = await self.analytics.eventFeed.updates.makeAsyncIterator() await self.analytics.trackScreen("some screen") let next = await feed.next() XCTAssertEqual(next, .screen(screen: "some screen")) } func testRegionEventEventFeed() async throws { let event = RegionEvent( regionID: "foo", source: "test", boundaryEvent: .enter )! var feed = await self.analytics.eventFeed.updates.makeAsyncIterator() self.analytics.recordRegionEvent(event) let next = await feed.next() 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 = 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: 1.0 ) ) } func testSDKExtensions() async throws { self.analytics.registerSDKExtension(.cordova, version: "1.2.3") self.analytics.registerSDKExtension(.unity, version: "5,.6,.7,,,") let headers = await self.eventManager.headers XCTAssertEqual( "cordova:1.2.3, unity:5.6.7", headers["X-UA-Frameworks"] ) } func testAnalyticsHeaders() async throws { self.channel.identifier = "someChannelID" self.locale.currentLocale = Locale(identifier: "en-US-POSIX") let expected = await [ "X-UA-Channel-ID": "someChannelID", "X-UA-Timezone": NSTimeZone.default.identifier, "X-UA-Locale-Language": "en", "X-UA-Locale-Country": "US", "X-UA-Locale-Variant": "POSIX", "X-UA-Device-Family": UIDevice.current.systemName, "X-UA-OS-Version": UIDevice.current.systemVersion, "X-UA-Device-Model": AirshipDevice.modelIdentifier, "X-UA-Lib-Version": AirshipVersion.version, "X-UA-App-Key": self.config.appCredentials.appKey, "X-UA-Package-Name": Bundle.main.infoDictionary?[kCFBundleIdentifierKey as String] as? String, "X-UA-Package-Version": AirshipUtils.bundleShortVersionString() ?? "", ] let headers = await self.eventManager.headers XCTAssertEqual(expected, headers) } func testAnalyticsHeaderExtension() async throws { await self.analytics.addHeaderProvider { return ["neat": "story"] } let headers = await self.eventManager.headers XCTAssertEqual( "story", headers["neat"] ) } @MainActor func testPermissionHeaders() async throws { let testPushDelegate = TestPermissionsDelegate() testPushDelegate.permissionStatus = .denied self.permissionsManager.setDelegate( testPushDelegate, permission: .displayNotifications ) let testLocationDelegate = TestPermissionsDelegate() testLocationDelegate.permissionStatus = .granted self.permissionsManager.setDelegate( testLocationDelegate, permission: .location ) let headers = await self.eventManager.headers XCTAssertEqual( "denied", headers["X-UA-Permission-display_notifications"] ) XCTAssertEqual("granted", headers["X-UA-Permission-location"]) } @MainActor func produceEvents( count: Int, eventProducingAction: @escaping @Sendable () async -> Void ) async throws -> [AirshipEventData] { var subscription: AnyCancellable? defer { subscription?.cancel() } let stream = AsyncThrowingStream<AirshipEventData, Error> { continuation in let cancelTask = Task { try await Task.sleep(nanoseconds: 10_000_000_000) continuation.finish( throwing: AirshipErrors.error("Failed to get event") ) } var received = 0 subscription = self.analytics.eventPublisher .sink { data in continuation.yield(data) received += 1 if (received >= count) { cancelTask.cancel() continuation.finish() } } } await eventProducingAction() var result: [AirshipEventData] = [] for try await value in stream { result.append(value) } return result } func testSessionEvents() async throws { let date = Date() let sessionTracker = self.sessionTracker let events = try await self.produceEvents(count: 4) { sessionTracker.eventsContinuation.yield( SessionEvent(type: .background, date: date, sessionState: SessionState()) ) sessionTracker.eventsContinuation.yield( SessionEvent(type: .foreground, date: date, sessionState: SessionState()) ) sessionTracker.eventsContinuation.yield( SessionEvent(type: .foregroundInit, date: date, sessionState: SessionState()) ) sessionTracker.eventsContinuation.yield( SessionEvent(type: .backgroundInit, date: date, sessionState: SessionState()) ) } XCTAssertEqual( [ EventType.appBackground.reportingName, EventType.appForeground.reportingName, EventType.appInit.reportingName, EventType.appInit.reportingName ], events.map { $0.type.reportingName } ) XCTAssertEqual( [ "app_background", "app_foreground", "app_foreground_init", "app_background_init" ], events.map { $0.body } ) XCTAssertEqual([date, date, date, date], events.map { $0.date }) } } final class TestEventManager: EventManagerProtocol, @unchecked Sendable { var uploadsEnabled: Bool = false var addEventCallabck: ((AirshipEventData) -> Void)? func addEvent(_ event: AirshipEventData) async throws { addEventCallabck?(event) } var deleteEventsCallback: (() -> Void)? func deleteEvents() async throws { self.deleteEventsCallback?() } var scheduleUploadCallback: ((AirshipEventPriority) -> Void)? func scheduleUpload(eventPriority: AirshipEventPriority) async { scheduleUploadCallback?(eventPriority) } var headerProviders: [() async -> [String : String]] = [] func addHeaderProvider( _ headerProvider: @escaping () async -> [String : String] ) { headerProviders.append(headerProvider) } public var headers: [String: String] { get async { var allHeaders: [String: String] = [:] for provider in self.headerProviders { let headers = await provider() allHeaders.merge(headers) { (_, new) in return new } } return allHeaders } } } 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" } return AirshipEvent(eventType: eventType, eventData: AirshipJSON.string(name)) } } final class TestSessionTracker: SessionTrackerProtocol { let eventsContinuation: AsyncStream<SessionEvent>.Continuation public let events: AsyncStream<SessionEvent> private let _sessionState: AirshipAtomicValue<SessionState> = AirshipAtomicValue(SessionState()) var sessionState: SessionState { return _sessionState.value } init() { (self.events, self.eventsContinuation) = AsyncStream<SessionEvent>.airshipMakeStreamWithContinuation() } func airshipReady() { } func launchedFromPush(sendID: String?, metadata: String?) { self._sessionState.update { state in var state = state state.conversionMetadata = metadata state.conversionSendID = sendID return state } } } ================================================ FILE: Airship/AirshipCore/Tests/AppIntegrationTests.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @testable import AirshipBasement class AppIntegrationTests: XCTestCase { private var testDelegate: TestIntegrationDelegate! @MainActor override func setUpWithError() throws { self.testDelegate = TestIntegrationDelegate() AppIntegration.integrationDelegate = self.testDelegate } @MainActor override func tearDownWithError() throws { AppIntegration.integrationDelegate = nil } @MainActor func testPerformFetchWithCompletionHandler() throws { let appCallbackCalled = expectation(description: "Callback called") AppIntegration.application( UIApplication.shared, performFetchWithCompletionHandler: { result in XCTAssertEqual(result, .noData) appCallbackCalled.fulfill() } ) wait(for: [appCallbackCalled], timeout: 10) XCTAssertTrue(self.testDelegate.onBackgroundAppRefreshCalled!) } @MainActor func testDidRegisterForRemoteNotificationsWithDeviceToken() throws { let token = Data("some token".utf8) AppIntegration.application( UIApplication.shared, didRegisterForRemoteNotificationsWithDeviceToken: token ) XCTAssertEqual(token, self.testDelegate.deviceToken) } @MainActor func testDidFailToRegisterForRemoteNotificationsWithError() throws { let error = AirshipErrors.error("some error") as NSError AppIntegration.application( UIApplication.shared, didFailToRegisterForRemoteNotificationsWithError: error ) XCTAssertEqual(error, self.testDelegate.registrationError as NSError?) } @MainActor func testDidReceiveRemoteNotifications() async throws { let notification = ["some": "alert"] let testHookCalled = expectation(description: "Callback called") self.testDelegate.didReceiveRemoteNotificationCallback = { userInfo, isForeground in XCTAssertEqual( notification as NSDictionary, userInfo as NSDictionary ) testHookCalled.fulfill() return .newData } let result = await AppIntegration.application( UIApplication.shared, didReceiveRemoteNotification: notification ) XCTAssertEqual(result, .newData) await fulfillment(of: [testHookCalled], timeout: 10) } } @MainActor final class TestIntegrationDelegate: NSObject, AppIntegrationDelegate { var onBackgroundAppRefreshCalled: Bool? var deviceToken: Data? var registrationError: Error? var didReceiveRemoteNotificationCallback: (@MainActor ([AnyHashable: Any], Bool) async -> UIBackgroundFetchResult)? func onBackgroundAppRefresh() { self.onBackgroundAppRefreshCalled = true } func didRegisterForRemoteNotifications(deviceToken: Data) { self.deviceToken = deviceToken } func didFailToRegisterForRemoteNotifications(error: Error) { self.registrationError = error } func didReceiveRemoteNotification( userInfo: [AnyHashable: Any], isForeground: Bool, completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { Task { let result = await self.didReceiveRemoteNotificationCallback?(userInfo, isForeground) ?? .noData completionHandler(result) } } func willPresentNotification( notification: UNNotification, presentationOptions: UNNotificationPresentationOptions, completionHandler: @escaping () -> Void ) { completionHandler() } func didReceiveNotificationResponse( response: UNNotificationResponse, completionHandler: @escaping () -> Void ) { completionHandler() } func presentationOptions( for notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { completionHandler([]) } } ================================================ FILE: Airship/AirshipCore/Tests/AppRemoteDataProviderDelegateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AppRemoteDataProviderDelegateTest: XCTestCase { private let client: TestRemoteDataAPIClient = TestRemoteDataAPIClient() private let config: RuntimeConfig = RuntimeConfig.testConfig() private var delegate: AppRemoteDataProviderDelegate! override func setUpWithError() throws { delegate = AppRemoteDataProviderDelegate(config: config, apiClient: client) } func testIsRemoteDataInfoUpToDate() async throws { let locale = Locale(identifier: "br") let randomValue = 1003 let remoteDatInfo = RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data/app/\(config.appCredentials.appKey)/ios", locale: locale, randomValue: randomValue ), lastModifiedTime: "some time", source: .app ) var isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, randomValue: randomValue ) XCTAssertTrue(isUpToDate) // Different locale isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: Locale(identifier: "en"), randomValue: randomValue ) XCTAssertFalse(isUpToDate) // Different randomValue isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, randomValue: randomValue + 1 ) XCTAssertFalse(isUpToDate) } func testIsRemoteDataInfoUpToDateDifferentURL() async throws { let locale = Locale(identifier: "br") let randomValue = 1003 let remoteDatInfo = RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data/app/\(config.appCredentials.appKey)/ios/something-else", locale: locale, randomValue: randomValue ), lastModifiedTime: "some time", source: .app ) let isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, randomValue: randomValue ) XCTAssertFalse(isUpToDate) } func testFetch() async throws { let locale = Locale(identifier: "br") let randomValue = 1003 let remoteDatInfo = RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data/app/\(config.appCredentials.appKey)/ios", locale: locale, randomValue: randomValue ), lastModifiedTime: "some time", source: .app ) client.lastModified = "some other time" client.fetchData = { url, auth, lastModified, info in XCTAssertEqual(remoteDatInfo.url, url) XCTAssertEqual(AirshipRequestAuth.generatedAppToken, auth) XCTAssertEqual("some time", lastModified) XCTAssertEqual( RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: self.config, path: "/api/remote-data/app/\(self.config.appCredentials.appKey)/ios", locale: locale, randomValue: randomValue ), lastModifiedTime: "some other time", source: .app ), info ) return AirshipHTTPResponse( result: RemoteDataResult( payloads: [], remoteDataInfo: remoteDatInfo ), statusCode: 200, headers: [:] ) } let result = try await self.delegate.fetchRemoteData( locale: locale, randomValue: randomValue, lastRemoteDataInfo: remoteDatInfo ) XCTAssertEqual(result.statusCode, 200) } func testFetchLastModifiedOutOfDate() async throws { let locale = Locale(identifier: "br") let randomValue = 1003 let remoteDatInfo = RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data/app/\(config.appCredentials.appKey)/ios", locale: locale, randomValue: randomValue ), lastModifiedTime: "some time", source: .app ) client.fetchData = { _, _, lastModified, _ in XCTAssertNil(lastModified) return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } let result = try await self.delegate.fetchRemoteData( locale: locale, randomValue: randomValue + 1, lastRemoteDataInfo: remoteDatInfo ) XCTAssertEqual(result.statusCode, 400) } } ================================================ FILE: Airship/AirshipCore/Tests/AppStateTrackerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AppStateTrackerTest: XCTestCase { private let adapter = TestAppStateAdapter() private let notificationCenter = NotificationCenter() private var tracker: AppStateTracker! @MainActor override func setUp() async throws { self.tracker = AppStateTracker( adapter: adapter, notificationCenter: self.notificationCenter ) } @MainActor func testDidBecomeActive() async throws { let expectations = [ expectNotification(name: AppStateTracker.didBecomeActiveNotification) ] adapter.dispatchEvent(event: .didBecomeActive) await self.fulfillment(of: expectations, timeout: 1.0) } @MainActor func testWillEnterForeground() async throws { let expectations = [ expectNotification(name: AppStateTracker.willEnterForegroundNotification) ] adapter.dispatchEvent(event: .willEnterForeground) await self.fulfillment(of: expectations, timeout: 1.0) } @MainActor func testDidEnterBackground() async throws { let expectations = [ expectNotification(name: AppStateTracker.didEnterBackgroundNotification) ] adapter.dispatchEvent(event: .didEnterBackground) await self.fulfillment(of: expectations, timeout: 1.0) } @MainActor func testWillResignActive() async throws { let expectations = [ expectNotification(name: AppStateTracker.willResignActiveNotification) ] adapter.dispatchEvent(event: .willResignActive) await self.fulfillment(of: expectations, timeout: 1.0) } @MainActor func testWillTerminate() async throws { let expectations = [ expectNotification(name: AppStateTracker.willTerminateNotification) ] adapter.dispatchEvent(event: .willTerminate) await self.fulfillment(of: expectations, timeout: 1.0) } @MainActor func testTransitionToForeground() async throws { adapter.dispatchEvent(event: .didBecomeActive) let expectations = [ expectNotification(name: AppStateTracker.didTransitionToForeground) ] adapter.dispatchEvent(event: .didEnterBackground) adapter.dispatchEvent(event: .didBecomeActive) await self.fulfillment(of: expectations, timeout: 1.0) } @MainActor func testTransitionToBackground() async throws { adapter.dispatchEvent(event: .didEnterBackground) let expectations = [ expectNotification(name: AppStateTracker.didTransitionToForeground) ] adapter.dispatchEvent(event: .didBecomeActive) adapter.dispatchEvent(event: .didEnterBackground) await self.fulfillment(of: expectations, timeout: 1.0) } private func expectNotification(name: Notification.Name) -> XCTestExpectation { let expectation = self.expectation(description: "Notification received") self.notificationCenter.addObserver( forName: name, object: nil, queue: nil ) { _ in expectation.fulfill() } return expectation } } final class TestAppStateAdapter: AppStateTrackerAdapter { @MainActor var state: AirshipCore.ApplicationState = .inactive @MainActor var eventHandlers: [@MainActor @Sendable (AppLifeCycleEvent) -> Void] = [] @MainActor func watchAppLifeCycleEvents( eventHandler: @escaping @MainActor @Sendable (AirshipCore.AppLifeCycleEvent) -> Void) { eventHandlers.append(eventHandler) } @MainActor public func dispatchEvent(event: AppLifeCycleEvent) { self.eventHandlers.forEach { $0(event) } } } ================================================ FILE: Airship/AirshipCore/Tests/AssociatedIdentifiersTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AssociatedIdentifiersTest: XCTestCase { func testIDs() { let identifiers = AssociatedIdentifiers(identifiers: ["custom key": "custom value"]) identifiers.vendorID = "vendor ID" identifiers.advertisingID = "advertising ID" identifiers.advertisingTrackingEnabled = false identifiers.set(identifier: "another custom value", key: "another custom key") XCTAssertEqual("vendor ID", identifiers.allIDs["com.urbanairship.vendor"]) XCTAssertEqual("advertising ID", identifiers.allIDs["com.urbanairship.idfa"]) XCTAssertFalse(identifiers.advertisingTrackingEnabled) XCTAssertEqual("true", identifiers.allIDs["com.urbanairship.limited_ad_tracking_enabled"]) XCTAssertEqual("another custom value", identifiers.allIDs["another custom key"]) identifiers.advertisingTrackingEnabled = true XCTAssertTrue(identifiers.advertisingTrackingEnabled) XCTAssertEqual("false", identifiers.allIDs["com.urbanairship.limited_ad_tracking_enabled"]) } } ================================================ FILE: Airship/AirshipCore/Tests/AttributeEditorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class AttributeEditorTest: XCTestCase { var date: UATestDate! override func setUp() { self.date = UATestDate() } func testEditor() throws { var out: [AttributeUpdate]? let editor = AttributesEditor(date: self.date) { updates in out = updates } editor.remove("bar") editor.set(string: "neat", attribute: "bar") editor.set(int: 10, attribute: "foo") editor.remove("foo") let applyDate = Date(timeIntervalSince1970: 1) self.date.dateOverride = applyDate editor.apply() XCTAssertEqual(2, out?.count) let foo = out?.first { $0.attribute == "foo" } let bar = out?.first { $0.attribute == "bar" } XCTAssertEqual(AttributeUpdateType.remove, foo?.type) XCTAssertEqual(applyDate, foo?.date) XCTAssertNil(foo?.jsonValue?.unWrap()) XCTAssertEqual(AttributeUpdateType.set, bar?.type) XCTAssertEqual("neat", bar?.jsonValue?.unWrap() as? String) XCTAssertEqual(applyDate, foo?.date) } func testDateAttribute() throws { var out: [AttributeUpdate]? let editor = AttributesEditor(date: self.date) { updates in out = updates } editor.set(date: Date(timeIntervalSince1970: 10000), attribute: "date") let applyDate = Date(timeIntervalSince1970: 1) self.date.dateOverride = applyDate editor.apply() let attribute = out?.first XCTAssertEqual(AttributeUpdateType.set, attribute?.type) XCTAssertEqual(applyDate, attribute?.date) XCTAssertEqual( "1970-01-01T02:46:40", attribute?.jsonValue?.unWrap() as! String ) } func testEditorNoAttributes() throws { var out: [AttributeUpdate]? let editor = AttributesEditor(date: self.date) { updates in out = updates } editor.apply() XCTAssertEqual(0, out?.count) } func testEditorEmptyString() throws { var out: [AttributeUpdate]? let editor = AttributesEditor(date: self.date) { updates in out = updates } editor.set(string: "", attribute: "cool") editor.set(string: "cool", attribute: "") editor.apply() XCTAssertEqual(0, out?.count) } func testSetJSONAttributeNoExpiration() throws { var out: [AttributeUpdate]? let editor = AttributesEditor(date: self.date) { updates in out = updates } let payload: [String: AirshipJSON] = [ "flavor": "vanilla", "rating": 5.0, "available": true, ] try editor.set( json: payload, attribute: "icecream", instanceID: "store-123" ) let now = Date(timeIntervalSince1970: 10) self.date.dateOverride = now editor.apply() XCTAssertEqual(1, out?.count) guard let first = out?.first else { XCTFail("missing update") return } XCTAssertEqual(AttributeUpdateType.set, first.type) XCTAssertEqual("icecream#store-123", first.attribute) XCTAssertEqual(now, first.date) let unwrapped = first.jsonValue?.unWrap() as? [String: AnyHashable] XCTAssertEqual(3, unwrapped?.count) XCTAssertEqual("vanilla", unwrapped?["flavor"] as? String) XCTAssertEqual(5.0, unwrapped?["rating"] as? Double) XCTAssertEqual(true, unwrapped?["available"] as? Bool) XCTAssertNil(unwrapped?["exp"], "Unexpected expiry key present") } func testSetJSONAttributeWithExpiration() throws { var out: [AttributeUpdate]? = nil let editor = AttributesEditor(date: self.date) { updates in out = updates } let payload: [String: AirshipJSON] = [ "size": .string("large"), ] let expiration = Date(timeIntervalSince1970: 1000) try editor.set( json: payload, attribute: "coffee", instanceID: "order-123", expiration: expiration ) self.date.dateOverride = Date(timeIntervalSince1970: 20) editor.apply() guard let update = out?.first else { XCTFail("Missing update") return } XCTAssertEqual("coffee#order-123", update.attribute) XCTAssertEqual(AttributeUpdateType.set, update.type) let dict = update.jsonValue?.unWrap() as? [String: AnyHashable] XCTAssertEqual("large", dict?["size"] as? String) if let exp = dict?["exp"] as? Double { XCTAssertEqual(expiration.timeIntervalSince1970, exp, accuracy: 0.001) } else { XCTFail("Missing expiration key in payload") } } func testRemoveJSONAttribute() throws { var out: [AttributeUpdate]? let editor = AttributesEditor(date: self.date) { updates in out = updates } try editor.remove(attribute: "coffee", instanceID: "order-123") self.date.dateOverride = Date(timeIntervalSince1970: 30) editor.apply() XCTAssertEqual(1, out?.count) XCTAssertEqual(AttributeUpdateType.remove, out?.first?.type) XCTAssertEqual("coffee#order-123", out?.first?.attribute) } func testJSONAttributeValidation() throws { let editor = AttributesEditor(date: self.date) { _ in } // Empty JSON XCTAssertThrowsError(try editor.set( json: [:], attribute: "test", instanceID: "id" )) // JSON contains reserved key let badPayload: [String: AirshipJSON] = [ "exp": 100 ] XCTAssertThrowsError(try editor.set( json: badPayload, attribute: "test", instanceID: "id" )) // Attribute or instanceID validation let payload: [String: AirshipJSON] = ["k": .string("v")] XCTAssertThrowsError(try editor.set( json: payload, attribute: "has#pound", instanceID: "id" )) XCTAssertThrowsError(try editor.set( json: payload, attribute: "", instanceID: "id" )) XCTAssertThrowsError(try editor.set( json: payload, attribute: "valid", instanceID: "bad#id" )) XCTAssertThrowsError(try editor.set( json: payload, attribute: "valid", instanceID: "" )) } } ================================================ FILE: Airship/AirshipCore/Tests/AttributeUpdateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class AttributeUpdateTest: XCTestCase { func testNumberCoding() throws { let original = AttributeUpdate( attribute: "some attribute", type: .set, jsonValue: 42, date: Date() ) let encoded = try JSONEncoder().encode(original) let decoded = try JSONDecoder() .decode(AttributeUpdate.self, from: encoded) XCTAssertEqual(original.attribute, decoded.attribute) XCTAssertEqual( original.jsonValue!, decoded.jsonValue! ) XCTAssertEqual(original.date, decoded.date) } func testStringCoding() throws { let original = AttributeUpdate( attribute: "some attribute", type: .set, jsonValue: "neat", date: Date() ) let encoded = try JSONEncoder().encode(original) let decoded = try JSONDecoder() .decode(AttributeUpdate.self, from: encoded) XCTAssertEqual(original.attribute, decoded.attribute) XCTAssertEqual( original.jsonValue!, decoded.jsonValue! ) XCTAssertEqual(original.date, decoded.date) } } ================================================ FILE: Airship/AirshipCore/Tests/AudienceHashSelectorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class AudienceHashSelectorTest: XCTestCase { func testCodable() throws { let json: String = """ { "audience_hash": { "hash_prefix": "e66a2371-fecf-41de-9238-cb6c28a86cec:", "num_hash_buckets": 16384, "hash_identifier": "contact", "hash_algorithm": "farm_hash", "hash_seed": 100, "hash_identifier_overrides": { "foo": "bar" } }, "audience_subset": { "min_hash_bucket": 10, "max_hash_bucket": 100 } } """ let decoded: AudienceHashSelector = try JSONDecoder().decode( AudienceHashSelector.self, from: json.data(using: .utf8)! ) let expected = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: [ "foo": "bar" ] ), bucket: AudienceHashSelector.Bucket(min: 10, max: 100) ) 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 testCodableWithSticky() throws { let json: String = """ { "audience_hash": { "hash_prefix": "e66a2371-fecf-41de-9238-cb6c28a86cec:", "num_hash_buckets": 16384, "hash_identifier": "contact", "hash_algorithm": "farm_hash", "hash_seed": 100, "hash_identifier_overrides": { "foo": "bar" } }, "audience_subset": { "min_hash_bucket": 10, "max_hash_bucket": 100 }, "sticky": { "id": "test-id", "reporting_metadata": "test", "last_access_ttl": 123 } } """ let decoded: AudienceHashSelector = try JSONDecoder().decode( AudienceHashSelector.self, from: json.data(using: .utf8)! ) let expected = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: [ "foo": "bar" ] ), bucket: AudienceHashSelector.Bucket(min: 10, max: 100), sticky: AudienceHashSelector.Sticky( id: "test-id", reportingMetadata: "test", lastAccessTTL: 0.123 ) ) 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 testBoundaries() throws { let selectorGenerator: (UInt64, UInt64) throws -> AudienceHashSelector = { min, max in let json = """ { "audience_hash":{ "hash_prefix":"686f2c15-cf8c-47a6-ae9f-e749fc792a9d:", "num_hash_buckets":16384, "hash_identifier":"contact", "hash_algorithm":"farm_hash" }, "audience_subset":{ "min_hash_bucket":\(min), "max_hash_bucket":\(max) } } """ return try JSONDecoder().decode( AudienceHashSelector.self, from: json.data(using: .utf8)! ) } // contactId = 9908 XCTAssertTrue( try selectorGenerator(9908, 9908) .evaluate( channelID: "", contactID: "contactId" ) ) XCTAssertTrue( try selectorGenerator(9907, 9908) .evaluate( channelID: "", contactID: "contactId" ) ) XCTAssertTrue( try selectorGenerator(9908, 9909) .evaluate( channelID: "", contactID: "contactId" ) ) XCTAssertFalse( try selectorGenerator(9907, 9907) .evaluate( channelID: "", contactID: "contactId" ) ) XCTAssertFalse( try selectorGenerator(9909, 9909) .evaluate( channelID: "", contactID: "contactId" ) ) } func testEvaluateChannel() throws { let experiment = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .channel, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000) ) // match = 12443 XCTAssertTrue(experiment.evaluate(channelID: "match", contactID: "not used")) // not a match = 11599 XCTAssertFalse(experiment.evaluate(channelID: "not a match", contactID: "not used")) } func testEvaluateContact() throws { let experiment = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000) ) // match = 12443 XCTAssertTrue(experiment.evaluate(channelID: "not used", contactID: "match")) // not a match = 11599 XCTAssertFalse(experiment.evaluate(channelID: "not used", contactID: "not a match")) } func testEvaluateOverrides() throws { let experiment = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: [ "not a match" : "match" ] ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000) ) // match = 12443 XCTAssertTrue(experiment.evaluate(channelID: "not used", contactID: "match")) // not a match = 11599 XCTAssertTrue(experiment.evaluate(channelID: "not used", contactID: "not a match")) } } ================================================ FILE: Airship/AirshipCore/Tests/AudienceUtilsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class AudienceUtilsTest: XCTestCase { func testCollapseTagGroupUpdates() throws { let updates = [ TagGroupUpdate( group: "some-group", tags: ["1", "2", "3"], type: .remove ), TagGroupUpdate(group: "some-group", tags: ["1", "2"], type: .add), TagGroupUpdate(group: "some-group", tags: ["4"], type: .set), TagGroupUpdate(group: "some-group", tags: ["5", "6"], type: .add), TagGroupUpdate(group: "some-group", tags: ["5"], type: .remove), TagGroupUpdate( group: "some-other-group", tags: ["10", "11"], type: .remove ), TagGroupUpdate(group: "some-other-group", tags: ["12"], type: .add), TagGroupUpdate(group: "some-other-group", tags: ["10"], type: .add), ] let collapsed = AudienceUtils.collapse(updates) XCTAssertEqual(3, collapsed.count) XCTAssertEqual("some-group", collapsed[0].group) XCTAssertEqual(Set(["6", "4"]), Set(collapsed[0].tags)) XCTAssertEqual(.set, collapsed[0].type) XCTAssertEqual("some-other-group", collapsed[1].group) XCTAssertEqual(Set(["12", "10"]), Set(collapsed[1].tags)) XCTAssertEqual(.add, collapsed[1].type) XCTAssertEqual("some-other-group", collapsed[2].group) XCTAssertEqual(Set(["11"]), Set(collapsed[2].tags)) XCTAssertEqual(.remove, collapsed[2].type) } func testCollapseTagGroupUpdatesEmptyTags() throws { let updates = [ TagGroupUpdate(group: "set-group", tags: [], type: .set), TagGroupUpdate(group: "add-group", tags: [], type: .add), TagGroupUpdate(group: "remove-group", tags: [], type: .remove), ] let collapsed = AudienceUtils.collapse(updates) XCTAssertEqual(1, collapsed.count) XCTAssertEqual("set-group", collapsed[0].group) XCTAssertEqual(Set([]), Set(collapsed[0].tags)) XCTAssertEqual(.set, collapsed[0].type) } func testCollapseAttributeUpdates() throws { let date = Date() let updates = [ AttributeUpdate.remove(attribute: "some-attribute", date: date), AttributeUpdate.set( attribute: "some-attribute", value: "neat", date: date ), AttributeUpdate.set( attribute: "some-other-attribute", value: 12, date: date ), AttributeUpdate.remove( attribute: "some-other-attribute", date: date ), ] let collapsed = AudienceUtils.collapse(updates) XCTAssertEqual(2, collapsed.count) XCTAssertEqual("some-attribute", collapsed[0].attribute) XCTAssertEqual("neat", collapsed[0].jsonValue?.unWrap() as! String) XCTAssertEqual(.set, collapsed[0].type) XCTAssertEqual(date, collapsed[0].date) XCTAssertEqual("some-other-attribute", collapsed[1].attribute) XCTAssertNil(collapsed[1].jsonValue?.unWrap()) XCTAssertEqual(.remove, collapsed[1].type) XCTAssertEqual(.set, collapsed[0].type) } func testCollapseSubscriptionListUpdates() throws { let updates = [ SubscriptionListUpdate(listId: "coffee", type: .unsubscribe), SubscriptionListUpdate(listId: "pizza", type: .subscribe), SubscriptionListUpdate(listId: "coffee", type: .subscribe), SubscriptionListUpdate(listId: "pizza", type: .unsubscribe), ] let expected = [ SubscriptionListUpdate(listId: "coffee", type: .subscribe), SubscriptionListUpdate(listId: "pizza", type: .unsubscribe), ] let collapsed = AudienceUtils.collapse(updates) XCTAssertEqual(expected, collapsed) } func testCollapseScopedSubscriptionListUpdates() throws { let now = Date() let updates = [ ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .sms, date: now.advanced(by: 1) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .app, date: now.advanced(by: 2) ), ScopedSubscriptionListUpdate( listId: "bar", type: .subscribe, scope: .web, date: now.advanced(by: 3) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .sms, date: now.advanced(by: 4) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .app, date: now.advanced(by: 5) ), ScopedSubscriptionListUpdate( listId: "bar", type: .subscribe, scope: .app, date: now.advanced(by: 6) ), ScopedSubscriptionListUpdate( listId: "bar", type: .unsubscribe, scope: .app, date: now.advanced(by: 7) ), ] let expected = [ ScopedSubscriptionListUpdate( listId: "bar", type: .subscribe, scope: .web, date: now.advanced(by: 3) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .sms, date: now.advanced(by: 4) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .app, date: now.advanced(by: 5) ), ScopedSubscriptionListUpdate( listId: "bar", type: .unsubscribe, scope: .app, date: now.advanced(by: 7) ), ] let collapsed = AudienceUtils.collapse(updates) XCTAssertEqual(expected, collapsed) } func testApplyScopedSubscriptionListsEmptyPayload() throws { let now = Date() let updates = [ ScopedSubscriptionListUpdate( listId: "bar", type: .subscribe, scope: .web, date: now.advanced(by: 3) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .sms, date: now.advanced(by: 4) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .app, date: now.advanced(by: 5) ), ScopedSubscriptionListUpdate( listId: "bar", type: .unsubscribe, scope: .app, date: now.advanced(by: 7) ), ] let expected: [String: [ChannelScope]] = [ "foo": [.sms, .app], "bar": [.web], ] XCTAssertEqual( expected, AudienceUtils.applySubscriptionListsUpdates(nil, updates: updates) ) XCTAssertEqual( expected, AudienceUtils.applySubscriptionListsUpdates([:], updates: updates) ) } func testApplyScopedSubscriptionLists() throws { let now = Date() let updates = [ ScopedSubscriptionListUpdate( listId: "bar", type: .subscribe, scope: .web, date: now.advanced(by: 3) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .sms, date: now.advanced(by: 4) ), ScopedSubscriptionListUpdate( listId: "foo", type: .subscribe, scope: .app, date: now.advanced(by: 5) ), ScopedSubscriptionListUpdate( listId: "bar", type: .unsubscribe, scope: .app, date: now.advanced(by: 7) ), ] let expected: [String: [ChannelScope]] = [ "baz": [.email], "foo": [.app, .web, .sms], "bar": [.web], ] let payload: [String: [ChannelScope]] = [ "baz": [.email], "bar": [.app], "foo": [.app, .web], ] XCTAssertEqual( expected, AudienceUtils.applySubscriptionListsUpdates( payload, updates: updates ) ) } } ================================================ FILE: Airship/AirshipCore/Tests/CachedListTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class CachedListTest: XCTestCase { let date = UATestDate(offset: 0, dateOverride: Date()) func testValue() throws { let cachedList = CachedList<String>(date: date) cachedList.append("foo", expiresIn: 100) XCTAssertEqual(["foo"], cachedList.values) date.offset += 99 cachedList.append("bar", expiresIn: 2) XCTAssertEqual(["foo", "bar"], cachedList.values) date.offset += 1 XCTAssertEqual(["bar"], cachedList.values) date.offset += 1 XCTAssertEqual([], cachedList.values) } } ================================================ FILE: Airship/AirshipCore/Tests/CachedValueTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class CachedValueTest: XCTestCase { let date = UATestDate(offset: 0, dateOverride: Date()) func testValue() throws { let cachedValue = CachedValue<String>(date: date) cachedValue.set(value: "Hello!", expiresIn: 100) XCTAssertEqual(100.0, cachedValue.timeRemaining) XCTAssertEqual("Hello!", cachedValue.value) date.offset += 99 XCTAssertEqual(1.0, cachedValue.timeRemaining) XCTAssertEqual("Hello!", cachedValue.value) date.offset += 1 XCTAssertEqual(0, cachedValue.timeRemaining) XCTAssertNil(cachedValue.value) } func testValueExpiration() throws { let cachedValue = CachedValue<String>(date: date) cachedValue.set(value: "Hello!", expiration: date.now.advanced(by: 1.0)) XCTAssertEqual(1.0, cachedValue.timeRemaining) XCTAssertEqual("Hello!", cachedValue.value) } } ================================================ FILE: Airship/AirshipCore/Tests/ChallengeResolverTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ChallengeResolverTest: XCTestCase { @MainActor override func tearDown() async throws { ChallengeResolver.shared.resolver = nil } func testResolverReturnsDefaultIfNotConfigured() async { await assertResolve(disposition: .performDefaultHandling, credentials: nil) } @MainActor func testResolverClosure() async { let credentials = URLCredential() ChallengeResolver.shared.resolver = { _ in return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, credentials) } let challenge = URLAuthenticationChallenge( protectionSpace: AirshipProtectionSpace( host: "urbanairship.com", port: 443, protocol: "https", realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust), proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: ChallengeSender()) await assertResolve( disposition: .cancelAuthenticationChallenge, credentials: credentials, challenge: challenge ) } @MainActor func testResolverClosureNotCalledOnNonServerTrust() async { let credentials = URLCredential() ChallengeResolver.shared.resolver = { _ in return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, credentials) } let challenge = URLAuthenticationChallenge( protectionSpace: AirshipProtectionSpace( host: "urbanairship.com", port: 443, protocol: "https", realm: nil, authenticationMethod: NSURLAuthenticationMethodClientCertificate), proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: ChallengeSender()) await assertResolve( disposition: .performDefaultHandling, credentials: nil, challenge: challenge ) } @MainActor func testResolverClosureNotCalledOnNoPublicKey() async { let credentials = URLCredential() ChallengeResolver.shared.resolver = { _ in return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, credentials) } let protectionSpace = AirshipProtectionSpace( host: "urbanairship.com", port: 443, protocol: "https", realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust) protectionSpace.useAirshipCert = false let challenge = URLAuthenticationChallenge( protectionSpace: protectionSpace, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: ChallengeSender()) await assertResolve( disposition: .performDefaultHandling, credentials: nil, challenge: challenge ) } private func assertResolve( disposition: URLSession.AuthChallengeDisposition, credentials: URLCredential? = nil, challenge: URLAuthenticationChallenge? = nil ) async { let actual = await ChallengeResolver.shared.resolve(challenge ?? URLAuthenticationChallenge()) XCTAssertEqual(disposition, actual.0) XCTAssertEqual(credentials, actual.1) } } private class ChallengeSender: NSObject, URLAuthenticationChallengeSender { func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) { } func continueWithoutCredential(for challenge: URLAuthenticationChallenge) { } func cancel(_ challenge: URLAuthenticationChallenge) { } } private class AirshipProtectionSpace: URLProtectionSpace, @unchecked Sendable { var useAirshipCert: Bool = true private func airshipCert() -> SecTrust? { guard let certFilePath = Bundle(for: type(of: self)).path(forResource: "airship", ofType: "der"), let data = NSData(contentsOfFile: certFilePath), let cert = SecCertificateCreateWithData(nil, data) else { return nil } var trust: SecTrust? SecTrustCreateWithCertificates(cert, SecPolicyCreateBasicX509(), &trust) return trust } override var serverTrust: SecTrust? { return useAirshipCert ? airshipCert() : nil } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelAPIClientTest.swift ================================================ import XCTest @testable import AirshipCore final class ChannelAPIClientTest: XCTestCase { private var config: RuntimeConfig = .testConfig() private let session = TestAirshipRequestSession() private var client: ChannelAPIClient! private let encoder = JSONEncoder() override func setUpWithError() throws { self.client = ChannelAPIClient( config: self.config, session: self.session ) } func testCreate() async throws { let payload = ChannelRegistrationPayload() self.session.data = try AirshipJSONUtils.data([ "channel_id": "some-channel-id" ]) self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) let response = try await self.client.createChannel(payload: payload) XCTAssertEqual("some-channel-id", response.result!.channelID) XCTAssertEqual( "https://device-api.urbanairship.com/api/channels/some-channel-id", response.result!.location.absoluteString ) let request = self.session.lastRequest! XCTAssertEqual("POST", request.method) XCTAssertEqual(AirshipRequestAuth.generatedAppToken, request.auth) XCTAssertEqual("https://device-api.urbanairship.com/api/channels/", request.url?.absoluteString) XCTAssertEqual(try! AirshipJSON.wrap(payload), try! AirshipJSON.from(data: request.body)) } func testCreateInvalidResponse() async throws { let payload = ChannelRegistrationPayload() self.session.data = try AirshipJSONUtils.data([ "not-right": "some-channel-id" ]) self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) do { _ = try await self.client.createChannel(payload: payload) XCTFail("Should throw") } catch {} } func testCreateError() async throws { let payload = ChannelRegistrationPayload() self.session.error = AirshipErrors.error("Error!") do { _ = try await self.client.createChannel(payload: payload) XCTFail("Should throw") } catch {} } func testCreateFailed() async throws { let payload = ChannelRegistrationPayload() self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 400, httpVersion: "", headerFields: [String: String]() ) let response = try await self.client.createChannel(payload: payload) XCTAssertEqual(400, response.statusCode) } func testUpdate() async throws { let payload = ChannelRegistrationPayload() self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) let response = try await self.client.updateChannel( "some-channel-id", payload: payload ) XCTAssertEqual("some-channel-id", response.result!.channelID) XCTAssertEqual( "https://device-api.urbanairship.com/api/channels/some-channel-id", response.result!.location.absoluteString ) let request = self.session.lastRequest! XCTAssertEqual("PUT", request.method) XCTAssertEqual(AirshipRequestAuth.channelAuthToken(identifier: "some-channel-id"), request.auth) XCTAssertEqual(try! AirshipJSON.wrap(payload), try! AirshipJSON.from(data: request.body)) XCTAssertEqual("https://device-api.urbanairship.com/api/channels/some-channel-id", request.url?.absoluteString) } func testUpdateError() async throws { let payload = ChannelRegistrationPayload() self.session.error = AirshipErrors.error("Error!") do { _ = try await self.client.updateChannel( "some-channel-id", payload: payload ) XCTFail("Should throw") } catch {} } func testUpdateFailed() async throws { let payload = ChannelRegistrationPayload() self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 400, httpVersion: "", headerFields: [String: String]() ) let response = try await self.client.updateChannel( "some-channel-id", payload: payload ) XCTAssertEqual(400, response.statusCode) } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelAudienceManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore import Combine class ChannelAudienceManagerTest: XCTestCase { private let workManager = TestWorkManager() private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter( notificationCenter: NotificationCenter() ) private let date: UATestDate = UATestDate() private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private let subscriptionListClient: TestSubscriptionListAPIClient = TestSubscriptionListAPIClient() private let updateClient: TestChannelBulkUpdateAPIClient = TestChannelBulkUpdateAPIClient() private let audienceOverridesProvider: DefaultAudienceOverridesProvider = DefaultAudienceOverridesProvider() private var privacyManager: TestPrivacyManager! private var audienceManager: ChannelAudienceManager! @MainActor override func setUp() async throws { self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: RuntimeConfig.testConfig(), defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) self.audienceManager = ChannelAudienceManager( dataStore: self.dataStore, workManager: self.workManager, subscriptionListProvider: ChannelSubscriptionListProvider( audienceOverrides: self.audienceOverridesProvider, apiClient: self.subscriptionListClient, date: self.date ), updateClient: self.updateClient, privacyManager: self.privacyManager, notificationCenter: self.notificationCenter, date: self.date, audienceOverridesProvider: self.audienceOverridesProvider ) self.audienceManager.enabled = true self.audienceManager.channelID = "some-channel" self.workManager.workRequests.removeAll() } func testBackgroundWorkRequest() async throws { XCTAssertEqual(1, self.workManager.backgroundWorkRequests.count) let expected = AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) XCTAssertEqual(expected, self.workManager.backgroundWorkRequests.first) } func testUpdates() async throws { let subscriptionListEditor = self.audienceManager .editSubscriptionLists() subscriptionListEditor.subscribe("pizza") subscriptionListEditor.unsubscribe("coffee") subscriptionListEditor.apply() subscriptionListEditor.subscribe("hotdogs") subscriptionListEditor.apply() let tagEditor = self.audienceManager.editTagGroups( allowDeviceGroup: true ) tagEditor.add(["tag"], group: "some-group") tagEditor.apply() let attributeEditor = self.audienceManager.editAttributes() attributeEditor.set(string: "hello", attribute: "some-attribute") attributeEditor.apply() let activityUpdate = LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 10 ) self.audienceManager.addLiveActivityUpdate(activityUpdate) XCTAssertEqual(5, self.workManager.workRequests.count) let expectation = XCTestExpectation(description: "callback called") self.updateClient.updateCallback = { identifier, update in expectation.fulfill() XCTAssertEqual("some-channel", identifier) XCTAssertEqual(3, update.subscriptionListUpdates.count) XCTAssertEqual(1, update.tagGroupUpdates.count) XCTAssertEqual(1, update.attributeUpdates.count) XCTAssertEqual([activityUpdate], update.liveActivityUpdates) return AirshipHTTPResponse(result: nil, statusCode: 200, headers: [:]) } var result = try? await self.workManager.launchTask( request: AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) ) XCTAssertEqual(result, .success) await fulfillment(of: [expectation]) result = try? await self.workManager.launchTask( request: AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) ) XCTAssertEqual(result, .success) } func testGet() async throws { let expectedLists = ["cool", "story"] self.subscriptionListClient.getCallback = { identifier in XCTAssertEqual("some-channel", identifier) return AirshipHTTPResponse( result: expectedLists, statusCode: 200, headers: [:] ) } let result = try await self.audienceManager.fetchSubscriptionLists() XCTAssertEqual(expectedLists, result) } func testGetCache() async throws { self.date.dateOverride = Date() var apiResult = ["cool", "story"] self.subscriptionListClient.getCallback = { identifier in XCTAssertEqual("some-channel", identifier) return AirshipHTTPResponse( result: apiResult, statusCode: 200, headers: [:] ) } // Populate cache var result = try await self.audienceManager.fetchSubscriptionLists() XCTAssertEqual(["cool", "story"], result) apiResult = ["some-other-result"] // From cache result = try await self.audienceManager.fetchSubscriptionLists() XCTAssertEqual(["cool", "story"], result) self.date.offset += 599 // 1 second before cache should invalidate // From cache result = try await self.audienceManager.fetchSubscriptionLists() XCTAssertEqual(["cool", "story"], result) self.date.offset += 1 // From api result = try await self.audienceManager.fetchSubscriptionLists() XCTAssertEqual(["some-other-result"], result) } func testNoPendingOperations() async throws { let result = try? await self.workManager.launchTask( request: AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) ) XCTAssertEqual(result, .success) XCTAssertEqual(0, self.workManager.workRequests.count) } func testEnableEnqueuesTask() throws { self.audienceManager.enabled = false XCTAssertEqual(0, self.workManager.workRequests.count) self.audienceManager.enabled = true XCTAssertEqual(1, self.workManager.workRequests.count) } func testSetChannelIDEnqueuesTask() throws { self.audienceManager.channelID = nil XCTAssertEqual(0, self.workManager.workRequests.count) self.audienceManager.channelID = "sweet" XCTAssertEqual(1, self.workManager.workRequests.count) } func testPrivacyManagerDisabledIgnoresUpdates() async throws { self.privacyManager.disableFeatures(.tagsAndAttributes) let editor = self.audienceManager.editSubscriptionLists() editor.subscribe("pizza") editor.unsubscribe("coffee") editor.apply() self.updateClient.updateCallback = { identifier, update in return AirshipHTTPResponse(result: nil, statusCode: 200, headers: [:]) } self.privacyManager.enableFeatures(.tagsAndAttributes) _ = try? await self.workManager.launchTask( request: AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) ) } func testMigrateMutations() async throws { let testDate = UATestDate() testDate.dateOverride = Date() let attributePayload = [ "action": "remove", "key": "some-attribute", "timestamp": AirshipDateFormatter.string(fromDate: testDate.now, format: .isoDelimitter) ] let attributeMutation = AttributePendingMutations(mutationsPayload: [ attributePayload ]) let attributeData = try! NSKeyedArchiver.archivedData( withRootObject: [attributeMutation], requiringSecureCoding: true ) dataStore.setObject( attributeData, forKey: ChannelAudienceManager.legacyPendingAttributesKey ) let tagMutation = TagGroupsMutation( adds: ["some-group": Set(["tag"])], removes: nil, sets: nil ) let tagData = try! NSKeyedArchiver.archivedData( withRootObject: [tagMutation], requiringSecureCoding: true ) dataStore.setObject( tagData, forKey: ChannelAudienceManager.legacyPendingTagGroupsKey ) self.audienceManager.migrateMutations() let pending = await self.audienceOverridesProvider.pendingOverrides(channelID: "some-channel") XCTAssertEqual( [ TagGroupUpdate(group: "some-group", tags: ["tag"], type: .add) ], pending?.tags ) XCTAssertEqual( [ AttributeUpdate.remove( attribute: "some-attribute", date: AirshipDateFormatter.date(fromISOString: attributePayload["timestamp"]!)! ) ], pending?.attributes ) } func testGetSubscriptionListOverrides() async throws { await self.audienceOverridesProvider.setStableContactIDProvider { "some contact ID" } await self.audienceOverridesProvider.contactUpdated( contactID: "some contact ID", tags: nil, attributes: nil, subscriptionLists: [ ScopedSubscriptionListUpdate(listId: "bar", type: .subscribe, scope: .app, date: Date()), ScopedSubscriptionListUpdate(listId: "baz", type: .unsubscribe, scope: .app, date: Date()) ], channels: [] ) self.subscriptionListClient.getCallback = { identifier in return AirshipHTTPResponse( result: ["cool", "baz"], statusCode: 200, headers: [:] ) } let result = try await self.audienceManager.fetchSubscriptionLists() XCTAssertEqual(["cool", "bar"], result) } func testSubscriptionListEdits() throws { var edits = [SubscriptionListEdit]() let expectation = self.expectation(description: "Publisher") expectation.expectedFulfillmentCount = 3 let cancellable = self.audienceManager.subscriptionListEdits.sink { edits.append($0) expectation.fulfill() } let editor = self.audienceManager.editSubscriptionLists() editor.unsubscribe("apple") editor.unsubscribe("pen") editor.subscribe("apple pen") editor.apply() self.waitForExpectations(timeout: 10.0) let expected: [SubscriptionListEdit] = [ .unsubscribe("apple"), .unsubscribe("pen"), .subscribe("apple pen"), ] XCTAssertEqual(expected, edits) cancellable.cancel() } func testLiveActivityUpdates() async throws { let activityUpdate = LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 10 ) self.audienceManager.addLiveActivityUpdate(activityUpdate) let expectation = XCTestExpectation(description: "callback called") self.updateClient.updateCallback = { identifier, update in expectation.fulfill() XCTAssertEqual("some-channel", identifier) XCTAssertEqual([activityUpdate], update.liveActivityUpdates) return AirshipHTTPResponse(result: nil, statusCode: 200, headers: [:]) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) ) XCTAssertEqual(result, .success) await fulfillment(of: [expectation]) } func testLiveActivityUpdateAdjustTimestamps() async throws { let activityUpdates = [ LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 10 ), LiveActivityUpdate( action: .remove, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 10 ), LiveActivityUpdate( action: .set, source: .liveActivity(id: "some other foo", name: "bar", startTimeMS: 10), actionTimeMS: 10 ), LiveActivityUpdate( action: .set, source: .liveActivity(id: "something else", name: "something else", startTimeMS: 10), actionTimeMS: 10 ), ] let expected = [ LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 10 ), LiveActivityUpdate( action: .remove, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 11 ), LiveActivityUpdate( action: .set, source: .liveActivity(id: "some other foo", name: "bar", startTimeMS: 10), actionTimeMS: 12 ), LiveActivityUpdate( action: .set, source: .liveActivity(id: "something else", name: "something else", startTimeMS: 10), actionTimeMS: 13 ), ] activityUpdates.forEach { update in self.audienceManager.addLiveActivityUpdate(update) } let expectation = XCTestExpectation(description: "callback called") self.updateClient.updateCallback = { identifier, update in expectation.fulfill() XCTAssertEqual("some-channel", identifier) XCTAssertEqual(expected, update.liveActivityUpdates) return AirshipHTTPResponse(result: nil, statusCode: 200, headers: [:]) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) ) XCTAssertEqual(result, .success) await fulfillment(of: [expectation]) } func testLiveActivityUpdatesStream() async throws { let updates = [ LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 10 ), LiveActivityUpdate( action: .remove, source: .liveActivity(id: "foo", name: "bar", startTimeMS: 10), actionTimeMS: 11 ), LiveActivityUpdate( action: .set, source: .liveActivity(id: "some other foo", name: "bar", startTimeMS: 10), actionTimeMS: 12 ), ] updates.forEach { update in self.audienceManager.addLiveActivityUpdate(update) } let expectation = XCTestExpectation(description: "callback called") self.updateClient.updateCallback = { identifier, update in expectation.fulfill() XCTAssertEqual(updates, update.liveActivityUpdates) return AirshipHTTPResponse(result: nil, statusCode: 200, headers: [:]) } let result = try? await self.workManager.launchTask( request: AirshipWorkRequest( workID: ChannelAudienceManager.updateTaskID ) ) XCTAssertEqual(result, .success) await fulfillment(of: [expectation]) var iterator = self.audienceManager.liveActivityUpdates.makeAsyncIterator() let actualUpdates = await iterator.next() XCTAssertEqual(actualUpdates, updates) } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelAuthTokenAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ChannelAuthTokenAPIClientTest: AirshipBaseTest { private var client: ChannelAuthTokenAPIClient! private let session = TestAirshipRequestSession() override func setUpWithError() throws { self.client = ChannelAuthTokenAPIClient( config: self.config, session: self.session ) } func testTokenWithChannelID() async throws { self.session.data = try AirshipJSONUtils.data([ "token": "abc123", "expires_in": 12345 ] as [String : Any]) self.session.response = HTTPURLResponse( url: URL(string: "https://www.linkedin.com/")!, statusCode: 200, httpVersion: nil, headerFields: nil) let token = try await self.client.fetchToken(channelID: "channel ID") XCTAssertNotNil(token) let request = try XCTUnwrap(self.session.lastRequest) XCTAssertEqual(request.method, "GET") XCTAssertEqual(request.url!.absoluteString, "\(self.config.deviceAPIURL!)/api/auth/device") XCTAssertEqual( AirshipRequestAuth.generatedChannelToken(identifier: "channel ID"), request.auth ) XCTAssertEqual(request.headers["Accept"], "application/vnd.urbanairship+json; version=3;") } func testTokenWithChannelIDMalformedPayload() async throws { self.session.data = try AirshipJSONUtils.data([ "not a token": "abc123", "expires_in_3_2_1": 12345 ] as [String : Any]) self.session.response = HTTPURLResponse( url: URL(string: "https://www.linkedin.com/")!, statusCode: 200, httpVersion: nil, headerFields: nil) do { let _ = try await self.client.fetchToken(channelID: "channel ID") XCTFail("Should throw") } catch { XCTAssertNotNil(error) } } func testTokenWithChannelIDClientError() async throws { self.session.data = try AirshipJSONUtils.data([ "too": "bad" ]) self.session.response = HTTPURLResponse( url: URL(string: "https://www.linkedin.com/")!, statusCode: 400, httpVersion: nil, headerFields: nil) let response = try await self.client.fetchToken(channelID: "channel ID") let unwrapResponse = try XCTUnwrap(response) XCTAssertNil(unwrapResponse.result?.token) XCTAssertTrue(unwrapResponse.isClientError) XCTAssertFalse(unwrapResponse.isSuccess) } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelAuthTokenProviderTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ChannelAuthTokenProviderTest: XCTestCase { var client = TestChannelAuthTokenAPIClient() var channel = TestChannel() var channelID = "channel ID" var testDate = UATestDate(offset: 0, dateOverride: Date()) var provider: ChannelAuthTokenProvider! override func setUpWithError() throws { self.channel.identifier = "channel ID" self.provider = ChannelAuthTokenProvider( channel: channel, apiClient: client, date: testDate ) } func testFetchToken() async throws { self.client.handler = { channelId in let response = ChannelAuthTokenResponse( token: "my token", expiresInMillseconds: 100000 ) return AirshipHTTPResponse( result: response, statusCode: 200, headers: [:]) } try await verifyToken(expected: "my token") } func testTokenCached() async throws { self.client.handler = { channelId in let response = ChannelAuthTokenResponse( token: "my token", expiresInMillseconds: 100000 ) return AirshipHTTPResponse( result: response, statusCode: 200, headers: [:]) } let _ = try await self.provider.resolveAuth(identifier: "channel ID") self.client.handler = { channelId in throw AirshipErrors.error("Failed") } // Should be cached try await verifyToken(expected: "my token") } func testTokenCachedExpired() async throws { self.client.handler = { channelId in let response = ChannelAuthTokenResponse( token: "my token", expiresInMillseconds: 100000 ) return AirshipHTTPResponse( result: response, statusCode: 200, headers: [:]) } let _ = try await self.provider.resolveAuth(identifier: "channel ID") self.client.handler = { channelId in let response = ChannelAuthTokenResponse( token: "some other token", expiresInMillseconds: 100000 ) return AirshipHTTPResponse( result: response, statusCode: 200, headers: [:]) } // Should be cached try await verifyToken(expected: "my token") testDate.offset += 70.0 // 30 second buffer try await verifyToken(expected: "my token") testDate.offset += 1.0 try await verifyToken(expected: "some other token") } private func verifyToken(expected: String, file: StaticString = #filePath, line: UInt = #line) async throws { let token = try await self.provider.resolveAuth(identifier: "channel ID") XCTAssertEqual(expected, token, file: file, line: line) } func testTokenWithNilChannelID() async { self.channel.identifier = nil do { let _ = try await self.provider.resolveAuth(identifier: "channel ID") XCTFail("Should throw") } catch {} } func testTokenMismatchChannelID() async { do { let _ = try await self.provider.resolveAuth(identifier: "some other channel ID") XCTFail("Should throw") } catch {} } func testClientError() async { self.client.handler = { channelId in throw AirshipErrors.error("some error") } do { let _ = try await self.provider.resolveAuth(identifier: "some other channel ID") XCTFail("Should throw") } catch {} } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelBulkUpdateAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class ChannelBulkUpdateAPIClientTest: XCTestCase { private var config: RuntimeConfig = .testConfig() private let session = TestAirshipRequestSession() var client: ChannelBulkUpdateAPIClient! override func setUpWithError() throws { self.client = ChannelBulkUpdateAPIClient( config: self.config, session: self.session ) } func testUpdate() async throws { let date = Date() self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) let update = AudienceUpdate( subscriptionListUpdates: [ SubscriptionListUpdate( listId: "coffee", type: .unsubscribe ), SubscriptionListUpdate( listId: "pizza", type: .subscribe ), ], tagGroupUpdates: [ TagGroupUpdate( group: "some-group", tags: ["tag-1", "tag-2"], type: .add ), TagGroupUpdate( group: "some-other-group", tags: ["tag-3", "tag-4"], type: .set ), ], attributeUpdates: [ AttributeUpdate( attribute: "some-attribute", type: .set, jsonValue: "hello", date: date ) ] ) let response = try await self.client.update( update, channelID: "some-channel" ) XCTAssertEqual(response.statusCode, 200) let expectedBody = [ "subscription_lists": [ [ "action": "unsubscribe", "list_id": "coffee", ], [ "action": "subscribe", "list_id": "pizza", ], ], "tags": [ "add": [ "some-group": ["tag-1", "tag-2"] ], "set": [ "some-other-group": ["tag-3", "tag-4"] ], ], "attributes": [ [ "action": "set", "key": "some-attribute", "timestamp": AirshipDateFormatter.string(fromDate: date, format: .isoDelimitter), "value": "hello", ] ], ] as NSDictionary let lastRequest = self.session.lastRequest! let body = AirshipJSONUtils.object(String(data: lastRequest.body!, encoding: .utf8)!) as? NSDictionary XCTAssertEqual("PUT", lastRequest.method) XCTAssertEqual(expectedBody, body) let url = lastRequest.url XCTAssertEqual( "https://device-api.urbanairship.com/api/channels/sdk/batch/some-channel?platform=ios", url?.absoluteString ) } func testUpdateError() async throws { let sessionError = AirshipErrors.error("error!") self.session.error = sessionError let update = AudienceUpdate( subscriptionListUpdates: [ SubscriptionListUpdate( listId: "coffee", type: .unsubscribe ), SubscriptionListUpdate( listId: "pizza", type: .subscribe ), ] ) do { _ = try await self.client.update( update, channelID: "some-channel" ) } catch { XCTAssertEqual(sessionError as NSError, error as NSError) } } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelCaptureTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ChannelCaptureTest: XCTestCase { private var config: AirshipConfig = AirshipConfig() private let channel: TestChannel = TestChannel() private let pasteboard: TestPasteboard = TestPasteboard() private let notificationCenter: NotificationCenter = NotificationCenter() private let date: UATestDate = UATestDate() private var channelCapture: (any AirshipChannelCapture)! @MainActor override func setUpWithError() throws { self.date.dateOverride = Date() self.config.isChannelCaptureEnabled = true self.channel.identifier = UUID().uuidString self.channelCapture = DefaultAirshipChannelCapture( config: .testConfig(), channel: channel, notificationCenter: notificationCenter, date: date, pasteboard: pasteboard ) } func testCapture() throws { knock(times: 6) let (text, expiry) = self.pasteboard.lastCopy! XCTAssertEqual("ua:\(self.channel.identifier!)", text) XCTAssertEqual(expiry, 60) } func testCaptureNilIdentifier() throws { self.channel.identifier = nil knock(times: 6) let (text, expiry) = self.pasteboard.lastCopy! XCTAssertEqual("ua:", text) XCTAssertEqual(expiry, 60) } func testKnock() throws { knock(times: 5) XCTAssertNil(self.pasteboard.lastCopy) self.date.offset += 30 XCTAssertNil(self.pasteboard.lastCopy) knock(times: 1) XCTAssertNotNil(self.pasteboard.lastCopy) } func testKnockTooSlow() throws { knock(times: 5) XCTAssertNil(self.pasteboard.lastCopy) self.date.offset += 31 XCTAssertNil(self.pasteboard.lastCopy) knock(times: 1) XCTAssertNil(self.pasteboard.lastCopy) } private func knock(times: UInt = 1) { for _ in 1...times { self.notificationCenter.post( name: AppStateTracker.didTransitionToForeground, object: nil ) } } } fileprivate final class TestPasteboard: AirshipPasteboardProtocol, @unchecked Sendable { var lastCopy: (String, TimeInterval)? func copy(value: String) { lastCopy = (value, -1) } func copy(value: String, expiry: TimeInterval) { lastCopy = (value, expiry) } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelRegistrarTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Combine @testable import AirshipCore import Foundation @Suite(.timeLimit(.minutes(1))) struct ChannelRegistrarTest { let dataStore: PreferenceDataStore let client: TestChannelRegistrationClient let date: UATestDate let workManager: TestWorkManager let appStateTracker: TestAppStateTracker let payloadProvider: ChannelRegistrationPayloadProvider let workID: String let channelRegistrar: ChannelRegistrar init() async throws { self.dataStore = PreferenceDataStore(appKey: UUID().uuidString) self.client = TestChannelRegistrationClient() self.date = UATestDate() self.workManager = TestWorkManager() self.appStateTracker = TestAppStateTracker() self.payloadProvider = ChannelRegistrationPayloadProvider() self.workID = "UAChannelRegistrar.registration" let dataStore = self.dataStore let client = self.client let date = self.date let workManager = self.workManager let appStateTracker = self.appStateTracker let payloadProvider = self.payloadProvider self.channelRegistrar = await MainActor.run { let registrar = ChannelRegistrar( dataStore: dataStore, channelAPIClient: client, date: date, workManager: workManager, appStateTracker: appStateTracker, channelCreateMethod: { return .automatic }, privacyManager: TestPrivacyManager( dataStore: dataStore, config: RuntimeConfig.testConfig(), defaultEnabledFeatures: AirshipFeature.all ) ) registrar.payloadCreateBlock = { await payloadProvider.getPayload() } return registrar } } actor ChannelRegistrationPayloadProvider { private var payload: ChannelRegistrationPayload init(deviceModel: String? = nil, appVersion: String? = nil) { var payload = ChannelRegistrationPayload() payload.channel.deviceModel = deviceModel ?? UUID().uuidString payload.channel.appVersion = appVersion ?? "test" self.payload = payload } func updatePayload(update: @Sendable @escaping(inout ChannelRegistrationPayload) -> Void ) { update(&payload) } func getPayload() -> ChannelRegistrationPayload { payload } } @Test("Register") func register() async throws { #expect(self.workManager.workRequests.count == 0) self.channelRegistrar.register(forcefully: false) #expect(self.workManager.workRequests.count == 1) let extras = ["forcefully": "false"] let request = self.workManager.workRequests[0] #expect(request.workID == workID) #expect(request.conflictPolicy == .keepIfNotStarted) #expect(request.extras == extras) #expect(request.initialDelay == 0) } @Test("Register forcefully") func registerForcefully() async throws { #expect(self.workManager.workRequests.count == 0) self.channelRegistrar.register(forcefully: true) #expect(self.workManager.workRequests.count == 1) let extras = ["forcefully": "true"] let request = self.workManager.workRequests[0] #expect(request.workID == workID) #expect(request.conflictPolicy == .replace) #expect(request.extras == extras) #expect(request.initialDelay == 0) } @Test("Create channel") func createChannel() async throws { var stream = await self.channelRegistrar.registrationUpdates.makeStream().makeAsyncIterator() let payload = await payloadProvider.getPayload() await MainActor.run { self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return payload } } self.client.createCallback = { channelPayload in #expect(channelPayload == payload) return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: "some-channel-id", location: try self.client.makeChannelLocation( channelID: "some-channel-id" ) ), statusCode: 201, headers: [:]) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) let update = await stream.next() #expect(update == .created(channelID: "some-channel-id", isExisting: false)) } @Test("Create channel restores") func createChannelRestores() async throws { let restoredUUID = UUID().uuidString let channelRegistrar = await MainActor.run { ChannelRegistrar( dataStore: self.dataStore, channelAPIClient: self.client, date: self.date, workManager: self.workManager, appStateTracker: self.appStateTracker, channelCreateMethod: { return .restore(channelID: restoredUUID) }, privacyManager: TestPrivacyManager( dataStore: self.dataStore, config: RuntimeConfig.testConfig(), defaultEnabledFeatures: AirshipFeature.all ) ) } var stream = await channelRegistrar.registrationUpdates.makeStream().makeAsyncIterator() let payload = await payloadProvider.getPayload() await MainActor.run { channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return payload } } self.client.createCallback = { channelPayload in Issue.record("Should not create") throw AirshipErrors.error("") } self.client.updateCallback = { channelID, channelPayload in #expect(restoredUUID == channelID) #expect(channelPayload.channel.deviceModel == nil) // minimized return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: restoredUUID, location: try self.client.makeChannelLocation( channelID: restoredUUID ) ), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) var update = await stream.next() #expect(update == .created(channelID: restoredUUID, isExisting: true)) update = await stream.next() #expect(update == .updated(channelID: restoredUUID)) #expect(result == .success) } @Test("Restore fall back to create on invalid ID") func restoreFallBackToCreateOnInvalidID() async throws { let channelRegistrar = await MainActor.run { ChannelRegistrar( dataStore: self.dataStore, channelAPIClient: self.client, date: self.date, workManager: self.workManager, appStateTracker: self.appStateTracker, channelCreateMethod: { return .restore(channelID: "invalid-uuid") }, privacyManager: TestPrivacyManager( dataStore: self.dataStore, config: RuntimeConfig.testConfig(), defaultEnabledFeatures: AirshipFeature.all ) ) } let payload = await payloadProvider.getPayload() await MainActor.run { channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return payload } } try await confirmation { confirm in self.client.createCallback = { channelPayload in #expect(channelPayload == payload) confirm() return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: "some-channel-id", location: try self.client.makeChannelLocation( channelID: "some-channel-id" ) ), statusCode: 201, headers: [:]) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) } } @Test("Create channel existing") func createChannelExisting() async throws { var stream = await self.channelRegistrar.registrationUpdates.makeStream().makeAsyncIterator() let payload = await payloadProvider.getPayload() self.client.createCallback = { channelPayload in #expect(channelPayload == payload) return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: "some-channel-id", location: try self.client.makeChannelLocation( channelID: "some-channel-id" ) ), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) let update = await stream.next() #expect(update == .created(channelID: "some-channel-id", isExisting: true)) } @Test("Create channel error") func createChannelError() async throws { self.client.createCallback = { channelPayload in throw AirshipErrors.error("Some error") } do { _ = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) Issue.record("Should throw") } catch { } } @Test("Create channel server error") func createChannelServerError() async throws { self.client.createCallback = { channelPayload in return AirshipHTTPResponse( result: nil, statusCode: 500, headers: [:]) } let payload = await payloadProvider.getPayload() await MainActor.run { self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return payload } } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .failure) } @Test("Create channel client error") func createChannelClientError() async throws { self.client.createCallback = { channelPayload in return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } let _ = await payloadProvider.getPayload() let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) } @Test("Update not configured") func updateNotConfigured() async { self.client.isURLConfigured = false self.channelRegistrar.register(forcefully: true) self.channelRegistrar.register(forcefully: false) #expect(self.workManager.workRequests.count == 0) self.client.isURLConfigured = true self.channelRegistrar.register(forcefully: true) self.channelRegistrar.register(forcefully: false) #expect(self.workManager.workRequests.count == 2) } @Test("Create channel 429 error") func createChannel429Error() async throws { self.client.createCallback = { channelPayload in return AirshipHTTPResponse( result: nil, statusCode: 429, headers: [:]) } let payload = await payloadProvider.getPayload() await MainActor.run { self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return payload } } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .failure) } @Test("Update channel") func updateChannel() async throws { var stream = await self.channelRegistrar.registrationUpdates.makeStream().makeAsyncIterator() let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) await payloadProvider.updatePayload { payload in payload.channel.deviceModel = UUID().uuidString } let payload = await payloadProvider.getPayload() self.client.updateCallback = { channelID, channelPayload in #expect(someChannelID == channelID) #expect( channelPayload.channel.deviceModel == payload.channel.deviceModel ) return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: someChannelID, location: try self.client.makeChannelLocation( channelID: someChannelID ) ), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) var update = await stream.next() #expect(update == .created(channelID: someChannelID, isExisting: true)) update = await stream.next() #expect(update == .updated(channelID: someChannelID)) } @Test("Update channel error") func updateChannelError() async throws { let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) await payloadProvider.updatePayload { payload in payload.channel.deviceModel = UUID().uuidString } self.client.updateCallback = { channelID, channelPayload in throw AirshipErrors.error("Error!") } do { _ = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) Issue.record("Should throw") } catch {} } @Test("Update channel server error") func updateChannelServerError() async throws { let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) await payloadProvider.updatePayload { payload in payload.channel.deviceModel = UUID().uuidString } self.client.updateCallback = { channelID, channelPayload in return AirshipHTTPResponse( result: nil, statusCode: 500, headers: [:]) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .failure) } @Test("Update channel client error") func updateChannelClientError() async throws { let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) let payload = await payloadProvider.getPayload() await MainActor.run { self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return payload } } self.client.updateCallback = { channelID, channelPayload in return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:]) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) } @Test("Update channel 429 error") @MainActor func updateChannel429Error() async throws { let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) await payloadProvider.updatePayload { payload in payload.channel.deviceModel = UUID().uuidString } self.client.updateCallback = { channelID, channelPayload in return AirshipHTTPResponse( result: nil, statusCode: 429, headers: [:]) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .failure) } @Test("Skip update channel up to date") func skipUpdateChannelUpToDate() async throws { let payload = await payloadProvider.getPayload() await MainActor.run { self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return payload } } let someChannelID = UUID().uuidString // Create the channel try await createChannel(channelID: someChannelID) let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) } @Test("Update forcefully") func updateForcefully() async throws { var stream = await self.channelRegistrar.registrationUpdates.makeStream().makeAsyncIterator() let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) self.client.updateCallback = { channelID, channelPayload in return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: someChannelID, location: try self.client.makeChannelLocation( channelID: someChannelID ) ), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID, extras: ["forcefully": "true"] ) ) #expect(result == .success) var update = await stream.next() #expect(update == .created(channelID: someChannelID, isExisting: true)) update = await stream.next() #expect(update == .updated(channelID: someChannelID)) } @Test("Update location changed") func updateLocationChanged() async throws { var stream = await self.channelRegistrar.registrationUpdates.makeStream().makeAsyncIterator() let someChannelID = UUID().uuidString let payload = await payloadProvider.getPayload() try await createChannel(channelID: someChannelID) self.client.channelLocation = { _ in return URL(string: "some:otherlocation")! } self.client.updateCallback = { channelID, channelPayload in #expect(payload == channelPayload) #expect(payload.minimizePayload(previous: payload) != channelPayload) return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: someChannelID, location: try self.client.makeChannelLocation( channelID: someChannelID ) ), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(result == .success) var update = await stream.next() #expect(update == .created(channelID: someChannelID, isExisting: true)) update = await stream.next() #expect(update == .updated(channelID: someChannelID)) } @Test("Update min payload") func updateMinPayload() async throws { var stream = await self.channelRegistrar.registrationUpdates.makeStream().makeAsyncIterator() let someChannelID = UUID().uuidString let firstPayload = await self.payloadProvider.getPayload() try await createChannel(channelID: someChannelID) await self.payloadProvider.updatePayload { payload in payload.channel.deviceModel = UUID().uuidString } let secondPayload = await payloadProvider.getPayload() self.client.updateCallback = { channelID, channelPayload in #expect( secondPayload.minimizePayload( previous: firstPayload ) == channelPayload ) #expect(secondPayload != channelPayload) return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: someChannelID, location: try self.client.makeChannelLocation( channelID: someChannelID ) ), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(result == .success) var update = await stream.next() #expect(update == .created(channelID: someChannelID, isExisting: true)) update = await stream.next() #expect(update == .updated(channelID: someChannelID)) } @Test("Update after 24 hours") @MainActor func updateAfter24Hours() async throws { self.appStateTracker.currentState = .active self.date.dateOverride = Date() let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) var updateCount = 0 self.client.updateCallback = { channelID, channelPayload in updateCount += 1 return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: someChannelID, location: try self.client.makeChannelLocation( channelID: someChannelID ) ), statusCode: 200, headers: [:] ) } _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(updateCount == 0) // Forward to almost 1 second before 24 hours self.date.offset = 24 * 60 * 60 - 1 _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(updateCount == 0) // 24 hours self.date.offset += 1 _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(updateCount == 1) } @Test("Full payload upload after 24 hours") @MainActor func fullPayloadUploadAfter24Hours() async throws { self.appStateTracker.currentState = .active self.date.dateOverride = Date() let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) let payload = await payloadProvider.getPayload() var updatePayload: ChannelRegistrationPayload? = nil self.client.updateCallback = { channelID, channelPayload in updatePayload = channelPayload return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: someChannelID, location: try self.client.makeChannelLocation( channelID: someChannelID ) ), statusCode: 200, headers: [:] ) } _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(updatePayload == nil) self.date.offset = 24 * 60 * 60 - 1 _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(updatePayload == nil) // 24 hours self.date.offset += 2 _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(payload == updatePayload) } @Test("Empty last full registration") @MainActor func emptyLastFullRegistration() async throws { self.appStateTracker.currentState = .active self.date.dateOverride = Date() let someChannelID = UUID().uuidString try await createChannel(channelID: someChannelID) var registrationInfo: LastRegistrationInfo = self.dataStore.safeCodable(forKey: "ChannelRegistrar.lastRegistrationInfo")! registrationInfo.lastFullPayloadSent = nil self.dataStore.setSafeCodable(registrationInfo, forKey: "ChannelRegistrar.lastRegistrationInfo") let payload = await payloadProvider.getPayload() var updatePayload: ChannelRegistrationPayload? = nil self.client.updateCallback = { channelID, channelPayload in updatePayload = channelPayload return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: someChannelID, location: try self.client.makeChannelLocation( channelID: someChannelID ) ), statusCode: 200, headers: [:] ) } _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) #expect(payload == updatePayload) updatePayload = nil _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: workID ) ) // No update #expect(updatePayload == nil) } private func createChannel(channelID: String) async throws { // Set a payload since the create flow now requires one let _ = await payloadProvider.getPayload() self.client.createCallback = { _ in return AirshipHTTPResponse( result: ChannelAPIResponse( channelID: channelID, location: try self.client.makeChannelLocation( channelID: channelID ) ), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest(workID: workID) ) #expect(result == .success) } } fileprivate struct LastRegistrationInfo: Codable { var date: Date var payload: ChannelRegistrationPayload var lastFullPayloadSent: Date? var location: URL } internal class TestChannelRegistrationClient: ChannelAPIClientProtocol, @unchecked Sendable { var isURLConfigured: Bool = true var createCallback:((ChannelRegistrationPayload) async throws -> AirshipHTTPResponse<ChannelAPIResponse>)? var updateCallback: ((String, ChannelRegistrationPayload) async throws -> AirshipHTTPResponse<ChannelAPIResponse>)? var channelLocation: ((String) throws -> URL)? func makeChannelLocation(channelID: String) throws -> URL { guard let channelLocation = channelLocation else { return URL(string: "channel:\(channelID)")! } return try channelLocation(channelID) } func createChannel( payload: ChannelRegistrationPayload ) async throws -> AirshipHTTPResponse<ChannelAPIResponse> { return try await createCallback!(payload) } func updateChannel( _ channelID: String, payload: ChannelRegistrationPayload ) async throws -> AirshipHTTPResponse<ChannelAPIResponse> { return try await updateCallback!(channelID, payload) } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelRegistrationPayloadTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ChannelRegistrationPayloadTest: XCTestCase { private lazy var fullPayload: ChannelRegistrationPayload = { let quietTime = ChannelRegistrationPayload.QuietTime( start: "16:00", end: "16:01" ) var fullPayload = ChannelRegistrationPayload() fullPayload.identityHints = ChannelRegistrationPayload.IdentityHints() fullPayload.channel.iOSChannelSettings = ChannelRegistrationPayload.iOSChannelSettings() // set up the full payload fullPayload.channel.isOptedIn = true fullPayload.channel.isBackgroundEnabled = true fullPayload.channel.pushAddress = "FAKEADDRESS" fullPayload.identityHints?.userID = "fakeUser" fullPayload.channel.contactID = "some-contact-id" fullPayload.channel.iOSChannelSettings?.badge = 1 fullPayload.channel.iOSChannelSettings?.quietTime = quietTime fullPayload.channel.iOSChannelSettings?.quietTimeTimeZone = "quietTimeTimeZone" fullPayload.channel.timeZone = "timezone" fullPayload.channel.language = "language" fullPayload.channel.country = "country" fullPayload.channel.tags = ["tagOne", "tagTwo"] fullPayload.channel.setTags = true fullPayload.channel.sdkVersion = "SDKVersion" fullPayload.channel.appVersion = "appVersion" fullPayload.channel.deviceModel = "deviceModel" fullPayload.channel.deviceOS = "deviceOS" return fullPayload }() func testMinimalUpdatePayloadSameValues() { let minPayload = self.fullPayload.minimizePayload( previous: self.fullPayload ) var expected = self.fullPayload expected.channel.appVersion = nil expected.channel.deviceModel = nil expected.channel.setTags = false expected.channel.tags = nil expected.channel.country = nil expected.channel.language = nil expected.channel.deviceOS = nil expected.channel.timeZone = nil expected.channel.sdkVersion = nil expected.identityHints = nil expected.channel.iOSChannelSettings?.isTimeSensitive = nil expected.channel.iOSChannelSettings?.isScheduledSummary = nil XCTAssertEqual(expected, minPayload) } func testMinimalUpdateDifferentValues() { var otherPayload = self.fullPayload otherPayload.channel.appVersion = UUID().uuidString otherPayload.channel.deviceModel = UUID().uuidString otherPayload.channel.tags = ["some other tag"] otherPayload.channel.country = UUID().uuidString otherPayload.channel.language = UUID().uuidString otherPayload.channel.deviceOS = UUID().uuidString otherPayload.channel.timeZone = UUID().uuidString otherPayload.channel.sdkVersion = UUID().uuidString otherPayload.identityHints?.userID = UUID().uuidString otherPayload.channel.iOSChannelSettings?.isTimeSensitive = true otherPayload.channel.iOSChannelSettings?.isScheduledSummary = true let minPayload = otherPayload.minimizePayload( previous: self.fullPayload ) var expected = otherPayload expected.identityHints = nil expected.channel.tagChanges = ChannelRegistrationPayload.TagChanges( adds: otherPayload.channel.tags!, removes: fullPayload.channel.tags! ) XCTAssertEqual(expected, minPayload) } func testMinimalUpdateDifferentContact() { var otherPayload = self.fullPayload otherPayload.channel.contactID = UUID().uuidString let minPayload = otherPayload.minimizePayload( previous: self.fullPayload ) var expected = otherPayload expected.channel.setTags = false expected.channel.tags = nil expected.identityHints = nil expected.channel.iOSChannelSettings?.isTimeSensitive = nil expected.channel.iOSChannelSettings?.isScheduledSummary = nil XCTAssertEqual(expected, minPayload) } } ================================================ FILE: Airship/AirshipCore/Tests/ChannelTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore import UIKit @Suite(.timeLimit(.minutes(1))) struct ChannelTest { let channelRegistrar: TestChannelRegistrar let localeManager: TestLocaleManager let audienceManager: TestChannelAudienceManager let appStateTracker: TestAppStateTracker let notificationCenter: AirshipNotificationCenter let dataStore: PreferenceDataStore let config: AirshipConfig let privacyManager: TestPrivacyManager let permissionsManager: DefaultAirshipPermissionsManager let channel: DefaultAirshipChannel // Helper to wait for async conditions with timeout private func waitForCondition( timeout: Duration = .seconds(2), pollingInterval: Duration = .milliseconds(10), condition: @escaping () async -> Bool ) async throws { let deadline = ContinuousClock.now + timeout while ContinuousClock.now < deadline { if await condition() { return } try await Task.sleep(for: pollingInterval) } throw NSError(domain: "TestTimeout", code: 1, userInfo: [NSLocalizedDescriptionKey: "Condition not met within timeout"]) } init() async throws { self.channelRegistrar = TestChannelRegistrar() self.localeManager = TestLocaleManager() self.audienceManager = TestChannelAudienceManager() self.appStateTracker = TestAppStateTracker() self.notificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) self.dataStore = PreferenceDataStore(appKey: UUID().uuidString) self.config = AirshipConfig() self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: RuntimeConfig.testConfig(), defaultEnabledFeatures: [], notificationCenter: self.notificationCenter ) self.permissionsManager = await DefaultAirshipPermissionsManager() self.channel = await Self.createChannel( dataStore: self.dataStore, config: self.config, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, localeManager: self.localeManager, audienceManager: self.audienceManager, channelRegistrar: self.channelRegistrar, notificationCenter: self.notificationCenter, appStateTracker: self.appStateTracker ) } @MainActor private static func createChannel( dataStore: PreferenceDataStore, config: AirshipConfig, privacyManager: TestPrivacyManager, permissionsManager: DefaultAirshipPermissionsManager, localeManager: TestLocaleManager, audienceManager: TestChannelAudienceManager, channelRegistrar: TestChannelRegistrar, notificationCenter: AirshipNotificationCenter, appStateTracker: TestAppStateTracker ) -> DefaultAirshipChannel { return DefaultAirshipChannel( dataStore: dataStore, config: RuntimeConfig.testConfig(airshipConfig: config), privacyManager: privacyManager, permissionsManager: permissionsManager, localeManager: localeManager, audienceManager: audienceManager, channelRegistrar: channelRegistrar, notificationCenter: notificationCenter, appStateTracker: appStateTracker ) } @Test("Registration feature enabled") @MainActor func registrationFeatureEnabled() async throws { #expect(!self.channelRegistrar.registerCalled) self.privacyManager.enableFeatures(.tagsAndAttributes) // Allow notification to propagate to observers try await Task.sleep(for: .milliseconds(100)) #expect(self.channelRegistrar.registerCalled) } @Test("Tags") func tags() throws { self.privacyManager.enableFeatures(.tagsAndAttributes) self.channelRegistrar.registerCalled = false self.channel.tags = ["foo", "bar"] #expect(self.channel.tags == ["foo", "bar"]) #expect(self.channelRegistrar.registerCalled) } @Test("Edit tags") func editTags() throws { self.privacyManager.enableFeatures(.tagsAndAttributes) self.channelRegistrar.registerCalled = false self.channel.editTags { editor in editor.add(["foo", "bar"]) editor.remove(["foo"]) editor.add(["baz"]) } #expect(self.channel.tags == ["bar", "baz"]) #expect(self.channelRegistrar.registerCalled) } @Test("Tags disabled") func tagsDisabled() throws { self.privacyManager.disableFeatures(.tagsAndAttributes) self.channelRegistrar.registerCalled = false self.channel.tags = ["neat"] self.channel.editTags { editor in editor.add(["foo", "bar"]) } #expect(self.channel.tags == []) #expect(!self.channelRegistrar.registerCalled) } @Test("Clear tags privacy manager disabled") func clearTagsPrivacyManagerDisabled() throws { self.privacyManager.enableFeatures(.tagsAndAttributes) self.channel.tags = ["neat"] self.privacyManager.disableFeatures(.tagsAndAttributes) #expect(self.channel.tags == []) } @Test("Normalize tags") func normalizeTags() throws { self.privacyManager.enableFeatures(.tagsAndAttributes) self.channel.tags = [ "함", "함수 목록", " neat ", "1", " ", "", String(repeating: "함", count: 128), String(repeating: "g", count: 128), String(repeating: "b", count: 129), ] let expected = [ "함", "함수 목록", "neat", "1", String(repeating: "함", count: 128), String(repeating: "g", count: 128), ] #expect(self.channel.tags == expected) } @Test("Channel creation flag disabled") @MainActor func channelCreationFlagDisabled() async throws { self.privacyManager.enableFeatures(.tagsAndAttributes) var config = self.config config.isChannelCreationDelayEnabled = true self.channelRegistrar.registerCalled = false _ = Self.createChannel( dataStore: self.dataStore, config: config, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, localeManager: self.localeManager, audienceManager: self.audienceManager, channelRegistrar: self.channelRegistrar, notificationCenter: self.notificationCenter, appStateTracker: self.appStateTracker ) #expect(!self.channelRegistrar.registerCalled) } @Test("Enable channel creation") func enableChannelCreation() async throws { self.privacyManager.enableFeatures(.tagsAndAttributes) var config = self.config config.isChannelCreationDelayEnabled = true self.channelRegistrar.registerCalled = false let channel = await Self.createChannel( dataStore: self.dataStore, config: config, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, localeManager: self.localeManager, audienceManager: self.audienceManager, channelRegistrar: self.channelRegistrar, notificationCenter: self.notificationCenter, appStateTracker: self.appStateTracker ) channel.enableChannelCreation() #expect(self.channelRegistrar.registerCalled) } @Test("CRA payload") @MainActor func craPayload() async throws { self.privacyManager.enableFeatures(.all) let locationPermission = TestPermissionsDelegate() locationPermission.permissionStatus = .granted let notificationPermission = TestPermissionsDelegate() notificationPermission.permissionStatus = .denied self.permissionsManager.setDelegate(locationPermission, permission: .location) self.permissionsManager.setDelegate(notificationPermission, permission: .displayNotifications) self.channel.tags = ["foo", "bar"] var expectedPayload = ChannelRegistrationPayload() expectedPayload.channel.language = Locale.autoupdatingCurrent.getLanguageCode() expectedPayload.channel.country = Locale.autoupdatingCurrent.region?.identifier expectedPayload.channel.timeZone = TimeZone.autoupdatingCurrent.identifier expectedPayload.channel.tags = ["foo", "bar"] expectedPayload.channel.appVersion = AirshipUtils.bundleShortVersionString() expectedPayload.channel.sdkVersion = AirshipVersion.version expectedPayload.channel.deviceOS = UIDevice.current.systemVersion expectedPayload.channel.deviceModel = AirshipDevice.modelIdentifier expectedPayload.channel.setTags = true expectedPayload.channel.permissions = [ "location": "granted", "display_notifications": "denied" ] await MainActor.run { [expectedPayload] in self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return expectedPayload } } let payload = await self.channelRegistrar.channelPayload #expect(expectedPayload == payload) } @Test("CRA payload permission on no feature") @MainActor func craPayloadPermissionOnNoFeature() async throws { self.privacyManager.enableFeatures(.all) self.privacyManager.disableFeatures(.tagsAndAttributes) let locationPermission = TestPermissionsDelegate() locationPermission.permissionStatus = .granted let notificationPermission = TestPermissionsDelegate() notificationPermission.permissionStatus = .denied self.permissionsManager.setDelegate(locationPermission, permission: .location) self.permissionsManager.setDelegate(notificationPermission, permission: .displayNotifications) self.channel.tags = ["foo", "bar"] var expectedPayload = ChannelRegistrationPayload() expectedPayload.channel.language = Locale.autoupdatingCurrent.getLanguageCode() expectedPayload.channel.country = Locale.autoupdatingCurrent.region?.identifier expectedPayload.channel.timeZone = TimeZone.autoupdatingCurrent.identifier expectedPayload.channel.tags = [] expectedPayload.channel.appVersion = AirshipUtils.bundleShortVersionString() expectedPayload.channel.sdkVersion = AirshipVersion.version expectedPayload.channel.deviceOS = UIDevice.current.systemVersion expectedPayload.channel.deviceModel = AirshipDevice.modelIdentifier expectedPayload.channel.setTags = true expectedPayload.channel.permissions = nil await MainActor.run { [expectedPayload] in self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return expectedPayload } } let payload = await self.channelRegistrar.channelPayload #expect(expectedPayload == payload) } @Test("CRA payload minify") @MainActor func craPayloadMinify() async throws { self.privacyManager.enableFeatures(.all) let locationPermission = TestPermissionsDelegate() locationPermission.permissionStatus = .granted let notificationPermission = TestPermissionsDelegate() notificationPermission.permissionStatus = .denied self.permissionsManager.setDelegate(locationPermission, permission: .location) self.permissionsManager.setDelegate(notificationPermission, permission: .displayNotifications) self.channel.tags = ["foo", "bar"] var expectedPayload = ChannelRegistrationPayload() expectedPayload.channel.language = Locale.autoupdatingCurrent.getLanguageCode() expectedPayload.channel.country = Locale.autoupdatingCurrent.region?.identifier expectedPayload.channel.timeZone = TimeZone.autoupdatingCurrent.identifier expectedPayload.channel.tags = ["foo", "bar"] expectedPayload.channel.appVersion = AirshipUtils.bundleShortVersionString() expectedPayload.channel.sdkVersion = AirshipVersion.version expectedPayload.channel.deviceOS = UIDevice.current.systemVersion expectedPayload.channel.deviceModel = AirshipDevice.modelIdentifier expectedPayload.channel.setTags = true expectedPayload.channel.permissions = [ "location": "granted", "display_notifications": "denied" ] await MainActor.run { [expectedPayload] in self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return expectedPayload } } let payload = await self.channelRegistrar.channelPayload #expect(expectedPayload == payload) notificationPermission.permissionStatus = .granted var expectedMinimized = ChannelRegistrationPayload() expectedMinimized.channel.permissions = [ "display_notifications": "granted", "location": "granted", ] await MainActor.run { [expectedMinimized] in self.channelRegistrar.payloadCreateBlock = { @Sendable () async -> ChannelRegistrationPayload? in return expectedMinimized } } let minimized = await self.channelRegistrar.channelPayload.minimizePayload(previous: payload) await MainActor.run { [expectedMinimized] in #expect(expectedMinimized == minimized) } } @Test("CRA payload disabled device tags") func craPayloadDisabledDeviceTags() async throws { self.privacyManager.enableFeatures(.all) self.channel.isChannelTagRegistrationEnabled = false self.channel.tags = ["foo", "bar"] var expectedPayload = ChannelRegistrationPayload() expectedPayload.channel.language = Locale.autoupdatingCurrent.getLanguageCode() expectedPayload.channel.country = Locale.autoupdatingCurrent.region?.identifier expectedPayload.channel.timeZone = TimeZone.autoupdatingCurrent.identifier expectedPayload.channel.appVersion = AirshipUtils.bundleShortVersionString() expectedPayload.channel.sdkVersion = AirshipVersion.version expectedPayload.channel.deviceOS = await AirshipDevice.osVersion expectedPayload.channel.deviceModel = AirshipDevice.modelIdentifier expectedPayload.channel.setTags = false expectedPayload.channel.permissions = [:] let payload = await self.channelRegistrar.channelPayload #expect(expectedPayload == payload) } @Test("CRA payload privacy manager disabled") func craPayloadPrivacyManagerDisabled() async throws { var expectedPayload = ChannelRegistrationPayload() expectedPayload.channel.setTags = true expectedPayload.channel.tags = [] let payload = await self.channelRegistrar.channelPayload #expect(expectedPayload == payload) } @Test("Extending CRA payload") func extendingCRAPayload() async throws { self.privacyManager.enableFeatures(.all) await self.channel.addRegistrationExtender { payload in payload.channel.pushAddress = "WHAT" } await self.channel.addRegistrationExtender { payload in payload.channel.pushAddress = "OK" } let payload = await self.channelRegistrar.channelPayload #expect(payload.channel.pushAddress == "OK") } @Test("Application did transition to foreground") func applicationDidTransitionToForeground() throws { self.privacyManager.enableFeatures(.all) self.channelRegistrar.registerCalled = false self.notificationCenter.post( name: AppStateTracker.didTransitionToForeground, object: nil ) #expect(self.channelRegistrar.registerCalled) } @Test("Existing channel created notification") @MainActor func existingChannelCreatedNotification() async throws { self.privacyManager.enableFeatures(.all) // Send the registration update await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someChannelID", isExisting: true ) ) // Wait for the async Task to process the update and update audience manager try await waitForCondition { self.audienceManager.channelID == "someChannelID" } // Verify the channel ID was set correctly #expect(self.audienceManager.channelID == "someChannelID") } @Test("New channel created notification") @MainActor func newChannelCreatedNotification() async throws { self.privacyManager.enableFeatures(.all) // Send the registration update for a new channel await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someChannelID", isExisting: false ) ) // Wait for the async Task to process the update and update audience manager try await waitForCondition { self.audienceManager.channelID == "someChannelID" } // Verify the channel ID was set correctly #expect(self.audienceManager.channelID == "someChannelID") } @Test("Identifier updates") @MainActor func identifierUpdates() async throws { var updates = self.channel.identifierUpdates.makeAsyncIterator() self.privacyManager.enableFeatures(.all) // Yield to ensure async stream is set up await Task.yield() await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someChannelID", isExisting: false ) ) // Yield between sends to ensure ordering await Task.yield() await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someOtherChannelID", isExisting: false ) ) var value = await updates.next() #expect(value == "someChannelID") value = await updates.next() #expect(value == "someOtherChannelID") } @Test("Identifier updates deduping") @MainActor func identifierUpdatesDeduping() async throws { self.channelRegistrar.channelID = "someChannelID" var updates = self.channel.identifierUpdates.makeAsyncIterator() self.privacyManager.enableFeatures(.all) await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someChannelID", isExisting: false ) ) await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someChannelID", isExisting: false ) ) await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someChannelID", isExisting: false ) ) await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someOtherChannelID", isExisting: false ) ) var value = await updates.next() #expect(value == "someChannelID") value = await updates.next() #expect(value == "someOtherChannelID") } @Test("Identifier update already created") func identifierUpdateAlreadyCreated() async throws { self.privacyManager.enableFeatures(.all) self.channelRegistrar.channelID = "someChannelID" var updates = self.channel.identifierUpdates.makeAsyncIterator() var value = await updates.next() #expect(value == "someChannelID") await self.channelRegistrar.registrationUpdates.send( .created( channelID: "someOtherChannelID", isExisting: false ) ) value = await updates.next() #expect(value == "someOtherChannelID") } @Test("Created identifier passed to audience manager") @MainActor func createdIdentifierPassedToAudienceManager() async throws { // Send the registration update await self.channelRegistrar.registrationUpdates.send( .created( channelID: "foo", isExisting: true ) ) // Wait for the async Task to process the update and pass ID to audience manager try await waitForCondition { self.audienceManager.channelID == "foo" } // Verify the audience manager received the channel ID #expect(self.audienceManager.channelID == "foo") } @Test("Initial identifier passed to audience manager") func initialIdentifierPassedToAudienceManager() async throws { self.channelRegistrar.channelID = "foo" _ = await Self.createChannel( dataStore: self.dataStore, config: self.config, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, localeManager: self.localeManager, audienceManager: self.audienceManager, channelRegistrar: self.channelRegistrar, notificationCenter: self.notificationCenter, appStateTracker: self.appStateTracker ) #expect(self.audienceManager.channelID == "foo") } @Test("Locale updated") func localeUpdated() throws { self.privacyManager.enableFeatures(.all) self.channelRegistrar.registerCalled = false self.notificationCenter.post( name: AirshipNotifications.LocaleUpdated.name, object: nil ) #expect(self.channelRegistrar.registerCalled) } @Test("Config update") func configUpdate() throws { self.channelRegistrar.channelID = "foo" self.privacyManager.enableFeatures(.all) self.channelRegistrar.registerCalled = false self.notificationCenter.post( name: RuntimeConfig.configUpdatedEvent, object: nil ) #expect(self.channelRegistrar.registerCalled) } @Test("Config update no channel ID") func configUpdateNoChannelID() throws { self.channelRegistrar.channelID = nil self.privacyManager.enableFeatures(.all) self.channelRegistrar.registerCalled = false self.notificationCenter.post( name: RuntimeConfig.configUpdatedEvent, object: nil ) #expect(self.channelRegistrar.registerCalled) } @Test("Migrate push tags to channel tags") func migratePushTagsToChannelTags() async throws { self.privacyManager.enableFeatures(.all) self.dataStore.setObject(["cool", "rad"], forKey: "UAPushTags") let channel = await Self.createChannel( dataStore: self.dataStore, config: self.config, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, localeManager: self.localeManager, audienceManager: self.audienceManager, channelRegistrar: self.channelRegistrar, notificationCenter: self.notificationCenter, appStateTracker: self.appStateTracker ) #expect(channel.tags == ["cool", "rad"]) } @Test("Migrate push tags to channel tags already migrated") func migratePushTagsToChannelTagsAlreadyMigrated() async throws { self.privacyManager.enableFeatures(.all) self.channel.tags = ["some-random-value"] let channel = await Self.createChannel( dataStore: self.dataStore, config: self.config, privacyManager: self.privacyManager, permissionsManager: self.permissionsManager, localeManager: self.localeManager, audienceManager: self.audienceManager, channelRegistrar: self.channelRegistrar, notificationCenter: self.notificationCenter, appStateTracker: self.appStateTracker ) #expect(channel.tags == ["some-random-value"]) } @Test("CRA payload is active flag in foreground") @MainActor func craPayloadIsActiveFlagInForeground() async throws { self.privacyManager.enableFeatures(.all) self.appStateTracker.currentState = .active let payload = await self.channelRegistrar.channelPayload #expect(payload.channel.isActive) } @Test("CRA payload is active flag in background") @MainActor func craPayloadIsActiveFlagInBackground() async throws { self.privacyManager.enableFeatures(.all) self.appStateTracker.currentState = .background let payload = await self.channelRegistrar.channelPayload #expect(!payload.channel.isActive) } } ================================================ FILE: Airship/AirshipCore/Tests/CircularRegionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class CircularRegionTest: XCTestCase { private var coordinates: (latitude: Double, longitude: Double) = (45.5200, 122.6819) /** * Test creating a circular region with a valid radius */ func testSetValidRadius() { let region = CircularRegion(radius: 10, latitude: coordinates.latitude, longitude: coordinates.longitude) XCTAssertNotNil(region) } /** * Test creating a circular region and adding an invalid radius */ func testSetInvalidRadius() { // test radius greater than max var region = CircularRegion(radius: 100001, latitude: coordinates.latitude, longitude: coordinates.longitude) XCTAssertNil(region, "Circular region should be nil if radius fails to set.") // test radius less than min region = CircularRegion(radius: 0, latitude: coordinates.latitude, longitude: coordinates.longitude) XCTAssertNil(region, "Circular region should be nil if radius fails to set.") } /** * Test creating a circular region and adding a valid latitude */ func testSetValidLatitude() { // test Portland's latitude var circularRegion = CircularRegion(radius: 10, latitude: coordinates.latitude, longitude: coordinates.longitude) XCTAssertNotNil(circularRegion) // test latitude of 0 degrees circularRegion = CircularRegion(radius: 10, latitude: 0, longitude: coordinates.longitude) XCTAssertNotNil(circularRegion) } /** * Test creating a circular region and adding invalid latitudes */ func testSetInvalidLatitude() { // test latitude greater than max var circularRegion = CircularRegion(radius: 10, latitude: 91, longitude: coordinates.longitude) XCTAssertNil(circularRegion, "Circular region should be nil if latitude fails to set.") // test latitude less than min circularRegion = CircularRegion(radius: 10, latitude: -91, longitude: coordinates.longitude) XCTAssertNil(circularRegion, "Circular region should be nil if latitude fails to set.") } /** * Test creating a circular region and adding a valid longitude */ func testSetValidLongitude() { // test Portland's longitude var circularRegion = CircularRegion(radius: 10, latitude: coordinates.latitude, longitude: coordinates.longitude) XCTAssertNotNil(circularRegion) // test longitude of 0 degrees circularRegion = CircularRegion(radius: 10, latitude: coordinates.latitude, longitude: 0) XCTAssertNotNil(circularRegion) } /** * Test creating a circular region and adding invalid longitudes */ func testSetInvalidLongitude() { // test longitude greater than max var circularRegion = CircularRegion(radius: 10, latitude: coordinates.latitude, longitude: 181) XCTAssertNil(circularRegion) // test longitude less than min circularRegion = CircularRegion(radius: 10, latitude: coordinates.latitude, longitude: -181) XCTAssertNil(circularRegion) } } ================================================ FILE: Airship/AirshipCore/Tests/CompoundDeviceAudienceSelectorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class CompoundDeviceAudienceSelectorTest: XCTestCase, @unchecked Sendable { func testCombing() { let selector = DeviceAudienceSelector(newUser: true) let compound = CompoundDeviceAudienceSelector.atomic(DeviceAudienceSelector(newUser: false)) let combined = CompoundDeviceAudienceSelector.combine(compoundSelector: compound, deviceSelector: selector) let expected = CompoundDeviceAudienceSelector.and( [ .atomic(DeviceAudienceSelector(newUser: true)), .atomic(DeviceAudienceSelector(newUser: false)) ] ) XCTAssertEqual(expected, combined) } func testParsing() throws { [ ( CompoundDeviceAudienceSelector.atomic(DeviceAudienceSelector(newUser: true)), "{\"type\":\"atomic\", \"audience\":{\"new_user\":true}}" ), ( CompoundDeviceAudienceSelector.not(.atomic(DeviceAudienceSelector(newUser: true))), "{\"type\":\"not\", \"selector\": {\"type\":\"atomic\", \"audience\":{\"new_user\":true}}}" ), ( CompoundDeviceAudienceSelector.and([ .atomic(DeviceAudienceSelector(newUser: true)), .atomic(DeviceAudienceSelector(newUser: false))] ), "{\"type\":\"and\", \"selectors\": [{\"type\":\"atomic\", \"audience\":{\"new_user\":true}},{\"type\":\"atomic\", \"audience\":{\"new_user\":false}}]}" ), ( CompoundDeviceAudienceSelector.and([]), "{\"type\":\"and\", \"selectors\": []}" ), ( CompoundDeviceAudienceSelector.or([ .atomic(DeviceAudienceSelector(newUser: true)), .atomic(DeviceAudienceSelector(newUser: false))] ), "{\"type\":\"or\", \"selectors\": [{\"type\":\"atomic\", \"audience\":{\"new_user\":true}},{\"type\":\"atomic\", \"audience\":{\"new_user\":false}}]}" ), ( CompoundDeviceAudienceSelector.or([]), "{\"type\":\"or\", \"selectors\": []}" ), ].forEach { (key, value) in checkEqualRoundTrip(original: key, json: value) } } private func checkEqualRoundTrip(original: CompoundDeviceAudienceSelector, json: String) { let decoder = JSONDecoder() let fromSource = try! decoder.decode(CompoundDeviceAudienceSelector.self, from: json.data(using: .utf8)!) XCTAssertEqual(original, fromSource) let roundTrip = try! decoder.decode(CompoundDeviceAudienceSelector.self, from: try JSONEncoder().encode(fromSource)) XCTAssertEqual(original, roundTrip) } } ================================================ FILE: Airship/AirshipCore/Tests/ContactAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class ContactAPIClientTest: XCTestCase { private let session: TestAirshipRequestSession = TestAirshipRequestSession() private var contactAPIClient: ContactAPIClient! private var config: RuntimeConfig = RuntimeConfig.testConfig() private let currentLocale = Locale(identifier: "fr-CA") override func setUpWithError() throws { self.session.response = HTTPURLResponse( url: URL(string: "https://contacts_test")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) self.contactAPIClient = ContactAPIClient( config: self.config, session: self.session ) } func testIdentify() async throws { self.session.data = """ { "ok": true, "contact": { "contact_id": "1a32e8c7-5a73-47c0-9716-99fd3d41924b", "is_anonymous": true, "channel_association_timestamp": "2022-12-29T10:15:30.00" }, "token": "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJLSHVNTE15RmVmYjdoeXR3WkV5VTF4IiwiaWF0IjoxNjAyMDY4NDIxLCJleHAiOjE2MDIwNjg0MjEsInN1YiI6InVMa2hSaktBYzVXQW1SdTFPTFZSVncifQ.kJPu3enbLJMX10xEtzlxxeum66R2ZWLs02OSVPhjomQ", "token_expires_in": 3600000 } """ .data(using: .utf8) let response = try await contactAPIClient.identify( channelID: "test_channel", namedUserID: "my-named-user", contactID: nil, possiblyOrphanedContactID: "1a32e8c7-5a73-47c0-9716-99fd3d41924c" ) let expected = ContactIdentifyResult( contact: ContactIdentifyResult.ContactInfo( channelAssociatedDate: AirshipDateFormatter.date(fromISOString: "2022-12-29T10:15:30.00")!, contactID: "1a32e8c7-5a73-47c0-9716-99fd3d41924b", isAnonymous: true ), token: "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJLSHVNTE15RmVmYjdoeXR3WkV5VTF4IiwiaWF0IjoxNjAyMDY4NDIxLCJleHAiOjE2MDIwNjg0MjEsInN1YiI6InVMa2hSaktBYzVXQW1SdTFPTFZSVncifQ.kJPu3enbLJMX10xEtzlxxeum66R2ZWLs02OSVPhjomQ", tokenExpiresInMilliseconds: 3600000 ) XCTAssertTrue(response.isSuccess) XCTAssertEqual(response.result!, expected) let request = session.lastRequest! let requestBody = try AirshipJSON.from(data: request.body).unWrap() as! [String: AnyHashable] let expectedBody = [ "device_info": [ "device_type": "ios" ], "action": [ "type": "identify", "named_user_id": "my-named-user", "possibly_orphaned_contact_id": "1a32e8c7-5a73-47c0-9716-99fd3d41924c" ] ] XCTAssertEqual(expectedBody, requestBody) XCTAssertEqual(request.url?.absoluteString, "\(config.deviceAPIURL!)/api/contacts/identify/v2") XCTAssertEqual(request.method, "POST") XCTAssertEqual(request.auth, .generatedChannelToken(identifier: "test_channel")) XCTAssertEqual( request.headers, [ "Content-Type": "application/json", "Accept": "application/vnd.urbanairship+json; version=3;", ] ) } func testResolve() async throws { self.session.data = """ { "ok": true, "contact": { "contact_id": "1a32e8c7-5a73-47c0-9716-99fd3d41924b", "is_anonymous": true, "channel_association_timestamp": "2022-12-29T10:15:30.00" }, "token": "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJLSHVNTE15RmVmYjdoeXR3WkV5VTF4IiwiaWF0IjoxNjAyMDY4NDIxLCJleHAiOjE2MDIwNjg0MjEsInN1YiI6InVMa2hSaktBYzVXQW1SdTFPTFZSVncifQ.kJPu3enbLJMX10xEtzlxxeum66R2ZWLs02OSVPhjomQ", "token_expires_in": 3600000 } """ .data(using: .utf8) let response = try await contactAPIClient.resolve( channelID: "test_channel", contactID: "some contact id", possiblyOrphanedContactID: "1a32e8c7-5a73-47c0-9716-99fd3d41924c" ) let expected = ContactIdentifyResult( contact: ContactIdentifyResult.ContactInfo( channelAssociatedDate: AirshipDateFormatter.date(fromISOString: "2022-12-29T10:15:30.00")!, contactID: "1a32e8c7-5a73-47c0-9716-99fd3d41924b", isAnonymous: true ), token: "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJLSHVNTE15RmVmYjdoeXR3WkV5VTF4IiwiaWF0IjoxNjAyMDY4NDIxLCJleHAiOjE2MDIwNjg0MjEsInN1YiI6InVMa2hSaktBYzVXQW1SdTFPTFZSVncifQ.kJPu3enbLJMX10xEtzlxxeum66R2ZWLs02OSVPhjomQ", tokenExpiresInMilliseconds: 3600000 ) XCTAssertTrue(response.isSuccess) XCTAssertEqual(response.result!, expected) let request = session.lastRequest! let requestBody = try AirshipJSON.from(data: request.body).unWrap() as! [String: AnyHashable] let expectedBody = [ "device_info": [ "device_type": "ios" ], "action": [ "type": "resolve", "contact_id": "some contact id", "possibly_orphaned_contact_id": "1a32e8c7-5a73-47c0-9716-99fd3d41924c" ] ] XCTAssertEqual(expectedBody, requestBody) XCTAssertEqual(request.url?.absoluteString, "https://device-api.urbanairship.com/api/contacts/identify/v2") XCTAssertEqual(request.method, "POST") XCTAssertEqual(request.auth, .generatedChannelToken(identifier: "test_channel")) XCTAssertEqual( request.headers, [ "Content-Type": "application/json", "Accept": "application/vnd.urbanairship+json; version=3;", ] ) } func testReset() async throws { self.session.data = """ { "ok": true, "contact": { "contact_id": "1a32e8c7-5a73-47c0-9716-99fd3d41924b", "is_anonymous": true, "channel_association_timestamp": "2022-12-29T10:15:30.00" }, "token": "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJLSHVNTE15RmVmYjdoeXR3WkV5VTF4IiwiaWF0IjoxNjAyMDY4NDIxLCJleHAiOjE2MDIwNjg0MjEsInN1YiI6InVMa2hSaktBYzVXQW1SdTFPTFZSVncifQ.kJPu3enbLJMX10xEtzlxxeum66R2ZWLs02OSVPhjomQ", "token_expires_in": 3600000 } """ .data(using: .utf8) let response = try await contactAPIClient.reset( channelID: "test_channel", possiblyOrphanedContactID: "1a32e8c7-5a73-47c0-9716-99fd3d41924c" ) let expected = ContactIdentifyResult( contact: ContactIdentifyResult.ContactInfo( channelAssociatedDate: AirshipDateFormatter.date(fromISOString: "2022-12-29T10:15:30.00")!, contactID: "1a32e8c7-5a73-47c0-9716-99fd3d41924b", isAnonymous: true ), token: "eyJhbGciOiJIUzI1NiJ9.eyJhdWQiOiJLSHVNTE15RmVmYjdoeXR3WkV5VTF4IiwiaWF0IjoxNjAyMDY4NDIxLCJleHAiOjE2MDIwNjg0MjEsInN1YiI6InVMa2hSaktBYzVXQW1SdTFPTFZSVncifQ.kJPu3enbLJMX10xEtzlxxeum66R2ZWLs02OSVPhjomQ", tokenExpiresInMilliseconds: 3600000 ) XCTAssertTrue(response.isSuccess) XCTAssertEqual(response.result!, expected) let request = session.lastRequest! let requestBody = try AirshipJSON.from(data: request.body).unWrap() as! [String: AnyHashable] let expectedBody = [ "device_info": [ "device_type": "ios" ], "action": [ "type": "reset", "possibly_orphaned_contact_id": "1a32e8c7-5a73-47c0-9716-99fd3d41924c" ] ] XCTAssertEqual(expectedBody, requestBody) XCTAssertEqual(request.url?.absoluteString, "\(config.deviceAPIURL!)/api/contacts/identify/v2") XCTAssertEqual(request.method, "POST") XCTAssertEqual(request.auth, .generatedChannelToken(identifier: "test_channel")) XCTAssertEqual( request.headers, [ "Content-Type": "application/json", "Accept": "application/vnd.urbanairship+json; version=3;", ] ) } func testRegisterEmail() async throws { self.session.data = """ { "channel_id": "some-channel", } """ .data(using: .utf8) let date = Date() let response = try await contactAPIClient.registerEmail( contactID: "some-contact-id", address: "ua@airship.com", options: EmailRegistrationOptions.options( transactionalOptedIn: date, properties: ["interests": "newsletter"], doubleOptIn: true ), locale: currentLocale ) XCTAssertTrue(response.isSuccess) if let associatedChannel = response.result, case .email = associatedChannel.channelType { XCTAssertEqual("some-channel", associatedChannel.channelID) let previousRequest = self.session.previousRequest! XCTAssertNotNil(previousRequest) XCTAssertEqual( "\(config.deviceAPIURL!)/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( "\(config.deviceAPIURL!)/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 ) } else { XCTAssertThrowsError("Error: Invalid associated channel type") } } func testRegisterSMS() async throws { self.session.data = """ { "channel_id": "some-channel", } """ .data(using: .utf8) let response = try await contactAPIClient.registerSMS( contactID: "some-contact-id", msisdn: "15035556789", options: SMSRegistrationOptions.optIn(senderID: "28855"), locale: currentLocale ) XCTAssertTrue(response.isSuccess) if let associatedChannel = response.result, case .sms = associatedChannel.channelType { XCTAssertEqual("some-channel", associatedChannel.channelID) let previousRequest = self.session.previousRequest! XCTAssertNotNil(previousRequest) XCTAssertEqual( "https://device-api.urbanairship.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://device-api.urbanairship.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 ) } else { XCTAssertThrowsError("Error: Invalid associated channel type") } } func testRegisterOpen() async throws { self.session.data = """ { "channel_id": "some-channel", } """ .data(using: .utf8) let response = try await contactAPIClient.registerOpen( contactID: "some-contact-id", address: "open_address", options: OpenRegistrationOptions.optIn( platformName: "my_platform", identifiers: ["model": "4", "category": "1"] ), locale: currentLocale ) XCTAssertTrue(response.isSuccess) if let associatedChannel = response.result, case .open = associatedChannel.channelType { XCTAssertEqual("some-channel", associatedChannel.channelID) let previousRequest = self.session.previousRequest! XCTAssertNotNil(previousRequest) XCTAssertEqual( "https://device-api.urbanairship.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://device-api.urbanairship.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://device-api.urbanairship.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 request = self.session.lastRequest! XCTAssertEqual( "https://device-api.urbanairship.com/api/contacts/disassociate/\(expectedContactID)", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] ) as! [String: Any] let expectedBody = [ "channel_type": expectedChannelType.rawValue, "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 ) ) XCTAssertTrue(response.isSuccess) let request = self.session.lastRequest! XCTAssertEqual( "https://device-api.urbanairship.com/api/contacts/disassociate/\(expectedContactID)", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] ) as! [String: Any] let expectedBody = [ "channel_type": expectedChannelType.rawValue, "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://device-api.urbanairship.com/api/contacts/disassociate/\(expectedContactID)", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] ) as! [String: Any] let expectedBody = [ "channel_type": expectedChannelType.rawValue, "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( "https://device-api.urbanairship.com/api/channels/resend", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] ) as! [String: Any] let expectedBody = [ "channel_type": expectedChannelType.rawValue, "email_address": expectedEmail ] as [String : Any] XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) } 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) let request = self.session.lastRequest! XCTAssertEqual( "https://device-api.urbanairship.com/api/channels/resend", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] ) as! [String: Any] let expectedBody = [ "channel_type": expectedChannelType.rawValue, "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://device-api.urbanairship.com/api/channels/resend", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] ) as! [String: Any] let expectedBody = [ "channel_type": expectedChannelType.rawValue, "channel_id": expectedChannelID ] as [String : Any] XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) } func testUpdate() async throws { let tagUpdates = [ TagGroupUpdate(group: "tag-set", tags: [], type: .set), TagGroupUpdate(group: "tag-add", tags: ["add tag"], type: .add), TagGroupUpdate( group: "tag-other-add", tags: ["other tag"], type: .add ), TagGroupUpdate( group: "tag-remove", tags: ["remove tag"], type: .remove ), ] let date = Date() let attributeUpdates = [ AttributeUpdate.set( attribute: "some-string", value: "Hello", date: date ), AttributeUpdate.set( attribute: "some-number", value: 32.0, date: date ), AttributeUpdate.remove(attribute: "some-remove", date: date), ] let listUpdates = [ ScopedSubscriptionListUpdate( listId: "bar", type: .subscribe, scope: .web, date: date ), ScopedSubscriptionListUpdate( listId: "foo", type: .unsubscribe, scope: .app, date: date ), ] let response = try await contactAPIClient.update( contactID: "some-contact-id", tagGroupUpdates: tagUpdates, attributeUpdates: attributeUpdates, subscriptionListUpdates: listUpdates ) XCTAssertTrue(response.isSuccess) let request = self.session.lastRequest! XCTAssertEqual( "https://device-api.urbanairship.com/api/contacts/some-contact-id", request.url!.absoluteString ) let body = try JSONSerialization.jsonObject( with: request.body!, options: [] ) as! [String: Any] let formattedDate = AirshipDateFormatter.string(fromDate: date, format: .isoDelimitter) let expectedBody = [ "attributes": [ [ "action": "set", "key": "some-string", "timestamp": formattedDate, "value": "Hello", ] as [String : Any], [ "action": "set", "key": "some-number", "timestamp": formattedDate, "value": 32, ], [ "action": "remove", "key": "some-remove", "timestamp": formattedDate, ], ], "tags": [ "add": [ "tag-add": [ "add tag" ], "tag-other-add": [ "other tag" ], ], "remove": [ "tag-remove": [ "remove tag" ] ], "set": [ "tag-set": [] ], ], "subscription_lists": [ [ "action": "subscribe", "list_id": "bar", "scope": "web", "timestamp": formattedDate, ], [ "action": "unsubscribe", "list_id": "foo", "scope": "app", "timestamp": formattedDate, ], ], ] as [String : Any] XCTAssertEqual(body as NSDictionary, expectedBody as NSDictionary) } } ================================================ FILE: Airship/AirshipCore/Tests/ContactChannelsProviderTest.swift ================================================ /* 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: TestPrivacyManager! 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 = TestPrivacyManager( dataStore: self.dataStore, config: .testConfig(), 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<String> { continuation in continuation.yield("test-contact-id-1") continuation.finish() } self.apiClient.fetchResponse = AirshipHTTPResponse( result: self.testChannels2, statusCode: 200, headers: [:] ) var resultStream = provider.contactChannels(stableContactIDUpdates: contactIDStream).makeAsyncIterator() let result = await resultStream.next() XCTAssertEqual(result, .error(.contactsDisabled)) } func testContactChannelsSuccess() async { let contactIDChannel = AirshipAsyncChannel<String>() 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 testContactChannelsRefresh() async { let contactIDChannel = AirshipAsyncChannel<String>() 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)) XCTAssertEqual(1, self.apiClient.fetchAssociatedChannelsCallCount) //from cache await contactIDChannel.send("test-contact-id-1") result = await resultStream.next() XCTAssertEqual(result, .success(self.testChannels1)) XCTAssertEqual(1, self.apiClient.fetchAssociatedChannelsCallCount) await provider.refresh() await contactIDChannel.send("test-contact-id-1") result = await resultStream.next() XCTAssertEqual(result, .success(self.testChannels1)) XCTAssertEqual(2, self.apiClient.fetchAssociatedChannelsCallCount) } func testContactChannelsFailure() async { let contactIDStream = AsyncStream<String> { 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<String> { 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<String> { 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<String> { 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<TimeInterval>() var sleepUpdates: AsyncStream<TimeInterval> { get async { await channel.makeStream() } } func advance() { continuations.forEach { $0.resume() } continuations.removeAll() } var sleeps: [TimeInterval] = [] var continuations: [CheckedContinuation<Void, Never>] = [] func sleep(timeInterval: TimeInterval) async throws { sleeps.append(timeInterval) await channel.send(timeInterval) await withCheckedContinuation { continuation in continuations.append(continuation) } } } ================================================ FILE: Airship/AirshipCore/Tests/ContactManagerTest.swift ================================================ import Testing @testable import AirshipCore import Foundation @Suite(.timeLimit(.minutes(1))) struct ContactManagerTest { let date: UATestDate let channel: TestChannel let localeManager: TestLocaleManager let workManager: TestWorkManager let dataStore: PreferenceDataStore let apiClient: TestContactAPIClient let contactManager: ContactManager let anonIdentifyResponse: ContactIdentifyResult let nonAnonIdentifyResponse: ContactIdentifyResult // Helper to wait for async conditions with timeout private func waitForCondition( timeout: Duration = .seconds(2), pollingInterval: Duration = .milliseconds(10), condition: @escaping () async -> Bool ) async throws { let deadline = ContinuousClock.now + timeout while ContinuousClock.now < deadline { if await condition() { return } try await Task.sleep(for: pollingInterval) } throw NSError(domain: "TestTimeout", code: 1, userInfo: [NSLocalizedDescriptionKey: "Condition not met within timeout"]) } init() async throws { self.date = UATestDate(offset: 0, dateOverride: Date()) self.channel = TestChannel() self.localeManager = TestLocaleManager() self.workManager = TestWorkManager() self.dataStore = PreferenceDataStore(appKey: UUID().uuidString) self.apiClient = TestContactAPIClient() self.anonIdentifyResponse = ContactIdentifyResult( contact: ContactIdentifyResult.ContactInfo( channelAssociatedDate: AirshipDateFormatter.date(fromISOString: "2022-12-29T10:15:30.00")!, contactID: "some contact", isAnonymous: true ), token: "some token", tokenExpiresInMilliseconds: 3600000 ) self.nonAnonIdentifyResponse = ContactIdentifyResult( contact: ContactIdentifyResult.ContactInfo( channelAssociatedDate: AirshipDateFormatter.date(fromISOString: "2022-12-29T10:15:30.00")!, contactID: "some other contact", isAnonymous: false ), token: "some other token", tokenExpiresInMilliseconds: 3600000 ) self.localeManager.currentLocale = Locale(identifier: "fr-CA") self.contactManager = ContactManager( dataStore: self.dataStore, channel: self.channel, localeManager: self.localeManager, apiClient: self.apiClient, date: self.date, workManager: self.workManager, internalIdentifyRateLimit: 0.0 ) await self.contactManager.setEnabled(enabled: true) self.channel.identifier = "some channel" } @Test("Enable enqueues work") func enableEnqueuesWork() async throws { await self.contactManager.setEnabled(enabled: false) #expect(self.workManager.workRequests.isEmpty) await self.contactManager.addOperation(.resolve) await self.contactManager.setEnabled(enabled: false) #expect(self.workManager.workRequests.isEmpty) await self.contactManager.setEnabled(enabled: true) #expect(!self.workManager.workRequests.isEmpty) } @Test("Channel creation enqueues work") func channelCreationEnqueuesWork() async throws { await self.contactManager.setEnabled(enabled: true) // Clear the channel identifier to simulate no channel self.channel.identifier = nil // Wait a moment for that to process try await Task.sleep(for: .milliseconds(100)) // Track initial work count let initialWorkCount = self.workManager.workRequests.count // Simulate channel creation by setting identifier self.channel.identifier = "newly-created-channel-id" // Wait for new work to be enqueued try await waitForCondition(timeout: .seconds(5)) { self.workManager.workRequests.count > initialWorkCount } // Verify new work was enqueued #expect(self.workManager.workRequests.count > initialWorkCount) } @Test("Add operation enqueues work") func addOperationEnqueuesWork() async throws { await self.contactManager.setEnabled(enabled: true) #expect(self.workManager.workRequests.isEmpty) await self.contactManager.addOperation(.resolve) #expect(!self.workManager.workRequests.isEmpty) } @Test("Add skippable operation enqueues work") func addSkippableOperationEnqueuesWork() async throws { await self.contactManager.setEnabled(enabled: true) await self.contactManager.addOperation(.resolve) self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) self.workManager.workRequests.removeAll() await self.contactManager.addOperation(.reset) #expect(!self.workManager.workRequests.isEmpty) } @Test("Rate limit config") func rateLimitConfig() async throws { let rateLimits = self.workManager.rateLimits #expect(rateLimits.count == 2) let updateRule = rateLimits[ContactManager.updateRateLimitID]! #expect(updateRule.rate == 1) #expect(abs(updateRule.timeInterval - 0.5) < 0.01) let identityRule = rateLimits[ContactManager.identityRateLimitID]! #expect(identityRule.rate == 1) #expect(abs(identityRule.timeInterval - 5.0) < 0.01) } @Test("Resolve") func resolve() async throws { await self.contactManager.addOperation(.resolve) try await confirmation { confirm in self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } let contactInfo = await self.contactManager.currentContactIDInfo() #expect(anonIdentifyResponse.contact.contactID == contactInfo?.contactID) await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ]) } @Test("Resolve with contact ID") func resolveWithContactID() async throws { await self.contactManager.generateDefaultContactIDIfNotSet() await self.contactManager.addOperation(.resolve) try await confirmation { confirm in self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID != nil) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } let contactInfo = await self.contactManager.currentContactIDInfo() #expect(anonIdentifyResponse.contact.contactID == contactInfo?.contactID) } @Test("Resolved failed") func resolvedFailed() async throws { await self.contactManager.addOperation(.resolve) self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 500, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .failure) } @Test("Verify") func verify() async throws { await self.contactManager.addOperation(.verify(self.date.now)) try await confirmation { confirm in self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } let contactInfo = await self.contactManager.currentContactIDInfo() #expect(anonIdentifyResponse.contact.contactID == contactInfo?.contactID) await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ]) } @Test("Required verify") func requiredVerify() async throws { // Resolve is called first if we do not have a valid token try await confirmation { confirm in self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } await self.contactManager.addOperation(.resolve) _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) } await self.contactManager.addOperation(.verify(self.date.now + 1, required: true)) await self.verifyUpdates( [ .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ), .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: false, namedUserID: nil, resolveDate: self.date.now ) ) ] ) } @Test("Verify failed") func verifyFailed() async throws { await self.contactManager.addOperation(.verify(self.date.now)) self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 500, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .failure) } @Test("Resolved failed client error") func resolvedFailedClientError() async throws { await self.contactManager.addOperation(.resolve) self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 400, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } @Test("Identify") func identify() async throws { await self.contactManager.addOperation(.identify("some named user")) await self.verifyUpdates([.namedUserUpdate("some named user")]) // Resolve is called first if we do not have a valid token let resolveExpectation = expectation(description: "resolve contact") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } try await confirmation { confirm in self.apiClient.identifyCallback = { channelID, namedUserID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect("some named user" == namedUserID) #expect(self.anonIdentifyResponse.contact.contactID == contactID) confirm() return AirshipHTTPResponse( result: self.nonAnonIdentifyResponse, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment let contactInfo = await self.contactManager.currentContactIDInfo() #expect(nonAnonIdentifyResponse.contact.contactID == contactInfo?.contactID) await self.verifyUpdates( [ .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: false, namedUserID: nil, resolveDate: self.date.now ) ), .contactIDUpdate( ContactIDInfo( contactID: self.nonAnonIdentifyResponse.contact.contactID, isStable: true, namedUserID: "some named user", resolveDate: self.date.now ) ), ] ) } @Test("Identify failed") func identifyFailed() async throws { await self.contactManager.addOperation(.identify("some named user")) // Resolve is called first if we do not have a valid token self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } self.apiClient.identifyCallback = { channelID, namedUserID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.nonAnonIdentifyResponse, statusCode: 500, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .failure) } @Test("Identify failed client error") func identifyFailedClientError() async throws { await self.contactManager.addOperation(.identify("some named user")) // Resolve is called first if we do not have a valid token self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } self.apiClient.identifyCallback = { channelID, namedUserID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.nonAnonIdentifyResponse, statusCode: 400, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } @Test("Reset") func reset() async throws { await self.contactManager.addOperation(.reset) // Resolve is called first if we do not have a valid token let resolveExpectation = expectation(description: "resolve contact") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.nonAnonIdentifyResponse, statusCode: 200, headers: [:] ) } try await confirmation { confirm in self.apiClient.resetCallback = { channelID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment await self.verifyUpdates( [ .contactIDUpdate( ContactIDInfo( contactID: self.nonAnonIdentifyResponse.contact.contactID, isStable: false, namedUserID: nil, resolveDate: self.date.now ) ), .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ] ) } @Test("Reset if needed") func resetIfNeeded() async throws { let info = await self.contactManager.currentContactIDInfo() #expect(info == nil) await self.contactManager.resetIfNeeded() // Resolve is called first if we do not have a valid token let resolveExpectation = expectation(description: "resolve contact") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) #expect(contactID == nil) resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.nonAnonIdentifyResponse, statusCode: 200, headers: [:] ) } try await confirmation { confirm in self.apiClient.resetCallback = { channelID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment await self.verifyUpdates( [ .contactIDUpdate( ContactIDInfo( contactID: self.nonAnonIdentifyResponse.contact.contactID, isStable: false, namedUserID: nil, resolveDate: self.date.now ) ), .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ] ) } @Test("Auth token no contact info") func authTokenNoContactInfo() async throws { try await confirmation { confirm in self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let authToken = try await self.contactManager.resolveAuth( identifier: self.anonIdentifyResponse.contact.contactID ) #expect(authToken == self.anonIdentifyResponse.token) } await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: self.anonIdentifyResponse.contact.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ]) } @Test("Auth token valid token mismatch contact ID") func authTokenValidTokenMismatchContactID() async throws { self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } await self.contactManager.addOperation(.resolve) let _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) do { let _ = try await self.contactManager.resolveAuth( identifier: "some other contactID" ) Issue.record("Should throw") } catch {} } @Test("Auth token resolve mismatch") func authTokenResolveMismatch() async { try await confirmation { confirm in self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) confirm() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } do { let _ = try await self.contactManager.resolveAuth( identifier: "some other contactID" ) Issue.record("Should throw") } catch {} } } @Test("Expire auth token") func expireAuthToken() async throws { let resolveExpectation = expectation(description: "resolve contact", expectedFulfillmentCount: 2) self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } var authToken = try await self.contactManager.resolveAuth( identifier: self.anonIdentifyResponse.contact.contactID ) await self.contactManager.authTokenExpired(token: authToken) authToken = try await self.contactManager.resolveAuth( identifier: self.anonIdentifyResponse.contact.contactID ) #expect(authToken == self.anonIdentifyResponse.token) await resolveExpectation.fulfillment } @Test("Auth token failed") func authTokenFailed() async { try await confirmation { confirm in self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in #expect(self.channel.identifier == channelID) confirm() return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } do { let _ = try await self.contactManager.resolveAuth( identifier: "some contact id" ) Issue.record("Should throw") } catch {} } } @Test("Generate default contact info") func generateDefaultContactInfo() async { var contactInfo = await self.contactManager.currentContactIDInfo() #expect(contactInfo == nil) await self.contactManager.generateDefaultContactIDIfNotSet() contactInfo = await self.contactManager.currentContactIDInfo() #expect(contactInfo != nil) #expect(contactInfo!.contactID.lowercased() == contactInfo!.contactID) await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: contactInfo!.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ]) } @Test("Generate default contact info lowercased ID") func generateDefaultContactInfoLowercasedID() async { await self.contactManager.generateDefaultContactIDIfNotSet() let contactInfo = await self.contactManager.currentContactIDInfo() #expect(contactInfo != nil) #expect(contactInfo!.contactID.lowercased() == contactInfo!.contactID) } @Test("Generate default contact info already set") func generateDefaultContactInfoAlreadySet() async throws { await self.contactManager.addOperation(.resolve) self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let _ = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) let contactInfo = await self.contactManager.currentContactIDInfo()! await self.contactManager.generateDefaultContactIDIfNotSet() let afterGenerate = await self.contactManager.currentContactIDInfo() #expect(contactInfo == afterGenerate) } @Test("Contact unstable pending reset") func contactUnstablePendingReset() async throws { await self.contactManager.generateDefaultContactIDIfNotSet() let contactInfo = await self.contactManager.currentContactIDInfo()! await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: contactInfo.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ]) await self.contactManager.addOperation(.reset) await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: contactInfo.contactID, isStable: false, namedUserID: nil, resolveDate: self.date.now ) ) ]) } @Test("Contact unstable pending identify") func contactUnstablePendingIdentify() async throws { await self.contactManager.generateDefaultContactIDIfNotSet() let contactInfo = await self.contactManager.currentContactIDInfo()! await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: contactInfo.contactID, isStable: true, namedUserID: nil, resolveDate: self.date.now ) ) ]) await self.contactManager.addOperation(.identify("something something")) await self.verifyUpdates([ .contactIDUpdate( ContactIDInfo( contactID: contactInfo.contactID, isStable: false, namedUserID: nil, resolveDate: self.date.now ) ) ]) } @Test("Pending updates combine operations") func pendingUpdatesCombineOperations() async throws { await self.contactManager.generateDefaultContactIDIfNotSet() let tags = [ TagGroupUpdate(group: "some group", tags: ["tag"], type: .add) ] let attributes = [ AttributeUpdate(attribute: "some attribute", type: .set, jsonValue: "cool", date: self.date.now) ] let subscriptions = [ ScopedSubscriptionListUpdate(listId: "some list", type: .unsubscribe, scope: .app, date: self.date.now) ] await self.contactManager.addOperation( .update( tagUpdates: tags, attributeUpdates: nil, subscriptionListsUpdates: nil ) ) await self.contactManager.addOperation( .update( tagUpdates: nil, attributeUpdates: attributes, subscriptionListsUpdates: nil ) ) await self.contactManager.addOperation( .update( tagUpdates: nil, attributeUpdates: nil, subscriptionListsUpdates: subscriptions ) ) let contactID = await self.contactManager.currentContactIDInfo()!.contactID let pendingOverrides = await self.contactManager.pendingAudienceOverrides( contactID: contactID ) #expect(tags == pendingOverrides.tags) #expect(attributes == pendingOverrides.attributes) #expect(subscriptions == pendingOverrides.subscriptionLists) } @Test("Pending updates") func pendingUpdates() async throws { let tags = [ TagGroupUpdate(group: "some group", tags: ["tag"], type: .add) ] let attributes = [ AttributeUpdate(attribute: "some attribute", type: .set, jsonValue: "cool", date: self.date.now) ] let subscriptions = [ ScopedSubscriptionListUpdate(listId: "some list", type: .unsubscribe, scope: .app, date: self.date.now) ] await self.contactManager.generateDefaultContactIDIfNotSet() let contactID = await self.contactManager.currentContactIDInfo()!.contactID await self.contactManager.addOperation( .update( tagUpdates: tags, attributeUpdates: nil, subscriptionListsUpdates: nil ) ) await self.contactManager.addOperation(.identify("some user")) await self.contactManager.addOperation( .update( tagUpdates: nil, attributeUpdates: attributes, subscriptionListsUpdates: nil ) ) await self.contactManager.addOperation(.identify("some other user")) await self.contactManager.addOperation( .update( tagUpdates: nil, attributeUpdates: nil, subscriptionListsUpdates: subscriptions ) ) // Since are an anon user ID, we should get the tags, // assume the identify will keep the same contact id, // get the attributes, then skip the subscriptions // because it will for sure be a different contact ID let anonUserOverrides = await self.contactManager.pendingAudienceOverrides(contactID: contactID) #expect(tags == anonUserOverrides.tags) #expect(attributes == anonUserOverrides.attributes) #expect([] == anonUserOverrides.subscriptionLists) // If we request a stale contact ID, it should return empty overrides let staleOverrides = await self.contactManager.pendingAudienceOverrides(contactID: "not the current contact id") #expect([] == staleOverrides.tags) #expect([] == staleOverrides.attributes) #expect([] == staleOverrides.subscriptionLists) } @Test("Register email") func registerEmail() async throws { let expectedAddress = "ua@airship.com" let expectedOptions = EmailRegistrationOptions.options( transactionalOptedIn: Date(), properties: ["interests": "newsletter"], doubleOptIn: true ) await self.contactManager.addOperation( .registerEmail(address: expectedAddress, options: expectedOptions) ) // Should resolve contact first let resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } // Then register the channel try await confirmation { confirm in self.apiClient.registerEmailCallback = { contactID, address, options, locale in #expect(contactID == self.anonIdentifyResponse.contact.contactID) #expect(address == expectedAddress) #expect(options == options) #expect(locale == self.localeManager.currentLocale) confirm() return AirshipHTTPResponse( result: .init(channelType: .email, channelID: "some channel"), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Register open") func registerOpen() async throws { let expectedAddress = "ua@airship.com" let expectedOptions = OpenRegistrationOptions.optIn( platformName: "my_platform", identifiers: ["model": "4"] ) await self.contactManager.addOperation( .registerOpen(address: expectedAddress, options: expectedOptions) ) // Should resolve contact first let resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } // Then register the channel try await confirmation { confirm in self.apiClient.registerOpenCallback = { contactID, address, options, locale in #expect(contactID == self.anonIdentifyResponse.contact.contactID) #expect(address == expectedAddress) #expect(options == options) #expect(locale == self.localeManager.currentLocale) confirm() return AirshipHTTPResponse( result: .init(channelType: .open, channelID: "some channel"), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Register SMS") func registerSMS() async throws { let expectedAddress = "15035556789" let expectedOptions = SMSRegistrationOptions.optIn(senderID: "28855") await self.contactManager.addOperation( .registerSMS(msisdn: expectedAddress, options: expectedOptions) ) // Should resolve contact first let resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } // Then register the channel try await confirmation { confirm in self.apiClient.registerSMSCallback = {contactID, address, options, locale in #expect(address == expectedAddress) #expect(options == options) #expect(locale == self.localeManager.currentLocale) confirm() return AirshipHTTPResponse( result: .init(channelType: .sms, channelID: "some channel"), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Resend email") func resendEmail() async throws { let expectedAddress: String = "example@email.com" let expectedResendOptions = ResendOptions(emailAddress: expectedAddress) // Should resolve contact first after checking the token let resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let pendingChannel = makePendingEmailContactChannel(address: expectedAddress) await self.contactManager.addOperation( .resend(channel: pendingChannel) ) try await confirmation { confirm in self.apiClient.resendCallback = { resendOptions in #expect(resendOptions == expectedResendOptions) confirm() return AirshipHTTPResponse( result: true, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Resend SMS") func resendSMS() 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 resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let pendingChannel = makePendingSMSContactChannel(msisdn: expectedMSISDN, sender: expectedSenderID) await self.contactManager.addOperation( .resend(channel: pendingChannel) ) try await confirmation { confirm in self.apiClient.resendCallback = { resendOptions in #expect(resendOptions == expectedResendOptions) confirm() return AirshipHTTPResponse( result: true, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Resend channel") func resendChannel() 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 resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } let registeredChannel = makeRegisteredContactChannel(from: expectedChannelID) await self.contactManager.addOperation( .resend(channel: registeredChannel) ) try await confirmation { confirm in self.apiClient.resendCallback = { resendOptions in #expect(resendOptions == expectedResendOptions) confirm() return AirshipHTTPResponse( result: true, statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Disassociate") func disassociate() async throws { let expectedChannelID = "12345" let registeredChannel = makeRegisteredContactChannel(from: expectedChannelID) await self.contactManager.addOperation( .disassociateChannel(channel: registeredChannel) ) // Should resolve contact first let resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } // Then disassociate the channel try await confirmation { confirm in self.apiClient.disassociateChannelCallback = { contactID, channelID, type in #expect(channelID == expectedChannelID) #expect(type == ChannelType.email) confirm() return AirshipHTTPResponse( result: ContactDisassociateChannelResult(channelID: channelID), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Associate channel") func associateChannel() async throws { await self.contactManager.addOperation( .associateChannel( channelID: "some channel", channelType: .open ) ) // Should resolve contact first let resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } // Then register the channel try await confirmation { confirm in self.apiClient.associateChannelCallback = { contactID, channelID, type in #expect(contactID == "some contact") #expect(channelID == "some channel") #expect(type == .open) confirm() return AirshipHTTPResponse( result: .init(channelType: type, channelID: "some channel"), statusCode: 200, headers: [:] ) } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) } await resolveExpectation.fulfillment } @Test("Update") func update() async throws { let tags = [ TagGroupUpdate(group: "some group", tags: ["tag"], type: .add), TagGroupUpdate(group: "some group", tags: ["tag"], type: .remove), TagGroupUpdate(group: "some group", tags: ["some other tag"], type: .remove) ] let attributes = [ AttributeUpdate(attribute: "some other attribute", type: .set, jsonValue: "cool", date: self.date.now), AttributeUpdate(attribute: "some attribute", type: .set, jsonValue: "cool", date: self.date.now), AttributeUpdate(attribute: "some attribute", type: .remove, jsonValue: "cool", date: self.date.now) ] let subscriptions = [ ScopedSubscriptionListUpdate(listId: "some other list", type: .subscribe, scope: .app, date: self.date.now), ScopedSubscriptionListUpdate(listId: "some list", type: .unsubscribe, scope: .app, date: self.date.now), ScopedSubscriptionListUpdate(listId: "some list", type: .subscribe, scope: .app, date: self.date.now) ] await self.contactManager.addOperation( .update( tagUpdates: tags, attributeUpdates: attributes, subscriptionListsUpdates: subscriptions ) ) // Should resolve contact first let resolveExpectation = expectation(description: "resolve") self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in resolveExpectation.fulfill() return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } // Then register the channel let updateExpectation = expectation(description: "update") self.apiClient.updateCallback = { contactID, tagUpdates, attributeUpdates, subscriptionUpdates in #expect(contactID == self.anonIdentifyResponse.contact.contactID) #expect(tagUpdates == AudienceUtils.collapse(tags)) #expect(attributeUpdates == AudienceUtils.collapse(attributes)) #expect(subscriptionUpdates == AudienceUtils.collapse(subscriptions)) updateExpectation.fulfill() return AirshipHTTPResponse( result: nil, statusCode: 200, headers: [:] ) } let audienceCallbackExpectation = expectation(description: "audience callback") await self.contactManager.onAudienceUpdated { update in #expect(update.tags == AudienceUtils.collapse(tags)) #expect(update.attributes == AudienceUtils.collapse(attributes)) #expect(update.subscriptionLists == AudienceUtils.collapse(subscriptions)) audienceCallbackExpectation.fulfill() } let result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) await resolveExpectation.fulfillment await updateExpectation.fulfillment await audienceCallbackExpectation.fulfillment } @Test("Conflict") func conflict() async throws { let tags = [ TagGroupUpdate(group: "some group", tags: ["tag"], type: .add), ] let attributes = [ AttributeUpdate(attribute: "some attribute", type: .set, jsonValue: "cool", date: self.date.now), ] let subscriptions = [ ScopedSubscriptionListUpdate(listId: "some list", type: .subscribe, scope: .app, date: self.date.now), ] // Adds some anon data await self.contactManager.addOperation( .update( tagUpdates: tags, attributeUpdates: attributes, subscriptionListsUpdates: subscriptions ) ) // resolve self.apiClient.resolveCallback = { channelID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.anonIdentifyResponse, statusCode: 200, headers: [:] ) } // update self.apiClient.updateCallback = { contactID, tagUpdates, attributeUpdates, subscriptionUpdates in return AirshipHTTPResponse( result: nil, statusCode: 200, headers: [:] ) } // identify self.apiClient.identifyCallback = { channelID, namedUserID, contactID, possiblyOrphanedContactID in return AirshipHTTPResponse( result: self.nonAnonIdentifyResponse, statusCode: 200, headers: [:] ) } var result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) await self.contactManager.addOperation(.identify("some named user")) result = try await self.workManager.launchTask( request: AirshipWorkRequest( workID: ContactManager.updateTaskID ) ) #expect(result == .success) let expctedConflictEvent = ContactConflictEvent( tags: ["some group": ["tag"]], attributes: ["some attribute": "cool"], associatedChannels: [], subscriptionLists: ["some list": [.app]], conflictingNamedUserID: "some named user" ) // resolve, update, resolve, conflict let conflict = await self.collectUpdates(count: 4).last #expect(conflict == .conflict(expctedConflictEvent)) } private func collectUpdates(count: Int) async -> [ContactUpdate] { guard count > 0 else { return [] } var collected: [ContactUpdate] = [] for await contactUpdate in await self.contactManager.contactUpdates { collected.append(contactUpdate) if (collected.count == count) { break } } 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], sourceLocation: SourceLocation = #_sourceLocation) async { let collected = await self.collectUpdates(count: expected.count) #expect(collected == expected, sourceLocation: sourceLocation) } private func expectation(description: String, expectedFulfillmentCount: Int = 1) -> Expectation { return Expectation(description: description, expectedFulfillmentCount: expectedFulfillmentCount) } } actor Expectation { private var count: Int = 0 private let expectedCount: Int private let description: String init(description: String, expectedFulfillmentCount: Int = 1) { self.description = description self.expectedCount = expectedFulfillmentCount } nonisolated func fulfill() { Task { await self.incrementCount() } } private func incrementCount() { count += 1 } var fulfillment: Void { get async { while count < expectedCount { await Task.yield() } } } } ================================================ FILE: Airship/AirshipCore/Tests/ContactOperationTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class ContactOperationTests: XCTestCase { // SDK 16 payload private let legacyPayload = """ [{\"type\":\"update\",\"payload\":{\"tagUpdates\":[{\"group\":\"group\",\"tags\":[\"tags\"],\"type\":2}]}},{\"type\":\"resolve\",\"payload\":null},{\"type\":\"identify\",\"payload\":{\"identifier\":\"some-user\"}},{\"type\":\"reset\",\"payload\":null},{\"type\":\"registerEmail\",\"payload\":{\"address\":\"ua@airship.com\",\"options\":{\"doubleOptIn\":true,\"transactionalOptedIn\":700424522.44925797,\"properties\":{\"jsonEncodedValue\":\"{\\\"interests\\\":\\\"newsletter\\\"}\"}}}},{\"type\":\"registerSMS\",\"payload\":{\"options\":{\"senderID\":\"28855\"},\"msisdn\":\"15035556789\"}},{\"type\":\"registerOpen\",\"payload\":{\"address\":\"open_address\",\"options\":{\"identifiers\":{\"model\":\"4\"},\"platformName\":\"my_platform\"}}}] """ // SDK 17 payload private let updatedPayload = """ [{\"type\":\"update\",\"payload\":{\"tagUpdates\":[{\"group\":\"group\",\"tags\":[\"tags\"],\"type\":2}]}},{\"type\":\"resolve\",\"payload\":null},{\"type\":\"identify\",\"payload\":{\"identifier\":\"some-user\"}},{\"type\":\"reset\",\"payload\":null},{\"type\":\"registerEmail\",\"payload\":{\"address\":\"ua@airship.com\",\"options\":{\"doubleOptIn\":true,\"transactionalOptedIn\":700424522.44925797,\"properties\":{\"interests\":\"newsletter\"}}}},{\"type\":\"registerSMS\",\"payload\":{\"options\":{\"senderID\":\"28855\"},\"msisdn\":\"15035556789\"}},{\"type\":\"registerOpen\",\"payload\":{\"address\":\"open_address\",\"options\":{\"identifiers\":{\"model\":\"4\"},\"platformName\":\"my_platform\"}}}, {\"type\":\"verify\",\"payload\":{\"date\":500.0}}] """ func testLegacyDecode() throws { let fromJSON = try JSONDecoder().decode([ContactOperation].self, from: legacyPayload.data(using: .utf8)!) let toJSON = try JSONEncoder().encode(fromJSON) XCTAssertEqual( try AirshipJSON.from(json: String(data: toJSON, encoding: .utf8)), try AirshipJSON.from(json: legacyPayload) ) } func testDecode() throws { let fromJSON = try JSONDecoder().decode([ContactOperation].self, from: updatedPayload.data(using: .utf8)!) let toJSON = try JSONEncoder().encode(fromJSON) XCTAssertEqual( try AirshipJSON.from(json: String(data: toJSON, encoding: .utf8)), try AirshipJSON.from(json: updatedPayload) ) } func testEncode() throws { let expected = [ ContactOperation.update(tagUpdates: [ TagGroupUpdate.init(group: "group", tags: ["tags"], type: .set) ]), ContactOperation.resolve, ContactOperation.identify("some-user"), ContactOperation.reset, ContactOperation.registerEmail( address: "ua@airship.com", options: EmailRegistrationOptions.options( transactionalOptedIn: Date(timeIntervalSinceReferenceDate: 700424522.44925797), properties: ["interests": "newsletter"], doubleOptIn: true ) ), ContactOperation.registerSMS( msisdn: "15035556789", options: SMSRegistrationOptions.optIn(senderID: "28855") ), ContactOperation.registerOpen( address: "open_address", options: OpenRegistrationOptions.optIn( platformName: "my_platform", identifiers: ["model": "4"] ) ), ContactOperation.verify(Date(timeIntervalSinceReferenceDate: 500)) ] let fromExpected = try JSONEncoder().encode(expected) XCTAssertEqual( try AirshipJSON.from(json: String(data: fromExpected, encoding: .utf8)), try AirshipJSON.from(json: updatedPayload) ) } } ================================================ FILE: Airship/AirshipCore/Tests/ContactRemoteDataProviderTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ContactRemoteDataProviderDelegateTest: XCTestCase { private let contact: TestContact = TestContact() private let client: TestRemoteDataAPIClient = TestRemoteDataAPIClient() private let config: RuntimeConfig = RuntimeConfig.testConfig() private var delegate: ContactRemoteDataProviderDelegate! override func setUpWithError() throws { delegate = ContactRemoteDataProviderDelegate( config: config, apiClient: client, contact: contact ) } func testIsRemoteDataInfoUpToDate() async throws { contact.contactIDInfo = ContactIDInfo(contactID: "some-contact-id", isStable: true, namedUserID: nil) let locale = Locale(identifier: "br") let randomValue = 1003 let remoteDatInfo = RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data-contact/ios/some-contact-id", locale: locale, randomValue: randomValue ), lastModifiedTime: "some time", source: .contact, contactID: "some-contact-id" ) var isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, randomValue: randomValue ) XCTAssertTrue(isUpToDate) // Different locale isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: Locale(identifier: "en"), randomValue: randomValue ) XCTAssertFalse(isUpToDate) // Different randomValue isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, randomValue: randomValue + 1 ) XCTAssertFalse(isUpToDate) // Different contact ID contact.contactIDInfo = ContactIDInfo(contactID: "some-other-contact-id", isStable: true, namedUserID: nil) isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, randomValue: randomValue ) XCTAssertFalse(isUpToDate) // Unstable contact ID contact.contactIDInfo = ContactIDInfo(contactID: "some-contact-id", isStable: false, namedUserID: nil) isUpToDate = await self.delegate.isRemoteDataInfoUpToDate( remoteDatInfo, locale: locale, randomValue: randomValue ) XCTAssertFalse(isUpToDate) } func testFetch() async throws { contact.contactID = "some-contact-id" let locale = Locale(identifier: "br") let randomValue = 1003 let remoteDatInfo = RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data-contact/ios/some-contact-id", locale: locale, randomValue: randomValue ), lastModifiedTime: "some time", source: .contact, contactID: "some-contact-id" ) client.lastModified = "some other time" client.fetchData = { url, auth, lastModified, info in XCTAssertEqual(remoteDatInfo.url, url) XCTAssertEqual(AirshipRequestAuth.contactAuthToken(identifier: "some-contact-id"), auth) XCTAssertEqual("some time", lastModified) XCTAssertEqual( RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: self.config, path: "/api/remote-data-contact/ios/some-contact-id", locale: locale, randomValue: randomValue ), lastModifiedTime: "some other time", source: .contact, contactID: "some-contact-id" ), info ) return AirshipHTTPResponse( result: RemoteDataResult( payloads: [], remoteDataInfo: remoteDatInfo ), statusCode: 200, headers: [:] ) } let result = try await self.delegate.fetchRemoteData( locale: locale, randomValue: randomValue, lastRemoteDataInfo: remoteDatInfo ) XCTAssertEqual(result.statusCode, 200) } func testFetchLastModifiedOutOfDate() async throws { contact.contactID = "some-other-contact-id" let locale = Locale(identifier: "br") let randomValue = 1003 let remoteDatInfo = RemoteDataInfo( url: try RemoteDataURLFactory.makeURL( config: config, path: "/api/remote-data-contact/ios/some-contact-id", locale: locale, randomValue: randomValue ), lastModifiedTime: "some time", source: .contact, contactID: "some-contact-id" ) client.fetchData = { _, _, lastModified, _ in XCTAssertNil(lastModified) return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } let result = try await self.delegate.fetchRemoteData( locale: locale, randomValue: randomValue + 1, lastRemoteDataInfo: remoteDatInfo ) XCTAssertEqual(result.statusCode, 400) } } ================================================ FILE: Airship/AirshipCore/Tests/ContactSubscriptionListAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ContactSubscriptionListAPIClientTest: XCTestCase { private let session: TestAirshipRequestSession = TestAirshipRequestSession() private var contactAPIClient: ContactSubscriptionListAPIClient! private var config: RuntimeConfig = RuntimeConfig.testConfig() override func setUpWithError() throws { self.contactAPIClient = ContactSubscriptionListAPIClient( config: self.config, session: self.session ) } func testGetContactLists() async throws { let responseBody = """ { "ok" : true, "subscription_lists": [ { "list_ids": ["example_listId-1", "example_listId-3"], "scope": "email" }, { "list_ids": ["example_listId-2", "example_listId-4"], "scope": "app" }, { "list_ids": ["example_listId-2"], "scope": "web" } ], } """ self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) self.session.data = responseBody.data(using: .utf8) let expected: [String: [ChannelScope]] = [ "example_listId-1": [.email], "example_listId-2": [.app, .web], "example_listId-3": [.email], "example_listId-4": [.app], ] let response = try await self.contactAPIClient.fetchSubscriptionLists( contactID: "some-contact" ) XCTAssertTrue(response.isSuccess) XCTAssertEqual(expected, response.result!) XCTAssertEqual("GET", self.session.lastRequest?.method) XCTAssertEqual( "\(self.config.deviceAPIURL!)/api/subscription_lists/contacts/some-contact", self.session.lastRequest?.url?.absoluteString ) } func testGetContactListParseError() async throws { let responseBody = "What?" self.session.data = responseBody.data(using: .utf8) self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) do { _ = try await self.contactAPIClient.fetchSubscriptionLists( contactID: "some-contact" ) XCTFail() } catch { } } func testGetContactListError() async throws { let sessionError = AirshipErrors.error("error!") self.session.error = sessionError do { _ = try await self.contactAPIClient.fetchSubscriptionLists( contactID: "some-contact" ) XCTFail() } catch { } } } ================================================ FILE: Airship/AirshipCore/Tests/CustomEventTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class CustomEventTest: XCTestCase { /** * Test creating a custom event. */ func testCustomEvent() { let eventName = "".padding(toLength: 255, withPad: "EVENT_NAME", startingAt: 0) let transactionId = "".padding(toLength: 255, withPad: "TRANSACTION_ID", startingAt: 0) let interactionId = "".padding(toLength: 255, withPad: "INTERACTION_ID", startingAt: 0) let interactionType = "".padding(toLength: 255, withPad: "INTERACTION_TYPE", startingAt: 0) let templateType = "".padding(toLength: 255, withPad: "TEMPLATE_TYPE", startingAt: 0) var event = CustomEvent(name: eventName, value: Double(Int32.min)) event.transactionID = transactionId event.interactionID = interactionId event.interactionType = interactionType event.templateType = templateType XCTAssertEqual(eventName, event.data["event_name"] as? String, "Unexpected event name.") XCTAssertEqual(transactionId, event.data["transaction_id"] as? String, "Unexpected transaction ID.") XCTAssertEqual(interactionId, event.data["interaction_id"] as? String, "Unexpected interaction ID.") XCTAssertEqual(interactionType, event.data["interaction_type"] as? String, "Unexpected interaction type.") XCTAssertEqual(templateType, event.data["template_type"] as? String, "Unexpected template type.") XCTAssertEqual(NSNumber(value: -2147483648000000), event.data["event_value"] as? NSNumber, "Unexpected event value.") } /** * Test setting an event name. */ func testSetCustomEventName() { var event = CustomEvent(name: "event name") XCTAssert(event.isValid()) let largeName = "".padding(toLength: 255, withPad: "event-name", startingAt: 0) event.eventName = largeName XCTAssert(event.isValid()) } /** * Test setting the interaction ID. */ func testSetInteractionID() { var event = CustomEvent(name: "event name") XCTAssertNil(event.interactionID, "Interaction ID should default to nil") let longInteractionId = "".padding(toLength: 255, withPad: "INTERACTION_ID", startingAt: 0) event.interactionID = longInteractionId XCTAssert(event.isValid()) event.interactionID = nil XCTAssert(event.isValid()) } /** * Test setting the interaction type. */ func testSetInteractionType() { var event = CustomEvent(name: "event name") XCTAssertNil(event.interactionType, "Interaction type should default to nil") let longInteractionType = "".padding(toLength: 255, withPad: "INTERACTION_TYPE", startingAt: 0) event.interactionType = longInteractionType XCTAssert(event.isValid()) event.interactionType = nil XCTAssert(event.isValid()) } /** * Test setting the transaction ID */ func testSetTransactionID() { var event = CustomEvent(name: "event name") XCTAssertNil(event.transactionID, "Transaction ID should default to nil") let longTransactionID = "".padding(toLength: 255, withPad: "TRANSACTION_ID", startingAt: 0) event.transactionID = longTransactionID XCTAssertTrue(event.isValid()) event.transactionID = nil XCTAssertTrue(event.isValid()) } /** * Test set template type */ func testSetTemplateType() { var event = CustomEvent(name: "event name") XCTAssertNil(event.templateType, "Template type should default to nil") let longTemplateType = "".padding(toLength: 255, withPad: "TEMPLATE_TYPE", startingAt: 0) event.templateType = longTemplateType XCTAssertTrue(event.isValid()) event.templateType = nil XCTAssertTrue(event.isValid()) } func testEventValue() { var event = CustomEvent(name: "event name", value: 100) XCTAssertEqual(100, event.eventValue) XCTAssert(event.isValid()) // Max value let maxValue = Double(Int32.max) event = CustomEvent(name: "event name", value: maxValue) XCTAssertEqual(NSNumber(value: 2147483647000000), event.data["event_value"] as? NSNumber) XCTAssertTrue(event.isValid()) // Above Max let aboveMax = Decimal(maxValue).advanced(by: 0.0001).doubleValue event = CustomEvent(name: "event name", value: aboveMax) XCTAssertFalse(event.isValid()) // Min value let minValue = Double(Int32.min) event = CustomEvent(name: "event name", value: minValue) XCTAssertEqual(NSNumber(value: -2147483648000000), event.data["event_value"] as? NSNumber) XCTAssertTrue(event.isValid()) // Below min let belowMin = Decimal(minValue).advanced(by: -0.000001).doubleValue event = CustomEvent(name: "event name", value: belowMin) XCTAssertFalse(event.isValid()) // 0 event = CustomEvent(name: "event name", value: 0) XCTAssertEqual(NSNumber(value: 0), event.data["event_value"] as? NSNumber) XCTAssertTrue(event.isValid()) // NaN event = CustomEvent(name: "event name", value: Double.nan) XCTAssertEqual(event.eventValue, Decimal(1.0)) XCTAssertTrue(event.isValid()) // Infinity event = CustomEvent(name: "event name", value: Double.infinity) XCTAssertEqual(event.eventValue, Decimal(1.0)) XCTAssertTrue(event.isValid()) } /** * Test event value to data conversion. The value should be a decimal multiplied by * 10^6 and cast to a long. */ func testEventValueToData() { let eventValues: [Decimal: Int64] = [ 123.123456789: 123123456, 9.999999999: 9999999, 99.999999999: 99999999, 999.999999999: 999999999, 9999.999999999: 9999999999, 99999.999999999: 99999999999, 999999.999999999: 999999999999, 9999999.999999999: 9999999999999 ] eventValues.forEach { value, expected in let event = CustomEvent(name: "event name", decimalValue: value) XCTAssertTrue(event.isValid()) XCTAssertEqual(NSNumber(value: expected), event.data["event_value"] as? NSNumber) } } func testConversionSendID() { let data = CustomEvent(name: "event name") .eventBody(sendID: "send id", metadata: "metadata", formatValue: false) XCTAssertEqual("send id", data.object?["conversion_send_id"]?.string) XCTAssertEqual("metadata", data.object?["conversion_metadata"]?.string) } func testConversionSendIDSet() { var event = CustomEvent(name: "event name") event.conversionSendID = "some other send id" event.conversionPushMetadata = "some other metadata" let data = event.eventBody(sendID: "send id", metadata: "metadata", formatValue: false) XCTAssertEqual("some other send id", data.object?["conversion_send_id"]?.string) XCTAssertEqual("some other metadata", data.object?["conversion_metadata"]?.string) } func testMaxTotalPropertySize() throws { var event = CustomEvent(name: "event name") var properties: [String: NSNumber] = [:] (0...5000).forEach({ properties["\($0)"] = 324 }) try event.setProperties(properties) XCTAssertTrue(event.isValid()) (0...2000).forEach({ properties["\(5000 + $0)"] = 324 }) try event.setProperties(properties) XCTAssertFalse(event.isValid()) } func testInApp() { var event = CustomEvent(name: "event name") // Defined in automation, just make sure it passes it through event.inApp = AirshipJSON.makeObject { builder in builder.set(string: "foo", key: "bar") } let result = try! AirshipJSON.wrap(event.data["in_app"]) XCTAssertEqual(event.inApp, result) } func testCodableProperties() throws { var event = CustomEvent(name: "event name") try event.setProperties([ "some-codable": TestCodable(string: "foo", bool: false) ]) let properties = event.data["properties"] as! [String: Any] let someCodable = properties["some-codable"] as! [String: Any] XCTAssertEqual("foo", someCodable["string"] as! String) XCTAssertEqual(false, someCodable["bool"] as! Bool) } func testDateProperties() throws { var event = CustomEvent(name: "event name") try event.setProperties([ "some-date": Date(timeIntervalSince1970: 10000.0) ]) let properties = event.data["properties"] as! [String: Any] XCTAssertEqual("1970-01-01T02:46:40Z", properties["some-date"] as! String) } } fileprivate struct TestCodable: Encodable { let string: String let bool: Bool } extension CustomEvent { var data: [AnyHashable: Any] { return self.eventBody( sendID: nil, metadata: nil, formatValue: true ).unWrap() as? [AnyHashable : Any] ?? [:] } } extension Decimal { var doubleValue: Double { return NSDecimalNumber(decimal:self).doubleValue } } ================================================ FILE: Airship/AirshipCore/Tests/CustomNotificationCategories.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>share_category</key> <array> <dict> <key>authorizationRequired</key> <false/> <key>destructive</key> <false/> <key>title</key> <string>Share</string> <key>foreground</key> <true/> <key>identifier</key> <string>share_button</string> </dict> </array> <key>follow_category</key> <array> <dict> <key>authorizationRequired</key> <false/> <key>destructive</key> <false/> <key>title_resource</key> <string>ua_follow_title_resource</string> <key>title</key> <string>FollowMe</string> <key>foreground</key> <true/> <key>identifier</key> <string>follow_button</string> </dict> </array> <key>yes_no_category</key> <array> <dict> <key>authenticationRequired</key> <false/> <key>destructive</key> <false/> <key>title</key> <string>Yes</string> <key>foreground</key> <true/> <key>identifier</key> <string>yes_button</string> </dict> <dict> <key>authenticationRequired</key> <true/> <key>destructive</key> <true/> <key>title</key> <string>No</string> <key>foreground</key> <false/> <key>identifier</key> <string>no_button</string> </dict> </array> <key>text_input_category</key> <array> <dict> <key>text_input_button_title</key> <string>text_input_button</string> <key>text_input_placeholder</key> <string>placeholder_text</string> <key>action_type</key> <string>text_input</string> <key>authorizationRequired</key> <false/> <key>destructive</key> <false/> <key>title_resource</key> <string>ua_text_input_title_resource</string> <key>title</key> <string>TextInput</string> <key>foreground</key> <true/> <key>identifier</key> <string>text_input</string> </dict> </array> </dict> </plist> ================================================ FILE: Airship/AirshipCore/Tests/DeepLinkActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class DeepLinkActionTest: XCTestCase { private let testURLOpener: TestURLOpener = TestURLOpener() private let urlAllowList: TestURLAllowList = TestURLAllowList() private var airship: TestAirshipInstance! private var action: DeepLinkAction! @MainActor override func setUp() { airship = TestAirshipInstance() self.action = DeepLinkAction(urlOpener: self.testURLOpener) self.airship.urlAllowList = self.urlAllowList self.airship.makeShared() } override func tearDown() async throws { TestAirshipInstance.clearShared() } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ] let rejectedSituations = [ ActionSituation.backgroundPush, ActionSituation.backgroundInteractiveButton ] for situation in validSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } @MainActor func testPerformDeepLinkDelegate() async throws { let deepLinkDelegate = TestDeepLinkDelegate() self.urlAllowList.isAllowedReturnValue = false self.testURLOpener.returnValue = false self.airship.deepLinkDelegate = deepLinkDelegate let args = ActionArguments( string: "http://some-valid-url", situation: .manualInvocation ) _ = try await action.perform(arguments: args) XCTAssertEqual("http://some-valid-url", deepLinkDelegate.lastDeepLink?.absoluteString) XCTAssertNil(self.testURLOpener.lastURL) } @MainActor func testPerformFallback() async throws { self.urlAllowList.isAllowedReturnValue = true self.testURLOpener.returnValue = true let args = ActionArguments( string: "http://some-valid-url", situation: .manualInvocation ) _ = try await action.perform(arguments: args) XCTAssertEqual("http://some-valid-url", self.testURLOpener.lastURL?.absoluteString) } @MainActor func testPerformFallbackRejectsURL() async throws { self.urlAllowList.isAllowedReturnValue = false self.testURLOpener.returnValue = true let args = ActionArguments( string: "http://some-valid-url", situation: .manualInvocation ) do { _ = try await action.perform(arguments: args) XCTFail("Should throw") } catch {} XCTAssertNil(self.testURLOpener.lastURL) } @MainActor func testPerformFallbackUnableToOpenURL() async throws { self.urlAllowList.isAllowedReturnValue = true self.testURLOpener.returnValue = false let args = ActionArguments( string: "http://some-valid-url", situation: .manualInvocation ) do { _ = try await action.perform(arguments: args) XCTFail("Should throw") } catch {} XCTAssertEqual("http://some-valid-url", self.testURLOpener.lastURL?.absoluteString) } @MainActor func testPerformInvalidURL() async throws { self.urlAllowList.isAllowedReturnValue = true self.testURLOpener.returnValue = true let args = ActionArguments( double: 10.0, situation: .manualInvocation ) do { _ = try await action.perform(arguments: args) XCTFail("Should throw") } catch {} XCTAssertNil(self.testURLOpener.lastURL) } } fileprivate final class TestDeepLinkDelegate: DeepLinkDelegate, @unchecked Sendable { var lastDeepLink: URL? func receivedDeepLink(_ deepLink: URL) async { self.lastDeepLink = deepLink } } ================================================ FILE: Airship/AirshipCore/Tests/DeepLinkHandlerTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore @Suite("Deep Link Handler Tests") struct DeepLinkHandlerTest { // MARK: - Test Helpers actor TestDeepLinkDelegate: DeepLinkDelegate { private(set) var receivedDeepLink: URL? func receivedDeepLink(_ deepLink: URL) async { self.receivedDeepLink = deepLink } func reset() { self.receivedDeepLink = nil } } final class TestComponent: AirshipComponent, @unchecked Sendable { var onDeepLink: ((URL) -> Bool)? private(set) var deepLink: URL? func deepLink(_ deepLink: URL) -> Bool { self.deepLink = deepLink return onDeepLink?(deepLink) ?? false } func reset() { self.deepLink = nil } } // MARK: - Tests @Test("Handler set - delegate not called") @MainActor func testHandlerPreventsDelegate() async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } let delegate = TestDeepLinkDelegate() let component = TestComponent() component.onDeepLink = { _ in Issue.record("Component should not be called for non-uairship URLs") return false } var handlerCalled = false let testURL = URL(string: "myapp://deep-link/test")! airshipInstance.deepLinkHandler = { url in #expect(url == testURL) handlerCalled = true } airshipInstance.deepLinkDelegate = delegate airshipInstance.components = [component] let result = await Airship.shared.deepLink(testURL) #expect(result == true) #expect(handlerCalled == true) #expect(component.deepLink == nil) await #expect(delegate.receivedDeepLink == nil) } @Test("No handler - uses delegate") @MainActor func testNoHandlerUsesDelegate() async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } let delegate = TestDeepLinkDelegate() let testURL = URL(string: "myapp://deep-link/direct")! airshipInstance.deepLinkDelegate = delegate airshipInstance.components = [] let result = await Airship.shared.deepLink(testURL) #expect(result == true) await #expect(delegate.receivedDeepLink == testURL) } @Test("Handler without delegate") @MainActor func testHandlerWithoutDelegate() async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } var handlerCalled = false let testURL = URL(string: "myapp://deep-link/no-delegate")! airshipInstance.deepLinkHandler = { url in #expect(url == testURL) handlerCalled = true } airshipInstance.components = [] let result = await Airship.shared.deepLink(testURL) #expect(result == true) #expect(handlerCalled == true) } @Test("No handler and no delegate") @MainActor func testNoHandlerNoDelegate() async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } let testURL = URL(string: "myapp://deep-link/unhandled")! airshipInstance.components = [] let result = await Airship.shared.deepLink(testURL) #expect(result == false) } @Test("UAirship scheme - handler intercepts") @MainActor func testUAirshipSchemeHandler() async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } let delegate = TestDeepLinkDelegate() let component = TestComponent() component.onDeepLink = { _ in false } var handlerCalled = false let testURL = URL(string: "uairship://custom-action")! airshipInstance.deepLinkHandler = { url in #expect(url == testURL) handlerCalled = true } airshipInstance.deepLinkDelegate = delegate airshipInstance.components = [component] let result = await Airship.shared.deepLink(testURL) #expect(result == true) #expect(handlerCalled == true) #expect(component.deepLink == testURL) await #expect(delegate.receivedDeepLink == nil) // Handler prevents delegate } @Test( "Different URL schemes", arguments: [ "https://example.com/deep", "myapp://home", "custom://action/test", "uairship://test" ] ) @MainActor func testDifferentURLSchemes(urlString: String) async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } let testURL = URL(string: urlString)! var handlerCalled = false airshipInstance.deepLinkHandler = { url in #expect(url == testURL) handlerCalled = true } airshipInstance.components = [] let result = await Airship.shared.deepLink(testURL) // Handler always returns true when set let isUAirshipScheme = testURL.scheme == "uairship" #expect(result == true) #expect(handlerCalled == true || isUAirshipScheme) } @Test("Handler priority over delegate") @MainActor func testHandlerPriorityOverDelegate() async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } let delegate = TestDeepLinkDelegate() var handlerCalled = false let testURL = URL(string: "myapp://test/priority")! // Both handler and delegate are set airshipInstance.deepLinkHandler = { url in #expect(url == testURL) handlerCalled = true } airshipInstance.deepLinkDelegate = delegate airshipInstance.components = [] let result = await Airship.shared.deepLink(testURL) // Handler takes priority, delegate not called #expect(result == true) #expect(handlerCalled == true) await #expect(delegate.receivedDeepLink == nil) } @Test("Thread safety - concurrent deep link handling") @MainActor func testConcurrentDeepLinkHandling() async throws { let airshipInstance = TestAirshipInstance() airshipInstance.makeShared() defer { TestAirshipInstance.clearShared() } var processedURLs: Set<String> = [] let lock = NSLock() airshipInstance.deepLinkHandler = { url in lock.lock() processedURLs.insert(url.absoluteString) lock.unlock() // Simulate some processing time try? await Task.sleep(nanoseconds: 10_000_000) // 10ms } airshipInstance.components = [] // Create multiple URLs to process concurrently let urls = (1...10).map { URL(string: "myapp://test/\($0)")! } // Process all URLs concurrently await withTaskGroup(of: Bool.self) { group in for url in urls { group.addTask { await Airship.shared.deepLink(url) } } for await result in group { #expect(result == true) } } // Verify all URLs were processed lock.lock() let finalCount = processedURLs.count lock.unlock() #expect(finalCount == urls.count) } } ================================================ FILE: Airship/AirshipCore/Tests/DefaultAirshipRequestSessionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class DefaultAirshipRequestSessionTest: AirshipBaseTest { private let testURLSession = TestURLRequestSession() private var airshipSession: DefaultAirshipRequestSession! private var nonce: String = UUID().uuidString private var date: UATestDate = UATestDate(offset: 0, dateOverride: Date()) override func setUpWithError() throws { self.airshipSession = DefaultAirshipRequestSession( appKey: "testAppKey", appSecret: "testAppSecret", session: self.testURLSession, date: date ) { return self.nonce } } func testDefaultHeaders() async throws { let request = AirshipRequest(url: URL(string: "http://neat.com")) let _ = try? await self.airshipSession.performHTTPRequest(request) let headers = testURLSession.requests.last?.allHTTPHeaderFields let expected = [ "Accept-Encoding": "gzip;q=1.0, compress;q=0.5", "User-Agent": "(UALib \(AirshipVersion.version); testAppKey)", "X-UA-App-Key": "testAppKey", ] XCTAssertEqual(expected, headers) } func testCombinedHeaders() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), headers: [ "foo": "bar", "User-Agent": "Something else", ] ) let _ = try? await self.airshipSession.performHTTPRequest(request) let headers = testURLSession.requests.last?.allHTTPHeaderFields let expected = [ "foo": "bar", "Accept-Encoding": "gzip;q=1.0, compress;q=0.5", "User-Agent": "Something else", "X-UA-App-Key": "testAppKey" ] XCTAssertEqual(expected, headers) } func testBasicAuth() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .basic(username: "name", password: "password") ) let _ = try? await self.airshipSession.performHTTPRequest(request) let auth = testURLSession.requests.last?.allHTTPHeaderFields?[ "Authorization" ] XCTAssertEqual("Basic bmFtZTpwYXNzd29yZA==", auth) } func testAppAuth() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .basicAppAuth ) let _ = try? await self.airshipSession.performHTTPRequest(request) let auth = testURLSession.requests.last?.allHTTPHeaderFields?[ "Authorization" ] XCTAssertEqual("Basic dGVzdEFwcEtleTp0ZXN0QXBwU2VjcmV0", auth) } @MainActor func testChannelAuthToken() async throws { let authProvider = TestAuthTokenProvider() { identifier in XCTAssertEqual("some identifier", identifier) return "some auth token" } airshipSession.channelAuthTokenProvider = authProvider let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .channelAuthToken(identifier: "some identifier") ) let _ = try? await self.airshipSession.performHTTPRequest(request) let authHeaders = [ "Authorization": "Bearer some auth token", "X-UA-Appkey": "testAppKey", "X-UA-Auth-Type": "SDK-JWT" ] let headers = testURLSession.requests.last?.allHTTPHeaderFields?.filter({ (key: String, value: String) in authHeaders[key] != nil }) XCTAssertEqual(authHeaders, headers) } @MainActor func testContactAuthToken() async throws { let authProvider = TestAuthTokenProvider() { identifier in XCTAssertEqual("some contact", identifier) return "some auth token" } airshipSession.contactAuthTokenProvider = authProvider let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .contactAuthToken(identifier: "some contact") ) let _ = try? await self.airshipSession.performHTTPRequest(request) let authHeaders = [ "Authorization": "Bearer some auth token", "X-UA-Appkey": "testAppKey", "X-UA-Auth-Type": "SDK-JWT" ] let headers = testURLSession.requests.last?.allHTTPHeaderFields?.filter({ (key: String, value: String) in authHeaders[key] != nil }) XCTAssertEqual(authHeaders, headers) } func testGeneratedAppToken() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .generatedAppToken ) let _ = try? await self.airshipSession.performHTTPRequest(request) let timeStamp = AirshipDateFormatter.string(fromDate: self.date.now, format: .iso) let token = try AirshipUtils.generateSignedToken( secret: "testAppSecret", tokenParams: ["testAppKey", nonce, timeStamp] ) let authHeaders = [ "Authorization": "Bearer \(token)", "X-UA-Appkey": "testAppKey", "X-UA-Nonce": nonce, "X-UA-Timestamp": timeStamp ] let headers = testURLSession.requests.last?.allHTTPHeaderFields?.filter({ (key: String, value: String) in authHeaders[key] != nil }) XCTAssertEqual(authHeaders, headers) } func testGeneratedChannelToken() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .generatedChannelToken(identifier: "some channel") ) let _ = try? await self.airshipSession.performHTTPRequest(request) let timeStamp = AirshipDateFormatter.string(fromDate: self.date.now, format: .iso) let token = try AirshipUtils.generateSignedToken( secret: "testAppSecret", tokenParams: ["testAppKey", "some channel", nonce, timeStamp] ) let authHeaders = [ "Authorization": "Bearer \(token)", "X-UA-Appkey": "testAppKey", "X-UA-Channel-ID": "some channel", "X-UA-Nonce": nonce, "X-UA-Timestamp": timeStamp ] let headers = testURLSession.requests.last?.allHTTPHeaderFields?.filter({ (key: String, value: String) in authHeaders[key] != nil }) XCTAssertEqual(authHeaders, headers) } @MainActor func testExpiredChannelAuth() async throws { let authProvider = TestAuthTokenProvider() { identifier in XCTAssertEqual("some identifier", identifier) return "some auth token" } airshipSession.channelAuthTokenProvider = authProvider let request = AirshipRequest( url: URL(string: "https://airship.com/something"), auth: .channelAuthToken(identifier: "some identifier") ) self.testURLSession.responses = [ Response.makeResponse(status: 401), Response.makeResponse(status: 401) ] let _ = try? await self.airshipSession.performHTTPRequest(request) XCTAssertEqual(2, authProvider.resolveAuthCount) XCTAssertEqual(["some auth token", "some auth token"], authProvider.expiredTokens) } @MainActor func testResolveAuthSequentially() async throws { // Using a stream to send a result later on var escapee: AsyncStream<String>.Continuation! let stream = AsyncStream<String>() { continuation in escapee = continuation } let authProvider = TestAuthTokenProvider() { identifier in for await token in stream { return token } throw AirshipErrors.error("Failed") } airshipSession.channelAuthTokenProvider = authProvider let request = AirshipRequest( url: URL(string: "https://airship.com/something"), auth: .channelAuthToken(identifier: "some identifier") ) let airshipSession = self.airshipSession await withTaskGroup(of: Void.self) { [escapee] group in for _ in 1...4 { group.addTask { let _ = try? await airshipSession?.performHTTPRequest(request) } } group.addTask { try? await Task.sleep(nanoseconds: 100) escapee?.yield("token") } } XCTAssertEqual(1, authProvider.resolveAuthCount) } @MainActor func testNilChannelAuthProviderThrows() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .channelAuthToken(identifier: "some identifier") ) do { let _ = try await self.airshipSession.performHTTPRequest(request) XCTFail() } catch {} } @MainActor func testNilContactAuthProviderThrows() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .contactAuthToken(identifier: "some contact") ) do { let _ = try await self.airshipSession.performHTTPRequest(request) XCTFail() } catch {} } func testBearerAuth() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), auth: .bearer(token: "some token") ) let _ = try? await self.airshipSession.performHTTPRequest(request) let auth = testURLSession.requests.last?.allHTTPHeaderFields?[ "Authorization" ] XCTAssertEqual("Bearer some token", auth) } func testBody() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), body: "body".data(using: .utf8) ) let _ = try? await self.airshipSession.performHTTPRequest(request) let body = testURLSession.requests.last?.httpBody XCTAssertEqual(request.body, body) } func testMethod() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), method: "HEAD" ) let _ = try? await self.airshipSession.performHTTPRequest(request) let method = testURLSession.requests.last?.httpMethod XCTAssertEqual("HEAD", method) } func testDeflateBody() async throws { let request = AirshipRequest( url: URL(string: "http://neat.com"), body: "body".data(using: .utf8), contentEncoding: .deflate ) let _ = try? await self.airshipSession.performHTTPRequest(request) let body = testURLSession.requests.last?.httpBody XCTAssertEqual( "S8pPqQQA", body?.base64EncodedString() ) let contentEncoding = testURLSession.requests.last?.allHTTPHeaderFields?["Content-Encoding"] XCTAssertEqual("deflate", contentEncoding) } func testDeflateRoundTrip() throws { let testInputs: [Data] = [ "body".data(using: .utf8)!, "Hello, World! This is a test of deflate compression.".data(using: .utf8)!, String(repeating: "ABCDEFGHIJ", count: 1000).data(using: .utf8)!, Data((0..<256).map { UInt8($0) }), "a".data(using: .utf8)!, ] for (index, input) in testInputs.enumerated() { let compressed = try (input as NSData).compressed(using: .zlib) as Data let decompressed = try (compressed as NSData).decompressed(using: .zlib) as Data XCTAssertEqual( input, decompressed, "Deflate round-trip failed for input \(index) (size \(input.count) bytes)" ) } } func testRequest() async throws { let request = AirshipRequest( url: URL(string: "https://airship.com") ) self.testURLSession.responses = [ Response.makeResponse(status: 301, responseBody: "Neat") ] let response = try! await self.airshipSession.performHTTPRequest( request ) { data, response in return String(data: data!, encoding: .utf8) } XCTAssertEqual("Neat", response.result) XCTAssertEqual(301, response.statusCode) } func testNilURL() async throws { let request = AirshipRequest( url: nil, body: "body".data(using: .utf8), contentEncoding: .deflate ) do { let _ = try await self.airshipSession.performHTTPRequest(request) XCTFail() } catch { } } func testParseError() async throws { let request = AirshipRequest( url: URL(string: "https://airship.com/something")! ) self.testURLSession.responses = [ Response.makeResponse(status: 301, responseBody: "Neat") ] do { let _ = try await self.airshipSession.performHTTPRequest(request) { _, _ in throw AirshipErrors.error("NEAT!") } XCTFail() } catch { } } } final class TestAuthTokenProvider: AuthTokenProvider, @unchecked Sendable { public var resolveAuthCount: Int = 0 public var expiredTokens: [String] = [] private let onResolve: (String) async throws -> String init(onResolve: @escaping (String) async throws -> String) { self.onResolve = onResolve } func resolveAuth(identifier: String) async throws -> String { resolveAuthCount += 1 return try await self.onResolve(identifier) } func authTokenExpired(token: String) async { expiredTokens.append(token) } } fileprivate final class TestURLRequestSession: URLRequestSessionProtocol, @unchecked Sendable { private let lock = AirshipLock() private var _requests: [URLRequest] = [] var requests: [URLRequest] { var result: [URLRequest]! lock.sync { result = _requests } return result } var responses: [Response] = [] func dataTask( request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void ) -> AirshipCancellable { lock.sync { self._requests.append(request) } let response = responses.isEmpty ? nil : responses.removeFirst() completionHandler( response?.responseBody?.data(using: .utf8), response?.httpResponse, response?.error ) return CancellableValueHolder<String>() { _ in} } } fileprivate struct Response { let httpResponse: HTTPURLResponse? let error: Error? let responseBody: String? init( httpResponse: HTTPURLResponse? = nil, responseBody: String? = nil, error: Error? = nil ) { self.httpResponse = httpResponse self.error = error self.responseBody = responseBody } static func makeError(_ error: Error) -> Response { return Response(error: error) } static func makeResponse( status: Int, responseHeaders: [String: String]? = nil, responseBody: String? = nil ) -> Response { return Response( httpResponse: HTTPURLResponse( url: URL(string: "https://example.com")!, statusCode: status, httpVersion: nil, headerFields: responseHeaders ?? [:] )!, responseBody: responseBody ) } } ================================================ FILE: Airship/AirshipCore/Tests/DefaultAppIntegrationDelegateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import Combine @testable import AirshipCore class DefaultAppIntegrationdelegateTest: XCTestCase { private var delegate: DefaultAppIntegrationDelegate! private let push = TestPush() private let analytics = TestAnalytics() private let pushableComponent = TestPushableComponent() private var airshipInstance: TestAirshipInstance! @MainActor override func setUp() async throws { airshipInstance = TestAirshipInstance() self.airshipInstance.actionRegistry = DefaultAirshipActionRegistry() self.airshipInstance.makeShared() self.delegate = DefaultAppIntegrationDelegate( push: self.push, analytics: self.analytics, pushableComponents: [pushableComponent] ) } @MainActor func testOnBackgroundAppRefresh() throws { delegate.onBackgroundAppRefresh() XCTAssertTrue(push.updateAuthorizedNotificationTypesCalled) } @MainActor func testDidRegisterForRemoteNotifications() async throws { let data = Data() delegate.didRegisterForRemoteNotifications(deviceToken: data) let token = push.deviceToken?.data(using: .utf8) XCTAssertEqual(data, token) } @MainActor func testDidFailToRegisterForRemoteNotifications() throws { let error = AirshipErrors.error("some error") delegate.didFailToRegisterForRemoteNotifications(error: error) XCTAssertEqual("some error", error.localizedDescription) } @MainActor func testDidReceiveRemoteNotification() async throws { let expectedUserInfo = ["neat": "story"] self.push.didReceiveRemoteNotificationCallback = { userInfo, isForeground in XCTAssertEqual( expectedUserInfo as NSDictionary, userInfo as NSDictionary ) XCTAssertTrue(isForeground) return .noData } self.pushableComponent.didReceiveRemoteNotificationCallback = { userInfo in XCTAssertEqual( expectedUserInfo as NSDictionary, userInfo as NSDictionary ) return .newData } let result = await withCheckedContinuation { continuation in delegate.didReceiveRemoteNotification( userInfo: expectedUserInfo, isForeground: true ) { result in continuation.resume(returning: result) } } XCTAssertEqual(result, .newData) } } class TestPushableComponent: AirshipPushableComponent, @unchecked Sendable { var didReceiveRemoteNotificationCallback:( ([AnyHashable: Any]) -> UABackgroundFetchResult )? public func receivedRemoteNotification( _ notification: AirshipJSON ) async -> UABackgroundFetchResult { let unwrapped = notification.unWrap() as? [AnyHashable: Any] ?? [:] return self.didReceiveRemoteNotificationCallback!(unwrapped) } public func receivedNotificationResponse(_ response: UNNotificationResponse) async { assertionFailure("Unable to create UNNotificationResponse in tests.") } } ================================================ FILE: Airship/AirshipCore/Tests/DefaultTaskSleeperTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class DefaultTaskSleeperTest: XCTestCase { private let date: UATestDate = UATestDate(dateOverride: Date()) private let sleeps: AirshipActorValue<[TimeInterval]> = AirshipActorValue([]) private var sleeper: AirshipTaskSleeper! override func setUp() async throws { sleeper = DefaultAirshipTaskSleeper(date: date) { [sleeps, date] interval in date.offset += interval await sleeps.update { current in current.append(interval) } } } func testIntervalSleep() async throws { try await sleeper.sleep(timeInterval: 85.0) let sleeps = await sleeps.value XCTAssertEqual(sleeps, [30.0, 30.0, 25.0]) } func testBelowIntervalSleep() async throws { try await sleeper.sleep(timeInterval: 30.0) let sleeps = await sleeps.value XCTAssertEqual(sleeps, [30.0]) } func testNegativeSleep() async throws { try await sleeper.sleep(timeInterval: -1.0) let sleeps = await sleeps.value XCTAssertEqual(sleeps, []) } func testNoSleep() async throws { try await sleeper.sleep(timeInterval: 0.0) let sleeps = await sleeps.value XCTAssertEqual(sleeps, []) } } ================================================ FILE: Airship/AirshipCore/Tests/DeferredAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class DeferredAPIClientTest: AirshipBaseTest { var apiClient: DeferredAPIClient! private let testSession: TestAirshipRequestSession = TestAirshipRequestSession() private let exampleURL: URL = URL(string: "exampleurl://")! let date = AirshipDateFormatter.date(fromISOString: "2023-10-27T21:18:15")! override func setUpWithError() throws { self.apiClient = DeferredAPIClient( config: self.config, session: self.testSession ) } func testResolve() async throws { self.testSession.response = HTTPURLResponse( url: exampleURL, statusCode: 200, httpVersion: "", headerFields: [:] ) let responseBody = "some response".data(using: .utf8) self.testSession.data = responseBody let audienceOverrides = ChannelAudienceOverrides( tags: [ TagGroupUpdate( group: "some-group", tags: ["tag-1", "tag-2"], type: .add ), TagGroupUpdate( group: "some-other-group", tags: ["tag-3", "tag-4"], type: .set ) ], attributes: [ AttributeUpdate( attribute: "some-attribute", type: .set, jsonValue: "hello", date: date ) ] ) let response = try await self.apiClient.resolve( url: exampleURL, channelID: "some channel id", contactID: "some contact id", stateOverrides: AirshipStateOverrides( appVersion: "1.0.0", sdkVersion: "2.0.0", notificationOptIn: true, localeLangauge: "en", localeCountry: "US" ), audienceOverrides: audienceOverrides, triggerContext: AirshipTriggerContext( type: "some trigger type", goal: 10.0, event: "event body" ) ) let expectedBody = """ { "state_overrides":{ "app_version":"1.0.0", "locale_language":"en", "sdk_version":"2.0.0", "locale_country":"US", "notification_opt_in":true }, "tag_overrides":{ "set":{ "some-other-group":[ "tag-3", "tag-4" ] }, "add":{ "some-group":[ "tag-1", "tag-2" ] } }, "channel_id":"some channel id", "platform":"ios", "trigger":{ "event":"event body", "type":"some trigger type", "goal":10 }, "contact_id":"some contact id", "attribute_overrides":[ { "value":"hello", "timestamp":"2023-10-27T21:18:15", "key":"some-attribute", "action":"set" } ] } """ XCTAssertEqual(200, response.statusCode) XCTAssertEqual(responseBody, response.result) XCTAssertEqual("POST", self.testSession.lastRequest?.method) XCTAssertEqual(self.exampleURL, self.testSession.lastRequest?.url) XCTAssertEqual(["Accept": "application/vnd.urbanairship+json; version=3;"], self.testSession.lastRequest?.headers) XCTAssertEqual(AirshipRequestAuth.channelAuthToken(identifier: "some channel id"), self.testSession.lastRequest?.auth) XCTAssertEqual( try AirshipJSON.from(json: expectedBody), try AirshipJSON.from(data:self.testSession.lastRequest?.body) ) } func testResolveMinimal() async throws { self.testSession.response = HTTPURLResponse( url: exampleURL, statusCode: 200, httpVersion: "", headerFields: [:] ) self.testSession.data = "some response".data(using: .utf8) _ = try await self.apiClient.resolve( url: exampleURL, channelID: "some channel id", contactID: nil, stateOverrides: AirshipStateOverrides( appVersion: "1.0.0", sdkVersion: "2.0.0", notificationOptIn: true, localeLangauge: nil, localeCountry: nil ), audienceOverrides: ChannelAudienceOverrides(), triggerContext: nil ) let expectedBody = """ { "state_overrides":{ "app_version":"1.0.0", "sdk_version":"2.0.0", "notification_opt_in":true }, "channel_id":"some channel id", "platform":"ios" } """ XCTAssertEqual( try AirshipJSON.from(json: expectedBody), try AirshipJSON.from(data:self.testSession.lastRequest?.body) ) } } ================================================ FILE: Airship/AirshipCore/Tests/DeferredResolverTest.swift ================================================ import XCTest @testable import AirshipCore final class DeferredResolverTest: XCTestCase { private var resolver: AirshipDeferredResolver! private let audienceOverridesProvider: DefaultAudienceOverridesProvider = DefaultAudienceOverridesProvider() private let client: TestDeferredAPIClient = TestDeferredAPIClient() private let exampleURL: URL = URL(string: "exampleurl://")! private let altExampleURL: URL = URL(string: "altexampleurl://")! override func setUp() { self.resolver = AirshipDeferredResolver( client: client, audienceOverrides: audienceOverridesProvider ) } func testResolve() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", contactID: "some contact ID", triggerContext: AirshipTriggerContext( type: "some type", goal: 10.0, event: "event" ), locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let tags = [ TagGroupUpdate( group: "some-group", tags: ["tag-1", "tag-2"], type: .add ), TagGroupUpdate( group: "some-other-group", tags: ["tag-3", "tag-4"], type: .set ) ] let attributes = [ AttributeUpdate( attribute: "some-attribute", type: .set, jsonValue: "hello", date: Date() ) ] /// Local history await self.audienceOverridesProvider.channelUpdated( channelID: "some channel ID", tags: tags, attributes: attributes, subscriptionLists: nil ) let body = "some body".data(using: .utf8)! self.client.onResolve = { url, channel, contact, stateOverrides, audienceOverrides, trigger in let expectedStateOverrides = AirshipStateOverrides( appVersion: request.appVersion, sdkVersion: request.sdkVersion, notificationOptIn: request.notificationOptIn, localeLangauge: request.locale.getLanguageCode(), localeCountry: request.locale.getRegionCode() ) let expectedAudienceOverrides = ChannelAudienceOverrides( tags: tags, attributes: attributes, subscriptionLists: [] ) XCTAssertEqual(url, request.url) XCTAssertEqual(channel, request.channelID) XCTAssertEqual(contact, request.contactID) XCTAssertEqual(trigger, request.triggerContext) XCTAssertEqual(trigger, request.triggerContext) XCTAssertEqual(stateOverrides, expectedStateOverrides) XCTAssertEqual(audienceOverrides, expectedAudienceOverrides) return AirshipHTTPResponse(result: body, statusCode: 200, headers: [:]) } let result = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .success(body)) } func testResolveNoAudienceOverrides() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let body = "some body".data(using: .utf8) self.client.onResolve = { _, _, _, _, audienceOverrides, _ in let expectedAudienceOverrides = ChannelAudienceOverrides( tags: [], attributes: [], subscriptionLists: [] ) XCTAssertEqual(audienceOverrides, expectedAudienceOverrides) return AirshipHTTPResponse(result: body, statusCode: 200, headers: [:]) } _ = await resolver.resolve(request: request) { data in return data } } func testResolveParseError() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let statusCode: Int = 200 let body = "some body".data(using: .utf8) self.client.onResolve = { _, _, _, _, _, _ in return AirshipHTTPResponse(result: body, statusCode: statusCode, headers: [:]) } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in throw AirshipErrors.error("parse error") } XCTAssertEqual(result, .retriableError(statusCode: statusCode)) } func testResolveTimedOut() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) self.client.onResolve = { _, _, _, _, _, _ in throw AirshipErrors.error("timed out") } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .timedOut) } func testResolve404() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let body = "some body".data(using: .utf8) self.client.onResolve = { _, _, _, _, _, _ in return AirshipHTTPResponse(result: body, statusCode: 404, headers: [:]) } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .notFound) } func testResolve409() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let body = "some body".data(using: .utf8) self.client.onResolve = { _, _, _, _, _, _ in return AirshipHTTPResponse(result: body, statusCode: 409, headers: [:]) } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .outOfDate) } func testResolveOutOfDateURL() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let body = "some body".data(using: .utf8) self.client.onResolve = { _, _, _, _, _, _ in return AirshipHTTPResponse(result: body, statusCode: 409, headers: [:]) } var result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .outOfDate) self.client.onResolve = { _, _, _, _, _, _ in XCTFail() throw AirshipErrors.error("Failed") } result = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .outOfDate) } func testResolve429() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let statusCode: Int = 429 let body = "some body".data(using: .utf8)! self.client.onResolve = { _, _, _, _, _, _ in return AirshipHTTPResponse(result: body, statusCode: statusCode, headers: ["Location": self.altExampleURL.absoluteString]) } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .retriableError(statusCode: statusCode)) self.client.onResolve = { url, _, _, _, _, _ in XCTAssertEqual(url, self.altExampleURL) return AirshipHTTPResponse(result: body, statusCode: 200, headers: [:]) } let anotherResult: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(anotherResult, .success(body)) } func testResolve429RetryAfter() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let statusCode: Int = 429 let body = "some body".data(using: .utf8)! self.client.onResolve = { _, _, _, _, _, _ in return AirshipHTTPResponse(result: body, statusCode: statusCode, headers: ["Retry-After": "100.0"]) } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .retriableError(retryAfter: 100.0, statusCode: statusCode)) } func testResolve307() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let body = "some body".data(using: .utf8)! self.client.onResolve = { url, _, _, _, _, _ in if (url == self.exampleURL) { return AirshipHTTPResponse(result: nil, statusCode: 307, headers: ["Location": self.altExampleURL.absoluteString]) } else { return AirshipHTTPResponse(result: body, statusCode: 200, headers: [:]) } } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .success(body)) } func testRedirectTwice() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let body = "some body".data(using: .utf8)! self.client.onResolve = { url, _, _, _, _, _ in if (url == self.exampleURL) { return AirshipHTTPResponse(result: nil, statusCode: 307, headers: ["Location": "altexampleurl://1"]) } else { return AirshipHTTPResponse(result: body, statusCode: 307, headers: ["Location": "altexampleurl://2"]) } } var result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .retriableError(retryAfter: nil, statusCode: 307)) self.client.onResolve = { url, _, _, _, _, _ in XCTAssertEqual(url.absoluteString, "altexampleurl://2") return AirshipHTTPResponse(result: body, statusCode: 200, headers: [:]) } result = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .success(body)) } func testResolve307RetryAfter() async throws { let request = DeferredRequest( url: exampleURL, channelID: "some channel ID", locale: Locale(identifier: "de-DE"), notificationOptIn: true ) let body = "some body".data(using: .utf8)! let statusCode: Int = 307 self.client.onResolve = { url, _, _, _, _, _ in if (url == self.exampleURL) { return AirshipHTTPResponse( result: nil, statusCode: statusCode, headers: [ "Location": self.altExampleURL.absoluteString, "Retry-After": "20.0" ] ) } else { return AirshipHTTPResponse(result: body, statusCode: 200, headers: [:]) } } let result: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(result, .retriableError(retryAfter: 20.0, statusCode: statusCode)) let anotherResult: AirshipDeferredResult<Data> = await resolver.resolve(request: request) { data in return data } XCTAssertEqual(anotherResult, .success(body)) } } fileprivate class TestDeferredAPIClient: DeferredAPIClientProtocol, @unchecked Sendable { var onResolve: ((URL, String, String?, AirshipStateOverrides, ChannelAudienceOverrides, AirshipTriggerContext?) throws -> AirshipHTTPResponse<Data>)? func resolve( url: URL, channelID: String, contactID: String?, stateOverrides: AirshipStateOverrides, audienceOverrides: ChannelAudienceOverrides, triggerContext: AirshipTriggerContext? ) async throws -> AirshipHTTPResponse<Data> { try onResolve!(url, channelID, contactID, stateOverrides, audienceOverrides, triggerContext) } } ================================================ FILE: Airship/AirshipCore/Tests/DeviceAudienceSelectorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class DefaultDeviceAudienceCheckerTest: XCTestCase, @unchecked Sendable { private let testDeviceInfo: TestAudienceDeviceInfoProvider = TestAudienceDeviceInfoProvider() private let audienceChecker = DefaultDeviceAudienceChecker(cache: TestCache()) private let stickyHash = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000), sticky: AudienceHashSelector.Sticky( id: "sticky ID", reportingMetadata: "sticky reporting", lastAccessTTL: 100.0 ) ) private let stickyHashInverse = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 0, max: 11600), sticky: AudienceHashSelector.Sticky( id: "sticky ID", reportingMetadata: "inverse sticky reporting", lastAccessTTL: 100.0 ) ) func testAirshipNotReadyThrows() async throws { testDeviceInfo.isAirshipReady = false do { _ = try await self.audienceChecker.evaluate( audienceSelector: .atomic(DeviceAudienceSelector()), newUserEvaluationDate: .now, deviceInfoProvider: self.testDeviceInfo ) XCTFail("Should throw") } catch {} } func testEmptyAudience() async throws { try await self.assert( audienceSelector: DeviceAudienceSelector(), isMatch: true ) } func testNewUserCondition() async throws { let now = Date() testDeviceInfo.installDate = now let audience = DeviceAudienceSelector(newUser: true) try await self.assert( audienceSelector: audience, newUserEvaluationDate: now, isMatch: true ) try await self.assert( audienceSelector: audience, newUserEvaluationDate: now.advanced(by: -1.0), isMatch: true ) try await self.assert( audienceSelector: audience, newUserEvaluationDate: now.advanced(by: 1.0), isMatch: false ) } func testNotifiicationOptIn() async throws { self.testDeviceInfo.isUserOptedInPushNotifications = false let audience = DeviceAudienceSelector(notificationOptIn: true) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.isUserOptedInPushNotifications = true try await self.assert( audienceSelector: audience, isMatch: true ) } func testNotifiicationOptOut() async throws { self.testDeviceInfo.isUserOptedInPushNotifications = true let audience = DeviceAudienceSelector(notificationOptIn: false) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.isUserOptedInPushNotifications = false try await self.assert( audienceSelector: audience, isMatch: true ) } func testRequireAnalyticsTrue() async throws { self.testDeviceInfo.analyticsEnabled = true let audience = DeviceAudienceSelector(requiresAnalytics: true) try await self.assert( audienceSelector: audience, isMatch: true ) self.testDeviceInfo.analyticsEnabled = false try await self.assert( audienceSelector: audience, isMatch: false ) } func testRequireAnalyticsFalse() async throws { self.testDeviceInfo.analyticsEnabled = true let audience = DeviceAudienceSelector(requiresAnalytics: false) try await self.assert( audienceSelector: audience, isMatch: true ) self.testDeviceInfo.analyticsEnabled = false try await self.assert( audienceSelector: audience, isMatch: true ) } func testLocale() async throws { self.testDeviceInfo.locale = Locale(identifier: "de") let audience = DeviceAudienceSelector( languageIDs: [ "fr", "en-CA"] ) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.locale = Locale(identifier: "en-GB") try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.locale = Locale(identifier: "en") try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.locale = Locale(identifier: "fr-FR") try await self.assert( audienceSelector: audience, isMatch: true ) self.testDeviceInfo.locale = Locale(identifier: "en-CA") try await self.assert( audienceSelector: audience, isMatch: true ) self.testDeviceInfo.locale = Locale(identifier: "en-CA-POSIX") try await self.assert( audienceSelector: audience, isMatch: true ) } func testTags() async throws { let audience = DeviceAudienceSelector( tagSelector: .and([.tag("bar"), .tag("foo")]) ) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.tags = Set(["foo"]) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.tags = Set(["foo", "bar"]) try await self.assert( audienceSelector: audience, isMatch: true ) } func testTestDevices() async throws { let audience = DeviceAudienceSelector( testDevices: ["obIvSbh47TjjqfCrPatbXQ==\n"] // test channel ) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.channelID = "wrong channnel" try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.channelID = "test channel" try await self.assert( audienceSelector: audience, isMatch: true ) } func testVersion() async throws { let audience = DeviceAudienceSelector( versionPredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: JSONValueMatcher.matcherWhereStringEquals("1.1.1"), scope: ["ios", "version"] ) ) ) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.appVersion = "1.0.0" try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.appVersion = "1.1.1" try await self.assert( audienceSelector: audience, isMatch: true ) } func testPermissions() async throws { let audience = DeviceAudienceSelector( permissionPredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: JSONValueMatcher.matcherWhereStringEquals("granted"), scope: ["display_notifications"] ) ) ) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.permissions = [.displayNotifications: .denied] try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.permissions = [.displayNotifications: .granted] try await self.assert( audienceSelector: audience, isMatch: true ) } func testLocationOptIn() async throws { let audience = DeviceAudienceSelector( locationOptIn: true ) try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.permissions = [.location: .denied] try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.permissions = [.location: .granted] try await self.assert( audienceSelector: audience, isMatch: true ) } func testLocationOptOut() async throws { let audience = DeviceAudienceSelector( locationOptIn: false ) try await self.assert( audienceSelector: audience, isMatch: true ) self.testDeviceInfo.permissions = [.location: .denied] try await self.assert( audienceSelector: audience, isMatch: true ) self.testDeviceInfo.permissions = [.location: .granted] try await self.assert( audienceSelector: audience, isMatch: false ) } func testContactHash() async throws { let hash = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000) ) let audience = DeviceAudienceSelector( hashSelector: hash ) self.testDeviceInfo.channelID = "not a match" self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "not a match") try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") try await self.assert( audienceSelector: audience, isMatch: true ) } func testChannelHash() async throws { let hash = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .channel, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000) ) let audience = DeviceAudienceSelector( hashSelector: hash ) self.testDeviceInfo.channelID = "not a match" self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "not a match") try await self.assert( audienceSelector: audience, isMatch: false ) self.testDeviceInfo.channelID = "match" try await self.assert( audienceSelector: audience, isMatch: true ) } func testDeviceTypes() async throws { let audience = DeviceAudienceSelector( deviceTypes: ["android", "ios"] ) try await self.assert( audienceSelector: audience, isMatch: true ) } func testDeviceTypesNoIOS() async throws { let audience = DeviceAudienceSelector( deviceTypes: ["android", "web"] ) try await self.assert( audienceSelector: audience, isMatch: false ) } func testEmtpyDeviceTypes() async throws { let audience = DeviceAudienceSelector( deviceTypes: [] ) try await self.assert( audienceSelector: audience, isMatch: false ) } func testStickyHash() async throws { self.testDeviceInfo.channelID = UUID().uuidString let audience = DeviceAudienceSelector( hashSelector: stickyHash ) self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "not a match") try await self.assert( audienceSelector: audience, isMatch: false, reportingMetadata: [stickyHash.sticky!.reportingMetadata!] ) self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") try await self.assert( audienceSelector: audience, isMatch: true, reportingMetadata: [stickyHash.sticky!.reportingMetadata!] ) // Update sticky hash to swap matches let updatedAudience = DeviceAudienceSelector( hashSelector: stickyHashInverse ) // Should be the same results self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "not a match") try await self.assert( audienceSelector: updatedAudience, isMatch: false, reportingMetadata: [stickyHash.sticky!.reportingMetadata!] ) self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") try await self.assert( audienceSelector: updatedAudience, isMatch: true, reportingMetadata: [stickyHash.sticky!.reportingMetadata!] ) // New contacts should reevaluate self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "also is a match") try await self.assert( audienceSelector: updatedAudience, isMatch: true, reportingMetadata: [stickyHashInverse.sticky!.reportingMetadata!] ) } func testORMatch() async throws { self.testDeviceInfo.analyticsEnabled = false let audience = CompoundDeviceAudienceSelector.or( [ .atomic(DeviceAudienceSelector(requiresAnalytics: false)), .atomic(DeviceAudienceSelector(requiresAnalytics: true)), ] ) try await self.assert( compoundSelector: audience, isMatch: true ) } func testORMatchFirstNoMatch() async throws { self.testDeviceInfo.analyticsEnabled = false let audience = CompoundDeviceAudienceSelector.or( [ .atomic(DeviceAudienceSelector(requiresAnalytics: true)), .atomic(DeviceAudienceSelector(requiresAnalytics: false)), ] ) try await self.assert( compoundSelector: audience, isMatch: true ) } func testORMiss() async throws { self.testDeviceInfo.analyticsEnabled = false self.testDeviceInfo.isUserOptedInPushNotifications = false let audience = CompoundDeviceAudienceSelector.or( [ .atomic(DeviceAudienceSelector(requiresAnalytics: true)), .atomic(DeviceAudienceSelector(notificationOptIn: true)), ] ) try await self.assert( compoundSelector: audience, isMatch: false ) } func testEmptyOR() async throws { let audience = CompoundDeviceAudienceSelector.or([]) try await self.assert( compoundSelector: audience, isMatch: false ) } func testANDMatch() async throws { self.testDeviceInfo.analyticsEnabled = true self.testDeviceInfo.isUserOptedInPushNotifications = true let audience = CompoundDeviceAudienceSelector.or( [ .atomic(DeviceAudienceSelector(requiresAnalytics: true)), .atomic(DeviceAudienceSelector(notificationOptIn: true)), ] ) try await self.assert( compoundSelector: audience, isMatch: true ) } func testANDMiss() async throws { self.testDeviceInfo.analyticsEnabled = false self.testDeviceInfo.isUserOptedInPushNotifications = true let audience = CompoundDeviceAudienceSelector.and( [ .atomic(DeviceAudienceSelector(requiresAnalytics: true)), .atomic(DeviceAudienceSelector(notificationOptIn: true)), ] ) try await self.assert( compoundSelector: audience, isMatch: false ) } func testEmptyAND() async throws { let audience = CompoundDeviceAudienceSelector.and([]) try await self.assert( compoundSelector: audience, isMatch: true ) } func testNOT() async throws { self.testDeviceInfo.analyticsEnabled = false self.testDeviceInfo.isUserOptedInPushNotifications = true let audience = CompoundDeviceAudienceSelector.not( .and( [ .atomic(DeviceAudienceSelector(requiresAnalytics: true)), .atomic(DeviceAudienceSelector(notificationOptIn: true)), ] ) ) try await self.assert( compoundSelector: audience, isMatch: true ) } func testStickyHashShortCircuitOR() async throws { var stickyHashDiffID = stickyHash stickyHashDiffID.sticky = AudienceHashSelector.Sticky( id: UUID().uuidString, reportingMetadata: .string(UUID().uuidString), lastAccessTTL: 100.0 ) self.testDeviceInfo.channelID = UUID().uuidString self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") // short circuits, only get the first one try await self.assert( compoundSelector: .or( [ .atomic(DeviceAudienceSelector(hashSelector: stickyHash)), .atomic(DeviceAudienceSelector(hashSelector: stickyHashDiffID)), ] ), isMatch: true, reportingMetadata: [stickyHash.sticky!.reportingMetadata!] ) } func testStickyHashShortCircuitAND() async throws { var stickyHashDiffID = stickyHash stickyHashDiffID.sticky = AudienceHashSelector.Sticky( id: UUID().uuidString, reportingMetadata: .string(UUID().uuidString), lastAccessTTL: 100.0 ) self.testDeviceInfo.channelID = UUID().uuidString self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") // short circuits, only get the first one try await self.assert( compoundSelector: .and( [ .atomic(DeviceAudienceSelector(hashSelector: stickyHashInverse)), .atomic(DeviceAudienceSelector(hashSelector: stickyHashDiffID)), ] ), isMatch: false, reportingMetadata: [stickyHashInverse.sticky!.reportingMetadata!] ) } func testStickyHashMultiple() async throws { var stickyHashDiffID = stickyHash stickyHashDiffID.sticky = AudienceHashSelector.Sticky( id: UUID().uuidString, reportingMetadata: .string(UUID().uuidString), lastAccessTTL: 100.0 ) self.testDeviceInfo.channelID = UUID().uuidString self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") // short circuits, only get the first one try await self.assert( compoundSelector: .and( [ .atomic(DeviceAudienceSelector(hashSelector: stickyHash)), .atomic(DeviceAudienceSelector(hashSelector: stickyHashDiffID)), ] ), isMatch: true, reportingMetadata: [ stickyHash.sticky!.reportingMetadata!, stickyHashDiffID.sticky!.reportingMetadata! ] ) } func assert( audienceSelector: DeviceAudienceSelector, newUserEvaluationDate: Date = Date.distantPast, isMatch: Bool, reportingMetadata: [AirshipJSON]? = nil, file: StaticString = #filePath, line: UInt = #line ) async throws { try await self.assert( compoundSelector: .atomic(audienceSelector), newUserEvaluationDate: newUserEvaluationDate, isMatch: isMatch, reportingMetadata: reportingMetadata, file: file, line: line ) } func assert( compoundSelector: CompoundDeviceAudienceSelector, newUserEvaluationDate: Date = Date.distantPast, isMatch: Bool, reportingMetadata: [AirshipJSON]? = nil, file: StaticString = #filePath, line: UInt = #line ) async throws { let result = try await self.audienceChecker.evaluate( audienceSelector: compoundSelector, newUserEvaluationDate: newUserEvaluationDate, deviceInfoProvider: self.testDeviceInfo ) XCTAssertEqual(result.isMatch, isMatch, file: file, line: line) XCTAssertEqual(result.reportingMetadata, reportingMetadata, file: file, line: line) } } ================================================ FILE: Airship/AirshipCore/Tests/DeviceTagSelectorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class DeviceTagSelectorTest: XCTestCase { func testCodable() throws { let json: String = """ { "or":[ { "and":[ { "tag":"some-tag" }, { "not":{ "tag":"not-tag" } } ] }, { "tag":"some-other-tag" } ] } """ let decoded: DeviceTagSelector = try JSONDecoder().decode( DeviceTagSelector.self, from: json.data(using: .utf8)! ) let expected = DeviceTagSelector.or( [ .and([.tag("some-tag"), .not(.tag("not-tag"))]), .tag("some-other-tag") ] ) 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 testEvaluate() { let selector = DeviceTagSelector.or( [ .and([.tag("some-tag"), .not(.tag("not-tag"))]), .tag("some-other-tag") ] ) XCTAssertFalse(selector.evaluate(tags: Set())) XCTAssertTrue(selector.evaluate(tags: Set<String>(["some-tag"]))) XCTAssertTrue(selector.evaluate(tags: Set<String>(["some-other-tag"]))) XCTAssertTrue(selector.evaluate(tags: Set<String>(["some-other-tag", "not-tag"]))) XCTAssertFalse(selector.evaluate(tags: Set<String>(["some-tag", "not-tag"]))) XCTAssertFalse(selector.evaluate(tags: Set<String>(["not-tag"]))) } } ================================================ FILE: Airship/AirshipCore/Tests/EnableFeatureActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class EnableFeatureActionTest: XCTestCase { let testPrompter = TestPermissionPrompter() var action: EnableFeatureAction! override func setUpWithError() throws { self.action = EnableFeatureAction { return self.testPrompter } } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ] let rejectedSituations = [ ActionSituation.backgroundPush, ActionSituation.backgroundInteractiveButton, ] for situation in validSituations { let args = ActionArguments( string: EnableFeatureAction.locationActionValue, situation: situation ) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments( string: EnableFeatureAction.locationActionValue, situation: situation ) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } func testLocation() async throws { let arguments = ActionArguments( string: EnableFeatureAction.locationActionValue, situation: .manualInvocation ) let prompted = self.expectation(description: "Prompted") testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in XCTAssertEqual(permission, .location) XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } _ = try await self.action.perform(arguments: arguments) await self.fulfillment(of: [prompted], timeout: 10) } func testBackgroundLocation() async throws { let arguments = ActionArguments( string: EnableFeatureAction.backgroundLocationActionValue, situation: .manualInvocation ) let prompted = self.expectation(description: "Prompted") testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in XCTAssertEqual(permission, .location) XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } _ = try await self.action.perform(arguments: arguments) await self.fulfillment(of: [prompted], timeout: 10) } func testNotifications() async throws { let arguments = ActionArguments( string: EnableFeatureAction.userNotificationsActionValue, situation: .manualInvocation ) let prompted = self.expectation(description: "Prompted") testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in XCTAssertEqual(permission, .displayNotifications) XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } _ = try await self.action.perform(arguments: arguments) await self.fulfillment(of: [prompted], timeout: 10) } func testInvalidArgument() async throws { let arguments = ActionArguments( string: "invalid", situation: .manualInvocation ) testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in XCTFail() return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } do { _ = try await self.action.perform(arguments: arguments) XCTFail("should throw") } catch {} } func testResultReceiver() async throws { let resultReceived = self.expectation(description: "Result received") let resultRecevier: @Sendable (AirshipPermission, AirshipPermissionStatus, AirshipPermissionStatus) async -> Void = { permission, start, end in XCTAssertEqual(.notDetermined, start) XCTAssertEqual(.granted, end) XCTAssertEqual(.location, permission) resultReceived.fulfill() } let metadata = [ PromptPermissionAction.resultReceiverMetadataKey: resultRecevier ] let arguments = ActionArguments( string: EnableFeatureAction.locationActionValue, situation: .manualInvocation, metadata: metadata ) testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .granted) } _ = try await self.action.perform(arguments: arguments) await self.fulfillment(of: [resultReceived], timeout: 10) } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasEnvironmentTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore // MARK: - Test Doubles @MainActor final class TestThomasDelegate: ThomasDelegate { var visibilityChanges: [(isVisible: Bool, isForegrounded: Bool)] = [] var reportedEvents: [ThomasReportingEvent] = [] var dismissals: [Bool] = [] var stateChanges: [AirshipJSON] = [] func onVisibilityChanged(isVisible: Bool, isForegrounded: Bool) { visibilityChanges.append((isVisible, isForegrounded)) } func onReportingEvent(_ event: ThomasReportingEvent) { reportedEvents.append(event) } func onDismissed(cancel: Bool) { dismissals.append(cancel) } func onStateChanged(_ state: AirshipJSON) { stateChanges.append(state) } } @MainActor final class TestTimer: AirshipTimerProtocol { var time: TimeInterval = 0 var isStarted: Bool = false var startCount: Int = 0 var stopCount: Int = 0 func start() { isStarted = true startCount += 1 } func stop() { isStarted = false stopCount += 1 } } // MARK: - Tests @MainActor struct ThomasEnvironmentTest { // MARK: - Helper Methods private func makeEnvironment( delegate: TestThomasDelegate? = nil, timer: TestTimer? = nil, pagerTracker: ThomasPagerTracker? = nil, extensions: ThomasExtensions? = nil, onDismiss: (() -> Void)? = nil ) -> (ThomasEnvironment, TestThomasDelegate, TestTimer) { let testDelegate = delegate ?? TestThomasDelegate() let testTimer = timer ?? TestTimer() let env = ThomasEnvironment( delegate: testDelegate, extensions: extensions, pagerTracker: pagerTracker, timer: testTimer, onDismiss: onDismiss ) return (env, testDelegate, testTimer) } private func setupAirship() -> (TestContact, TestChannel) { let testAirship = TestAirshipInstance() let testContact = TestContact() let testChannel = TestChannel() let date = UATestDate() testContact.attributeEditor = AttributesEditor(date: date) { _ in } testChannel.attributeEditor = AttributesEditor(date: date) { _ in } testAirship.components = [testContact, testChannel] testAirship.makeShared() return (testContact, testChannel) } // MARK: - Initialization Tests @Test func testCustomDependencies() { let customDelegate = TestThomasDelegate() let customTimer = TestTimer() let customTracker = ThomasPagerTracker() var dismissCalled = false let (env, delegate, timer) = makeEnvironment( delegate: customDelegate, timer: customTimer, pagerTracker: customTracker, onDismiss: { dismissCalled = true } ) // Verify custom dependencies are used #expect(delegate === customDelegate) #expect(timer === customTimer) // Verify onDismiss callback env.dismiss() #expect(dismissCalled) } // MARK: - Visibility & Timer Tests @Test func testVisibilityStartsTimer() { let (env, delegate, timer) = makeEnvironment() env.onVisibilityChanged(isVisible: true, isForegrounded: true) #expect(timer.isStarted) #expect(timer.startCount == 1) #expect(delegate.visibilityChanges.count == 1) #expect(delegate.visibilityChanges[0].isVisible == true) #expect(delegate.visibilityChanges[0].isForegrounded == true) } @Test func testVisibilityStopsTimerWhenNotVisible() { let (env, delegate, timer) = makeEnvironment() // Start timer first env.onVisibilityChanged(isVisible: true, isForegrounded: true) #expect(timer.isStarted) // Stop when not visible env.onVisibilityChanged(isVisible: false, isForegrounded: true) #expect(!timer.isStarted) #expect(timer.stopCount == 1) #expect(delegate.visibilityChanges.count == 2) #expect(delegate.visibilityChanges[1].isVisible == false) } @Test func testVisibilityStopsTimerWhenBackgrounded() { let (env, delegate, timer) = makeEnvironment() // Start timer first env.onVisibilityChanged(isVisible: true, isForegrounded: true) #expect(timer.isStarted) // Stop when backgrounded env.onVisibilityChanged(isVisible: true, isForegrounded: false) #expect(!timer.isStarted) #expect(timer.stopCount == 1) #expect(delegate.visibilityChanges.count == 2) #expect(delegate.visibilityChanges[1].isForegrounded == false) } @Test func testVisibilityTimerRestart() { let (env, _, timer) = makeEnvironment() // Start env.onVisibilityChanged(isVisible: true, isForegrounded: true) #expect(timer.isStarted) #expect(timer.startCount == 1) // Background env.onVisibilityChanged(isVisible: true, isForegrounded: false) #expect(!timer.isStarted) // Foreground again - should restart env.onVisibilityChanged(isVisible: true, isForegrounded: true) #expect(timer.isStarted) #expect(timer.startCount == 2) } // MARK: - State Management Tests @Test func testRetrieveStateCreatesNewState() { let (env, _, _) = makeEnvironment() let state = env.retrieveState(identifier: "test") { ThomasState.MutableState() } #expect(state != nil) } @Test func testRetrieveStateReturnsSameInstance() { let (env, _, _) = makeEnvironment() let state1 = env.retrieveState(identifier: "test") { ThomasState.MutableState() } let state2 = env.retrieveState(identifier: "test") { ThomasState.MutableState() } #expect(state1 === state2) } @Test func testRetrieveStateIsolatesDifferentIdentifiers() { let (env, _, _) = makeEnvironment() let state1 = env.retrieveState(identifier: "test1") { ThomasState.MutableState() } let state2 = env.retrieveState(identifier: "test2") { ThomasState.MutableState() } #expect(state1 !== state2) } // MARK: - Form Event Tests @Test func testFormDisplayed() { let (env, delegate, _) = makeEnvironment() let formState = ThomasFormState( identifier: "test-form", formType: .form, formResponseType: "response-type", validationMode: .immediate ) env.formDisplayed(formState, layoutState: .empty) #expect(delegate.reportedEvents.count == 1) if case .formDisplay(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "test-form") #expect(event.formType == "form") } else { Issue.record("Expected formDisplay event") } } @Test func testFormDisplayedWithNPSType() { let (env, delegate, _) = makeEnvironment() let formState = ThomasFormState( identifier: "nps-form", formType: .nps("score-id"), formResponseType: "response-type", validationMode: .immediate ) env.formDisplayed(formState, layoutState: .empty) #expect(delegate.reportedEvents.count == 1) if case .formDisplay(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "nps-form") #expect(event.formType == "nps") } else { Issue.record("Expected formDisplay event with NPS type") } } @Test func testSubmitFormReportsEvent() { let (env, delegate, _) = makeEnvironment() let result = ThomasFormResult( identifier: "test-form", formData: .object([:]) ) env.submitForm( result: result, channels: [], attributes: [], layoutState: .empty ) #expect(delegate.reportedEvents.count == 1) if case .formResult = delegate.reportedEvents[0] { // Success } else { Issue.record("Expected formResult event") } } @Test func testSubmitFormCallsAirshipWithEmailAndSMS() { let (testContact, testChannel) = setupAirship() defer { TestAirshipInstance.clearShared() } let (env, delegate, _) = makeEnvironment() let result = ThomasFormResult( identifier: "test-form", formData: .object([:]) ) let channels: [ThomasFormField.Channel] = [ .email("test@example.com", ThomasEmailRegistrationOptions.optIn()), .sms("15035551234", ThomasSMSRegistrationOptions.optIn(senderID: "12345")) ] let attributes: [ThomasFormField.Attribute] = [ ThomasFormField.Attribute( attributeName: ThomasAttributeName(channel: "test_attr", contact: "contact_attr"), attributeValue: .string("test_value") ) ] env.submitForm( result: result, channels: channels, attributes: attributes, layoutState: .empty ) // Verify event was reported #expect(delegate.reportedEvents.count == 1) // TestContact and TestChannel provide no-ops for registerEmail/SMS/editAttributes // but the fact that we didn't crash proves the Airship singleton was accessible } // MARK: - Button Event Tests @Test func testButtonTapped() { let (env, delegate, _) = makeEnvironment() let metadata = AirshipJSON.object(["key": .string("value")]) env.buttonTapped( buttonIdentifier: "test-button", reportingMetadata: metadata, layoutState: .empty ) #expect(delegate.reportedEvents.count == 1) if case .buttonTap(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "test-button") #expect(event.reportingMetadata == metadata) } else { Issue.record("Expected buttonTap event") } } // MARK: - Pager Event Tests @Test func testPageViewed() { let (env, delegate, timer) = makeEnvironment() timer.time = 5.0 let pagerState = PagerState( identifier: "test-pager", branching: nil ) let pageInfo = ThomasPageInfo( identifier: "page-1", index: 0, viewCount: 1 ) env.pageViewed( pagerState: pagerState, pageInfo: pageInfo, layoutState: .empty ) #expect(delegate.reportedEvents.count == 1) if case .pageView(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "test-pager") #expect(event.pageIdentifier == "page-1") #expect(event.pageIndex == 0) } else { Issue.record("Expected pageView event") } } @Test func testPagerCompleted() { let (env, delegate, _) = makeEnvironment() let pagerState = PagerState( identifier: "test-pager", branching: nil ) env.pagerCompleted(pagerState: pagerState, layoutState: .empty) #expect(delegate.reportedEvents.count == 1) if case .pagerCompleted(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "test-pager") } else { Issue.record("Expected pagerCompleted event") } } @Test func testPageSwiped() { let (env, delegate, _) = makeEnvironment() let pagerState = PagerState( identifier: "test-pager", branching: nil ) let fromPage = ThomasPageInfo(identifier: "page-0", index: 0, viewCount: 1) let toPage = ThomasPageInfo(identifier: "page-1", index: 1, viewCount: 1) env.pageSwiped( pagerState: pagerState, from: fromPage, to: toPage, layoutState: .empty ) #expect(delegate.reportedEvents.count == 1) if case .pageSwipe(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "test-pager") #expect(event.fromPageIdentifier == "page-0") #expect(event.toPageIdentifier == "page-1") #expect(event.fromPageIndex == 0) #expect(event.toPageIndex == 1) } else { Issue.record("Expected pageSwipe event") } } @Test func testPageGestureWithIdentifier() { let (env, delegate, _) = makeEnvironment() env.pageGesture( identifier: "test-gesture", reportingMetadata: nil, layoutState: .empty ) #expect(delegate.reportedEvents.count == 1) if case .gesture(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "test-gesture") } else { Issue.record("Expected gesture event") } } @Test func testPageGestureWithoutIdentifier() { let (env, delegate, _) = makeEnvironment() env.pageGesture( identifier: nil, reportingMetadata: nil, layoutState: .empty ) // Should not report event when identifier is nil #expect(delegate.reportedEvents.isEmpty) } @Test func testPageAutomatedWithIdentifier() { let (env, delegate, _) = makeEnvironment() env.pageAutomated( identifier: "test-action", reportingMetadata: nil, layoutState: .empty ) #expect(delegate.reportedEvents.count == 1) if case .pageAction(let event, _) = delegate.reportedEvents[0] { #expect(event.identifier == "test-action") } else { Issue.record("Expected pageAction event") } } @Test func testPageAutomatedWithoutIdentifier() { let (env, delegate, _) = makeEnvironment() env.pageAutomated( identifier: nil, reportingMetadata: nil, layoutState: .empty ) // Should not report event when identifier is nil #expect(delegate.reportedEvents.isEmpty) } @Test func testMultiplePageViewsWithHistory() { let tracker = ThomasPagerTracker() let (env, delegate, timer) = makeEnvironment(pagerTracker: tracker) let pagerState = PagerState( identifier: "test-pager", branching: nil ) // View multiple pages in sequence let page1 = ThomasPageInfo(identifier: "page-0", index: 0, viewCount: 1) timer.time = 0 env.pageViewed(pagerState: pagerState, pageInfo: page1, layoutState: .empty) let page2 = ThomasPageInfo(identifier: "page-1", index: 1, viewCount: 1) timer.time = 5.0 env.pageViewed(pagerState: pagerState, pageInfo: page2, layoutState: .empty) let page3 = ThomasPageInfo(identifier: "page-2", index: 2, viewCount: 1) timer.time = 10.0 env.pageViewed(pagerState: pagerState, pageInfo: page3, layoutState: .empty) // Verify all page views were reported #expect(delegate.reportedEvents.count == 3) // Verify each event has progressively more history in context if case .pageView(_, let context1) = delegate.reportedEvents[0] { #expect(context1.pager?.pageHistory.count == 0) } if case .pageView(_, let context2) = delegate.reportedEvents[1] { #expect(context2.pager?.pageHistory.count == 1) #expect(context2.pager?.pageHistory[0].identifier == "page-0") } if case .pageView(_, let context3) = delegate.reportedEvents[2] { #expect(context3.pager?.pageHistory.count == 2) #expect(context3.pager?.pageHistory[0].identifier == "page-0") #expect(context3.pager?.pageHistory[1].identifier == "page-1") } } // MARK: - Dismiss Tests @Test func testDismissWithButtonVerifyEventDetails() { let (env, delegate, timer) = makeEnvironment() timer.time = 10.5 env.dismiss( buttonIdentifier: "close-btn", buttonDescription: "Close Button", cancel: true, layoutState: .empty ) #expect(env.isDismissed) #expect(!timer.isStarted) #expect(timer.stopCount == 1) #expect(delegate.reportedEvents.count == 1) if case .dismiss(let dismissType, let displayTime, _) = delegate.reportedEvents[0] { // Verify it's buttonTapped type if case .buttonTapped(let id, let desc) = dismissType { #expect(id == "close-btn") #expect(desc == "Close Button") } else { Issue.record("Expected buttonTapped dismiss type") } // Verify display time #expect(displayTime == 10.5) } else { Issue.record("Expected dismiss event") } #expect(delegate.dismissals[0] == true) } @Test func testDismissUserDismissedEventType() { let (env, delegate, timer) = makeEnvironment() timer.time = 7.3 env.dismiss(cancel: false, layoutState: .empty) #expect(delegate.reportedEvents.count == 1) if case .dismiss(let dismissType, let displayTime, _) = delegate.reportedEvents[0] { // Verify it's userDismissed type if case .userDismissed = dismissType { // Success } else { Issue.record("Expected userDismissed dismiss type") } // Verify display time #expect(displayTime == 7.3) } else { Issue.record("Expected dismiss event") } } @Test func testTimedOutEventType() { let (env, delegate, timer) = makeEnvironment() timer.time = 30.0 env.timedOut(layoutState: .empty) #expect(delegate.reportedEvents.count == 1) if case .dismiss(let dismissType, let displayTime, _) = delegate.reportedEvents[0] { // Verify it's timedOut type if case .timedOut = dismissType { // Success } else { Issue.record("Expected timedOut dismiss type") } // Verify display time #expect(displayTime == 30.0) } else { Issue.record("Expected dismiss event") } } @Test func testRepeatedDismissIsIdempotent() { let (env, delegate, timer) = makeEnvironment() env.dismiss() env.dismiss() env.dismiss() // Should only dismiss once #expect(env.isDismissed) #expect(timer.stopCount == 1) #expect(delegate.dismissals.count == 1) } @Test func testOnDismissCallbackCalledOnce() { var callCount = 0 let (env, _, _) = makeEnvironment(onDismiss: { callCount += 1 }) env.dismiss() env.dismiss() #expect(callCount == 1) } @Test func testDismissFromWithinCallback() { var env: ThomasEnvironment! var recursiveCallAttempted = false let delegate = TestThomasDelegate() let timer = TestTimer() timer.time = 5.0 env = ThomasEnvironment( delegate: delegate, extensions: nil, pagerTracker: nil, timer: timer, onDismiss: { // Attempt to dismiss again from within callback recursiveCallAttempted = true env.dismiss() } ) env.dismiss() // Should be dismissed only once #expect(env.isDismissed) #expect(recursiveCallAttempted) #expect(delegate.dismissals.count == 1) #expect(delegate.reportedEvents.count == 1) } // MARK: - Pager Summary Tests @Test func testPagerSummaryEmittedBeforeDismiss() { let tracker = ThomasPagerTracker() let (env, delegate, timer) = makeEnvironment(pagerTracker: tracker) let pagerState = PagerState(identifier: "test-pager", branching: nil) let pageInfo = ThomasPageInfo(identifier: "page-0", index: 0, viewCount: 1) // View a page timer.time = 0 env.pageViewed(pagerState: pagerState, pageInfo: pageInfo, layoutState: .empty) timer.time = 10.0 env.dismiss() // Should have pageView, pagerSummary, then dismiss - in that order #expect(delegate.reportedEvents.count == 3) // Verify order if case .pageView = delegate.reportedEvents[0] { // Correct } else { Issue.record("Expected pageView as first event") } if case .pagerSummary = delegate.reportedEvents[1] { // Correct - summary before dismiss } else { Issue.record("Expected pagerSummary before dismiss") } if case .dismiss = delegate.reportedEvents[2] { // Correct - dismiss last } else { Issue.record("Expected dismiss as last event") } } @Test func testPagerTrackerStoppedOnDismiss() { let tracker = ThomasPagerTracker() let (env, _, timer) = makeEnvironment(pagerTracker: tracker) // Track a page view using environment let pagerState = PagerState(identifier: "test-pager", branching: nil) let pageInfo = ThomasPageInfo(identifier: "page-0", index: 0, viewCount: 1) timer.time = 0 env.pageViewed(pagerState: pagerState, pageInfo: pageInfo, layoutState: .empty) timer.time = 5.0 env.dismiss() // Verify tracker was stopped (viewed pages should be captured) let viewedPages = tracker.viewedPages(pagerIdentifier: "test-pager") #expect(viewedPages.count == 1) #expect(viewedPages[0].displayTime == 5.0) } // MARK: - State Change Tests @Test func testStateChangeForwardedToDelegate() { let (env, delegate, _) = makeEnvironment() let state = AirshipJSON.object(["key": .string("value")]) env.onStateChange(state) #expect(delegate.stateChanges.count == 1) #expect(delegate.stateChanges[0] == state) } // MARK: - Layout Context Tests @Test func testLayoutContextWithNilStates() { let (env, delegate, _) = makeEnvironment() env.buttonTapped( buttonIdentifier: "test", reportingMetadata: nil, layoutState: .empty ) #expect(delegate.reportedEvents.count == 1) if case .buttonTap(_, let context) = delegate.reportedEvents[0] { #expect(context.pager == nil) #expect(context.form == nil) } else { Issue.record("Expected buttonTap event with context") } } // MARK: - Action Runner Tests @Test func testRunActionsWithNilPayload() { let (env, _, _) = makeEnvironment() // Should not crash with nil payload env.runActions(nil, layoutState: .empty) } @Test func testRunActionsWithEmptyValue() { let (env, _, _) = makeEnvironment() // Create payload with nil value let emptyPayload = ThomasActionsPayload(value: .null) // Should return early when value is nil env.runActions(emptyPayload, layoutState: .empty) } @Test func testRunActionsWithCustomRunner() { let testRunner = TestThomasActionRunner() let extensions = ThomasExtensions( imageProvider: nil, actionRunner: testRunner ) let (env, _, _) = makeEnvironment(extensions: extensions) let payload = ThomasActionsPayload(value: .object(["test_action": .string("test_value")])) env.runActions(payload, layoutState: .empty) // Verify custom runner was called #expect(testRunner.runAsyncCalled) #expect(testRunner.lastActions != nil) } @Test func testRunActionWithCustomRunner() async { let testRunner = TestThomasActionRunner() let extensions = ThomasExtensions( imageProvider: nil, actionRunner: testRunner ) let (env, _, _) = makeEnvironment(extensions: extensions) let arguments = ActionArguments( string: "test_value", situation: .automation ) _ = await env.runAction( "test_action", arguments: arguments, layoutState: .empty ) // Verify custom runner was called #expect(testRunner.runCalled) #expect(testRunner.lastActionName == "test_action") } // MARK: - Integration Tests @Test func testFullLifecycleWithPager() { let tracker = ThomasPagerTracker() let (env, delegate, timer) = makeEnvironment(pagerTracker: tracker) // Initialize - not visible #expect(!timer.isStarted) // Make visible and foregrounded env.onVisibilityChanged(isVisible: true, isForegrounded: true) #expect(timer.isStarted) // View pages let pagerState = PagerState(identifier: "lifecycle-pager", branching: nil) timer.time = 1.0 env.pageViewed(pagerState: pagerState, pageInfo: ThomasPageInfo(identifier: "page-0", index: 0, viewCount: 1), layoutState: .empty) timer.time = 5.0 env.pageViewed(pagerState: pagerState, pageInfo: ThomasPageInfo(identifier: "page-1", index: 1, viewCount: 1), layoutState: .empty) // Background env.onVisibilityChanged(isVisible: true, isForegrounded: false) #expect(!timer.isStarted) // Foreground again env.onVisibilityChanged(isVisible: true, isForegrounded: true) #expect(timer.isStarted) // Dismiss timer.time = 10.0 env.dismiss(buttonIdentifier: "close", buttonDescription: "Close", cancel: false, layoutState: .empty) // Verify full sequence #expect(env.isDismissed) #expect(!timer.isStarted) #expect(delegate.visibilityChanges.count == 3) // Events: 2 pageViews + 1 pagerSummary + 1 dismiss #expect(delegate.reportedEvents.count == 4) // Verify summary came before dismiss if case .pagerSummary = delegate.reportedEvents[2] { // Correct } else { Issue.record("Expected pagerSummary before dismiss") } if case .dismiss = delegate.reportedEvents[3] { // Correct } else { Issue.record("Expected dismiss last") } } @Test func testPagerTrackerIsolationBetweenEnvironments() { let sharedTracker = ThomasPagerTracker() // Create two environments sharing same tracker let (env1, delegate1, timer1) = makeEnvironment(pagerTracker: sharedTracker) let (env2, delegate2, timer2) = makeEnvironment(pagerTracker: sharedTracker) let pagerState = PagerState(identifier: "shared-pager", branching: nil) // View page in env1 timer1.time = 0 env1.pageViewed(pagerState: pagerState, pageInfo: ThomasPageInfo(identifier: "page-0", index: 0, viewCount: 1), layoutState: .empty) // View page in env2 timer2.time = 5.0 env2.pageViewed(pagerState: pagerState, pageInfo: ThomasPageInfo(identifier: "page-1", index: 1, viewCount: 1), layoutState: .empty) // Dismiss env1 timer1.time = 10.0 env1.dismiss() // Env1 should have pager summary let env1Summaries = delegate1.reportedEvents.filter { if case .pagerSummary = $0 { return true } return false } #expect(env1Summaries.count == 1) // Env2 should still be able to emit its own summary timer2.time = 15.0 env2.dismiss() let env2Summaries = delegate2.reportedEvents.filter { if case .pagerSummary = $0 { return true } return false } #expect(env2Summaries.count == 1) } } // MARK: - Test Action Runner @MainActor final class TestThomasActionRunner: ThomasActionRunner { var runAsyncCalled = false var runCalled = false var lastActions: AirshipJSON? var lastActionName: String? var lastLayoutContext: ThomasLayoutContext? func runAsync(actions: AirshipJSON, layoutContext: ThomasLayoutContext) { runAsyncCalled = true lastActions = actions lastLayoutContext = layoutContext } func run(actionName: String, arguments: ActionArguments, layoutContext: ThomasLayoutContext) async -> ActionResult { runCalled = true lastActionName = actionName lastLayoutContext = layoutContext return .completed(AirshipJSON.null) } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasFormDataCollectorTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation import Combine @testable import AirshipCore @MainActor struct ThomasFormDataCollectorTest { private let pagerState: PagerState = PagerState( identifier: UUID().uuidString, branching: nil ) private let formState: ThomasFormState = ThomasFormState( identifier: UUID().uuidString, formType: .form, formResponseType: nil, validationMode: .onDemand ) private let pages: [ThomasViewInfo.Pager.Item] = [ .init( identifier: UUID().uuidString, view: .emptyView(.init(commonProperties: .init(), properties: .init())), displayActions: nil, automatedActions: nil, accessibilityActions: nil, stateActions: nil, branching: nil ), .init( identifier: UUID().uuidString, view: .emptyView(.init(commonProperties: .init(), properties: .init())), displayActions: nil, automatedActions: nil, accessibilityActions: nil, stateActions: nil, branching: nil ), .init( identifier: UUID().uuidString, view: .emptyView(.init(commonProperties: .init(), properties: .init())), displayActions: nil, automatedActions: nil, accessibilityActions: nil, stateActions: nil, branching: nil ) ] init() { pagerState.setPagesAndListenForUpdates( pages: self.pages, thomasState: .init(formState: self.formState, pagerState: .init(identifier: "", branching: nil)) { _ in }, swipeDisableSelectors: nil ) } @Test("Test collect no page ID.") func testCollectNoPageID() async throws { let collector = ThomasFormDataCollector( formState: self.formState, pagerState: self.pagerState ) collector.updateField(.invalidField(identifier: "invalid", input: .score(1.0)), pageID: nil) #expect(self.formState.activeFields["invalid"] != nil) } @Test("Test collect with page ID.") func testCollectWithPageID() async throws { let collector = ThomasFormDataCollector( formState: self.formState, pagerState: self.pagerState ) var activeFields = self.formState.$activeFields.values.makeAsyncIterator() collector.updateField( .invalidField( identifier: "invalid", input: .score(1.0) ), pageID: pages[1].id ) await #expect(activeFields.next()?["invalid"] == nil) self.pagerState.process(request: .next) await #expect(activeFields.next()?["invalid"] != nil) } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasFormFieldProcessorTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore @MainActor struct ThomasFormFieldProcessorTest { private let processor: DefaultThomasFormFieldProcessor private let taskSleeper = TestTaskSleeper() private let date = UATestDate(offset: 0, dateOverride: Date()) init() { self.processor = DefaultThomasFormFieldProcessor( date: self.date, taskSleeper: self.taskSleeper ) } @Test("Test early process delay") func testProcessingEarlyProcessDelay() async throws { await taskSleeper.pause() let request = processor.submit(processDelay: 200.0) { return .invalid } var sleeps = await taskSleeper.sleepUpdates.makeAsyncIterator() #expect(await sleeps.next() == [200]) #expect(request.result == nil) await taskSleeper.resume() await request.process(retryErrors: false) #expect(request.result == .invalid) } @Test("Test negative early process delay should pass to task sleeper") func testProcessingEarlyProcessNatativeDelay() async throws { await taskSleeper.pause() let request = processor.submit(processDelay: -1.0) { return .invalid } var sleeps = await taskSleeper.sleepUpdates.makeAsyncIterator() #expect(await sleeps.next() == [-1.0]) #expect(request.result == nil) await taskSleeper.resume() await request.process(retryErrors: false) #expect(request.result == .invalid) } @Test("Test invalid result does not retry") func testInvalidResultDoesNotRetry() async throws { await confirmation { confirmation in let request = processor.submit(processDelay: 1.0) { confirmation.confirm() return .invalid } await request.process(retryErrors: true) await request.process(retryErrors: true) await request.process(retryErrors: true) #expect(request.result == .invalid) } #expect(await taskSleeper.sleeps == [1.0]) } @Test("Test valid result does not retry") func testValidResultDoesNotRetry() async throws { let result = ThomasFormFieldPendingResult.valid(.init(value: .score(100.0))) await confirmation { confirmation in let request = processor.submit(processDelay: 1.0) { confirmation.confirm() return result } await request.process(retryErrors: true) await request.process(retryErrors: true) await request.process(retryErrors: true) #expect(request.result == result) } #expect(await taskSleeper.sleeps == [1.0]) } @Test("Test error result will retry") func testErrorRetries() async throws { await confirmation(expectedCount: 3) { confirmation in let request = processor.submit(processDelay: 1.0) { confirmation.confirm() return .error } await request.process(retryErrors: true) await request.process(retryErrors: true) await request.process(retryErrors: true) #expect(request.result == .error) } } @Test("Test retry backoff") func testAsyncValidationError() async throws { await confirmation(expectedCount: 8) { confirmation in let request = processor.submit(processDelay: 1.0) { confirmation.confirm() return .error } await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0]) await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0, 3.0]) await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0, 3.0, 6.0]) await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0, 3.0, 6.0, 12.0]) await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0, 3.0, 6.0, 12.0, 15.0]) await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0, 3.0, 6.0, 12.0, 15.0, 15.0]) date.offset += 10.0 await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0, 3.0, 6.0, 12.0, 15.0, 15.0, 5.0]) await request.process(retryErrors: true) #expect(await taskSleeper.sleeps == [1.0, 3.0, 6.0, 12.0, 15.0, 15.0, 5.0, 15.0]) } } @Test("Test updates") func testUpdates() async throws { var resultStream = AsyncStream<ThomasFormFieldPendingResult> { continuation in continuation.yield(.error) continuation.yield(.error) continuation.yield(.invalid) }.makeAsyncIterator() let request = processor.submit(processDelay: 1.0) { return await resultStream.next()! } var updates = request.resultUpdates { result in guard let result else { return "pending" } return if result == .error { "error" } else { "not an error" } }.makeAsyncIterator() #expect(await updates.next() == "pending") await request.process(retryErrors: true) #expect(await updates.next() == "error") await request.process(retryErrors: true) #expect(await updates.next() == "error") await request.process(retryErrors: true) #expect(await updates.next() == "not an error") } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasFormFieldTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore @MainActor struct ThomasFormFieldTest { @Test("Test invalid field.") func testInvalidField() async throws { let field = ThomasFormField.invalidField( identifier: "some-ID", input: .text("some-text") ) #expect(field.status == .invalid) // Process does nothing await field.process(retryErrors: true) await field.process(retryErrors: false) #expect(field.status == .invalid) var statusUpdates = field.statusUpdates.makeAsyncIterator() #expect(await statusUpdates.next() == .invalid) } @Test("Test valid field.") func testValidFieldStatus() async throws { let field = ThomasFormField.validField( identifier: "some-ID", input: .text("some-text"), result: .init(value: .text("some-other-text")) ) #expect(field.status == .valid(.init(value: .text("some-other-text")))) // Process does nothing await field.process(retryErrors: true) await field.process(retryErrors: false) #expect(field.status == .valid(.init(value: .text("some-other-text")))) var statusUpdates = field.statusUpdates.makeAsyncIterator() #expect(await statusUpdates.next() == .valid(.init(value: .text("some-other-text")))) } @Test("Test async field.") func testAsyncField() async throws { let pendingRequest = TestPendinRequest() let processor = TestProcesssor() processor.onSubmit = { interval, resultBlock in #expect(interval == 3.0) pendingRequest.resultBlock = resultBlock return pendingRequest } let field = ThomasFormField.asyncField( identifier: "some-ID", input: .text("some-text"), processDelay: 3.0, processor: processor ) { .valid(.init(value: .text("some valid text"))) } #expect(field.status == .pending) #expect(pendingRequest.didProcess == false) #expect(pendingRequest.didRetry == false) await field.process(retryErrors: false) #expect(pendingRequest.didProcess == true) #expect(pendingRequest.didRetry == false) pendingRequest.didProcess = false await field.process(retryErrors: true) #expect(pendingRequest.didProcess == true) #expect(pendingRequest.didRetry == true) var statusUpdates = field.statusUpdates.makeAsyncIterator() #expect(await statusUpdates.next() == .pending) // Update the result pendingRequest.result = try await pendingRequest.resultBlock?() #expect(await statusUpdates.next() == .valid(.init(value: .text("some valid text")))) // Update the result to the error pendingRequest.result = .error #expect(await statusUpdates.next() == .error) #expect(field.status == .error) // Update the result to the error pendingRequest.result = .invalid #expect(await statusUpdates.next() == .invalid) #expect(field.status == .invalid) } @MainActor fileprivate class TestPendinRequest: ThomasFormFieldPendingRequest { func cancel() { } var result: ThomasFormFieldPendingResult? { didSet { onResult.values.forEach { $0(result) } } } var onResult: [String: (ThomasFormFieldPendingResult?) -> Void] = [:] var resultBlock: (@MainActor @Sendable () async throws -> ThomasFormFieldPendingResult)? func resultUpdates<T>( mapper: @escaping @Sendable (ThomasFormFieldPendingResult?) -> T ) -> AsyncStream<T> where T : Sendable { return AsyncStream { continuation in continuation.yield(mapper(result)) let id = UUID().uuidString onResult[id] = { result in continuation.yield(mapper(result)) } continuation.onTermination = { _ in Task { @MainActor in self.onResult[id] = nil } } } } var didProcess: Bool = false var didRetry: Bool = false func process(retryErrors: Bool) async { didProcess = true didRetry = retryErrors } } @MainActor fileprivate class TestProcesssor: ThomasFormFieldProcessor { var onSubmit: ((TimeInterval, @escaping @MainActor @Sendable () async throws -> ThomasFormFieldPendingResult) -> TestPendinRequest)? func submit( processDelay: TimeInterval, resultBlock: @escaping @MainActor @Sendable () async throws -> ThomasFormFieldPendingResult ) -> any ThomasFormFieldPendingRequest { return onSubmit!(processDelay, resultBlock) } } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasFormPayloadGeneratorTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore @MainActor struct ThomasFormPayloadGeneratorTest { @Test("Test form data") func testFormData() throws { let form: ThomasFormField.Value = .form( responseType: "user_feedback", children: [ "some-radio-input": .radio(AirshipJSON.string("some-radio-input-value")), "some-toggle-input": .toggle(true), "some-score-input": .score(7.0), "some-text-input": .text("neat text"), "some-email-input": .email("email@email.email"), "some-sms-input": .sms("123", nil), "some-child-score": .score(8.0), "some-child-form": .form( responseType: "some-child-form-response", children: [ "some-other-text-input": .text("other neat text") ] ), "some-child-nps-form": .npsForm( responseType: "some-nps-child-form-response", scoreID: "some-other-child-score", children: [ "some-other-child-score": .score(9.0) ] ), "text-nil": .text(nil), "email-nil": .email(nil), "sms-nil": .sms(nil, .init(countryCode: "US", prefix: "+1")), "score-nil": .score(nil), "radio-nil": .radio(nil) ] ) let expectedJSON: String = """ { "some-form-id": { "type": "form", "response_type": "user_feedback", "children": { "some-radio-input": { "type": "single_choice", "value": "some-radio-input-value" }, "some-toggle-input": { "type": "toggle", "value": true }, "some-score-input": { "type": "score", "value": 7.0 }, "some-text-input": { "type": "text_input", "value": "neat text" }, "some-email-input": { "type": "email_input", "value": "email@email.email" }, "some-sms-input": { "type": "sms_input", "value": "123" }, "some-child-score": { "type": "score", "value": 8.0 }, "text-nil": { "type": "text_input" }, "email-nil": { "type": "email_input" }, "sms-nil": { "type": "sms_input" }, "score-nil": { "type": "score" }, "radio-nil": { "type": "single_choice" }, "some-child-form": { "type": "form", "response_type": "some-child-form-response", "children": { "some-other-text-input": { "type": "text_input", "value": "other neat text" } } }, "some-child-nps-form": { "type": "nps", "response_type": "some-nps-child-form-response", "score_id": "some-other-child-score", "children": { "some-other-child-score": { "type": "score", "value": 9 } } } } } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try ThomasFormPayloadGenerator.makeFormEventPayload( identifier: "some-form-id", formValue: form ) #expect(actual == expected) } @Test("Test nps form data") func testNPSFormData() throws { let npsForm: ThomasFormField.Value = .npsForm( responseType: "user_feedback", scoreID: "some-child-score", children: [ "some-text-input": .text("neat text"), "some-email-input": .email("email@email.email"), "some-child-score": .score(8.0), ] ) let expectedJSON: String = """ { "some-form-id": { "type": "nps", "score_id": "some-child-score", "response_type": "user_feedback", "children": { "some-child-score": { "type": "score", "value": 8 }, "some-text-input": { "type": "text_input", "value": "neat text" }, "some-email-input": { "type": "email_input", "value": "email@email.email" } } } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = try ThomasFormPayloadGenerator.makeFormEventPayload( identifier: "some-form-id", formValue: npsForm ) #expect(actual == expected) } @Test("Test passing other values throws") func testFormDataThrows() throws { #expect(throws: NSError.self) { try ThomasFormPayloadGenerator.makeFormEventPayload( identifier: "some-form-id", formValue: .text("some-text") ) } } @Test( "Test state data", arguments: [ ThomasFormState.Status.valid, ThomasFormState.Status.invalid, ThomasFormState.Status.error, ThomasFormState.Status.pendingValidation, ThomasFormState.Status.submitted, ThomasFormState.Status.validating ] ) func testStateData(formStatus: ThomasFormState.Status) async throws { let errorField = ThomasFormField.asyncField( identifier: "some-async-id", input: .score(7.0), processDelay: 0 ) { .error } await errorField.process() // gets the error let pendingField = ThomasFormField.asyncField( identifier: "some-pending-async-id", input: .score(7.0), processDelay: 100.0 ) { .invalid } let fields: [ThomasFormField] = [ ThomasFormField.invalidField(identifier: "some-invalid-id", input: .email("neat")), ThomasFormField.validField(identifier: "some-valid-id", input: .email("neat"), result: .init(value: .email("actual"))), errorField, pendingField ] let expectedJSON = """ { "data":{ "children":{ "some-valid-id":{ "value":"neat", "type":"email_input", "status":{ "result":{ "value":"actual", "type":"email_input" }, "type":"valid" } }, "some-invalid-id":{ "status":{ "type":"invalid" }, "value":"neat", "type":"email_input" }, "some-async-id":{ "value":7, "status":{ "type":"error" }, "type":"score" }, "some-pending-async-id":{ "type":"score", "value":7, "status":{ "type":"pending" } } }, "type": "form" }, "status":{ "type": "\(formStatus.rawValue)" } } """ let expected = try AirshipJSON.from(json: expectedJSON) let actual = ThomasFormPayloadGenerator.makeFormStatePayload( status: formStatus, fields: fields, formType: .form ) #expect(actual == expected) } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasFormStateTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore import Combine @MainActor struct ThomasFormStateTest { @Test("Test empty form") func testEmptyForm() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate ) #expect(form.identifier == "some-form-id") #expect(form.formType == .form) #expect(form.formResponseType == "response type") #expect(form.validationMode == .immediate) #expect(form.status == .invalid) #expect(form.isFormInputEnabled == true) #expect(form.isEnabled == true) #expect(form.isVisible == false) #expect(form.activeFields.isEmpty == true) } @Test("Test empty nps form") func testEmptyNPSForm() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .nps("score-id"), formResponseType: "response type", validationMode: .immediate ) #expect(form.identifier == "some-form-id") #expect(form.formType == .nps("score-id")) #expect(form.formResponseType == "response type") #expect(form.validationMode == .immediate) #expect(form.status == .invalid) #expect(form.isFormInputEnabled == true) #expect(form.isEnabled == true) #expect(form.isVisible == false) #expect(form.activeFields.isEmpty == true) } @Test("Test empty form with on demand validation") func testEmptyFormOnDemandValidation() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) #expect(form.identifier == "some-form-id") #expect(form.formType == .form) #expect(form.formResponseType == "response type") #expect(form.validationMode == .onDemand) #expect(form.status == .pendingValidation) #expect(form.isFormInputEnabled == true) #expect(form.isEnabled == true) #expect(form.isVisible == false) #expect(form.activeFields.isEmpty == true) } @Test("Test empty form submit") func testEmptyFormSubmit() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) form.onSubmit = { _, _, _ in } await #expect(throws: NSError.self) { try await form.submit(layoutState: .empty) } } @Test("Test submit empty data throws.") func testInvalidFormSubmit() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) form.onSubmit = { _, _, _ in } form.updateField(.invalidField(identifier: "some-id", input: .email(nil))) await #expect(throws: NSError.self) { try await form.submit(layoutState: .empty) } } @Test("Test update field predicate does not apply") func testSubmitSingleFilteredField() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) form.onSubmit = { _, _, _ in } form.updateField( .validField(identifier: "some-id", input: .email(nil), result: .init(value: .email("valid email"))) ) { false } await #expect(throws: NSError.self) { try await form.submit(layoutState: .empty) } } @Test("Test submit.") func testSubmit() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) let field: ThomasFormField = .validField( identifier: "some-id", input: .email(nil), result: .init( value: .email("valid email"), channels: [ .email("some email", .doubleOptIn(.init())) ], attributes: [ .init( attributeName: .init(channel: "some-id"), attributeValue: .string("some value") ) ] ) ) let anotherField: ThomasFormField = .validField( identifier: "some-other-id", input: .email(nil), result: .init( value: .email("other valid email"), channels: [ .email("some other email", .doubleOptIn(.init())) ], attributes: [ .init( attributeName: .init(channel: "some-id"), attributeValue: .string("some other value") ) ] ) ) form.updateField(field) form.updateField(anotherField) #expect(form.activeFields.count == 2) try await confirmation { confirmation in form.onSubmit = { id, result, _ in let expectedResult: ThomasFormField.Result = .init( value: .form( responseType: form.formResponseType, children: [ "some-other-id": .email("other valid email"), "some-id": .email("valid email"), ] ), channels: field.channels + anotherField.channels, attributes: field.attributes + anotherField.attributes ) #expect(id == "some-form-id") #expect(result == expectedResult) confirmation.confirm() } try await form.submit(layoutState: .empty) } } @Test("Test submit checks predicate") func testSubmitChecksPredicate() async throws { let screen = AirshipMainActorValue("foo") let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) let fooField: ThomasFormField = .validField( identifier: "foo-id", input: .email(nil), result: .init( value: .email("foo"), channels: [ .email("some email", .doubleOptIn(.init())) ], attributes: [ .init( attributeName: .init(channel: "some-id"), attributeValue: .string("some value") ) ] ) ) let barField: ThomasFormField = .validField( identifier: "bar-id", input: .email(nil), result: .init( value: .email("bar"), channels: [ .email("some other email", .doubleOptIn(.init())) ], attributes: [ .init( attributeName: .init(channel: "some-id"), attributeValue: .string("some other value") ) ] ) ) form.updateField(fooField) { screen.value == "foo" } #expect(form.activeFields.count == 1) form.updateField(barField) { screen.value == "bar" } #expect(form.activeFields.count == 1) screen.update { $0 = "bar" } try await confirmation { confirmation in form.onSubmit = { id, result, _ in let expectedResult: ThomasFormField.Result = .init( value: .form( responseType: form.formResponseType, children: [ "bar-id": .email("bar"), ] ), channels: barField.channels, attributes: barField.attributes ) #expect(id == "some-form-id") #expect(result == expectedResult) confirmation.confirm() } try await form.submit(layoutState: .empty) } } @Test("Test data change for onDemand mode.") func testDataChangeOnDemand() async throws { let screen = AirshipMainActorValue("foo") let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) var updates = form.statusUpdates.makeAsyncIterator() await #expect(updates.next() == .pendingValidation) print("start") let fooField: ThomasFormField = .validField( identifier: "foo-id", input: .email(nil), result: .init( value: .email("foo") ) ) let barField: ThomasFormField = .invalidField( identifier: "bar-id", input: .email(nil) ) form.updateField(fooField) { screen.value == "foo" } #expect(form.activeFields.count == 1) form.updateField(barField) { screen.value == "bar" } #expect(form.activeFields.count == 1) await #expect(form.validate() == true) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) screen.update { $0 = "bar" } form.dataChanged() await #expect(form.validate() == false) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .invalid) screen.update { $0 = "foo" } form.dataChanged() await #expect(updates.next() == .valid) await #expect(form.validate() == true) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) form.updateField( .asyncField( identifier: "bar-id", input: .score(2.0), processDelay: 0.1 ) { .valid(.init(value: .score(1.0))) } ) { screen.value == "bar" } await #expect(form.validate() == true) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) screen.update { $0 = "bar" } form.dataChanged() await #expect(updates.next() == .pendingValidation) } @Test("Test data change for immediate mode.") func testDataChangeImmediate() async throws { let screen = AirshipMainActorValue("foo") let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate ) var updates = form.statusUpdates.makeAsyncIterator() await #expect(updates.next() == .invalid) let fooField: ThomasFormField = .validField( identifier: "foo-id", input: .email(nil), result: .init( value: .email("foo") ) ) let barField: ThomasFormField = .invalidField( identifier: "bar-id", input: .email(nil) ) form.updateField(barField) { screen.value == "bar" } await #expect(updates.next() == .valid) form.updateField(fooField) { screen.value == "foo" } #expect(form.activeFields.count == 1) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) await #expect(form.validate() == true) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) screen.update { $0 = "bar" } form.dataChanged() await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .invalid) await #expect(form.validate() == false) await #expect(updates.next() == .validating) await #expect(updates.next() == .invalid) screen.update { $0 = "foo" } form.dataChanged() await #expect(updates.next() == .valid) await #expect(form.validate() == true) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) form.updateField( .asyncField( identifier: "bar-id", input: .score(2.0), processDelay: 0.1 ) { .valid(.init(value: .score(1.0))) } ) { screen.value == "bar" } await #expect(form.validate() == true) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) screen.update { $0 = "bar" } form.dataChanged() await #expect(updates.next() == .pendingValidation) } @Test("Test updating fields on demand") func testUpdateFieldsOnDemand() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .onDemand ) var updates = form.statusUpdates.makeAsyncIterator() await #expect(updates.next() == .pendingValidation) form.updateField(.validField(identifier: "some-valid-id", input: .score(1.0), result: .init(value: .score(1.0)))) #expect(form.status == .pendingValidation) form.updateField(.invalidField(identifier: "some-id", input: .score(1.0))) #expect(form.status == .pendingValidation) form.updateField(.invalidField(identifier: "some-other-id", input: .score(2.0))) #expect(form.status == .pendingValidation) await #expect(form.validate() == false) await #expect(updates.next() == .validating) await #expect(updates.next() == .invalid) // Update the invalid fields with more invalid data form.updateField(.invalidField(identifier: "some-id", input: .score(1.0))) #expect(form.status == .invalid) form.updateField(.invalidField(identifier: "some-other-id", input: .score(2.0))) #expect(form.status == .invalid) // Update the invalid fields with valid and pending fields form.updateField(.validField(identifier: "some-id", input: .score(1.0), result: .init(value: .score(1.0)))) #expect(form.status == .invalid) form.updateField( .asyncField( identifier: "some-other-id", input: .score(2.0), processDelay: 0.1 ) { .valid(.init(value: .score(1.0))) } ) #expect(form.status == .pendingValidation) await #expect(updates.next() == .pendingValidation) await #expect(form.validate() == true) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) #expect(form.status == .valid) } @Test("Test updating fields in immediate mode starts a validation task") func testUpdateFieldsImmediate() async throws { let form = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate ) var updates = form.statusUpdates.makeAsyncIterator() await #expect(updates.next() == .invalid) form.updateField(.validField(identifier: "some-valid-id", input: .score(1.0), result: .init(value: .score(1.0)))) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) form.updateField(.invalidField(identifier: "some-id", input: .score(1.0))) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .invalid) form.updateField(.invalidField(identifier: "some-other-id", input: .score(2.0))) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .invalid) // Update the invalid fields with more invalid data form.updateField(.invalidField(identifier: "some-id", input: .score(1.0))) form.updateField(.invalidField(identifier: "some-other-id", input: .score(2.0))) // Update the invalid fields with valid fields form.updateField(.validField(identifier: "some-id", input: .score(1.0), result: .init(value: .score(1.0)))) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .invalid) form.updateField(.validField(identifier: "some-other-id", input: .score(1.0), result: .init(value: .score(1.0)))) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) // Update a field with pending form.updateField( .asyncField( identifier: "some-other-id", input: .score(2.0), processDelay: 0.1 ) { .valid(.init(value: .score(1.0))) } ) await #expect(updates.next() == .pendingValidation) await #expect(updates.next() == .validating) await #expect(updates.next() == .valid) } @Test("Test enable effects form input enabled") func testEnable() async throws { let parent = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate ) let child = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate, parentFormState: parent ) #expect(child.isEnabled) #expect(child.isFormInputEnabled) #expect(parent.isEnabled) #expect(parent.isFormInputEnabled) parent.isEnabled = false #expect(parent.isEnabled == false) #expect(parent.isFormInputEnabled == false) #expect(child.isEnabled) #expect(child.isFormInputEnabled == false) parent.isEnabled = true child.isEnabled = false #expect(parent.isEnabled) #expect(parent.isFormInputEnabled) #expect(child.isEnabled == false) #expect(child.isFormInputEnabled == false) } @Test("Test mark child visible.") func testMarkChildVisible() async throws { let parent = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate ) let child = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate, parentFormState: parent ) #expect(child.isVisible == false) #expect(parent.isVisible == false) child.markVisible() #expect(child.isVisible) #expect(parent.isVisible) } @Test("Test mark parent visible.") func testMarkParentVisible() async throws { let parent = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate ) let child = ThomasFormState( identifier: "some-form-id", formType: .form, formResponseType: "response type", validationMode: .immediate, parentFormState: parent ) #expect(child.isVisible == false) #expect(parent.isVisible == false) parent.markVisible() #expect(parent.isVisible) #expect(child.isVisible == false) child.markVisible() #expect(child.isVisible) #expect(parent.isVisible) } } extension ThomasFormField { var channels: [ThomasFormField.Channel] { return if case let .valid(result) = self.status { result.channels ?? [] } else { [] } } var attributes: [ThomasFormField.Attribute] { return if case let .valid(result) = self.status { result.attributes ?? [] } else { [] } } } extension ThomasFormState { // $status.values seems to debounce updates so using a custom updates for // testing var statusUpdates: AsyncStream<ThomasFormState.Status> { return AsyncStream { continuation in let sub = self.$status.sink { status in continuation.yield(status) } continuation.onTermination = { _ in sub.cancel() } } } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasPagerTrackerTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore @MainActor struct ThomasPagerTrackerTest { private let tracker: ThomasPagerTracker = ThomasPagerTracker() @Test func testSummary() throws { let fooPage0 = makePageViewEvent(pager: "foo", page: 0) let fooPage1 = makePageViewEvent(pager: "foo", page: 1) let barPage0 = makePageViewEvent(pager: "bar", page: 0) let barPage1 = makePageViewEvent(pager: "bar", page: 1) #expect(self.tracker.summary.isEmpty) self.tracker.onPageView(pageEvent: fooPage0, currentDisplayTime: 0) #expect( self.tracker.summary == Set([ ThomasReportingEvent.PagerSummaryEvent( identifier: "foo", viewedPages: [], pageCount: fooPage0.pageCount, completed: fooPage0.completed ) ]) ) self.tracker.onPageView(pageEvent: fooPage1, currentDisplayTime: 10) #expect( self.tracker.summary == Set([ ThomasReportingEvent.PagerSummaryEvent( identifier: "foo", viewedPages: [ .init( identifier: "page-0", index: 0, displayTime: 10 ) ], pageCount: fooPage0.pageCount, completed: fooPage0.completed ) ]) ) self.tracker.onPageView(pageEvent: barPage0, currentDisplayTime: 10) #expect( self.tracker.summary == Set([ ThomasReportingEvent.PagerSummaryEvent( identifier: "foo", viewedPages: [ .init( identifier: "page-0", index: 0, displayTime: 10 ) ], pageCount: fooPage1.pageCount, completed: fooPage1.completed ), ThomasReportingEvent.PagerSummaryEvent( identifier: "bar", viewedPages: [], pageCount: barPage0.pageCount, completed: barPage0.completed ) ]) ) self.tracker.onPageView(pageEvent: fooPage0, currentDisplayTime: 20) #expect( self.tracker.summary == Set([ ThomasReportingEvent.PagerSummaryEvent( identifier: "foo", viewedPages: [ .init( identifier: "page-0", index: 0, displayTime: 10 ), .init( identifier: "page-1", index: 1, displayTime: 10 ) ], pageCount: fooPage0.pageCount, completed: fooPage0.completed ), ThomasReportingEvent.PagerSummaryEvent( identifier: "bar", viewedPages: [], pageCount: barPage0.pageCount, completed: barPage0.completed ) ]) ) self.tracker.onPageView(pageEvent: barPage1, currentDisplayTime: 30) #expect( self.tracker.summary == Set([ ThomasReportingEvent.PagerSummaryEvent( identifier: "foo", viewedPages: [ .init( identifier: "page-0", index: 0, displayTime: 10 ), .init( identifier: "page-1", index: 1, displayTime: 10 ) ], pageCount: fooPage0.pageCount, completed: fooPage0.completed ), ThomasReportingEvent.PagerSummaryEvent( identifier: "bar", viewedPages: [ .init( identifier: "page-0", index: 0, displayTime: 20 ), ], pageCount: barPage0.pageCount, completed: barPage0.completed ) ]) ) self.tracker.stopAll(currentDisplayTime: 40) #expect( self.tracker.summary == Set([ ThomasReportingEvent.PagerSummaryEvent( identifier: "foo", viewedPages: [ .init( identifier: "page-0", index: 0, displayTime: 10 ), .init( identifier: "page-1", index: 1, displayTime: 10 ), .init( identifier: "page-0", index: 0, displayTime: 20 ) ], pageCount: fooPage0.pageCount, completed: fooPage0.completed ), ThomasReportingEvent.PagerSummaryEvent( identifier: "bar", viewedPages: [ .init( identifier: "page-0", index: 0, displayTime: 20 ), .init( identifier: "page-1", index: 1, displayTime: 10 ) ], pageCount: barPage0.pageCount, completed: barPage0.completed ) ]) ) } @Test func testViewedPages() throws { self.tracker.onPageView( pageEvent: makePageViewEvent(pager: "foo", page: 0), currentDisplayTime: 0 ) self.tracker.onPageView( pageEvent: makePageViewEvent(pager: "foo", page: 1), currentDisplayTime: 1 ) self.tracker.onPageView( pageEvent: makePageViewEvent(pager: "bar", page: 0), currentDisplayTime: 1 ) self.tracker.onPageView( pageEvent: makePageViewEvent(pager: "foo", page: 2), currentDisplayTime: 4 ) #expect( self.tracker.viewedPages(pagerIdentifier: "foo") == [ .init( identifier: "page-0", index: 0, displayTime: 1 ), .init( identifier: "page-1", index: 1, displayTime: 3 ) ] ) // Still on page 0 so its empty #expect(self.tracker.viewedPages(pagerIdentifier: "bar") == []) // Baz does not exist #expect(self.tracker.viewedPages(pagerIdentifier: "baz") == []) } private func makePageViewEvent(pager: String, page: Int) -> ThomasReportingEvent.PageViewEvent { return ThomasReportingEvent.PageViewEvent( identifier: pager, pageIdentifier: "page-\(page)", pageIndex: page, pageViewCount: 1, pageCount: 100, completed: false ) } } ================================================ FILE: Airship/AirshipCore/Tests/Environment/ThomasStateTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore struct ThomasStateTest { } ================================================ FILE: Airship/AirshipCore/Tests/EventAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class EventAPIClientTest: XCTestCase { private let requestSession = TestAirshipRequestSession() private var client: EventAPIClient! private let eventData = [ AirshipEventData.makeTestData() ] private let headers: [String: String] = [ "some": "header" ] override func setUpWithError() throws { client = EventAPIClient( config: .testConfig(), session: requestSession ) } func testUpload() async throws { let responseHeaders = [ "X-UA-Max-Total": "200", "X-UA-Max-Batch": "100", "X-UA-Min-Batch-Interval": "10.4" ] self.requestSession.response = HTTPURLResponse( url: URL(string: "https://www.airship.com")!, statusCode: 200, httpVersion: "", headerFields: responseHeaders ) let response = try await self.client.uploadEvents( self.eventData, channelID: "some channel", headers: self.headers ) XCTAssertEqual(100, response.result!.maxBatchSizeKB) XCTAssertEqual(200, response.result!.maxTotalStoreSizeKB) XCTAssertEqual(10.4, response.result!.minBatchInterval) XCTAssertEqual(self.requestSession.lastRequest?.auth, .channelAuthToken(identifier: "some channel")) } func testUploadBadHeaders() async throws { let responseHeaders = [ "X-UA-Max-Total": "string", "X-UA-Max-Batch": "true", ] self.requestSession.response = HTTPURLResponse( url: URL(string: "https://www.airship.com")!, statusCode: 200, httpVersion: "", headerFields: responseHeaders ) let response = try await self.client.uploadEvents( self.eventData, channelID: "some channel", headers: self.headers ) XCTAssertNil(response.result!.maxBatchSizeKB) XCTAssertNil(response.result!.maxTotalStoreSizeKB) XCTAssertNil(response.result!.minBatchInterval) } func testUploadFailed() async throws { self.requestSession.response = HTTPURLResponse( url: URL(string: "https://www.airship.com")!, statusCode: 400, httpVersion: "", headerFields: [:] ) let response = try await self.client.uploadEvents( self.eventData, channelID: "some channel", headers: self.headers ) XCTAssertNil(response.result!.maxBatchSizeKB) XCTAssertNil(response.result!.maxTotalStoreSizeKB) XCTAssertNil(response.result!.minBatchInterval) } } ================================================ FILE: Airship/AirshipCore/Tests/EventManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class EventManagerTest: XCTestCase { private let eventAPIClient = TestEventAPIClient() private let eventScheduler = TestEventUploadScheduler() private let channel = TestChannel() private let eventStore = EventStore( appKey: UUID().uuidString, inMemory: true ) private let dataStore = PreferenceDataStore( appKey: UUID().uuidString ) private var eventManager: EventManager! @MainActor override func setUp() async throws { self.eventManager = EventManager( dataStore: dataStore, channel: channel, eventStore: eventStore, eventAPIClient: eventAPIClient, eventScheduler: eventScheduler ) channel.identifier = "some channel" } func testAddEvent() async throws { let eventData = AirshipEventData.makeTestData() try await eventManager.addEvent(eventData) let events = try await eventStore.fetchEvents( maxBatchSizeKB: 1000 ) XCTAssertEqual([eventData], events) } func testScheduleUpload() async throws { self.eventManager.uploadsEnabled = true await self.eventManager.scheduleUpload(eventPriority: .high) XCTAssertEqual( 60, // min batch interval self.eventScheduler.lastMinBatchInterval ) XCTAssertEqual( AirshipEventPriority.high, self.eventScheduler.lastScheduleUploadPriority ) } func testScheduleUploadDisabled() async throws { self.eventManager.uploadsEnabled = false await self.eventManager.scheduleUpload(eventPriority: .high) XCTAssertNil(self.eventScheduler.lastMinBatchInterval) XCTAssertNil(self.eventScheduler.lastScheduleUploadPriority) } func testDeleteAll() async throws { let eventData = AirshipEventData.makeTestData() try await eventManager.addEvent(eventData) try await eventManager.deleteEvents() let events = try await eventStore.fetchEvents( maxBatchSizeKB: 1000 ) XCTAssertTrue(events.isEmpty) } func testEventUpload() async throws { self.eventManager.uploadsEnabled = true var requestCalled = false let events = [ AirshipEventData.makeTestData(), AirshipEventData.makeTestData() ] let headers = ["some": "header"] await self.eventManager.addHeaderProvider { return headers } for event in events { try await self.eventStore.save(event: event) } self.eventAPIClient.requestBlock = { reqEvents, channelID, reqHeaders in requestCalled = true XCTAssertEqual(events, reqEvents) XCTAssertEqual(headers, reqHeaders) XCTAssertEqual(channelID, "some channel") let tuningInfo = EventUploadTuningInfo( maxTotalStoreSizeKB: nil, maxBatchSizeKB: nil, minBatchInterval: nil ) return AirshipHTTPResponse( result: tuningInfo, statusCode: 200, headers: [:] ) } let result = try await self.eventScheduler.workBlock?() XCTAssertEqual(AirshipWorkResult.success, result) XCTAssertTrue(requestCalled) let storedEvents = try await self.eventStore.fetchEvents( maxBatchSizeKB: 1000 ) XCTAssertTrue(storedEvents.isEmpty) } func testEventUploadFailed() async throws { self.eventManager.uploadsEnabled = true try await self.eventStore.save( event: AirshipEventData.makeTestData() ) self.eventAPIClient.requestBlock = { reqEvents, _, reqHeaders in return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } let result = try await self.eventScheduler.workBlock?() XCTAssertEqual(AirshipWorkResult.failure, result) let storedEvents = try await self.eventStore.fetchEvents( maxBatchSizeKB: 1000 ) XCTAssertEqual(1, storedEvents.count) } func testEventUploadNoTuningInfo() async throws { self.eventManager.uploadsEnabled = true try await self.eventStore.save( event: AirshipEventData.makeTestData() ) self.eventAPIClient.requestBlock = { reqEvents, _, reqHeaders in return AirshipHTTPResponse( result: nil, statusCode: 200, headers: [:] ) } let result = try await self.eventScheduler.workBlock?() XCTAssertEqual(AirshipWorkResult.success, result) } func testEventUploadHeaders() async throws { self.eventManager.uploadsEnabled = true var requestCalled = false await self.eventManager.addHeaderProvider { ["foo": "1", "baz": "1"] } await self.eventManager.addHeaderProvider { ["foo": "2", "bar": "2"] } try await self.eventStore.save( event: AirshipEventData.makeTestData() ) self.eventAPIClient.requestBlock = { reqEvents, _, reqHeaders in let expectedHeaders = [ "foo": "2", "bar": "2", "baz": "1" ] XCTAssertEqual(expectedHeaders, reqHeaders) requestCalled = true return AirshipHTTPResponse( result: nil, statusCode: 200, headers: [:] ) } let result = try await self.eventScheduler.workBlock?() XCTAssertEqual(AirshipWorkResult.success, result) XCTAssertTrue(requestCalled) } func testEventUploadDisabled() async throws { self.eventManager.uploadsEnabled = false try await self.eventStore.save( event: AirshipEventData.makeTestData() ) self.eventAPIClient.requestBlock = { reqEvents, _, reqHeaders in XCTFail("Should not be called") return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } let result = try await self.eventScheduler.workBlock?() XCTAssertEqual(AirshipWorkResult.success, result) } func testEventUploadUpdatedMinInterval() async throws { self.eventManager.uploadsEnabled = true try await self.eventStore.save( event: AirshipEventData.makeTestData() ) self.eventAPIClient.requestBlock = { reqEvents, _, reqHeaders in let tuningInfo = EventUploadTuningInfo( maxTotalStoreSizeKB: nil, maxBatchSizeKB: nil, minBatchInterval: 100 ) return AirshipHTTPResponse( result: tuningInfo, statusCode: 200, headers: [:] ) } let result = try await self.eventScheduler.workBlock?() XCTAssertEqual(AirshipWorkResult.success, result) await self.eventManager.scheduleUpload(eventPriority: .normal) XCTAssertEqual( 100, // min batch interval self.eventScheduler.lastMinBatchInterval ) } } final class TestEventAPIClient: EventAPIClientProtocol, @unchecked Sendable { var requestBlock: (([AirshipEventData], String, [String: String]) async throws -> AirshipHTTPResponse<EventUploadTuningInfo>)? func uploadEvents(_ events: [AirshipEventData], channelID: String, headers: [String : String]) async throws -> AirshipHTTPResponse<EventUploadTuningInfo> { guard let block = requestBlock else { throw AirshipErrors.error("Request block not set") } return try await block(events, channelID, headers) } } final class TestEventUploadScheduler: EventUploadSchedulerProtocol, @unchecked Sendable { var workBlock: (() async throws -> AirshipWorkResult)? var lastScheduleUploadPriority: AirshipEventPriority? var lastMinBatchInterval: TimeInterval? func scheduleUpload( eventPriority: AirshipEventPriority, minBatchInterval: TimeInterval ) async { self.lastMinBatchInterval = minBatchInterval self.lastScheduleUploadPriority = eventPriority } func setWorkBlock( _ workBlock: @escaping () async throws -> AirshipCore.AirshipWorkResult ) async { self.workBlock = workBlock } } ================================================ FILE: Airship/AirshipCore/Tests/EventSchedulerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class EventSchedulerTest: XCTestCase { private let date = UATestDate() private let workManager = TestWorkManager() private let appStateTracker = TestAppStateTracker() private var eventScheduler: EventUploadScheduler! private let taskSleeper: TestTaskSleeper = TestTaskSleeper() @MainActor override func setUp() async throws { self.eventScheduler = EventUploadScheduler( appStateTracker: appStateTracker, workManager: workManager, date: date, taskSleeper: taskSleeper ) } @MainActor func testScheduleNormalPriority() async throws { self.appStateTracker.currentState = .active await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 60.0 ) XCTAssertEqual(1, self.workManager.workRequests.count) XCTAssertEqual(15.0, self.workManager.workRequests[0].initialDelay) } @MainActor func testScheduleHighPriority() async throws { self.appStateTracker.currentState = .active await self.eventScheduler.scheduleUpload( eventPriority: .high, minBatchInterval: 60.0 ) XCTAssertEqual(1, self.workManager.workRequests.count) XCTAssertEqual(0, self.workManager.workRequests[0].initialDelay) } @MainActor func testScheduleNormalPriorityBackground() async throws { self.appStateTracker.currentState = .background await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 60.0 ) XCTAssertEqual(1, self.workManager.workRequests.count) XCTAssertEqual(0, self.workManager.workRequests[0].initialDelay) } @MainActor func testAlreadyScheduled() async throws { self.appStateTracker.currentState = .active await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 60.0 ) await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 60.0 ) XCTAssertEqual(1, self.workManager.workRequests.count) XCTAssertEqual(15.0, self.workManager.workRequests[0].initialDelay) } @MainActor func testScheduleEarlier() async throws { self.appStateTracker.currentState = .active await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 60.0 ) await self.eventScheduler.scheduleUpload( eventPriority: .high, minBatchInterval: 60.0 ) XCTAssertEqual(2, self.workManager.workRequests.count) XCTAssertEqual(15.0, self.workManager.workRequests[0].initialDelay) XCTAssertEqual(0, self.workManager.workRequests[1].initialDelay) } @MainActor func testBatchInterval() async throws { self.date.dateOverride = Date() let request = AirshipWorkRequest(workID: "neat") let _ = try await self.workManager.workers[0].workHandler(request) self.appStateTracker.currentState = .active await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 60.0 ) XCTAssertEqual(1, self.workManager.workRequests.count) XCTAssertEqual(60.0, self.workManager.workRequests[0].initialDelay) } @MainActor func testSmallerBatchInterval() async throws { self.date.dateOverride = Date() let request = AirshipWorkRequest(workID: "neat") let _ = try await self.workManager.workers[0].workHandler(request) self.appStateTracker.currentState = .active await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 60.0 ) await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 90.0 ) await self.eventScheduler.scheduleUpload( eventPriority: .normal, minBatchInterval: 30.0 ) XCTAssertEqual(2, self.workManager.workRequests.count) XCTAssertEqual(60.0, self.workManager.workRequests[0].initialDelay) XCTAssertEqual(30.0, self.workManager.workRequests[1].initialDelay) } func testWorkHandlerNotSet() async throws { let request = AirshipWorkRequest(workID: "neat") let result = try await self.workManager.workers[0].workHandler(request) XCTAssertEqual(AirshipWorkResult.success, result) } func testWorkBlockFailed() async throws { let called = AirshipAtomicValue<Bool>(false) await self.eventScheduler.setWorkBlock { called.value = true return .failure } let request = AirshipWorkRequest(workID: "neat") let result = try await self.workManager.workers[0].workHandler(request) XCTAssertEqual(AirshipWorkResult.failure, result) XCTAssertTrue(called.value) } func testWorkBlockSuccess() async throws { let called = AirshipAtomicValue<Bool>(false) await self.eventScheduler.setWorkBlock { called.value = true return .success } let request = AirshipWorkRequest(workID: "neat") let result = try await self.workManager.workers[0].workHandler(request) XCTAssertEqual(AirshipWorkResult.success, result) XCTAssertTrue(called.value) } @MainActor func testBatchDelay() async throws { self.appStateTracker.currentState = .inactive let request = AirshipWorkRequest(workID: "neat") let result = try await self.workManager.workers[0].workHandler(request) XCTAssertEqual(AirshipWorkResult.success, result) let sleeps = await self.taskSleeper.sleeps XCTAssertEqual([1.0], sleeps) } @MainActor func testActiveBatchDelay() async throws { self.appStateTracker.currentState = .active let request = AirshipWorkRequest(workID: "neat") let result = try await self.workManager.workers[0].workHandler(request) XCTAssertEqual(AirshipWorkResult.success, result) let sleeps = await self.taskSleeper.sleeps XCTAssertEqual([5.0], sleeps) } } ================================================ FILE: Airship/AirshipCore/Tests/EventStoreTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class EventStoreTest: XCTestCase { private let eventStore = EventStore( appKey: UUID().uuidString, inMemory: true ) func testAdd() async throws { let events = generateEvents(count: 2) for event in events { try await self.eventStore.save(event: event) } let storedEvents = try await eventStore.fetchEvents( maxBatchSizeKB: 1000 ) XCTAssertEqual(events, storedEvents) } func testDeleteAll() async throws { let events = generateEvents(count: 10) for event in events { try await self.eventStore.save(event: event) } try await self.eventStore.deleteAllEvents() let storedEvents = try await eventStore.fetchEvents( maxBatchSizeKB: 1000 ) XCTAssertTrue(storedEvents.isEmpty) } func testDeleteEventIDs() async throws { let events = generateEvents(count: 10) for event in events { try await self.eventStore.save(event: event) } try await self.eventStore.deleteEvents( eventIDs: [ events[0].id, events[1].id, events[2].id ] ) let storedEvents = try await eventStore.fetchEvents( maxBatchSizeKB: 1000 ) let expectedEvents = Array(events[3...9]) XCTAssertEqual(expectedEvents, storedEvents) } func generateEvents( count: Int ) -> [AirshipEventData] { var events: [AirshipEventData] = [] for _ in 1...count { events.append( AirshipEventData.makeTestData() ) } return events } } ================================================ FILE: Airship/AirshipCore/Tests/EventTestUtils.swift ================================================ import Foundation @testable import AirshipCore extension 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: type ) } } ================================================ FILE: Airship/AirshipCore/Tests/ExperimentManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ExperimentManagerTest: XCTestCase { private var deviceInfo: TestAudienceDeviceInfoProvider = TestAudienceDeviceInfoProvider() private let remoteData: TestRemoteData = TestRemoteData() private var subject: ExperimentManager! private let audienceChecker: TestAudienceChecker = TestAudienceChecker() private let testDate: UATestDate = UATestDate(offset: 0, dateOverride: Date()) override func setUpWithError() throws { self.deviceInfo.channelID = "channel-id" self.deviceInfo.stableContactInfo = StableContactInfo(contactID: "some-contact-id") self.subject = ExperimentManager( dataStore: PreferenceDataStore(appKey: UUID().uuidString), remoteData: remoteData, audienceChecker: audienceChecker, date: testDate ) } func testExperimentManagerOmitsInvalidExperiments() async throws { let experiment = Experiment.generate(id: "valid") self.remoteData.payloads = [createPayload([experiment.toString, "{ \"not valid\": true }"])] self.audienceChecker.onEvaluate = { audience, _, _ in .init(isMatch: true) } let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo )! XCTAssertEqual( [ experiment.reportingMetadata ], result.reportingMetadata ) } func testExperimentManagerParseMultipleExperiments() async throws { let experiment1 = Experiment.generate(id: "id1") let experiment2 = Experiment.generate(id: "id2") self.remoteData.payloads = [ createPayload([experiment1.toString]), createPayload([experiment2.toString]) ] self.audienceChecker.onEvaluate = { _, _, _ in .init(isMatch: false) } let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo )! XCTAssertEqual( [ experiment1.reportingMetadata, experiment2.reportingMetadata ], result.reportingMetadata ) } func testExperimentManagerHandleNoExperimentsPayload() async throws { self.remoteData.payloads = [createPayload(["{}"])] let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo ) XCTAssertNil(result) } func testExperimentManagerHandleInvalidPayload() async throws { let experiment = "{\"invalid\": \"experiment\"}" self.remoteData.payloads = [createPayload([experiment])] let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo ) XCTAssertNil(result) } func testResultNoExperiments() async throws { self.remoteData.payloads = [createPayload([])] let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo ) XCTAssertNil(result) } func testResultNoMatch() async throws { let experiment = Experiment.generate(id: "fake-id", reportingMetadata: AirshipJSON.string("reporting data!")) self.remoteData.payloads = [createPayload([experiment.toString])] self.audienceChecker.onEvaluate = { _, _, _ in .init(isMatch: false) } let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo )! XCTAssertFalse(result.isMatch) XCTAssertEqual(self.deviceInfo.stableContactInfo.contactID, result.contactID) XCTAssertEqual(self.deviceInfo.channelID, result.channelID) XCTAssertEqual( [ experiment.reportingMetadata ], result.reportingMetadata ) } func testResultMatch() async throws { let audienceSelector1 = DeviceAudienceSelector(newUser: true) let experiment1 = Experiment.generate( id: "id1", reportingMetadata: AirshipJSON.string("reporting data 1"), audienceSelector: audienceSelector1 ) let audienceSelector2 = DeviceAudienceSelector(newUser: false) let experiment2 = Experiment.generate( id: "id2", reportingMetadata: AirshipJSON.string("reporting data 2"), audienceSelector: audienceSelector2 ) self.deviceInfo.stableContactInfo = StableContactInfo(contactID: "active-contact-id") self.remoteData.payloads = [createPayload([ experiment1.toString, experiment2.toString ])] self.audienceChecker.onEvaluate = { audience, _, _ in .init(isMatch: audience == .atomic(audienceSelector2)) } let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo )! XCTAssertTrue(result.isMatch) XCTAssertEqual("active-contact-id", result.contactID) XCTAssertEqual("channel-id", result.channelID) XCTAssertEqual( [ experiment1.reportingMetadata, experiment2.reportingMetadata ], result.reportingMetadata ) } func testResultMatchExcludesInactive() async throws { let audienceSelector1 = DeviceAudienceSelector(newUser: true) let experiment1 = Experiment.generate( id: "id1", reportingMetadata: AirshipJSON.string("reporting data 1"), audienceSelector: audienceSelector1, timeCriteria: AirshipTimeCriteria( start: self.testDate.now + 0.01, end: self.testDate.now + 0.02 ) ) let audienceSelector2 = DeviceAudienceSelector(newUser: false) let experiment2 = Experiment.generate( id: "id2", reportingMetadata: AirshipJSON.string("reporting data 2"), audienceSelector: audienceSelector2, timeCriteria: AirshipTimeCriteria( start: self.testDate.now, end: self.testDate.now + 0.01 ) ) self.deviceInfo.stableContactInfo = StableContactInfo(contactID: "active-contact-id") self.remoteData.payloads = [createPayload([ experiment1.toString, experiment2.toString ])] self.audienceChecker.onEvaluate = { audience, _, _ in .init(isMatch: audience == .atomic(audienceSelector2)) } let result = try await subject.evaluateExperiments( info: MessageInfo.empty, deviceInfoProvider: self.deviceInfo )! XCTAssertTrue(result.isMatch) XCTAssertEqual("active-contact-id", result.contactID) XCTAssertEqual("channel-id", result.channelID) XCTAssertEqual( [ experiment2.reportingMetadata ], result.reportingMetadata ) } func testResultMatchExclusions() async throws { let messageTypePredicate = JSONPredicate( jsonMatcher: JSONMatcher(valueMatcher: .matcherWhereStringEquals("transactional")) ) let campaignsPredicate = JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: JSONValueMatcher.matcherWithArrayContainsPredicate( JSONPredicate( jsonMatcher: JSONMatcher(valueMatcher: .matcherWhereStringEquals("transactional campaign")) ) )!, scope: ["categories"] ) ) let experiment = Experiment.generate( id: "id1", reportingMetadata: AirshipJSON.string("reporting data 1"), exclusions: [ MessageCriteria( messageTypePredicate: messageTypePredicate, campaignsPredicate: campaignsPredicate ) ] ) self.remoteData.payloads = [createPayload([experiment.toString])] self.audienceChecker.onEvaluate = { _, _, _ in return .match } let result = try await subject.evaluateExperiments( info: MessageInfo( messageType: "commercial", campaigns: try! AirshipJSON.wrap(["categories": ["foo", "bar"]]) ), deviceInfoProvider: self.deviceInfo )! XCTAssertTrue(result.isMatch) XCTAssertEqual([experiment.reportingMetadata], result.reportingMetadata) var emptyResult = try await subject.evaluateExperiments( info: MessageInfo(messageType: "transactional"), deviceInfoProvider: self.deviceInfo ) XCTAssertNil(emptyResult) emptyResult = try await subject.evaluateExperiments( info: MessageInfo( messageType: "commercial", campaigns: try! AirshipJSON.wrap(["categories": ["foo", "bar", "transactional campaign"]]) ), deviceInfoProvider: self.deviceInfo ) XCTAssertNil(emptyResult) } private func createPayload(_ json: [String], type: String = "experiments") -> RemoteDataPayload { let wrapped = "{\"\(type)\": [\(json.joined(separator: ","))]}" let data = try! JSONSerialization.jsonObject( with: wrapped.data(using: .utf8)!, options: [] ) as! [AnyHashable: Any] return RemoteDataPayload( type: type, timestamp: Date(), data: try! AirshipJSON.wrap(data), remoteDataInfo: nil ) } } private extension MessageInfo { static let empty = MessageInfo(messageType: "", campaigns: nil) } fileprivate extension Experiment { var toString: String { let encoder = JSONEncoder() let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" encoder.dateEncodingStrategy = .formatted(formatter) return try! AirshipJSON.wrap(self).toString(encoder: encoder) } static func generate( id: String, created: Date = Date(), reportingMetadata: AirshipJSON = AirshipJSON.string("reporting!"), audienceSelector: DeviceAudienceSelector = DeviceAudienceSelector(), exclusions: [MessageCriteria]? = nil, timeCriteria: AirshipTimeCriteria? = nil ) -> Experiment { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" let dateString = formatter.string(from: created) let normalized = formatter.date(from: dateString)! return Experiment( id: id, lastUpdated: normalized, created: normalized, reportingMetadata: reportingMetadata, audienceSelector: audienceSelector, exclusions: exclusions, timeCriteria: timeCriteria ) } } ================================================ FILE: Airship/AirshipCore/Tests/ExperimentTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ExperimentTest: XCTestCase { var encoder: JSONEncoder { let encoder = JSONEncoder() let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" encoder.dateEncodingStrategy = .formatted(dateFormatter) return encoder } var decoder: JSONDecoder { let decoder = JSONDecoder() let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" decoder.dateDecodingStrategy = .formatted(dateFormatter) return decoder } func testCodable() throws { let json: String = """ { "created" : "2023-07-10T18:10:46.203", "experiment_definition" : { "audience_selector" : { "hash" : { "audience_hash" : { "hash_algorithm" : "farm_hash", "hash_identifier" : "contact", "hash_prefix" : "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c:", "num_hash_buckets" : 16384 }, "audience_subset" : { "max_hash_bucket" : 8192, "min_hash_bucket" : 0 } } }, "experiment_type" : "holdout", "message_exclusions" : [ { "message_type" : { "value" : { "equals" : "transactional" } } } ], "reporting_metadata" : { "experiment_id" : "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c" }, "time_criteria" : { "end_timestamp" : 1689091608000, "start_timestamp" : 1689012595000 }, "type" : "static" }, "experiment_id" : "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c", "last_updated" : "2023-07-11T16:06:49.003", } """ let decoded: Experiment = try self.decoder.decode( Experiment.self, from: json.data(using: .utf8)! ) let expected = Experiment( id: "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c", lastUpdated: decoded.lastUpdated, created: decoded.created, reportingMetadata: ["experiment_id" : "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c"], audienceSelector: DeviceAudienceSelector( hashSelector: .init( hash: .init( prefix: "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c:", property: .contact, algorithm: .farm, seed: nil, numberOfBuckets: 16384, overrides: nil), bucket: .init(min: 0, max: 8192)) ), exclusions: [ .init(messageTypePredicate: try! .fromJson(json: ["value": ["equals": "transactional"]]), campaignsPredicate: nil) ], timeCriteria: .init(start: Date(milliseconds: 1689012595000), end: Date(milliseconds: 1689091608000)) ) let encoded = String(data: try encoder.encode(decoded), encoding: .utf8) XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.from(json: encoded)) XCTAssertEqual(expected, decoded) } func testCodableWithCompoundAudience() throws { let json: String = """ { "created": "2023-07-10T18:10:46.203", "experiment_definition": { "audience_selector": { "hash": { "audience_hash": { "hash_algorithm": "farm_hash", "hash_identifier": "contact", "hash_prefix": "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c:", "num_hash_buckets": 16384 }, "audience_subset": { "max_hash_bucket": 8192, "min_hash_bucket": 0 } } }, "compound_audience": { "selector": { "type": "atomic", "audience": { "new_user": true } } }, "experiment_type": "holdout", "message_exclusions": [ { "message_type": { "value": { "equals": "transactional" } } } ], "reporting_metadata": { "experiment_id": "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c" }, "time_criteria": { "end_timestamp": 1689091608000, "start_timestamp": 1689012595000 }, "type": "static" }, "experiment_id": "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c", "last_updated": "2023-07-11T16:06:49.003" } """ let decoded: Experiment = try self.decoder.decode( Experiment.self, from: json.data(using: .utf8)! ) let expected = Experiment( id: "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c", lastUpdated: decoded.lastUpdated, created: decoded.created, reportingMetadata: ["experiment_id" : "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c"], audienceSelector: DeviceAudienceSelector( hashSelector: .init( hash: .init( prefix: "cf9b8c05-05e2-4b8e-a2a3-7ed06d99cc1c:", property: .contact, algorithm: .farm, seed: nil, numberOfBuckets: 16384, overrides: nil), bucket: .init(min: 0, max: 8192)) ), compoundAudience: .init(selector: .atomic(.init(newUser: true))), exclusions: [ .init(messageTypePredicate: try! .fromJson(json: ["value": ["equals": "transactional"]]), campaignsPredicate: nil) ], timeCriteria: .init(start: Date(milliseconds: 1689012595000), end: Date(milliseconds: 1689091608000)) ) let encoded = String(data: try encoder.encode(decoded), encoding: .utf8) XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.from(json: encoded)) XCTAssertEqual(expected, decoded) } } ================================================ FILE: Airship/AirshipCore/Tests/FarmHashFingerprint64Test.swift ================================================ import XCTest @testable import AirshipCore final class FarmHashFingerprint64Test: XCTestCase { private let testData: [String: UInt64] = [ "dXB@tDQ-v5<H]rq2Pcc*s>nC-[Mdy": 8365906589669344754, "!@#$%^&*():=-_][\\|/?.,<> ": 11772040268694734364, "&&3gRU?[^&ok:He[|K:": 11792583603419566171, "9JqLl0AW7e69Y.&vMHQ5C": 2827089714349584095, "F7479877-4690-4A44-AFC9-8FE987EA512F:some_other_id": 6862335115798125349, "hg[F|$D&hb$,V4OeXHOa": 11873385450325105043, "/dWQW6&i7h$1@": 11452602314494946942, "2/?98ns)xbzEVL^:wCS$7l3@_g!zP^<D.-bd6": 9728733090894310797, "?c^6BkI#-SLw": 13133570674398037786, "wE,gHSvhK Jv=KR#(R |!%vctTJ0fx)": 413905253809041649, "5C $WnO2K@:(4#h": 2463546464490189, "Ijiq13Mb_Nn]sA^jhM7eZ\\ExAzSJ": 12345582939087509209, ")D<l91": 6440615040757739207, "mC=6Tz,AYH|&n99(G!6LyG&QfZ=1^:": 10432240328043398052, "7.b^/n=oR_w(vLN?c?xN<5t$p8HY2!s:U": 2506644862971557451, "t,SRdW>l=?AH4\\JQ!.A)Wh,O4\\8": 4614517891525318442, "K6Pjv<>ad": 16506019169567922731, "": 11160318154034397263, "Q": 13816109650407654260, "bF&d$MYIhB.Ac=qC": 17582099205024456557, "#cDR^sLO": 328147020574120176, "NXooOPwHej5=c_V0(47=-)N!vNdd:$fMs1B": 5510670329273533446, "y2=B@rsu:g9bWU": 2493222351070148393, "wi=%v]GoIPI6zm[Rrgmq]7J?.|": 8222623341199956836, "Sl,xx&O^l@=TQ[QI(TJ^aD*PS3.K]@Mk:e)e": 12943788826636368730, "@05Mz\\\\)VhZ\\S&9vVU,egF%sW)IMIGVHE%#I)D|": 134798502762145076, "e#p8": 252499459662674074, ">EtzDE,xUUZ%!aCvx#vyN(][Q.eRQO2sBZCwFH": 5047171257402399197, "ECCD828C-5D7A-4C8B-9A1B-F244747E96C3": 9693850515132481856, "D<wQ1DVVpS": 876767294656777789, ",=": 1326014594494455617, "EsIIjI65<^!j$)V.,!]M]@Q5[$(oyxI_nF": 4212098974972931626, "fDVY|(%&aF#3<l>b?1Y Hqt)qY(0%b@VIk#Rlofs": 1687730506231245221, "^b2z)XYJ\\95": 3150268469206098298, "9>Nleb)=|CR#4=G2&7[HOP": 10511875379468936029, "M)(iJ1-nf>5XCc0L?": 9968500208262240300, "WW5": 6316074107149058620, "ZyzWj:&3hH78.WUCNW4e&Z ": 13218358187761524434, "P9|0-Xg": 15614415556471156694, "n?(o|a[EX|KN-9./=tCVEmN%?<MXe8F<": 1754206644017466002, "&QEO\\": 673322083973757863, "T#e:),mqALpU]hrJ%f.*|=&r": 11789374840096016445, "xi\\PvQpHpM:$5\\Zh^U": 4169389423472268625, "!/tU|0cMaw=/-Yg)m_*4UNvwB": 14890523890635468863 ] private let crossPlatformCases: [String: UInt64] = [ "0376d8dc-a717-425a-9dd2-d4b36bcaddac:65d52a5d-4f88-4a78-97c9-08464d44bbb6": 14340110469024474010, "62087079-4f3b-4350-88a9-67667493a48f:a9689ef1-ef7f-45e7-8841-ba0f6cdc6b4d": 3536341280875387670, "3d50751d-360f-4c37-a818-0e2d7b83a795:962a1b84-8ecc-44d7-95ff-3ec60215075a": 6852554232698863320, "9ecfe0bf-b24b-422f-83e2-e8de9c336493:27abe283-5937-43f0-a4f2-c8ee71f682e8": 16343172889285518932, "601cfb3b-b69f-4b88-b52d-e1fc201df11c:29d407e8-bbc9-4b48-b789-e5af84b22810": 18171507073648632955, "67b9dcdf-4d8d-42ed-a000-bf90c3b47fa8:e90c7986-0231-4c80-a10d-fc60bdf05ebc": 6180626819026048726, "9431edf5-a862-405e-82bd-0f64283304e9:478f73b9-f324-42f6-9d8f-bb0445f11247": 5342572022420056632, "5c24c242-4b81-496d-9d56-31f320d20a26:8155f96f-f3b1-4bcd-8a54-378150ea3d03": 5403761470481847248, "976bddc7-7b5c-48fa-a285-a10dc2d64009:6a4ed766-017e-4b19-9cb2-0299e97a995b": 404533724234009115, "cede4631-b57a-447d-b94b-4c31a71e1f3c:f46f1e00-1e78-4c01-8a89-989106182ecd": 2662685979233479610, "dd464de7-2f57-4787-a14b-35bd57dd515d:2da0af42-35b0-423e-99ed-bc5cb5dd7099": 5656984155782857542, "3f1d41bc-ac7e-49e2-8f88-30744f0fff4e:8561c5d7-cbb1-4b67-afd0-669e369420b6": 3506311998853318899, "c65ef2ae-44b0-4c5e-b37e-57b4fda7ee8e:d4933f58-d257-41ff-b0fa-11aa524d642e": 14192866033732275238, "d19964d4-59e3-49d6-8d61-4dfa97e794f7:6cbc589e-3695-4cc6-afe7-4bcdcea01480": 8310185173796126101, "813c09f5-a0ae-410d-99c6-7bf7e87b2738:c5d50a64-bacf-4887-b3ac-e53d8cdc555b": 15599208209427113891, "a1db3c20-673e-48e4-9967-b49834b6fac6:a3bc58d1-f389-4113-97d1-28d3bf12cbe5": 1700656031758233133, "5b431ab8-975e-4207-8550-62da7665a01b:095c1b48-131e-477c-90d4-17894acc1246": 7441422609642864761, "92f4a2ca-46d5-4e15-87c5-f7b33497286c:7811c125-2348-47a6-84e6-9343bb12a0f7": 592674394864765514, "cbb0399d-a803-4a27-91a0-7732b308278e:f61a366a-7c20-45eb-b40a-bed0b012607c": 492797389607996305, "2c333e41-e702-4096-a71f-8c3df488a990:9c1d45d9-439c-490e-99cc-f159ce7010e7": 764412364649713065, "optic_acquit:eef25358-6577-4b84-bbcd-82c0f2de80e2": 2791352902118037828, "warthog_punts:bbe20d0c-143d-4d1a-8973-182b7d10c7bb": 7332285015592839891, "vanilla_hither:70d7d1ce-09da-468c-9864-d0188f70c1fe": 8273296097385490599, "crepe_frumps:bbe484c8-af06-4477-863e-35cfdb284f71": 8795467158546487560, "clinic_scouts:ec85318f-dd20-4cde-bc81-69bb1e21b12b": 6650034920187666365, "trying_gapped:d72565c0-2d7e-4e37-a309-ad47c9c14da9": 4989233212801864762, "snuffly_pithy:84811fbf-badd-405a-8564-7f354190943f": 15791669038156053022, "graters_fields:37281aec-3848-4ef2-ac7d-e926618865f7": 9056534536604691350, "mirrors_dangs:ddb42326-49f6-40e5-b428-b966f6ab4887": 4084541845741700082, "expend_raying:b3054772-ed90-4a79-8866-4a8753f93d2d": 11334098313106439423, "peewees_autobus:5de6faf8-e039-4b2c-ba37-ccdd0401758e": 1590885424516612823, "giant_boozy:9ceecbc5-0372-4a5c-b12f-4a6d696dece9": 3196424533567189237, "glazers_zagging:74e3f557-3064-4d99-8809-f6b4c897a710": 18418949167652646364, "paces_acuate:4c08c06d-7ddc-4773-8fcb-4833c5a03b36": 13404925037839805568, "makes_coiner:108af86f-b273-463c-96ee-9b4c948e92ac": 13939548535417169537, "patinas_posted:e8ff9cd9-e335-4b6c-93ff-51aba68951c9": 15877907202098665149, "further_agents:4fb082f2-2db8-4cf0-b367-b22ee0e590e1": 16609400165765915699, "hubbubs_parked:b97ff960-af53-42a2-b5fe-9b8e248012f9": 1116732196685121691, "deeply_outworn:ef48d2be-76ef-465d-a993-b92cf8f958ac": 16625481623000662505, "girded_heave:30aebf7f-8a0c-4b89-adbc-b010b0619f94": 4262921022933957472, ] func testKnownOutputs() throws { self.testData.forEach { (key: String, value: UInt64) in XCTAssertEqual(value, key.farmHashFingerprint64) } } func testCrossPlatformCases() throws { self.crossPlatformCases.forEach { (key: String, value: UInt64) in XCTAssertEqual(value, key.farmHashFingerprint64) } } /** * Based on https://github.com/google/guava/blob/master/guava-tests/test/com/google/common/hash/FarmHashFingerprint64Test.java#L38 */ func testReallySimpleFingerprints() throws { XCTAssertEqual( 8581389452482819506, "test".farmHashFingerprint64 ) XCTAssertEqual( UInt64(bitPattern: -4196240717365766262), String(repeating: "test", count: 8).farmHashFingerprint64 ) XCTAssertEqual( 3500507768004279527, String(repeating: "test", count: 64).farmHashFingerprint64 ) } /** * Based on https://github.com/google/guava/blob/master/guava-tests/test/com/google/common/hash/FarmHashFingerprint64Test.java#L158 */ func testMultipleLengths() throws { let iterations = 800 var buf = [UInt8](repeating: 0, count: iterations * 4) var bufLen : Int = 0 var h : UInt64 = 0 for i in 0..<iterations { h ^= FarmHashFingerprint64.fingerprint(buf, i) h = remix(h) buf[bufLen] = getChar(h) bufLen += 1 h ^= FarmHashFingerprint64.fingerprint(buf, i * i % bufLen) h = remix(h) buf[bufLen] = getChar(h) bufLen += 1 h ^= FarmHashFingerprint64.fingerprint(buf, i * i * i % bufLen) h = remix(h) buf[bufLen] = getChar(h) bufLen += 1 h ^= FarmHashFingerprint64.fingerprint(buf, bufLen) h = remix(h) buf[bufLen] = getChar(h) bufLen += 1 let x0 : Int = Int(buf[bufLen - 1]) let x1 : Int = Int(buf[bufLen - 2]) let x2 : Int = Int(buf[bufLen - 3]) let x3 : Int = Int(buf[bufLen / 2]) buf[((x0 << 16) + (x1 << 8) + x2) % bufLen] ^= UInt8(x3) buf[((x1 << 16) + (x2 << 8) + x3) % bufLen] ^= UInt8(i % 256) } XCTAssertEqual(0x7a1d67c50ec7e167, h) } private func remix(_ v: UInt64) -> UInt64 { var h = v h ^= h >> 41 h &*= 949921979 return h } private func getChar(_ h: UInt64) -> UInt8 { return UInt8(0x61/*a*/) + UInt8((h & 0xfffff) % 26) } } ================================================ FILE: Airship/AirshipCore/Tests/FetchDeviceInfoActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class FetchDeviceInfoActionTest: XCTestCase { private let channel = TestChannel() private let contact = TestContact() private let push = TestPush() var action: FetchDeviceInfoAction! override func setUp() async throws { action = FetchDeviceInfoAction( channel: { [channel] in return channel }, contact: { [contact] in return contact }, push: { [push] in return push } ) } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ActionSituation.backgroundInteractiveButton, ActionSituation.backgroundPush ] for situation in validSituations { let args = ActionArguments(value: AirshipJSON.null, situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } } @MainActor func testPerform() async throws { self.channel.identifier = "some-channel-id" self.contact.namedUserID = "some-named-user" self.channel.tags = ["tag1", "tag2", "tag3"] self.push.isPushNotificationsOptedIn = true let actionResult = try await self.action.perform( arguments: ActionArguments( value: AirshipJSON.null, situation: .manualInvocation ) ) let expectedResult = try! AirshipJSON.wrap([ "tags": ["tag1", "tag2", "tag3"], "push_opt_in": true, "named_user": "some-named-user", "channel_id": "some-channel-id" ] as [String : Any]) XCTAssertEqual(actionResult, expectedResult) } } ================================================ FILE: Airship/AirshipCore/Tests/HashCheckerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class HashCheckerTest: XCTestCase { private let cache: TestCache = TestCache() private let testDeviceInfo: TestAudienceDeviceInfoProvider = TestAudienceDeviceInfoProvider() private var checker: HashChecker! override func setUp() async throws { self.checker = HashChecker(cache: cache) } func testStickyCacheMatch() async throws { self.testDeviceInfo.channelID = "some channel" self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") let stickyHash = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000), sticky: AudienceHashSelector.Sticky( id: "sticky ID", reportingMetadata: "sticky reporting", lastAccessTTL: 100.0 ) ) let result = try await checker.evaluate( hashSelector: stickyHash, deviceInfoProvider: self.testDeviceInfo ) XCTAssertEqual( AirshipDeviceAudienceResult( isMatch: true, reportingMetadata: [.string("sticky reporting")] ), result ) let entry = await self.cache.entry(key: "StickyHash:match:some channel:sticky ID")! XCTAssertEqual(entry.ttl, 100.0) let decodedData = try JSONDecoder().decode(AirshipDeviceAudienceResult.self, from: entry.data) XCTAssertEqual(decodedData, result) } func testStickyHashFromCacheStillCaches() async throws { self.testDeviceInfo.channelID = "some channel" self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "match") var stickyHash = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000), sticky: AudienceHashSelector.Sticky( id: "sticky ID", reportingMetadata: "sticky reporting", lastAccessTTL: 100.0 ) ) var result = try await checker.evaluate( hashSelector: stickyHash, deviceInfoProvider: self.testDeviceInfo ) XCTAssertEqual( AirshipDeviceAudienceResult( isMatch: true, reportingMetadata: [.string("sticky reporting")] ), result ) var entry = await self.cache.entry(key: "StickyHash:match:some channel:sticky ID")! XCTAssertEqual(entry.ttl, 100.0) stickyHash.sticky = AudienceHashSelector.Sticky( id: "sticky ID", reportingMetadata: "updated sticky reporting", lastAccessTTL: 50.0 ) result = try await checker.evaluate( hashSelector: stickyHash, deviceInfoProvider: self.testDeviceInfo ) XCTAssertEqual( AirshipDeviceAudienceResult( isMatch: true, reportingMetadata: [.string("sticky reporting")] ), result ) entry = await self.cache.entry(key: "StickyHash:match:some channel:sticky ID")! XCTAssertEqual(entry.ttl, 50.0) } func testStickyCacheMiss() async throws { self.testDeviceInfo.channelID = "some channel" self.testDeviceInfo.stableContactInfo = StableContactInfo(contactID: "not a match") let stickyHash = AudienceHashSelector( hash: AudienceHashSelector.Hash( prefix: "e66a2371-fecf-41de-9238-cb6c28a86cec:", property: .contact, algorithm: .farm, seed: 100, numberOfBuckets: 16384, overrides: nil ), bucket: AudienceHashSelector.Bucket(min: 11600, max: 13000), sticky: AudienceHashSelector.Sticky( id: "sticky ID", reportingMetadata: "sticky reporting", lastAccessTTL: 100.0 ) ) let result = try await checker.evaluate( hashSelector: stickyHash, deviceInfoProvider: self.testDeviceInfo ) XCTAssertEqual( AirshipDeviceAudienceResult( isMatch: false, reportingMetadata: [.string("sticky reporting")] ), result ) let entry = await self.cache.entry(key: "StickyHash:not a match:some channel:sticky ID")! XCTAssertEqual(entry.ttl, 100.0) let decodedData = try JSONDecoder().decode(AirshipDeviceAudienceResult.self, from: entry.data) XCTAssertEqual(decodedData, result) } } ================================================ FILE: Airship/AirshipCore/Tests/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>en</string> <key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundlePackageType</key> <string>BNDL</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>1</string> <key>PROJECT_FILE_PATH</key> <string>$(PROJECT_FILE_PATH)</string> </dict> </plist> ================================================ FILE: Airship/AirshipCore/Tests/Input Validation/AirshipInputValidationTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore struct AirshipInputValidationTest { private let smsValidatorAPIClient = TestSMSValidatorAPIClient() @Test( "Test valid email addresses", arguments: [ "simple@example.com", "very.common@example.com", "disposable.style.email.with+symbol@example.com", "other.email-with-hyphen@example.com", "fully-qualified-domain@example.com", "user.name+tag+sorting@example.com", "x@y.z", "user123@domain.com", "user.name@domain.com", "a@domain.com", "user@sub.domain.com", "user-name@domain.com", "user.@domain.com", ".user@domain.com", "user@.domain.com", "user@domain..com", "user..name@domain.com", "user+name@domain.com", "user!#$%&'*+-/=?^_`{|}~@domain.com" ] ) func testValidEmail(arg: String) async throws { let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) let request = AirshipInputValidation.Request.email( .init(rawInput: arg) ) let result = try await validator.validateRequest(request) #expect(result == .valid(address: arg)) } @Test( "Test invalid emails", arguments: [ "user", "user ", "", "user@", "user@domain", "user @domain.com", "user@ domain.com", "us er@domain.com", "user@do main.com", "user@domain.com.", "user@domain@example.com" ] ) func testInvalidEmails(arg: String) async throws { let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) let request = AirshipInputValidation.Request.email( .init(rawInput: arg) ) let result = try await validator.validateRequest(request) #expect(result == .invalid) } @Test( "Test valid email formatting", arguments: [ " user@domain.com", "user@domain.com ", " user@domain.com " ] ) func testEmailFormatting(arg: String) async throws { let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) let request = AirshipInputValidation.Request.email( .init(rawInput: arg) ) let result = try await validator.validateRequest(request) let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines) #expect(result == .valid(address: trimmed)) } @Test("Test email override.") func testEmailOverride() async throws { let request = AirshipInputValidation.Request.email( .init(rawInput: "some-valid@email.com") ) try await confirmation { confirmation in let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) { arg in #expect(arg == request) confirmation.confirm() return .override(.valid(address: "some other result")) } let result = try await validator.validateRequest(request) #expect(result == .valid(address: "some other result")) } } @Test("Test email override default fallback.") func testEmailOverrideFallback() async throws { let request = AirshipInputValidation.Request.email( .init(rawInput: " some-valid@email.com ") ) try await confirmation { confirmation in let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) { arg in #expect(arg == request) confirmation.confirm() return .useDefault } let result = try await validator.validateRequest(request) #expect(result == .valid(address: "some-valid@email.com")) } } @Test("Test sms validation with sender ID") func testSMSValidationWithSenderID() async throws { let request = AirshipInputValidation.Request.sms( .init( rawInput: "555555555", validationOptions: .sender(senderID: "some sender", prefix: nil) ) ) let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) try await confirmation { confirmation in await smsValidatorAPIClient.setOnValidate { apiRequest in #expect(apiRequest.msisdn == "555555555") #expect(apiRequest.sender == "some sender") confirmation.confirm() return AirshipHTTPResponse( result: .valid("+1555555555"), statusCode: 200, headers: [:] ) } let result = try await validator.validateRequest(request) #expect(result == .valid(address: "+1555555555")) } } @Test("Test sms validation with prefix") func testSMSValidationWithPrefix() async throws { let request = AirshipInputValidation.Request.sms( .init( rawInput: "555555555", validationOptions: .prefix(prefix: "+1") ) ) let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) try await confirmation { confirmation in await smsValidatorAPIClient.setOnValidate { apiRequest in #expect(apiRequest.msisdn == "555555555") #expect(apiRequest.prefix == "+1") confirmation.confirm() return AirshipHTTPResponse( result: .valid("+1555555555"), statusCode: 200, headers: [:] ) } let result = try await validator.validateRequest(request) #expect(result == .valid(address: "+1555555555")) } } @Test("Test sms validation 4xx response should return invalid") func testSMSValidationWith400Response() async throws { let request = AirshipInputValidation.Request.sms( .init( rawInput: "555555555", validationOptions: .sender(senderID: "some sender", prefix: nil) ) ) let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) try await confirmation { confirmation in await smsValidatorAPIClient.setOnValidate { apiRequest in #expect(apiRequest.msisdn == "555555555") #expect(apiRequest.sender == "some sender") confirmation.confirm() return AirshipHTTPResponse( result: nil, statusCode: Int.random(in: 400...499), headers: [:] ) } let result = try await validator.validateRequest(request) #expect(result == .invalid) } } @Test("Test sms validation 5xx should throw") func testSMSValidationWith500Response() async throws { let request = AirshipInputValidation.Request.sms( .init( rawInput: "555555555", validationOptions: .sender(senderID: "some sender", prefix: nil) ) ) let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) await confirmation { confirmation in await smsValidatorAPIClient.setOnValidate { apiRequest in #expect(apiRequest.msisdn == "555555555") #expect(apiRequest.sender == "some sender") confirmation.confirm() return AirshipHTTPResponse( result: nil, statusCode: Int.random(in: 500...599), headers: [:] ) } await #expect(throws: NSError.self) { _ = try await validator.validateRequest(request) } } } @Test("Test sms validation 2xx without a result should throw") func testSMSValidationWith200ResponseNoResult() async throws { let request = AirshipInputValidation.Request.sms( .init( rawInput: "555555555", validationOptions: .sender(senderID: "some sender", prefix: nil) ) ) let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) await confirmation { confirmation in await smsValidatorAPIClient.setOnValidate { apiRequest in #expect(apiRequest.msisdn == "555555555") #expect(apiRequest.sender == "some sender") confirmation.confirm() return AirshipHTTPResponse( result: nil, statusCode: Int.random(in: 200...299), headers: [:] ) } await #expect(throws: NSError.self) { _ = try await validator.validateRequest(request) } } } @Test("Test validation hints are checked before API client") func testValidationHints() async throws { // Setup a valid response await smsValidatorAPIClient.setOnValidate { apiRequest in return AirshipHTTPResponse( result: .valid("+1555555555"), statusCode: 200, headers: [:] ) } let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) // Test 0-3 digits for i in 0...3 { let request = AirshipInputValidation.Request.sms( .init( rawInput: generateRandomNumberString(length: i), validationOptions: .sender(senderID: "some sender", prefix: nil), validationHints: .init(minDigits: 4, maxDigits: 6) ) ) try await #expect(validator.validateRequest(request) == .invalid) } // Test 4-6 digits for i in 4...6 { let request = AirshipInputValidation.Request.sms( .init( rawInput: generateRandomNumberString(length: i), validationOptions: .sender(senderID: "some sender", prefix: nil), validationHints: .init(minDigits: 4, maxDigits: 6) ) ) try await #expect(validator.validateRequest(request) == .valid(address: "+1555555555")) } // Test over 6 digits for i in 7...10 { let request = AirshipInputValidation.Request.sms( .init( rawInput: generateRandomNumberString(length: i), validationOptions: .sender(senderID: "some sender", prefix: nil), validationHints: .init(minDigits: 4, maxDigits: 6) ) ) try await #expect(validator.validateRequest(request) == .invalid) } // Test digits with other characters let request = AirshipInputValidation.Request.sms( .init( rawInput: "a1b2c3d4b5e6", validationOptions: .sender(senderID: "some sender", prefix: nil), validationHints: .init(minDigits: 4, maxDigits: 6) ) ) try await #expect(validator.validateRequest(request) == .valid(address: "+1555555555")) } @Test("Test SMS override.") func testSMSOverride() async throws { let request = AirshipInputValidation.Request.sms( .init( rawInput: "555555555", validationOptions: .sender(senderID: "some sender", prefix: nil) ) ) try await confirmation { confirmation in let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) { arg in #expect(arg == request) confirmation.confirm() return .override(.valid(address: "some other result")) } let result = try await validator.validateRequest(request) #expect(result == .valid(address: "some other result")) } } @Test("Test SMS override default fallback.") func testSMSOverrideFallback() async throws { let request = AirshipInputValidation.Request.sms( .init( rawInput: "555555555", validationOptions: .sender(senderID: "some sender", prefix: nil) ) ) try await confirmation(expectedCount: 2) { confirmation in await smsValidatorAPIClient.setOnValidate { apiRequest in #expect(apiRequest.msisdn == "555555555") #expect(apiRequest.sender == "some sender") confirmation.confirm() return AirshipHTTPResponse( result: .valid("API result"), statusCode: Int.random(in: 200...299), headers: [:] ) } let validator = AirshipInputValidation.DefaultValidator( smsValidatorAPIClient: smsValidatorAPIClient ) { arg in #expect(arg == request) confirmation.confirm() return .useDefault } let result = try await validator.validateRequest(request) #expect(result == .valid(address: "API result")) } } } // Helpers fileprivate extension AirshipInputValidationTest { func generateRandomNumberString(length: Int) -> String { let digits = "0123456789" var result = "" for _ in 0..<length { if let randomCharacter = digits.randomElement() { result.append(randomCharacter) } } return result } } ================================================ FILE: Airship/AirshipCore/Tests/Input Validation/CachingSMSValidatorAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore struct CachingSMSValidatorAPIClientTest { private let testClient: TestSMSValidatorAPIClient private let apiClient: CachingSMSValidatorAPIClient private static let maxCacheEntries: UInt = 5 init() { let testClient = TestSMSValidatorAPIClient() self.testClient = testClient self.apiClient = CachingSMSValidatorAPIClient( client: testClient, maxCachedEntries: Self.maxCacheEntries ) } @Test("Test caches success results for prefix") func testCachesSuccessResultsPrefix() async throws { let successResult = AirshipHTTPResponse( result: SMSValidatorAPIClientResult.valid("valid string"), statusCode: 200, headers: [:] ) await testClient.setOnValidate { _ in return successResult } let msisdn = UUID().uuidString let prefix = UUID().uuidString var result = try await self.apiClient.validateSMS(msisdn: msisdn, prefix: prefix) #expect(result.isSuccess) #expect(result.result == successResult.result) await #expect(testClient.requests.count == 1) // Should be cached result = try await self.apiClient.validateSMS(msisdn: msisdn, prefix: prefix) #expect(result.isSuccess) #expect(result.result == successResult.result) await #expect(testClient.requests.count == 1) } @Test("Test caches success results for sender") func testCachesSuccessResultsSender() async throws { let successResult = AirshipHTTPResponse( result: SMSValidatorAPIClientResult.valid("valid string"), statusCode: 200, headers: [:] ) await testClient.setOnValidate { _ in return successResult } let msisdn = UUID().uuidString let sender = UUID().uuidString var result = try await self.apiClient.validateSMS(msisdn: msisdn, prefix: sender) #expect(result.isSuccess) #expect(result.result == successResult.result) await #expect(testClient.requests.count == 1) // Should be cached result = try await self.apiClient.validateSMS(msisdn: msisdn, prefix: sender) #expect(result.isSuccess) #expect(result.result == successResult.result) await #expect(testClient.requests.count == 1) } @Test("Test caches results for the given parameters even for same msisdn") func testCachesResultForRequestParams() async throws { await testClient.setOnValidate { call in return AirshipHTTPResponse( result: SMSValidatorAPIClientResult.valid(call.msisdn + " valid"), statusCode: 200, headers: [:] ) } let msisdn = UUID().uuidString let prefix = UUID().uuidString let sender = UUID().uuidString var result = try await self.apiClient.validateSMS(msisdn: msisdn, prefix: prefix) #expect(result.isSuccess) #expect(result.result == .valid(msisdn + " valid")) await #expect(testClient.requests.count == 1) // Should not be cached since we are requesting the validation on a sender instead of a prefix result = try await self.apiClient.validateSMS(msisdn: msisdn, sender: sender) #expect(result.isSuccess) #expect(result.result == .valid(msisdn + " valid")) await #expect(testClient.requests.count == 2) let expectedRequests: [TestSMSValidatorAPIClient.Request] = [ .init(msisdn: msisdn, prefix: prefix), .init(msisdn: msisdn, sender: sender) ] await #expect(testClient.requests == expectedRequests) } @Test("Test max cache entries") func testMaxCacheEntries() async throws { await testClient.setOnValidate { call in return AirshipHTTPResponse( result: SMSValidatorAPIClientResult.valid(call.msisdn + " valid"), statusCode: 200, headers: [:] ) } // Fill the cache for i in 1...Self.maxCacheEntries { print("cool i: \(i)") _ = try await self.apiClient.validateSMS( msisdn: UUID().uuidString, prefix: UUID().uuidString ) } await #expect(testClient.requests.count == Self.maxCacheEntries) _ = try await self.apiClient.validateSMS( msisdn: UUID().uuidString, prefix: UUID().uuidString ) await #expect(testClient.requests.count == Self.maxCacheEntries + 1) // Do the second request again, should still be cached _ = try await self.apiClient.validateSMS( msisdn: testClient.requests[1].msisdn, prefix: testClient.requests[1].prefix! ) await #expect(testClient.requests.count == Self.maxCacheEntries + 1) } } ================================================ FILE: Airship/AirshipCore/Tests/Input Validation/SMSValidatorAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore struct SMSValidatorAPIClientTest { private let session: TestAirshipRequestSession private let config: RuntimeConfig private let apiClient: SMSValidatorAPIClient private let msisdn = UUID().uuidString private let sender = UUID().uuidString private let prefix = UUID().uuidString init() { let config = RuntimeConfig.testConfig() let session = TestAirshipRequestSession() self.session = session self.config = RuntimeConfig.testConfig() self.apiClient = SMSValidatorAPIClient(config: config, session: session) } @Test("Test validate SMS with sender") func testSendSMSWithSender() async throws { let expectedRequest = AirshipRequest( url: URL(string: "https://device-api.urbanairship.com/api/channels/sms/format"), headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json" ], method: "POST", auth: .generatedAppToken, body: try JSONEncoder().encode( [ "msisdn": msisdn, "sender": sender ] ) ) _ = try? await apiClient.validateSMS( msisdn: msisdn, sender: sender ) #expect( try requestsMatch(expectedRequest, session.lastRequest) ) } @Test("Test validate SMS with prefix") func testSendSMSWithPrefix() async throws { let expectedRequest = AirshipRequest( url: URL(string: "https://device-api.urbanairship.com/api/channels/sms/format"), headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json" ], method: "POST", auth: .generatedAppToken, body: try JSONEncoder().encode( [ "msisdn": msisdn, "prefix": prefix ] ) ) _ = try? await apiClient.validateSMS( msisdn: msisdn, prefix: prefix ) #expect( try requestsMatch(expectedRequest, session.lastRequest) ) } @Test("Test valid number response parsing.") func testResponseParsing() async throws { self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) self.session.data = try AirshipJSONUtils.data([ "valid": true, "msisdn": msisdn + "valid" ]) let response = try await apiClient.validateSMS( msisdn: msisdn, sender: sender ) #expect(response.isSuccess) #expect(response.result == .valid(msisdn + "valid")) } @Test("Test invalid number response parsing.") func testResponseParsingInvalidNumber() async throws { self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) self.session.data = try AirshipJSONUtils.data([ "valid": false ]) let response = try await apiClient.validateSMS( msisdn: msisdn, sender: sender ) #expect(response.isSuccess) #expect(response.result == .invalid) } private func requestsMatch( _ first: AirshipRequest, _ second: AirshipRequest? ) throws -> Bool { guard let second, first.auth == second.auth, first.contentEncoding == second.contentEncoding, first.headers == second.headers, first.url == second.url, first.method == second.method else { return false } let firstBody = try AirshipJSON.from(data: first.body) let secondBody = try AirshipJSON.from(data: second.body) return firstBody == secondBody } } ================================================ FILE: Airship/AirshipCore/Tests/Input Validation/TestSMSValidatorAPIClient.swift ================================================ /* Copyright Airship and Contributors */ @testable import AirshipCore actor TestSMSValidatorAPIClient: SMSValidatorAPIClientProtocol { struct Request: Sendable, Equatable { var msisdn: String var sender: String? var prefix: String? } private var onValidate: ((Request) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult>)? func setOnValidate(_ onValidate: ((Request) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult>)?) { self.onValidate = onValidate } private(set) var requests: [Request] = [] func validateSMS(msisdn: String, sender: String) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> { let request = Request(msisdn: msisdn, sender: sender) self.requests.append(request) guard let onValidate else { throw AirshipErrors.error("Validator not set") } return try await onValidate(request) } func validateSMS(msisdn: String, prefix: String) async throws -> AirshipHTTPResponse<SMSValidatorAPIClientResult> { let request = Request(msisdn: msisdn, prefix: prefix) self.requests.append(request) guard let onValidate else { throw AirshipErrors.error("Validator not set") } return try await onValidate(request) } } ================================================ FILE: Airship/AirshipCore/Tests/JSONPredicateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class JSONPredicateTest: XCTestCase { var fooMatcher: JSONMatcher! var storyMatcher: JSONMatcher! var stringMatcher: JSONMatcher! override func setUp() { fooMatcher = JSONMatcher(valueMatcher: JSONValueMatcher.matcherWhereStringEquals("bar"), scope: ["foo"]) storyMatcher = JSONMatcher(valueMatcher: JSONValueMatcher.matcherWhereStringEquals("story"), scope: ["cool"]) stringMatcher = JSONMatcher(valueMatcher: JSONValueMatcher.matcherWhereStringEquals("cool")) } func testCodable() throws { let json: String = """ { "or":[ { "value":{ "equals":"bar" }, "scope":[ "foo" ] }, { "value":{ "equals":"story" }, "scope":[ "cool" ] } ] } """ let decoded: JSONPredicate = try JSONDecoder().decode( JSONPredicate.self, from: json.data(using: .utf8)! ) let expected: JSONPredicate = .orPredicate( subpredicates: [ JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: JSONValueMatcher.matcherWhereStringEquals("bar"), scope: ["foo"] ) ), JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: JSONValueMatcher.matcherWhereStringEquals("story"), scope: ["cool"] ) ) ] ) 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 testJSONMatcherPredicate() throws { let predicate = JSONPredicate(jsonMatcher: stringMatcher) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap("cool"))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(predicate))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap("falset cool"))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(true))) } func testJSONMatcherPredicatePayload() throws { let json = ["value": ["equals": "cool"]] let predicate = JSONPredicate(jsonMatcher: stringMatcher) XCTAssertEqual(try AirshipJSON.wrap(json), try AirshipJSON.wrap(predicate)) // Verify the JSONValue recreates the expected payload XCTAssertEqual(predicate, try AirshipJSON.wrap(json).decode()) } func testNotPredicate() throws { let predicate = JSONPredicate.notPredicate(subpredicate: JSONPredicate(jsonMatcher: stringMatcher)) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap("cool"))) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap("no cool"))) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(true))) } func testNotPredicatePayload() throws { let json = [ "not": [ ["value": ["equals": "cool" ]] ] ] let predicate = JSONPredicate.notPredicate(subpredicate: JSONPredicate(jsonMatcher: stringMatcher)) XCTAssertEqual(try AirshipJSON.wrap(json), try AirshipJSON.wrap(predicate)) // Verify the JSONValue recreates the expected payload XCTAssertEqual(predicate, try AirshipJSON.wrap(json).decode()) } func testJSONPredicateNotNoArray() throws { let json: String = """ { "not": { "value":{ "equals":"bar" }, "scope":[ "foo" ] } } """ let decoded: JSONPredicate = try JSONDecoder().decode( JSONPredicate.self, from: json.data(using: .utf8)! ) let expected: JSONPredicate = .notPredicate( subpredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: JSONValueMatcher.matcherWhereStringEquals("bar"), scope: ["foo"] ) ) ) XCTAssertEqual(decoded, expected) } func testJSONPredicateNotWithArray() throws { let json: String = """ { "not": [{ "value":{ "equals":"bar" }, "scope":[ "foo" ] }] } """ let decoded: JSONPredicate = try JSONDecoder().decode( JSONPredicate.self, from: json.data(using: .utf8)! ) let expected: JSONPredicate = .notPredicate( subpredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: JSONValueMatcher.matcherWhereStringEquals("bar"), scope: ["foo"] ) ) ) XCTAssertEqual(decoded, expected) } func testJSONPredicateNotWithArrayMultipleElements() throws { let json: String = """ { "not":[ { "value":{ "equals":"bar" }, "scope":[ "foo" ] }, { "value":{ "equals":"bar" }, "scope":[ "foo" ] } ] } """ do { _ = try JSONDecoder().decode( JSONPredicate.self, from: json.data(using: .utf8)! ) XCTFail("shoudl throw") } catch { } } func testJSONPredicateArrayLength() throws { // This JSON is flawed as you cant have an array of matchers for value. However it shows // order of matcher parsing and its the same test on web, so we are using it. let json: String = """ { "value": { "array_contains": { "value": { "equals": 2, }, }, "array_length": { "value": { "equals": 1, }, }, }, } """ let predicate: JSONPredicate = try JSONDecoder().decode( JSONPredicate.self, from: json.data(using: .utf8)! ) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap([2]))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap([0, 1, 2]))) } func testAndPredicate() throws { let fooPredicate = JSONPredicate(jsonMatcher: fooMatcher) let storyPredicate = JSONPredicate(jsonMatcher: storyMatcher) let predicate = JSONPredicate.andPredicate(subpredicates: [fooPredicate, storyPredicate]) var payload: [String: String] = ["foo": "bar", "cool": "story"] XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["foo": "bar", "cool": "story", "something": "else"] XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["foo": "bar", "cool": "book"] XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["foo": "bar"] XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["cool": "story"] XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(payload))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(predicate))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap("bar"))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(true))) } func testAndPredicatePayload() throws { let json = [ "and": [ ["value": ["equals": "bar"], "scope": ["foo"]], ["value": ["equals": "story"], "scope": ["cool"]], ] ] let fooPredicate = JSONPredicate(jsonMatcher: fooMatcher) let storyPredicate = JSONPredicate(jsonMatcher: storyMatcher) let predicate = JSONPredicate.andPredicate(subpredicates: [fooPredicate, storyPredicate]) XCTAssertEqual(try AirshipJSON.wrap(json), try AirshipJSON.wrap(predicate)) // Verify the JSONValue recreates the expected payload XCTAssertEqual(predicate, try AirshipJSON.wrap(json).decode()) } func testOrPredicate() throws { let fooPredicate = JSONPredicate(jsonMatcher: fooMatcher) let storyPredicate = JSONPredicate(jsonMatcher: storyMatcher) let predicate = JSONPredicate.orPredicate(subpredicates: [fooPredicate, storyPredicate]) var payload: [String: String] = ["foo": "bar", "cool": "story"] XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["foo": "bar", "cool": "story", "something": "else"] XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["foo": "bar"] XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["cool": "story"] XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(payload))) payload = ["foo": "falset bar", "cool": "book"] XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(payload))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(predicate))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap("bar"))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(true))) } func testOrPredicatePayload() throws { let json = [ "or": [ ["value": ["equals": "bar"], "scope": ["foo"]], ["value": ["equals": "story"], "scope": ["cool"]], ] ] let fooPredicate = JSONPredicate(jsonMatcher: fooMatcher) let storyPredicate = JSONPredicate(jsonMatcher: storyMatcher) let predicate = JSONPredicate.orPredicate(subpredicates: [fooPredicate, storyPredicate]) XCTAssertEqual(try AirshipJSON.wrap(json), try AirshipJSON.wrap(predicate)) // Verify the JSONValue recreates the expected payload XCTAssertEqual(predicate, try AirshipJSON.wrap(json).decode()) } func testEqualArray() throws { let json = ["value": [ "equals": ["cool", "story"]]] let predicate = try JSONPredicate(json: json) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(["cool", "story"]))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(["cool"]))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(["cool", "story", "afalsether key"]))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(predicate))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap("bar"))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(true))) } func testEqualObject() throws { let json = ["value": [ "equals": [ "cool": "story" ] ]] let predicate = try JSONPredicate(json: json) XCTAssertTrue(predicate.evaluate(json: try AirshipJSON.wrap(["cool": "story"]))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(["cool": "story?"]))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(["cool": "story", "afalsether_key": "afalsether_value"]))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(predicate))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap("bar"))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(predicate.evaluate(json: try AirshipJSON.wrap(true))) } func testInvalidPayload() throws { // Invalid type var json: [String: Any] = [ "what": [ ["value": [ "equals": "bar" ], "key": "foo"], ["value": [ "equals": "story" ], "key": "cool"] ] ] XCTAssertThrowsError(try JSONPredicate(json: json)) // Invalid key value json = [ "or": [ "not_cool", ["value": ["equals": "story"], "key": "cool" ] ] ] XCTAssertThrowsError(try JSONPredicate(json: json)) // Invalid object XCTAssertThrowsError(try JSONPredicate(json: "not cool")) } } ================================================ FILE: Airship/AirshipCore/Tests/JavaScriptCommandTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore final class JavaScriptCommandTest: XCTestCase { func testCommandForURL() { let URL = URL(string: "uairship://whatever/argument-one/argument-two?foo=bar&foo=barbar&foo")! let command = JavaScriptCommand(url: URL) XCTAssertNotNil(command, "data should be non-nil") XCTAssertEqual(command.arguments.count, 2, "data should have two arguments") XCTAssertEqual(command.arguments.first, "argument-one", "first arg should be 'argument-one'") XCTAssertEqual(command.arguments[1], "argument-two", "second arg should be 'argument-two'") let expectedValues = ["bar", "barbar", ""] XCTAssertEqual(command.options["foo"], expectedValues, "key 'foo' should have values 'bar', 'barbar', and ''") } func testCommandForURLSlashBeforeArgs() { let URL = URL(string: "uairship://whatever/?foo=bar")! let command = JavaScriptCommand(url: URL) XCTAssertNotNil(command, "data should be non-nil") XCTAssertEqual(command.arguments.count, 0, "data should have no arguments") XCTAssertEqual(command.options["foo"], ["bar"], "key 'foo' should have values 'bar'") } func testCallDataForURLEncodedArguments() { let URL = URL(string: "uairship://run-action-cb/%5Eu/%22https%3A%2F%2Fdocs.urbanairship.com%2Fengage%2Frich-content-editor%2F%23rich-content-image%22/ua-cb-2?query%20argument=%5E")! let command = JavaScriptCommand(url: URL) XCTAssertEqual(command.arguments.count, 3) XCTAssertEqual(command.arguments[0], "^u") XCTAssertEqual(command.arguments[1], "\"https://docs.urbanairship.com/engage/rich-content-editor/#rich-content-image\"") XCTAssertEqual(command.arguments[2], "ua-cb-2") XCTAssertEqual(command.options["query argument"], ["^"]) } } ================================================ FILE: Airship/AirshipCore/Tests/JsonMatcherTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class JsonMatcherTest: XCTestCase { var subject = JSONValueMatcher.matcherWhereStringEquals("cool") func testMatcherOnly() throws { let matcher = JSONMatcher(valueMatcher: subject) XCTAssertNotNil(matcher) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap("cool"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(true))) } func testMatcherOnlyIgnoreCase() throws { let matcher = JSONMatcher(valueMatcher: subject, ignoreCase: true) XCTAssertNotNil(matcher) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap("cool"))) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap("COOL"))) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap("CooL"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap("NOT COOL"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "cool"]))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(true))) } func testMatcherOnlyPayload() throws { let json = """ { "value": { "equals": "cool" } } """ let matcher = JSONMatcher(valueMatcher: subject) XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.wrap(matcher)) let fromJSON: JSONMatcher = try AirshipJSON.from(json: json).decode() XCTAssertEqual(matcher, fromJSON) } func testMatcherOnlyIgnoreCasePayload() throws { let json = """ { "value": { "equals": "cool" }, "ignore_case": true } """ let matcher = JSONMatcher(valueMatcher: subject, ignoreCase: true) XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.wrap(matcher)) // Verify a matcher created from the JSON matches var fromJsonMatcher: JSONMatcher = try AirshipJSON.from(json: json).decode() XCTAssertNotNil(fromJsonMatcher) XCTAssertEqual(fromJsonMatcher, matcher) // Verify a matcher created from the JSON from the first matcher matches fromJsonMatcher = try AirshipJSON.wrap(matcher).decode() XCTAssertNotNil(fromJsonMatcher) XCTAssertEqual(fromJsonMatcher, matcher) } func testMatcherOnlyPayloadWithUnknownKey() throws { let json = """ { "value": { "equals": "cool" }, "unknown": true } """ let matcher = JSONMatcher(valueMatcher: subject) XCTAssertNotNil(matcher) // Verify a matcher created from the JSON matches var fromJsonMatcher: JSONMatcher = try AirshipJSON.from(json: json).decode() XCTAssertNotNil(fromJsonMatcher) XCTAssertEqual(fromJsonMatcher, matcher) // Verify a matcher created from the JSON from the first matcher matches fromJsonMatcher = try AirshipJSON.wrap(matcher).decode() XCTAssertNotNil(fromJsonMatcher) XCTAssertEqual(fromJsonMatcher, matcher) } func testMatcherWithKey() throws { let matcher = JSONMatcher(valueMatcher: subject, scope: ["property"]) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "cool"]))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap("property"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "not cool"]))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(true))) } func testMatcherWithScopeIgnoreCase() throws { let matcher = JSONMatcher(valueMatcher: subject, scope: ["property"], ignoreCase: true) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "cool"]))) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "COOL"]))) XCTAssertTrue(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "CooL"]))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap("property"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "not cool"]))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(["property": "NOT COOL"]))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try! AirshipJSON.wrap(true))) } func testScopeAsString() throws { let json = """ { "value": { "equals": "cool" }, "key": "subproperty", "scope": ["property"] } """ let fromJSON: JSONMatcher = try AirshipJSON.from(json: json).decode() XCTAssertEqual(try AirshipJSON.from(json: json), try AirshipJSON.wrap(fromJSON)) } func testInvalidKey() { // Invalid key value let json = """ { "value": { "equals": "cool" }, "key": 123, "scope": ["property"] } """ do { let _: JSONMatcher = try AirshipJSON.from(json: json).decode() XCTFail() } catch {} } func testInvalidPayload() { let json = """ { "not": "cool" } """ do { let _: JSONMatcher = try AirshipJSON.from(json: json).decode() XCTFail() } catch {} } } ================================================ FILE: Airship/AirshipCore/Tests/JsonValueMatcherTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class JsonValueMatcherTest: XCTestCase { func testEqualsString() throws { let matcher = JSONValueMatcher.matcherWhereStringEquals("cool") XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("cool"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("cool"), ignoreCase:false)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("cool"), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("COOL"), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("CooL"), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("COOL"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("CooL"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("NOT COOL"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil), ignoreCase: false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("COOL"), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("CooL"), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("NOT COOL"), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(1), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("NOT COOL"), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(1), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true), ignoreCase:true)) } func testEqualsStringPayload() throws { let json = """ { "equals": "cool" } """ let matcher = JSONValueMatcher.matcherWhereStringEquals("cool") // Verify the JSONValue recreates the expected matcher XCTAssertEqual(matcher, try AirshipJSON.from(json: json).decode()) } func testEqualsBoolean() throws { let matcher = JSONValueMatcher.matcherWhereBooleanEquals(false) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(false))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(false), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(false), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true), ignoreCase:false)) } func testEqualsBooleanPayload() throws { let json = """ { "equals": true } """ let matcher = JSONValueMatcher.matcherWhereBooleanEquals(true) // Verify the JSONValue recreates the expected matcher XCTAssertEqual(matcher, try AirshipJSON.from(json: json).decode()) } func testEqualsNumber() throws { let matcher = JSONValueMatcher.matcherWhereNumberEquals(to: 123.35) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.35))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.350))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.350), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.350), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.3))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.3), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.3), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true))) } func testEqualsNumberPayload() throws { let json = """ { "equals": 123.456 } """ let match = JSONValueMatcher.matcherWhereNumberEquals(to: 123.456) // Verify the JSONValue recreates the expected matcher XCTAssertEqual(match, try AirshipJSON.from(json: json).decode()) } func testAtLeast() throws { let matcher = JSONValueMatcher.matcherWhereNumberAtLeast(123.35) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.35))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.36))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.36), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.36), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.3))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.3), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.3), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(true))) } func testAtLeastPayload() throws { let json = """ { "at_least": 100 } """ let matcher = JSONValueMatcher.matcherWhereNumberAtLeast(100) // Verify the JSONValue recreates the expected matcher XCTAssertEqual(matcher, try AirshipJSON.from(json: json).decode()) } func testAtMost() throws { let matcher = JSONValueMatcher.matcherWhereNumberAtMost(123.35) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.35))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.34))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.34), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.34), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.36))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.36), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(123.36), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(124))) } func testAtMostPayload() throws { let json = """ { "at_most": 100 } """ let matcher = JSONValueMatcher.matcherWhereNumberAtMost(100) // Verify the JSONValue recreates the expected matcher XCTAssertEqual(matcher, try AirshipJSON.from(json: json).decode()) } func testAtLeastAtMost() throws { let matcher = JSONValueMatcher.matcherWhereNumberAtLeast(100, atMost: 150) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(100))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(150))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.456))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.456), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(123.456), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("not cool"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(99))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(151))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(151), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(151), ignoreCase:false)) } func testAtLeastAtMostPayload() throws { let json = """ { "at_least": 1, "at_most": 100 } """ let matcher = JSONValueMatcher.matcherWhereNumberAtLeast(1, atMost: 100) // Verify the JSONValue recreates the expected matcher XCTAssertEqual(matcher, try AirshipJSON.from(json: json).decode()) } func testPresence() throws { let matcher = JSONValueMatcher.matcherWhereValueIsPresent(true) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(100))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("cool"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("cool"), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil), ignoreCase:false)) } func testPresencePayload() throws { let json = """ { "is_present": true } """ let matcher = JSONValueMatcher.matcherWhereValueIsPresent(true) // Verify the JSONValue recreates the expected matcher XCTAssertEqual(matcher, try AirshipJSON.from(json: json).decode()) } func testAbsence() throws { let matcher = JSONValueMatcher.matcherWhereValueIsPresent(false) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(nil))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(nil), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(nil), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(100))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(matcher))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("cool"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("cool"), ignoreCase:true)) } func testAbsencePayload() throws { let json = """ { "is_present": false } """ let matcher = JSONValueMatcher.matcherWhereValueIsPresent(false) // Verify the JSONValue recreates the expected matcher XCTAssertEqual(matcher, try AirshipJSON.from(json: json).decode()) } func testVersionRangeConstraints() throws { var matcher = JSONValueMatcher.matcherWithVersionConstraint("1.0")! XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0"), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(" 2.0 "))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(" 2.0 "), ignoreCase:true)) matcher = JSONValueMatcher.matcherWithVersionConstraint("1.0+")! XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0.0"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0"), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("2"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("2"), ignoreCase:true)) matcher = JSONValueMatcher.matcherWithVersionConstraint("[1.0,2.0]")! XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0.0"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("1.0"), ignoreCase:true)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("2.0.0"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("2.0.1"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("2.0.1"), ignoreCase:true)) } func testArrayContains() throws { let valueMatcher = JSONValueMatcher.matcherWhereStringEquals("bingo") var jsonMatcher = JSONMatcher(valueMatcher: valueMatcher) var predicate = JSONPredicate(jsonMatcher: jsonMatcher) var matcher = JSONValueMatcher.matcherWithArrayContainsPredicate(predicate)! // Invalid values XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("1.0"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(["bingo": "what"]))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(["BINGO": "what"]), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) var value = ["thats", "a", "BINGO"] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:true)) value = ["thats", "a"] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) value = [] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) // Valid values value = ["thats", "a", "bingo"] XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:false)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:true)) // ignore case jsonMatcher = JSONMatcher(valueMatcher: valueMatcher, ignoreCase: true) predicate = JSONPredicate(jsonMatcher: jsonMatcher) matcher = JSONValueMatcher.matcherWithArrayContainsPredicate(predicate)! value = ["thats", "a", "BINGO"] XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:false)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:true)) } func testArrayContainsAtIndex() throws { let valueMatcher = JSONValueMatcher.matcherWhereStringEquals("bingo") var jsonMatcher = JSONMatcher(valueMatcher: valueMatcher) var predicate = JSONPredicate(jsonMatcher: jsonMatcher) var matcher = JSONValueMatcher.matcherWithArrayContainsPredicate(predicate, at: 1)! // Invalid values XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("1.0"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(["bingo": "what"]))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(["bingo": "what"]), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(1))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(nil))) var value = ["thats", "a", "BINGO"] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:true)) value = ["thats", "a"] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) value = [] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) value = ["thats", "BINGO", "a"] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:true)) // Valid values value = ["thats", "bingo", "a"] XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value))) value = ["thats", "bingo"] XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value))) value = ["a", "bingo"] XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value))) // ignore case jsonMatcher = JSONMatcher(valueMatcher: valueMatcher, ignoreCase: true) predicate = JSONPredicate(jsonMatcher: jsonMatcher) matcher = JSONValueMatcher.matcherWithArrayContainsPredicate(predicate, at: 1)! value = ["thats", "a", "BINGO"] XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:false)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:true)) value = ["thats", "BINGO", "a"] XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:false)) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap(value), ignoreCase:true)) } func testVersionMatcher() throws { let jsonV9 = """ { "version_matches": "9.9" } """ let jsonV8 = """ { "version_matches": "8.9" } """ var matcher: JSONValueMatcher = try AirshipJSON.from(json: jsonV9).decode() XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("9.0"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("9.9"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("9.9"), ignoreCase:true)) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("10.0"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("10.0"), ignoreCase:true)) matcher = try AirshipJSON.from(json: jsonV8).decode() XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("8.0"))) XCTAssertTrue(matcher.evaluate(json: try AirshipJSON.wrap("8.9"))) XCTAssertFalse(matcher.evaluate(json: try AirshipJSON.wrap("9.0"))) } func testInvalidPayload() { let invalid = """ { "cool": "neat" } """ // Invalid object do { let _: JSONValueMatcher = try AirshipJSON.from(json: invalid).decode() XCTFail() } catch { } } func testStringBeginsMatcherParsing() throws { let json = """ { "string_begins": "neat" } """ let fromJSON: JSONValueMatcher = try AirshipJSON.from(json: json).decode() let expected = JSONValueMatcher( predicate: JSONValueMatcher.StringBeginsPredicate(stringBegins: "neat") ) XCTAssertEqual(fromJSON, expected) } func testStringEndsMatcherParsing() throws { let json = """ { "string_ends": "neat" } """ let fromJSON: JSONValueMatcher = try AirshipJSON.from(json: json).decode() let expected = JSONValueMatcher( predicate: JSONValueMatcher.StringEndsPredicate(stringEnds: "neat") ) XCTAssertEqual(fromJSON, expected) } func testStringContainsMatcherParsing() throws { let json = """ { "string_contains": "neat" } """ let fromJSON: JSONValueMatcher = try AirshipJSON.from(json: json).decode() let expected = JSONValueMatcher( predicate: JSONValueMatcher.StringContainsPredicate(stringContains: "neat") ) XCTAssertEqual(fromJSON, expected) } func testStringBeginsMatcher() throws { let matcher = JSONValueMatcher( predicate: JSONValueMatcher.StringBeginsPredicate(stringBegins: "foo") ) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("foobar"))) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("FOOBAR"), ignoreCase: true)) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("FOOBAR"))) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("barfoo"))) } func testStringEndsMatcher() throws { let matcher = JSONValueMatcher( predicate: JSONValueMatcher.StringEndsPredicate(stringEnds: "bar") ) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("foobar"))) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("FOOBAR"), ignoreCase: true)) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("FOOBAR"))) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("barfoo"))) } func testStringContainsMatcher() throws { let matcher = JSONValueMatcher( predicate: JSONValueMatcher.StringContainsPredicate(stringContains: "oob") ) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("foobar"))) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("FOOBAR"), ignoreCase: true)) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("FOOBAR"))) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("barfoo"))) } func testStringEndsMatcherEdgeCase() throws { let matcher = JSONValueMatcher( predicate: JSONValueMatcher.StringEndsPredicate(stringEnds: "i") ) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("fooİ"))) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("fooİ"), ignoreCase: true)) } func testStringBeginsMatcherEdgeCase() throws { let matcher = JSONValueMatcher( predicate: JSONValueMatcher.StringBeginsPredicate(stringBegins: "i") ) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("İfoo"))) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("İfoo"), ignoreCase: true)) } func testStringContainsMatcherEdgeCase() throws { let matcher = JSONValueMatcher( predicate: JSONValueMatcher.StringContainsPredicate(stringContains: "i") ) XCTAssertFalse(matcher.evaluate(json: AirshipJSON.string("fooİẞar"))) XCTAssertTrue(matcher.evaluate(json: AirshipJSON.string("FOOİẞAR"), ignoreCase: true)) } } ================================================ FILE: Airship/AirshipCore/Tests/LayoutModelsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class LayoutModelsTest: XCTestCase { func testSize() throws { let json = """ { "presentation": { "type": "modal", "default_placement": { "size": { "width": "60%", "height": "60%" }, "placement": { "horizontal": "center", "vertical": "center" } } }, "version": 1, "view": { "type": "container", "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "75%" }, "view": { "type": "empty_view" } } ] } } """ let layout = try! self.decode(json.data(using: .utf8)!) guard case .container(let container) = layout.view else { XCTFail() return } let size = container.properties.items.first?.size XCTAssertEqual(ThomasSizeConstraint.auto, size?.height) XCTAssertEqual(ThomasSizeConstraint.percent(75), size?.width) } func testComplexExample() throws { let json = """ { "presentation": { "type": "modal", "default_placement": { "size": { "width": "60%", "height": "60%" }, "placement": { "horizontal": "center", "vertical": "center" } } }, "version": 1, "view": { "type": "container", "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "margin": { "top": 0, "bottom": 0, "start": 16, "end": 16 }, "size": { "width": "100%", "height": "auto" }, "view": { "type": "label_button", "identifier": "BUTTON", "background_color": { "default": { "hex": "#FF00FF" } }, "label": { "type": "label", "text_appearance": { "font_size": 24, "alignment": "center", "text_styles": [ "bold", "italic", "underlined" ], "font_families": [ "permanent_marker" ], "color": { "default": { "hex": "#FF00FF"} } }, "text": "NO" } } } ] } } ] } } """ let layout = try self.decode(json.data(using: .utf8)!) XCTAssertNotNil(layout) } private func decode(_ data: Data) throws -> AirshipLayout { try JSONDecoder().decode(AirshipLayout.self, from: data) } } ================================================ FILE: Airship/AirshipCore/Tests/LiveActivityRegistryTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class LiveActivityRegistryTest: XCTestCase { let date: UATestDate = UATestDate() let dataStore = PreferenceDataStore(appKey: UUID().uuidString) var registry: LiveActivityRegistry! var tracker = TestPushToStartTracker() override func setUpWithError() throws { self.date.dateOverride = Date(timeIntervalSince1970: 0) self.registry = LiveActivityRegistry( dataStore: self.dataStore, date: self.date ) } func testAdd() async throws { let activity = TestLiveActivity("foo id") await self.registry.addLiveActivity(activity, name: "foo") self.date.offset += 1.0 activity.pushTokenString = "foo token" await assertUpdate( LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo id", name: "foo", startTimeMS: 0), actionTimeMS: 1000, token: "foo token" ) ) self.date.offset += 1.0 activity.isUpdatable = false await assertUpdate( LiveActivityUpdate( action: .remove, source: .liveActivity(id: "foo id", name: "foo", startTimeMS: 0), actionTimeMS: 2000 ) ) } func testReplace() async throws { let activityFirst = TestLiveActivity("first id") activityFirst.pushTokenString = "first token" await self.registry.addLiveActivity(activityFirst, name: "foo") await assertUpdate( LiveActivityUpdate( action: .set, source: .liveActivity(id: "first id", name: "foo", startTimeMS: 0), actionTimeMS: 0, token: "first token" ) ) let activitySecond = TestLiveActivity("second id") await self.registry.addLiveActivity(activitySecond, name: "foo") await assertUpdate( LiveActivityUpdate( action: .remove, source: .liveActivity(id: "first id", name: "foo", startTimeMS: 0), actionTimeMS: 0 ) ) } func testRestore() async throws { var activity = TestLiveActivity("foo id") await self.registry.addLiveActivity(activity, name: "foo") // Recreate it self.registry = LiveActivityRegistry( dataStore: self.dataStore, date: self.date ) activity = TestLiveActivity("foo id") await self.registry.restoreTracking(activities: [activity], startTokenTrackers: []) activity.pushTokenString = "neat" await assertUpdate( LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo id", name: "foo", startTimeMS: 0), actionTimeMS: 0, token: "neat" ) ) } func testRestoreEmitsStartTokenEvent() async throws { tracker.token = "activity-token" await self.registry.restoreTracking(activities: [], startTokenTrackers: [tracker]) await assertUpdate(LiveActivityUpdate( action: .set, source: .startToken(attributeType: "TestPushToStartTracker"), actionTimeMS: 0, token: "activity-token" )) // Recreate it self.registry = LiveActivityRegistry( dataStore: self.dataStore, date: self.date ) await self.registry.restoreTracking(activities: [], startTokenTrackers: []) await assertUpdate(LiveActivityUpdate( action: .remove, source: .startToken(attributeType: "TestPushToStartTracker"), actionTimeMS: 0 )) } func testRestoreResendsStaleTokens() async throws { tracker.token = "activity-token" await self.registry.restoreTracking(activities: [], startTokenTrackers: [tracker]) await assertUpdate(LiveActivityUpdate( action: .set, source: .startToken(attributeType: "TestPushToStartTracker"), actionTimeMS: 0, token: "activity-token" )) self.date.offset = 172800 + 2 // Recreate it self.registry = LiveActivityRegistry( dataStore: self.dataStore, date: self.date ) await self.registry.restoreTracking(activities: [], startTokenTrackers: [tracker]) await assertUpdate( LiveActivityUpdate( action: .set, source: .startToken(attributeType: "TestPushToStartTracker"), actionTimeMS: 172802000, token: "activity-token" ) ) } func testCleareUntracked() async throws { let activity = TestLiveActivity("foo id") activity.pushTokenString = "neat" await self.registry.addLiveActivity(activity, name: "foo") await assertUpdate( LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo id", name: "foo", startTimeMS: 0), actionTimeMS: 0, token: "neat" ) ) // Recreate it self.registry = LiveActivityRegistry( dataStore: self.dataStore, date: self.date ) self.date.offset += 3 await self.registry.restoreTracking(activities: [], startTokenTrackers: []) await assertUpdate( LiveActivityUpdate( action: .remove, source: .liveActivity(id: "foo id", name: "foo", startTimeMS: 0), actionTimeMS: 3000 ) ) } func testCleareUntrackedMaxActionTime() async throws { let activity = TestLiveActivity("foo id") activity.pushTokenString = "neat" await self.registry.addLiveActivity(activity, name: "foo") await assertUpdate( LiveActivityUpdate( action: .set, source: .liveActivity(id: "foo id", name: "foo", startTimeMS: 0), actionTimeMS: 0, token: "neat" ) ) // Recreate it self.registry = LiveActivityRegistry( dataStore: self.dataStore, date: self.date ) self.date.offset += 28800.1 // 8 hours and .1 second await self.registry.restoreTracking(activities: [], startTokenTrackers: []) await assertUpdate( LiveActivityUpdate( action: .remove, source: .liveActivity(id: "foo id", name: "foo", startTimeMS: 0), actionTimeMS: 2_880_0000 // 8 hours ) ) } @available(iOS 16.1, *) public func testRegistrationStatusByID() async { // notTracked var updates = registry.registrationUpdates(name: nil, id: "some-id").makeAsyncIterator() var status = await updates.next() XCTAssertEqual(status, .notTracked) let activity = TestLiveActivity("some-id") await self.registry.addLiveActivity(activity, name: "some-name") // pending status = await updates.next() XCTAssertEqual(status, .pending) await self.registry.updatesProcessed( updates: [ LiveActivityUpdate( action: .set, source: .liveActivity(id: "some-id", name: "some-name", startTimeMS: 100), actionTimeMS: 100 ) ] ) // registered status = await updates.next() XCTAssertEqual(status, .registered) // Register an activity over it let otherActivity = TestLiveActivity("some-other-id") await self.registry.addLiveActivity(otherActivity, name: "some-name") // notTracked since its by ID and has been replaced status = await updates.next() XCTAssertEqual(status, .notTracked) } @available(iOS 16.1, *) public func testRegistrationStatusByName() async { // notTracked var updates = registry.registrationUpdates(name: "some-name", id: nil).makeAsyncIterator() var status = await updates.next() XCTAssertEqual(status, .notTracked) let activity = TestLiveActivity("some-id") await self.registry.addLiveActivity(activity, name: "some-name") // pending status = await updates.next() XCTAssertEqual(status, .pending) await self.registry.updatesProcessed( updates: [ LiveActivityUpdate( action: .set, source: .liveActivity(id: "some-id", name: "some-name", startTimeMS: 100), actionTimeMS: 100 ) ] ) // registered status = await updates.next() XCTAssertEqual(status, .registered) let otherActivity = TestLiveActivity("some-other-id") await self.registry.addLiveActivity(otherActivity, name: "some-name") // pending since its by name status = await updates.next() XCTAssertEqual(status, .pending) } @available(iOS 16.1, *) public func testRegistrationStatus() async { // Not tracked var updates = registry.registrationUpdates(name: "some-name", id: nil).makeAsyncIterator() var status = await updates.next() XCTAssertEqual(status, .notTracked) let activity = TestLiveActivity("some-id") await self.registry.addLiveActivity(activity, name: "some-name") // pending status = await updates.next() XCTAssertEqual(status, .pending) await self.registry.updatesProcessed( updates: [ LiveActivityUpdate( action: .set, source: .liveActivity(id: "some-id", name: "some-name", startTimeMS: 100), actionTimeMS: 100 ) ] ) // registered status = await updates.next() XCTAssertEqual(status, .registered) } @available(iOS 16.1, *) public func testStatusPending() async { let activity = TestLiveActivity("foo id") await self.registry.addLiveActivity(activity, name: "foo") var updates = registry.registrationUpdates(name: "foo", id: nil).makeAsyncIterator() let status = await updates.next() XCTAssertEqual(status, .pending) } func testLiveUpdateV1Restoring() throws { let payload: [String: Any] = [ "id": "test-id", "action": "set", "name": "update-name", "token": "some token", "action_ts_ms": 123, "start_ts_ms": 100 ] let updateToken = try decode(payload) let expected = LiveActivityUpdate( action: .set, source: .liveActivity( id: "test-id", name: "update-name", startTimeMS: 100 ), actionTimeMS: 123, token: "some token" ) XCTAssertEqual(updateToken, expected) } func testLiveUpdateV2RestoringUpdateToken() throws { let payload: [String: Any] = [ "id": "test-id", "action": "set", "name": "update-name", "token": "some token", "action_ts_ms": 123, "start_ts_ms": 100, "type": "update_token" ] let updateToken = try decode(payload) let expected = LiveActivityUpdate( action: .set, source: .liveActivity( id: "test-id", name: "update-name", startTimeMS: 100 ), actionTimeMS: 123, token: "some token" ) XCTAssertEqual(updateToken, expected) } func testLiveUpdateV2RestoringStartToken() throws { let payload: [String: Any] = [ "action": "set", "token": "some token", "action_ts_ms": 123, "attributes_type": "test-attribute types", "type": "start_token" ] let startToken = try decode(payload) let expected = LiveActivityUpdate( action: .set, source: .startToken(attributeType: "test-attribute types"), actionTimeMS: 123, token: "some token" ) XCTAssertEqual(startToken, expected) } private func decode(_ dict: [String: Any]) throws -> LiveActivityUpdate { let data = try JSONSerialization.data(withJSONObject: dict) return try JSONDecoder().decode(LiveActivityUpdate.self, from: data) } private func assertUpdate( _ update: LiveActivityUpdate, file: StaticString = #filePath, line: UInt = #line ) async { let next = await self.registry.updates.first(where: { _ in true }) XCTAssertEqual(update, next, file: file, line: line) } } /// Tried to match as closely as I coudl to the real object private final class TestLiveActivity: LiveActivityProtocol, @unchecked Sendable { let id: String var isUpdatable: Bool = true { didSet { statusUpdatesContinuation.yield(isUpdatable) } } var pushTokenString: String? { didSet { pushTokenUpdatesContinuation.yield(pushTokenString ?? "") } } private let pushTokenUpdates: AsyncStream<String> private let pushTokenUpdatesContinuation: AsyncStream<String>.Continuation private let statusUpdates: AsyncStream<Bool> private let statusUpdatesContinuation: AsyncStream<Bool>.Continuation init(_ id: String) { self.id = id var pushTokenUpdatesEscapee: AsyncStream<String>.Continuation? = nil self.pushTokenUpdates = AsyncStream { continuation in pushTokenUpdatesEscapee = continuation } self.pushTokenUpdatesContinuation = pushTokenUpdatesEscapee! var statusUpdateEscapee: AsyncStream<Bool>.Continuation? = nil self.statusUpdates = AsyncStream { continuation in statusUpdateEscapee = continuation } self.statusUpdatesContinuation = statusUpdateEscapee! } func track(tokenUpdates: @Sendable @escaping (String) async -> Void) async { guard self.isUpdatable else { return } let task = Task { for await token in self.pushTokenUpdates { try Task.checkCancellation() await tokenUpdates(token) } } if let token = self.pushTokenString { await tokenUpdates(token) } for await update in self.statusUpdates { if !update || Task.isCancelled { task.cancel() break } } } } final class TestPushToStartTracker: LiveActivityPushToStartTrackerProtocol, @unchecked Sendable { var attributeType: String { return String(describing: Self.self) } var token: String? func track(tokenUpdates: @escaping @Sendable (String) async -> Void) async { guard let token = self.token else { return } await tokenUpdates(token) } } ================================================ FILE: Airship/AirshipCore/Tests/MediaEventTemplateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class MediaEventTemplateTest: XCTestCase { func testBrowsed() { let event = CustomEvent(mediaTemplate: .browsed) XCTAssertEqual("browsed_content", event.eventName) XCTAssertEqual("media", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testConsumed() { let event = CustomEvent(mediaTemplate: .consumed) XCTAssertEqual("consumed_content", event.eventName) XCTAssertEqual("media", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testShared() { let event = CustomEvent(mediaTemplate: .shared(source: "some source", medium: "some medium")) XCTAssertEqual("shared_content", event.eventName) XCTAssertEqual("media", event.templateType) let expectedProperties: [String: AirshipJSON] = [ "ltv": false, "source": "some source", "medium": "some medium" ] XCTAssertEqual(expectedProperties, event.properties) } func testSharedEmptyDetails() { let event = CustomEvent(mediaTemplate: .shared()) XCTAssertEqual("shared_content", event.eventName) XCTAssertEqual("media", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testStarred() { let event = CustomEvent(mediaTemplate: .starred) XCTAssertEqual("starred_content", event.eventName) XCTAssertEqual("media", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testProperties() { let date = Date.now let properties = CustomEvent.MediaProperties( id: "some id", category: "some category", type: "some type", eventDescription: "some description", isLTV: true, author: "some author", publishedDate: date, isFeature: true ) let event = CustomEvent( mediaTemplate: .shared(source: "some source", medium: "some medium"), properties: properties ) XCTAssertEqual("shared_content", event.eventName) XCTAssertEqual("media", event.templateType) let expectedProperties: [String: AirshipJSON] = [ "id": "some id", "category": "some category", "type": "some type", "description": "some description", "ltv": true, "author": "some author", "published_date": try! AirshipJSON.wrap(date, encoder: CustomEvent.defaultEncoder()), "feature": true, "source": "some source", "medium": "some medium" ] XCTAssertEqual(expectedProperties, event.properties) } } ================================================ FILE: Airship/AirshipCore/Tests/MeteredUsageApiClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class MeteredUsageApiClientTest: XCTestCase { private let requestSession = TestAirshipRequestSession() private let configDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var target: MeteredUsageAPIClient! private var config: RuntimeConfig = RuntimeConfig.testConfig() @MainActor override func setUp() async throws { self.config.updateRemoteConfig( RemoteConfig( airshipConfig: RemoteConfig.AirshipConfig( remoteDataURL: "test://remoteUrl", deviceAPIURL: "test://device", analyticsURL: "test://analytics", meteredUsageURL: "test://meteredUsage" ) ) ) target = MeteredUsageAPIClient(config: config, session: requestSession) } func testUploadEventsNoConfig() async throws { await self.config.updateRemoteConfig(RemoteConfig()) let timestamp = Date() let events = [ AirshipMeteredUsageEvent( eventID: "event.1", entityID: "message.id", usageType: .inAppExperienceImpression, product: "message", reportingContext: try! AirshipJSON.wrap("event.1"), timestamp: timestamp, contactID: "contact-id-1" ), AirshipMeteredUsageEvent( eventID: "event.2", entityID: "landing-page.id", usageType: .inAppExperienceImpression, product: "landingpage", reportingContext: try! AirshipJSON.wrap("event.2"), timestamp: timestamp, contactID: "contact-id-2" ), AirshipMeteredUsageEvent( eventID: "event.3", entityID: "scene.id", usageType: .inAppExperienceImpression, product: "Scene", reportingContext: try! AirshipJSON.wrap("event.3"), timestamp: timestamp, contactID: "contact-id-3" ), AirshipMeteredUsageEvent( eventID: "event.4", entityID: "survey.id", usageType: .inAppExperienceImpression, product: "Survey", reportingContext: try! AirshipJSON.wrap("event.4"), timestamp: timestamp, contactID: "contact-id-4" ) ] requestSession.response = HTTPURLResponse( url: URL(string: "test://repose.url")!, statusCode: 200, httpVersion: "1", headerFields: nil) await self.config.updateRemoteConfig(RemoteConfig()) do { let _ = try await target.uploadEvents(events, channelID: "test.channel.id") XCTFail("Should throw") } catch { } } func testUploadEvents() async throws { let timestamp = Date() let events = [ AirshipMeteredUsageEvent( eventID: "event.1", entityID: "message.id", usageType: .inAppExperienceImpression, product: "message", reportingContext: try! AirshipJSON.wrap("event.1"), timestamp: timestamp, contactID: "contact-id-1" ), AirshipMeteredUsageEvent( eventID: "event.2", entityID: "landing-page.id", usageType: .inAppExperienceImpression, product: "landingpage", reportingContext: try! AirshipJSON.wrap("event.2"), timestamp: timestamp, contactID: "contact-id-2" ), AirshipMeteredUsageEvent( eventID: "event.3", entityID: "scene.id", usageType: .inAppExperienceImpression, product: "Scene", reportingContext: try! AirshipJSON.wrap("event.3"), timestamp: timestamp, contactID: "contact-id-3" ), AirshipMeteredUsageEvent( eventID: "event.4", entityID: "survey.id", usageType: .inAppExperienceImpression, product: "Survey", reportingContext: try! AirshipJSON.wrap("event.4"), timestamp: timestamp, contactID: "contact-id-4" ) ] requestSession.response = HTTPURLResponse( url: URL(string: "test://repose.url")!, statusCode: 200, httpVersion: "1", headerFields: nil) let _ = try await target.uploadEvents(events, channelID: "test.channel.id") let request = requestSession.lastRequest XCTAssertNotNil(request) XCTAssertEqual("test://meteredUsage/api/metered-usage", request?.url?.absoluteString) XCTAssertEqual([ "Content-Type": "application/json", "X-UA-Lib-Version": AirshipVersion.version, "X-UA-Device-Family": "ios", "X-UA-Channel-ID": "test.channel.id", "Accept": "application/vnd.urbanairship+json; version=3;" ], request?.headers) XCTAssertEqual("POST", request?.method) let body = request?.body XCTAssertNotNil(body) let decodedBody = try JSONSerialization.jsonObject(with: body!) as! [String : [[String: String]]] let timestampString = AirshipDateFormatter.string(fromDate: timestamp, format: .isoDelimitter) XCTAssertEqual([ [ "entity_id": "message.id", "event_id": "event.1", "product": "message", "occurred": timestampString, "usage_type": "iax_impression", "reporting_context": "event.1", "contact_id": "contact-id-1" ], [ "entity_id": "landing-page.id", "event_id": "event.2", "product": "landingpage", "occurred": timestampString, "usage_type": "iax_impression", "reporting_context": "event.2", "contact_id": "contact-id-2" ], [ "entity_id": "scene.id", "event_id": "event.3", "product": "Scene", "occurred": timestampString, "usage_type": "iax_impression", "reporting_context": "event.3", "contact_id": "contact-id-3" ], [ "entity_id": "survey.id", "event_id": "event.4", "product": "Survey", "occurred": timestampString, "usage_type": "iax_impression", "reporting_context": "event.4", "contact_id": "contact-id-4" ]], decodedBody["usage"]) } func testUploadStrippedEvents() async throws { let timestamp = Date() let events = [ AirshipMeteredUsageEvent( eventID: "event.1", entityID: "message.id", usageType: .inAppExperienceImpression, product: "message", reportingContext: try! AirshipJSON.wrap("event.1"), timestamp: timestamp, contactID: "contact-id-1" ).withDisabledAnalytics(), AirshipMeteredUsageEvent( eventID: "event.2", entityID: "landing-page.id", usageType: .inAppExperienceImpression, product: "landingpage", reportingContext: try! AirshipJSON.wrap("event.2"), timestamp: timestamp, contactID: "contact-id-2" ).withDisabledAnalytics(), AirshipMeteredUsageEvent( eventID: "event.3", entityID: "scene.id", usageType: .inAppExperienceImpression, product: "Scene", reportingContext: try! AirshipJSON.wrap("event.3"), timestamp: timestamp, contactID: "contact-id-3" ).withDisabledAnalytics(), AirshipMeteredUsageEvent( eventID: "event.4", entityID: "survey.id", usageType: .inAppExperienceImpression, product: "Survey", reportingContext: try! AirshipJSON.wrap("event.4"), timestamp: timestamp, contactID: "contact-id-4" ).withDisabledAnalytics() ] requestSession.response = HTTPURLResponse( url: URL(string: "test://repose.url")!, statusCode: 200, httpVersion: "1", headerFields: nil) let _ = try await target.uploadEvents(events, channelID: "test.channel.id") let request = requestSession.lastRequest XCTAssertNotNil(request) XCTAssertEqual("test://meteredUsage/api/metered-usage", request?.url?.absoluteString) XCTAssertEqual([ "Content-Type": "application/json", "X-UA-Lib-Version": AirshipVersion.version, "X-UA-Device-Family": "ios", "X-UA-Channel-ID": "test.channel.id", "Accept": "application/vnd.urbanairship+json; version=3;" ], request?.headers) XCTAssertEqual("POST", request?.method) let body = request?.body XCTAssertNotNil(body) let decodedBody = try JSONSerialization.jsonObject(with: body!) as! [String : [[String: String]]] XCTAssertEqual([ [ "event_id": "event.1", "product": "message", "usage_type": "iax_impression", ], [ "event_id": "event.2", "product": "landingpage", "usage_type": "iax_impression", ], [ "event_id": "event.3", "product": "Scene", "usage_type": "iax_impression", ], [ "event_id": "event.4", "product": "Survey", "usage_type": "iax_impression", ]], decodedBody["usage"]) } } ================================================ FILE: Airship/AirshipCore/Tests/ModifyAttributesActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ModifyAttributesActionTest: XCTestCase { private let channel = TestChannel() private let contact = TestContact() private let push = TestPush() private let date = UATestDate() private var action: ModifyAttributesAction! override func setUp() async throws { date.dateOverride = Date() action = ModifyAttributesAction( channel: { [channel] in return channel }, contact: { [contact] in return contact } ) } func testAcceptsArguments() async throws { let validValue = [ "channel": [ "set": ["name": "clive"], ] ] let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ActionSituation.backgroundInteractiveButton, ] let rejectedSituations = [ ActionSituation.backgroundPush ] for situation in validSituations { let args = ActionArguments(value: try AirshipJSON.wrap(validValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in validSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } for situation in rejectedSituations { let args = ActionArguments(value: try AirshipJSON.wrap(validValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } func testAcceptReturnsFalseForInvalidJsonValue() async throws { let jsons = [ [[ "action": "set", "type": "channel", "name": "another name", "value": [ "json_test": [ "exp": 1012, "nested": [ "foo": "bar" ] ] ] ]], [[ "action": "set", "type": "channel", "name": "another name", "value": [ "json#te#st#": [ "exp": 1012, "nested": [ "foo": "bar" ] ] ] ]], [[ "action": "set", "type": "channel", "name": "another name", "value": [ "json_test#": [ "exp": 1012, "nested": [ "foo": "bar" ] ] ] ]] ] for item in jsons { let args = ActionArguments(value: try AirshipJSON.wrap(item), situation: .manualInvocation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } func testPerform() async throws { let value: [String: Any] = [ "channel": [ "set": ["name": "clive"], "remove": ["zipcode"] ] as [String : Any], "named_user": [ "set": ["some other name": "owen"], "remove": ["location"] ] as [String : Any] ] let expectedChannelAttributes = [ AttributeUpdate( attribute: "zipcode", type: .remove, jsonValue: nil, date: self.date.now ), AttributeUpdate( attribute: "name", type: .set, jsonValue: "clive", date: self.date.now ) ] let expectedContactAttributes = [ AttributeUpdate( attribute: "location", type: .remove, jsonValue: nil, date: self.date.now ), AttributeUpdate( attribute: "some other name", type: .set, jsonValue: "owen", date: self.date.now ) ] let attributesSet = self.expectation(description: "attributes") attributesSet.expectedFulfillmentCount = 2 self.channel.attributeEditor = AttributesEditor( date: self.date ) { attributes in XCTAssertEqual(expectedChannelAttributes, attributes) attributesSet.fulfill() } self.contact.attributeEditor = AttributesEditor( date: self.date ) { attributes in XCTAssertEqual(expectedContactAttributes, attributes) attributesSet.fulfill() } let _ = try await self.action.perform(arguments: ActionArguments( value: try AirshipJSON.wrap(value), situation: .manualInvocation ) ) await fulfillment(of: [attributesSet]) } func testJsonValue() async throws { let value = [ [ "action": "set", "type": "channel", "name": "another name", "value": [ "json#test": [ "exp": 1234567890, "nested": ["foo": "bar"] ] ] ] ] let expectedAttributes = [ AttributeUpdate( attribute: "json#test", type: .set, jsonValue: try AirshipJSON.wrap(["nested": ["foo": "bar"], "exp": 1234567890]), date: self.date.now ) ] let attributesSet = self.expectation(description: "attributes") self.contact.attributeEditor = AttributesEditor( date: self.date ) { attributes in XCTFail("shouldn't be called") } self.channel.attributeEditor = AttributesEditor( date: self.date ) { attributes in XCTAssertEqual(expectedAttributes, attributes) attributesSet.fulfill() } let _ = try await self.action.perform(arguments: ActionArguments( value: try AirshipJSON.wrap(value), situation: .manualInvocation ) ) await fulfillment(of: [attributesSet]) } func testJsonValueNoExpiration() async throws { let value = [ [ "action": "set", "type": "channel", "name": "another name", "value": [ "json#test": [ "nested": ["foo": "bar"] ] ] ] ] let expectedAttributes = [ AttributeUpdate( attribute: "json#test", type: .set, jsonValue: try AirshipJSON.wrap(["nested": ["foo": "bar"]]), date: self.date.now ) ] let attributesSet = self.expectation(description: "attributes") self.contact.attributeEditor = AttributesEditor( date: self.date ) { attributes in XCTFail("shouldn't be called") } self.channel.attributeEditor = AttributesEditor( date: self.date ) { attributes in XCTAssertEqual(expectedAttributes, attributes) attributesSet.fulfill() } let _ = try await self.action.perform(arguments: ActionArguments( value: try AirshipJSON.wrap(value), situation: .manualInvocation ) ) await fulfillment(of: [attributesSet]) } } ================================================ FILE: Airship/AirshipCore/Tests/ModifyTagsActionTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore struct ModifyTagsActionTest { private let channel = TestChannel() private let contact = TestContact() private func makeAction() -> ModifyTagsAction { return ModifyTagsAction( channel: { [channel] in return channel }, contact: { [contact] in return contact } ) } @Test( "Test accepts arguments", arguments: [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ActionSituation.backgroundInteractiveButton ] ) func testAcceptsArguments(situation: ActionSituation) async throws { let args = ActionArguments( value: try AirshipJSON.wrap(channelAddPayload), situation: situation ) #expect(await makeAction().accepts(arguments: args)) } @Test( "Rejects backgound push situation", arguments: [ActionSituation.backgroundPush] ) func testRejectSituations(situation: ActionSituation) async throws { let args = ActionArguments( value: try AirshipJSON.wrap(channelAddPayload), situation: situation ) #expect(!(await makeAction().accepts(arguments: args))) } @Test func testChannelAdd() async throws { self.channel.tags = ["channel_tag_1", "channel_tag_3"] mockEditors() #expect(self.channel.tags == ["channel_tag_1", "channel_tag_3"]) _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([channelAddPayload]), situation: .launchedFromPush )) #expect(self.channel.tags.sorted() == ["channel_tag_1", "channel_tag_1", "channel_tag_2", "channel_tag_3"]) } @Test func testChannelAddGroup() async throws { var groupUpdates: [TagGroupUpdate] = [] mockEditors( channelGroup: TagGroupsEditor { groupUpdates = $0 } ) #expect(groupUpdates == []) _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([channelAddGroupPayload]), situation: .launchedFromPush )) #expect(groupUpdates == [ .init( group: "test_group", tags: ["channel_tag_1", "channel_tag_2"], type: .add) ]) } @Test func testChannelRemove() async throws { self.channel.tags = ["channel_tag_1", "channel_tag_3"] mockEditors() #expect(self.channel.tags == ["channel_tag_1", "channel_tag_3"]) _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([channelRemovePayload]), situation: .launchedFromPush )) #expect(self.channel.tags.sorted() == ["channel_tag_3"]) } @Test func testChannelRemoveGroup() async throws { var groupUpdates: [TagGroupUpdate] = [] mockEditors(channelGroup: TagGroupsEditor { groupUpdates = $0 }) #expect(groupUpdates == []) _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([channelRemoveGroupPayload]), situation: .launchedFromPush )) #expect(groupUpdates == [ .init( group: "test_group", tags: ["channel_tag_1", "channel_tag_2"], type: .remove) ]) } @Test("Throws on invalid JSON") func testThrowsOnInvalidChannelJson() async throws { mockEditors() await #expect(throws: DecodingError.self) { _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([channelInvalidPayload]), situation: .launchedFromPush )) } } @Test func testAddContactTags() async throws { var groupUpdates: [TagGroupUpdate] = [] mockEditors(contactGroup: TagGroupsEditor { groupUpdates = $0 }) #expect(groupUpdates == []) _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([contactAddPayload]), situation: .launchedFromPush )) #expect(groupUpdates == [ .init( group: "test_group", tags: ["contact_tag_1", "contact_tag_2"], type: .add) ]) } @Test func testRemoveContactTags() async throws { var groupUpdates: [TagGroupUpdate] = [] mockEditors(contactGroup: TagGroupsEditor { groupUpdates = $0 }) #expect(groupUpdates == []) _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([contactRemovePayload]), situation: .launchedFromPush )) #expect(groupUpdates == [ .init( group: "test_group", tags: ["contact_tag_1", "contact_tag_2"], type: .remove) ]) } @Test("Throws on invalid payload") func testThrowsOnInvalidContactPayload() async throws { mockEditors() await #expect(throws: DecodingError.self) { _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap(contactInvalidPayload), situation: .launchedFromPush )) } } @Test func testMultipleOperations() async throws { self.channel.tags = ["channel_tag_1", "channel_tag_3"] #expect(self.channel.tags.sorted() == ["channel_tag_1", "channel_tag_3"]) var contactUpdate: [TagGroupUpdate] = [] var channelUpdates: [TagGroupUpdate] = [] mockEditors( channelGroup: TagGroupsEditor { channelUpdates = $0 }, contactGroup: TagGroupsEditor { contactUpdate = $0 } ) #expect(channelUpdates == []) #expect(contactUpdate == []) _ = try await makeAction().perform( arguments: ActionArguments( value: try! AirshipJSON.wrap([ contactRemovePayload, channelAddPayload, channelAddGroupPayload, channelRemovePayload ]), situation: .launchedFromPush )) #expect(self.channel.tags.sorted() == ["channel_tag_3"]) #expect(contactUpdate == [ .init(group: "test_group", tags: ["contact_tag_1", "contact_tag_2"], type: .remove), ]) #expect(channelUpdates == [ .init(group: "test_group", tags: ["channel_tag_1", "channel_tag_2"], type: .add), ]) } private func mockEditors( channelGroup: TagGroupsEditor = TagGroupsEditor { _ in }, contactGroup: TagGroupsEditor = TagGroupsEditor { _ in } ) { self.contact.tagGroupEditor = contactGroup self.channel.tagGroupEditor = channelGroup } private let channelAddPayload: [String: Any] = [ "action": "add", "tags": [ "channel_tag_1", "channel_tag_2" ], "type": "channel" ] private let channelAddGroupPayload: [String: Any] = [ "action": "add", "group": "test_group", "tags": [ "channel_tag_1", "channel_tag_2" ], "type": "channel" ] private let channelRemovePayload: [String: Any] = [ "action": "remove", "tags": [ "channel_tag_1", "channel_tag_2" ], "type": "channel" ] private let channelRemoveGroupPayload: [String: Any] = [ "action": "remove", "group": "test_group", "tags": [ "channel_tag_1", "channel_tag_2" ], "type": "channel" ] private let contactAddPayload: [String: Any] = [ "action": "add", "group": "test_group", "tags": [ "contact_tag_1", "contact_tag_2" ], "type": "contact" ] private let contactRemovePayload: [String: Any] = [ "action": "remove", "group": "test_group", "tags": [ "contact_tag_1", "contact_tag_2" ], "type": "contact" ] private let channelInvalidPayload: [String: Any] = [ "action": "remove", "type": "channel" ] private let contactInvalidPayload: [String: Any] = [ "action": "remove", "tags": [ "contact_tag_1", "contact_tag_2" ], "type": "contact" ] } ================================================ FILE: Airship/AirshipCore/Tests/NativeBridgeActionHandlerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable public import AirshipCore import WebKit final class NativeBridgeActionHandlerTest: XCTestCase { private let metadata: [String: String] = ["some": UUID().uuidString] private let testActionRunner = TestActionRunner() private let webView = WKWebView() private var actionHandler: NativeBridgeActionHandler! override func setUpWithError() throws { self.actionHandler = NativeBridgeActionHandler(actionRunner: testActionRunner) } @MainActor func testRunActionsMultiple() async throws { let command = JavaScriptCommand( url: URL( string: "uairship://run-actions?test%2520action=%22hi%22&also_test_action" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) XCTAssertNil(result) let expecteActions: [String: [ActionArguments]] = [ "test%20action": [ActionArguments(string: "hi", situation: .webViewInvocation, metadata: metadata)], "also_test_action": [ActionArguments(situation: .webViewInvocation, metadata: metadata)] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunActionsMultipleArgs() async throws { let command = JavaScriptCommand( url: URL( string: "uairship://run-actions?test_action&test_action" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) XCTAssertNil(result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ ActionArguments(situation: .webViewInvocation, metadata: metadata), ActionArguments(situation: .webViewInvocation, metadata: metadata) ] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunActionsInvalidArgs() async throws { let command = JavaScriptCommand( url: URL( string: "uairship://run-actions?test_action=blah" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) XCTAssertNil(result) XCTAssertEqual([:], self.testActionRunner.ranActions) } @MainActor func testRunActionCBNullResult() async throws { self.testActionRunner.actionResult = .completed(AirshipJSON.null) let command = JavaScriptCommand( url: URL( string: "uairship://run-action-cb/test_action/%22hi%22/callback-ID-1" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) let expectedResult = "UAirship.finishAction(null, null, \"callback-ID-1\");" XCTAssertEqual(expectedResult, result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ActionArguments(string: "hi", situation: .webViewInvocation, metadata: metadata)] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunActionCBValueResult() async throws { self.testActionRunner.actionResult = .completed(AirshipJSON.string("neat")) let command = JavaScriptCommand( url: URL( string: "uairship://run-action-cb/test_action/%22hi%22/callback-ID-2" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) let expectedResult = "UAirship.finishAction(null, \"neat\", \"callback-ID-2\");" XCTAssertEqual(expectedResult, result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ActionArguments(string: "hi", situation: .webViewInvocation, metadata: metadata)] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunActionCBError() async throws { self.testActionRunner.actionResult = .error(AirshipErrors.error("Some error")) let command = JavaScriptCommand( url: URL( string: "uairship://run-action-cb/test_action/%22hi%22/callback-ID-2" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) let expectedResult = "var error = new Error(); error.message = \"Some error\"; UAirship.finishAction(error, null, \"callback-ID-2\");" XCTAssertEqual(expectedResult, result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ActionArguments(string: "hi", situation: .webViewInvocation, metadata: metadata)] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunActionCBActionNotFound() async throws { self.testActionRunner.actionResult = .actionNotFound let command = JavaScriptCommand( url: URL( string: "uairship://run-action-cb/test_action/%22hi%22/callback-ID-2" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) let expectedResult = "var error = new Error(); error.message = \"No action found with name test_action, skipping action.\"; UAirship.finishAction(error, null, \"callback-ID-2\");" XCTAssertEqual(expectedResult, result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ActionArguments(string: "hi", situation: .webViewInvocation, metadata: metadata)] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunActionCBActionArgsRejected() async throws { self.testActionRunner.actionResult = .argumentsRejected let command = JavaScriptCommand( url: URL( string: "uairship://run-action-cb/test_action/%22hi%22/callback-ID-2" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) let expectedResult = "var error = new Error(); error.message = \"Action test_action rejected arguments.\"; UAirship.finishAction(error, null, \"callback-ID-2\");" XCTAssertEqual(expectedResult, result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ActionArguments(string: "hi", situation: .webViewInvocation, metadata: metadata)] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunBasicActions() async throws { let command = JavaScriptCommand( url: URL( string: "uairship://run-basic-actions?test_action=hi&also_test_action" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) XCTAssertNil(result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ActionArguments(string: "hi", situation: .webViewInvocation, metadata: metadata)], "also_test_action": [ActionArguments(situation: .webViewInvocation, metadata: metadata)] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } @MainActor func testRunBasicActionsMultipleArgs() async throws { let command = JavaScriptCommand( url: URL( string: "uairship://run-basic-actions?test_action&test_action" )! ) let result = await self.actionHandler.runActionsForCommand(command: command, metadata: metadata, webView: self.webView) XCTAssertNil(result) let expecteActions: [String: [ActionArguments]] = [ "test_action": [ ActionArguments(situation: .webViewInvocation, metadata: metadata), ActionArguments(situation: .webViewInvocation, metadata: metadata) ] ] XCTAssertEqual(expecteActions, self.testActionRunner.ranActions) } } final class TestActionRunner: NativeBridgeActionRunner { @MainActor var actionResult: ActionResult = .completed(AirshipJSON.null) @MainActor var ranActions: [String: [ActionArguments]] = [:] @MainActor func runAction(actionName: String, arguments: AirshipCore.ActionArguments, webView: WKWebView) async -> AirshipCore.ActionResult { ranActions[actionName] = ranActions[actionName] ?? [] ranActions[actionName]?.append(arguments) return actionResult } } extension ActionArguments: @retroactive Equatable { public static func == (lhs: AirshipCore.ActionArguments, rhs: AirshipCore.ActionArguments) -> Bool { lhs.value == rhs.value && lhs.situation == rhs.situation && NSDictionary(dictionary: lhs.metadata) == NSDictionary(dictionary: rhs.metadata) } } ================================================ FILE: Airship/AirshipCore/Tests/NotificationCategoriesTest.swift ================================================ // Copyright Airship and Contributors import XCTest @testable import AirshipCore final class NotificationCategoriesTest: XCTestCase { func testDefaultCategories() { let categories = NotificationCategories.defaultCategories() XCTAssertEqual(37, categories.count) // Require auth defaults to true for background actions categories.forEach { category in category.actions .filter({ !$0.options.contains(.foreground) }) .forEach { action in XCTAssert(action.options.contains(.authenticationRequired)) } } } func testDefaultCategoriesOverrideAuth() { let categories = NotificationCategories.defaultCategories(withRequireAuth: false) XCTAssertEqual(37, categories.count) // Verify require auth is false for background actions categories.forEach { category in category.actions .filter({ !$0.options.contains(.foreground) }) .forEach { action in XCTAssertFalse(action.options.contains(.authenticationRequired)) } } } func testCreateFromPlist() { let plist = Bundle(for: self.classForCoder).path(forResource: "CustomNotificationCategories", ofType: "plist")! let categories = NotificationCategories.createCategories(fromFile: plist) XCTAssertEqual(4, categories.count) // Share category let share = categories.first(where: { $0.identifier == "share_category" }) XCTAssertNotNil(share) XCTAssertEqual(1, share?.actions.count) // Share action in share category let shareAction = share?.actions.first(where: { $0.identifier == "share_button" }) XCTAssertNotNil(shareAction) XCTAssertEqual("Share", shareAction?.title) XCTAssertTrue(shareAction!.options.contains(.foreground)) XCTAssertFalse(shareAction!.options.contains(.authenticationRequired)) XCTAssertFalse(shareAction!.options.contains(.destructive)) // Yes no category let yesNo = categories.first(where: { $0.identifier == "yes_no_category" }) XCTAssertNotNil(yesNo) XCTAssertEqual(2, yesNo?.actions.count) // Yes action in yes no category let yesAction = yesNo?.actions.first(where: { $0.identifier == "yes_button" }) XCTAssertNotNil(yesAction) XCTAssertEqual("Yes", yesAction?.title) XCTAssertTrue(yesAction!.options.contains(.foreground)) XCTAssertFalse(yesAction!.options.contains(.authenticationRequired)) XCTAssertFalse(yesAction!.options.contains(.destructive)) // No action in yes no category let noAction = yesNo?.actions.first(where: { $0.identifier == "no_button" }) XCTAssertNotNil(noAction) XCTAssertEqual("No", noAction?.title) XCTAssertFalse(noAction!.options.contains(.foreground)) XCTAssertTrue(noAction!.options.contains(.authenticationRequired)) XCTAssertTrue(noAction!.options.contains(.destructive)) // text_input category let textInput = categories.first(where: { $0.identifier == "text_input_category" }) XCTAssertNotNil(textInput) XCTAssertEqual(1, textInput?.actions.count) // Follow action in follow category let textInputAction = textInput?.actions.first(where: { $0.identifier == "text_input" }) as? UNTextInputNotificationAction XCTAssertNotNil(textInputAction) // Test when 'title_resource' value does not exist will fall back to 'title' value XCTAssertEqual("TextInput", textInputAction?.title) XCTAssertEqual("text_input_button", textInputAction?.textInputButtonTitle) XCTAssertEqual("placeholder_text", textInputAction?.textInputPlaceholder) XCTAssertTrue(textInputAction!.options.contains(.foreground)) XCTAssertFalse(textInputAction!.options.contains(.authenticationRequired)) XCTAssertFalse(textInputAction!.options.contains(.destructive)) // Follow category let follow = categories.first(where: { $0.identifier == "follow_category" }) XCTAssertNotNil(follow) XCTAssertEqual(1, follow?.actions.count) // Follow action in follow category let followAction = follow?.actions.first(where: { $0.identifier == "follow_button" }) XCTAssertNotNil(followAction) // Test when 'title_resource' value does not exist will fall back to 'title' value XCTAssertEqual("FollowMe", followAction?.title) XCTAssertTrue(followAction!.options .contains(.foreground)) XCTAssertFalse(followAction!.options.contains(.authenticationRequired)) XCTAssertFalse(followAction!.options.contains(.destructive)) } func testDoesNotCreateCategoryMissingTitle() { let actions = [ ["identifier": "yes", "foreground": true, "authenticationRequired": true], ["identifier": "no", "foreground": false, "destructive": true, "authenticationRequired": false] ] XCTAssertNil(NotificationCategories.createCategory("category", actions: actions)) } func testCreateFromInvalidPlist() { let categories = NotificationCategories.createCategories(fromFile: "no file") XCTAssertEqual(0, categories.count, "No categories should be created.") } func testCreateCategory() { let actions = [ ["identifier": "yes", "foreground": true, "title": "Yes", "authenticationRequired": true], ["identifier": "no", "foreground": false, "title": "No", "destructive": true, "authenticationRequired": false] ] let category = NotificationCategories.createCategory("category", actions: actions) // Yes action let yesAction = category?.actions.first(where: { $0.identifier == "yes" }) XCTAssertNotNil(yesAction) XCTAssertEqual("Yes", yesAction?.title) XCTAssertTrue(yesAction!.options.contains(.foreground)) XCTAssertTrue(yesAction!.options.contains(.authenticationRequired)) XCTAssertFalse(yesAction!.options.contains(.destructive)) // No action let noAction = category?.actions.first(where: { $0.identifier == "no" }) XCTAssertNotNil(noAction) XCTAssertEqual("No", noAction?.title) XCTAssertFalse(noAction!.options.contains(.foreground)) XCTAssertFalse(noAction!.options.contains(.authenticationRequired)) XCTAssertTrue(noAction!.options.contains(.destructive)) } } ================================================ FILE: Airship/AirshipCore/Tests/OpenExternalURLActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class OpenExternalURLActionTest: XCTestCase { private let testURLOpener: TestURLOpener = TestURLOpener() private let urlAllowList: TestURLAllowList = TestURLAllowList() private var airship: TestAirshipInstance! private var action: OpenExternalURLAction! @MainActor override func setUp() { airship = TestAirshipInstance() self.action = OpenExternalURLAction(urlOpener: self.testURLOpener) self.airship.urlAllowList = self.urlAllowList self.airship.makeShared() } override func tearDown() async throws { TestAirshipInstance.clearShared() } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ] let rejectedSituations = [ ActionSituation.backgroundPush, ActionSituation.backgroundInteractiveButton ] for situation in validSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } @MainActor func testPerform() async throws { self.urlAllowList.isAllowedReturnValue = true self.testURLOpener.returnValue = true let args = ActionArguments( string: "http://some-valid-url", situation: .manualInvocation ) let result = try await action.perform(arguments: args) XCTAssertEqual(args.value, result) XCTAssertEqual("http://some-valid-url", self.testURLOpener.lastURL?.absoluteString) } @MainActor func testPerformRejectsURL() async throws { self.urlAllowList.isAllowedReturnValue = false self.testURLOpener.returnValue = true let args = ActionArguments( string: "http://some-valid-url", situation: .manualInvocation ) do { _ = try await action.perform(arguments: args) XCTFail("Should throw") } catch {} XCTAssertNil(self.testURLOpener.lastURL) } @MainActor func testPerformUnableToOpenURL() async throws { self.urlAllowList.isAllowedReturnValue = true self.testURLOpener.returnValue = false let args = ActionArguments( string: "http://some-valid-url", situation: .manualInvocation ) do { _ = try await action.perform(arguments: args) XCTFail("Should throw") } catch {} XCTAssertEqual("http://some-valid-url", self.testURLOpener.lastURL?.absoluteString) } @MainActor func testPerformInvalidURL() async throws { self.urlAllowList.isAllowedReturnValue = true self.testURLOpener.returnValue = true let args = ActionArguments( double: 10.0, situation: .manualInvocation ) do { _ = try await action.perform(arguments: args) XCTFail("Should throw") } catch {} XCTAssertNil(self.testURLOpener.lastURL) } } ================================================ FILE: Airship/AirshipCore/Tests/PagerControllerTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import Foundation @testable import AirshipCore struct PagerControllerTest { @MainActor @Test func initWithNullState() { let controller = AirshipSceneController.PagerController(pagerState: nil) #expect(controller.canGoBack == false) #expect(controller.canGoNext == false) #expect(controller.navigate(request: .back) == false) #expect(controller.navigate(request: .next) == false) } @MainActor @Test func controllerDisplaysCorrectStateOnNavigation() { let pagerState = PagerState( identifier: "test", branching: nil ) pagerState.setPagesAndListenForUpdates( pages: [ makePageItem(id: "page-1"), makePageItem(id: "page-2") ], thomasState: .empty, swipeDisableSelectors: nil ) let controller = AirshipSceneController.PagerController(pagerState: pagerState) #expect(controller.canGoBack == false) #expect(controller.canGoNext == true) #expect(controller.navigate(request: .back) == false) #expect(controller.navigate(request: .next) == true) #expect(controller.canGoBack == true) #expect(controller.canGoNext == false) #expect(controller.navigate(request: .next) == false) #expect(controller.navigate(request: .back) == true) #expect(controller.canGoBack == false) #expect(controller.canGoNext == true) } @MainActor @Test func disableTouchDuringNavigation() async throws { let sleeper = TestTaskSleeper() let sleepUpdates = await sleeper.sleepUpdates var iterator = sleepUpdates.makeAsyncIterator() let pagerState = PagerState( identifier: "test", branching: nil, taskSleeper: sleeper ) #expect(pagerState.isNavigationInProgress == false) pagerState.disableTouchDuringNavigation() #expect(pagerState.isNavigationInProgress == true) let sleeps = await iterator.next() #expect(sleeps?.count == 1) #expect(pagerState.isNavigationInProgress == false) } private func makePageItem(id: String) -> ThomasViewInfo.Pager.Item { return .init( identifier: id, view: .emptyView(.init(commonProperties: .init(), properties: .init())), displayActions: nil, automatedActions: nil, accessibilityActions: nil, stateActions: nil, branching: nil ) } } extension ThomasState { static var empty: ThomasState { return .init( formState: .init( identifier: "empty", formType: .form, formResponseType: "none", validationMode: .immediate, ), pagerState: .init(identifier: "", branching: nil), onStateChange: { _ in } ) } } ================================================ FILE: Airship/AirshipCore/Tests/PasteboardActionTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore import Foundation @Suite struct PasteboardActionTest { private let testPasteboard: TestPasteboard = TestPasteboard() private let action: PasteboardAction! init() { self.action = PasteboardAction(pasteboard: self.testPasteboard) } @Test func testAcceptsArguments() async throws { let validStringValue = "pasteboard string" let validDictValue = ["text": "pasteboard string"] let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.backgroundInteractiveButton, ] let rejectedSituations = [ ActionSituation.foregroundPush, ActionSituation.backgroundPush ] for situation in validSituations { let args = ActionArguments( string: validStringValue, situation: situation ) let result = await self.action.accepts(arguments: args) #expect(result, "Should accept valid situation: \(situation)") } for situation in validSituations { let args = ActionArguments( value: try AirshipJSON.wrap(validDictValue), situation: situation ) let result = await self.action.accepts(arguments: args) #expect(result, "Should accept valid dictionary value in situation: \(situation)") } for situation in validSituations { let args = ActionArguments( situation: situation ) let result = await self.action.accepts(arguments: args) #expect(!result, "Should reject empty arguments in situation: \(situation)") } for situation in rejectedSituations { let args = ActionArguments( string: validStringValue, situation: situation ) let result = await self.action.accepts(arguments: args) #expect(!result, "Should reject invalid situation: \(situation)") } } @Test @MainActor func testPerformWithString() async throws { let value = "pasteboard_string" let arguments = ActionArguments(string: value) let result = try await self.action.perform(arguments: arguments) #expect(result == arguments.value) #expect(testPasteboard.lastCopyValue == value) } @Test @MainActor func testPerformWithDictionary() async throws { let value = "pasteboard string" let arguments = ActionArguments(value: try AirshipJSON.wrap(["text": value])) let result = try await self.action.perform(arguments: arguments) #expect(result == arguments.value) #expect(testPasteboard.lastCopyValue == value) } } fileprivate final class TestPasteboard: AirshipPasteboardProtocol, @unchecked Sendable { private let lock = NSLock() private var _lastCopyValue: String? var lastCopyValue: String? { lock.lock() defer { lock.unlock() } return _lastCopyValue } func copy(value: String, expiry: TimeInterval) { lock.lock() defer { lock.unlock() } _lastCopyValue = value } func copy(value: String) { lock.lock() defer { lock.unlock() } _lastCopyValue = value } } ================================================ FILE: Airship/AirshipCore/Tests/PermissionsManagerTests.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable public import AirshipCore class PermissionsManagerTests: XCTestCase { var delegate: TestPermissionsDelegate! var systemSettingsNavigator: TestSystemSettingsNavigator! var permissionsManager: DefaultAirshipPermissionsManager! let appStateTracker = TestAppStateTracker() @MainActor override func setUp() async throws { self.systemSettingsNavigator = TestSystemSettingsNavigator() permissionsManager = DefaultAirshipPermissionsManager( appStateTracker: appStateTracker, systemSettingsNavigator: systemSettingsNavigator ) self.delegate = TestPermissionsDelegate() } func testCheckPermissionNotConfigured() async throws { let status = await self.permissionsManager.checkPermissionStatus(.displayNotifications) XCTAssertEqual(AirshipPermissionStatus.notDetermined, status) } @MainActor func testCheckPermission() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .granted let status = await self.permissionsManager.checkPermissionStatus(.location) XCTAssertEqual(AirshipPermissionStatus.granted, status) XCTAssertTrue(self.delegate.checkCalled) XCTAssertFalse(self.delegate.requestCalled) } @MainActor func testStatusUpdate() async { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .denied var stream = self.permissionsManager.statusUpdate(for: .location).makeAsyncIterator() let status = await self.permissionsManager.requestPermission(.location) let currentStatus = await stream.next() XCTAssertEqual(AirshipPermissionStatus.denied, status) XCTAssertEqual(status, currentStatus) } @MainActor func testStatusRefreshOnActive() async { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .denied var stream = self.permissionsManager.statusUpdate(for: .location).makeAsyncIterator() var currentStatus = await stream.next() XCTAssertEqual(AirshipPermissionStatus.denied, currentStatus) self.delegate.permissionStatus = .granted await self.appStateTracker.updateState(.active) currentStatus = await stream.next() XCTAssertEqual(AirshipPermissionStatus.granted, currentStatus) } func testRequestPermissionNotConfigured() async throws { let status = await self.permissionsManager.requestPermission(.displayNotifications) XCTAssertEqual(AirshipPermissionStatus.notDetermined, status) } @MainActor func testRequestPermissionNotDetermined() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .notDetermined let status = await self.permissionsManager.requestPermission(.location) XCTAssertEqual(AirshipPermissionStatus.notDetermined, status) XCTAssertTrue(self.delegate.requestCalled) XCTAssertTrue(self.delegate.checkCalled) } @MainActor func testRequestPermissionDenied() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .denied let status = await self.permissionsManager.requestPermission(.location) XCTAssertEqual(AirshipPermissionStatus.denied, status) XCTAssertTrue(self.delegate.requestCalled) XCTAssertTrue(self.delegate.checkCalled) } @MainActor func testRequestPermissionGranted() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .granted let status = await self.permissionsManager.requestPermission(.location) XCTAssertEqual(AirshipPermissionStatus.granted, status) XCTAssertTrue(self.delegate.requestCalled) XCTAssertTrue(self.delegate.checkCalled) } @MainActor func testRequestPermissionSystemSettingsFallback() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .denied _ = await self.permissionsManager.requestPermission(.location, enableAirshipUsageOnGrant: false, fallback: .systemSettings) XCTAssertTrue(self.delegate.requestCalled) XCTAssertTrue(self.delegate.checkCalled) XCTAssertEqual(systemSettingsNavigator.permissionOpens, [.location]) } @MainActor func testRequestPermissionSystemSettingsFallbackFailsToOpen() async throws { self.systemSettingsNavigator.permissionOpenResult = false self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .denied _ = await self.permissionsManager.requestPermission(.location, enableAirshipUsageOnGrant: false, fallback: .systemSettings) XCTAssertTrue(self.delegate.requestCalled) XCTAssertTrue(self.delegate.checkCalled) XCTAssertEqual(systemSettingsNavigator.permissionOpens, [.location]) } @MainActor func testRequestPermissionCallbackFallback() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .denied let status = await self.permissionsManager.requestPermission( .location, enableAirshipUsageOnGrant: false, fallback: .callback({ self.delegate.permissionStatus = .granted }) ) XCTAssertEqual(AirshipPermissionStatus.granted, status.endStatus) XCTAssertTrue(self.delegate.requestCalled) XCTAssertTrue(self.delegate.checkCalled) } func testConfiguredPermissionsEmpty() throws { XCTAssertTrue(self.permissionsManager.configuredPermissions.isEmpty) } func testConfiguredPermissions() throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.permissionsManager.setDelegate( self.delegate, permission: .displayNotifications ) let expected = Set<AirshipPermission>([.location, .displayNotifications]) let configured = self.permissionsManager.configuredPermissions XCTAssertEqual(expected, configured) } @MainActor func testAirshipEnablers() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .displayNotifications ) self.delegate.permissionStatus = .granted let enablerCalled = self.expectation(description: "Enabler called") self.permissionsManager.addAirshipEnabler( permission: .displayNotifications ) { enablerCalled.fulfill() } let _ = await self.permissionsManager.requestPermission( .displayNotifications, enableAirshipUsageOnGrant: true ) await self.fulfillment(of: [enablerCalled], timeout: 1) } @MainActor func testRequestExtender() async throws { self.permissionsManager.setDelegate( self.delegate, permission: .location ) self.delegate.permissionStatus = .denied let listener1 = self.expectation(description: "Listener 1") self.permissionsManager.addRequestExtender(permission: .location) { status in listener1.fulfill() } let listener2 = self.expectation(description: "Listener 2") self.permissionsManager.addRequestExtender(permission: .location) { status in listener2.fulfill() } let status = await self.permissionsManager.requestPermission(.location) XCTAssertEqual(AirshipPermissionStatus.denied, status) await self.fulfillment( of: [listener1, listener2], timeout: 1, enforceOrder: true ) } } @MainActor final class TestPermissionsDelegate: AirshipPermissionDelegate { public var permissionStatus: AirshipPermissionStatus = .notDetermined var checkCalled: Bool = false var requestCalled: Bool = false public func checkPermissionStatus() async -> AirshipPermissionStatus { self.checkCalled = true return permissionStatus } public func requestPermission() async -> AirshipPermissionStatus { self.requestCalled = true return permissionStatus } } @MainActor public final class TestSystemSettingsNavigator: SystemSettingsNavigatorProtocol { var permissionOpens: [AirshipPermission] = [] var permissionOpenResult = false public func open(for permission: AirshipPermission) async -> Bool { permissionOpens.append(permission) return permissionOpenResult } } ================================================ FILE: Airship/AirshipCore/Tests/PreferenceDataStoreTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class PreferenceDataStoreTest: XCTestCase { private let airshipDefaults = UserDefaults( suiteName: "\(Bundle.main.bundleIdentifier ?? "").airship.settings" )! private let appKey = UUID().uuidString private let testDeviceID = TestDeviceID() func testPrefix() throws { let dataStore = PreferenceDataStore( appKey: self.appKey, dispatcher: TestDispatcher(), deviceID: testDeviceID ) dataStore.setObject("neat", forKey: "some-key") XCTAssertEqual( "neat", airshipDefaults.string(forKey: "\(self.appKey)some-key") ) } /// Tests merging data from the old keys in either standard or the Airship defaults: /// - If a value exists under the old key but not the new key, it will be restored under the new key /// - If channel tags exists under both keys we will merge the two tag arrays func testMergeKeys() throws { let standardDefaults = UserDefaults.standard let legacyPrefix = "com.urbanairship.\(appKey)." let newPrefix = self.appKey let tagsKey = "com.urbanairship.channel.tags" standardDefaults.set("keep-new: old", forKey: "\(legacyPrefix)keep-new") self.airshipDefaults.set( "keep-new: new", forKey: "\(newPrefix)keep-new" ) standardDefaults.set( "restore-old: old", forKey: "\(legacyPrefix)restore-old" ) self.airshipDefaults.set( "another-keep-new: old", forKey: "\(legacyPrefix)another-keep-new" ) self.airshipDefaults.set( "another-keep-new: new", forKey: "\(newPrefix)another-keep-new" ) self.airshipDefaults.set( "another-restore-old: old", forKey: "\(legacyPrefix)another-restore-old" ) standardDefaults.set(["a", "b"], forKey: "\(legacyPrefix)\(tagsKey)") self.airshipDefaults.set(["c"], forKey: "\(newPrefix)\(tagsKey)") let dataStore = PreferenceDataStore(appKey: self.appKey) XCTAssertEqual( "another-keep-new: new", dataStore.string(forKey: "another-keep-new") ) XCTAssertEqual( "another-restore-old: old", dataStore.string(forKey: "another-restore-old") ) XCTAssertEqual("keep-new: new", dataStore.string(forKey: "keep-new")) XCTAssertEqual( "restore-old: old", dataStore.string(forKey: "restore-old") ) XCTAssertEqual(["a", "b", "c"], dataStore.stringArray(forKey: tagsKey)) } func testData() throws { let dataStore = PreferenceDataStore(appKey: self.appKey) let data = "neat".data(using: .utf8) dataStore.setObject(data, forKey: "data") XCTAssertEqual(data, dataStore.data(forKey: "data")) dataStore.setBool(false, forKey: "falseBool") XCTAssertFalse(dataStore.bool(forKey: "falseBool")) dataStore.setBool(true, forKey: "trueBool") XCTAssertTrue(dataStore.bool(forKey: "trueBool")) let array = ["neat", "rad"] dataStore.setObject(array, forKey: "array") XCTAssertEqual(array, dataStore.array(forKey: "array")) let dict = ["neat": "rad"] dataStore.setObject(dict, forKey: "dict") XCTAssertEqual( dict, dataStore.dictionary(forKey: "dict") as! [String: String] ) let float: Float = 2.0 dataStore.setFloat(float, forKey: "float") XCTAssertEqual(float, dataStore.float(forKey: "float")) let double: Double = 3.0 dataStore.setDouble(double, forKey: "double") XCTAssertEqual(double, dataStore.double(forKey: "double")) let int: Int = 1 dataStore.setInteger(int, forKey: "int") XCTAssertEqual(int, dataStore.integer(forKey: "int")) let date = Date() dataStore.setObject(date, forKey: "date") XCTAssertEqual(date, dataStore.object(forKey: "date") as! Date) } func testNil() throws { let dataStore = PreferenceDataStore(appKey: self.appKey) XCTAssertNil(dataStore.object(forKey: "nil?")) dataStore.setObject("not nil", forKey: "nil?") XCTAssertNotNil(dataStore.object(forKey: "nil?")) dataStore.setObject(nil, forKey: "nil?") XCTAssertNil(dataStore.object(forKey: "nil?")) } func testDefaults() throws { let dataStore = PreferenceDataStore(appKey: self.appKey) XCTAssertEqual( 100.0, dataStore.double(forKey: "neat", defaultValue: 100.0) ) XCTAssertEqual(true, dataStore.bool(forKey: "neat", defaultValue: true)) XCTAssertEqual( dataStore.double(forKey: "neat"), self.airshipDefaults.double(forKey: "neat") ) XCTAssertEqual( dataStore.float(forKey: "neat"), self.airshipDefaults.float(forKey: "neat") ) XCTAssertEqual( dataStore.bool(forKey: "neat"), self.airshipDefaults.bool(forKey: "neat") ) XCTAssertEqual( dataStore.integer(forKey: "neat"), self.airshipDefaults.integer(forKey: "neat") ) } func testCodable() throws { let dataStore = PreferenceDataStore(appKey: self.appKey) let nilValue: FooCodable? = try dataStore.codable(forKey: "codable") XCTAssertNil(nilValue) let codable = FooCodable(foo: "woot") try dataStore.setCodable(codable, forKey: "codable") XCTAssertEqual(codable, try dataStore.codable(forKey: "codable")) } func testCodableWrongType() throws { let dataStore = PreferenceDataStore(appKey: self.appKey) let foo = FooCodable(foo: "woot") try dataStore.setCodable(foo, forKey: "codable") XCTAssertThrowsError( try { let _: BarCodable? = try dataStore.codable(forKey: "codable") }() ) } func testAppNotRestoredNoData() async throws { let dataStore = PreferenceDataStore( appKey: self.appKey, dispatcher: TestDispatcher(), deviceID: testDeviceID ) let value = await dataStore.isAppRestore XCTAssertFalse(value) } func testAppRestoredDeviceIDChange() async throws { let dataStore = PreferenceDataStore( appKey: self.appKey, dispatcher: TestDispatcher(), deviceID: testDeviceID ) var value = await dataStore.isAppRestore XCTAssertFalse(value) await self.testDeviceID.setValue(value: UUID().uuidString) value = await dataStore.isAppRestore XCTAssertTrue(value) } func testKeyIsStoredAndRetrieved() { let dataStore = PreferenceDataStore( appKey: self.appKey, dispatcher: TestDispatcher(), deviceID: testDeviceID ) let value = ProcessInfo.processInfo.globallyUniqueString dataStore.setObject(value, forKey: "key") XCTAssertEqual(dataStore.string(forKey: "key"), value) } func testKeyIsRemoved() { let dataStore = PreferenceDataStore( appKey: self.appKey, dispatcher: TestDispatcher(), deviceID: testDeviceID ) let value = ProcessInfo.processInfo.globallyUniqueString dataStore.setObject(value, forKey: "key") XCTAssertEqual(dataStore.object(forKey: "key") as? String, value) dataStore.removeObject(forKey: "key") XCTAssertNil(dataStore.object(forKey: "key")) } func testMigration() { let prefix = UUID().uuidString UserDefaults.standard.set(true, forKey: "\(prefix)some-key") let dataStore = PreferenceDataStore( appKey: prefix, dispatcher: TestDispatcher(), deviceID: testDeviceID ) XCTAssertTrue(dataStore.bool(forKey: "some-key")) } } private struct FooCodable: Codable, Equatable { let foo: String } private struct BarCodable: Codable, Equatable { let bar: String } fileprivate actor TestDeviceID: AirshipDeviceIDProtocol { var value: String = UUID().uuidString init() {} public func setValue(value: String) { self.value = value } } ================================================ FILE: Airship/AirshipCore/Tests/PromptPermissionActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class PromptPermissionActionTest: XCTestCase { let testPrompter = TestPermissionPrompter() var action: PromptPermissionAction! override func setUpWithError() throws { self.action = PromptPermissionAction { return self.testPrompter } } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ] let rejectedSituations = [ ActionSituation.backgroundPush, ActionSituation.backgroundInteractiveButton, ] for situation in validSituations { let args = ActionArguments(value: AirshipJSON.string("anything"), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(value: AirshipJSON.string("anything"), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } func testPrompt() async throws { let actionValue: [String: Any] = [ "permission": AirshipPermission.location.rawValue, "enable_airship_usage": true, "fallback_system_settings": true, ] let arguments = ActionArguments( value: try! AirshipJSON.wrap(actionValue) ) let prompted = self.expectation(description: "Prompted") testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in XCTAssertEqual(permission, .location) XCTAssertTrue(enableAirshipUsage) XCTAssertTrue(fallbackSystemSetting) prompted.fulfill() return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } let result = try await self.action.perform(arguments: arguments) XCTAssertNil(result) await self.fulfillment(of: [prompted], timeout: 10) } func testPromptDefaultArguments() async throws { let actionValue = [ "permission": AirshipPermission.displayNotifications.rawValue ] let arguments = ActionArguments( value: try! AirshipJSON.wrap(actionValue), situation: .manualInvocation ) let prompted = self.expectation(description: "Prompted") testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in XCTAssertEqual(permission, .displayNotifications) XCTAssertFalse(enableAirshipUsage) XCTAssertFalse(fallbackSystemSetting) prompted.fulfill() return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } let result = try await self.action.perform(arguments: arguments) XCTAssertNil(result) await self.fulfillment(of: [prompted], timeout: 10) } func testInvalidPermission() async throws { let actionValue: [String: Any] = [ "permission": "not a permission" ] let arguments = ActionArguments( value: try! AirshipJSON.wrap(actionValue), situation: .manualInvocation ) testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in XCTFail() return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .notDetermined) } do { _ = try await self.action.perform(arguments: arguments) XCTFail("Should throw") } catch {} } func testResultReceiver() async throws { let actionValue: [String: Any] = [ "permission": AirshipPermission.location.rawValue ] let resultReceived = self.expectation(description: "Result received") let resultRecevier: @Sendable (AirshipPermission, AirshipPermissionStatus, AirshipPermissionStatus) async -> Void = { permission, start, end in XCTAssertEqual(.notDetermined, start) XCTAssertEqual(.granted, end) XCTAssertEqual(.location, permission) resultReceived.fulfill() } let metadata = [ PromptPermissionAction.resultReceiverMetadataKey: resultRecevier ] let arguments = ActionArguments( value: try! AirshipJSON.wrap(actionValue), situation: .manualInvocation, metadata: metadata ) testPrompter.onPrompt = { permission, enableAirshipUsage, fallbackSystemSetting in return AirshipPermissionResult(startStatus: .notDetermined, endStatus: .granted) } _ = try await self.action.perform(arguments: arguments) await self.fulfillment(of: [resultReceived], timeout: 10) } } ================================================ FILE: Airship/AirshipCore/Tests/ProximityRegionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ProximityRegionTest: XCTestCase { private var defaultRegionId: String { return "".padding(toLength: 255, withPad: "PROXIMITY_ID", startingAt: 0) } /** * Test creating a proximity region with a valid proximity ID major and minor */ func testCreateValidProximityRegion() { let region = ProximityRegion(proximityID: defaultRegionId, major: 1.0, minor: 2.0) XCTAssertNotNil(region) } /** * Test creating a proximity region with invalid proximity IDs */ func testSetInvalidProximityID() { var id = "".padding(toLength: 256, withPad: "PROXIMITY_ID", startingAt: 0) // test proximity ID greater than max var region = ProximityRegion(proximityID: id, major: 1.0, minor: 2.0) XCTAssertNil(region, "Proximity region should be nil if proximity ID fails to set.") // test proximity ID less than min id = "" region = ProximityRegion(proximityID: id, major: 1.0, minor: 2.0) XCTAssertNil(region, "Proximity region should be nil if proximity ID fails to set.") } /** * Test creating a proximity region with invalid major and minor */ func testSetInvalidMajorMinor() { var major: Double = -1 var minor: Double = -2 // test major and minor less than min var region = ProximityRegion(proximityID: defaultRegionId, major: major, minor: minor) XCTAssertNil(region, "Proximity region should be nil if major or minor fails to set.") // test major and minor greater than max major = Double(UINT16_MAX + 1) minor = Double(UINT16_MAX + 1) region = ProximityRegion(proximityID: defaultRegionId, major: major, minor: minor) XCTAssertNil(region, "Proximity region should be nil if major or minor fails to set.") } /** * Test creating a proximity region with a valid RSSI */ func testSetValidRSSI() { let region59dBm = ProximityRegion(proximityID: defaultRegionId, major: 1, minor: 2, rssi: -59) // test an RSSI of -59 dBm XCTAssertNotNil(region59dBm) let region0dBm = ProximityRegion(proximityID: defaultRegionId, major: 1, minor: 2, rssi: 0) // test RSSI of 0 dBm XCTAssertNotNil(region0dBm) } /** * Test creating a proximity region and setting a invalid RSSIs */ func testSetInvalidRSSI() { var region = ProximityRegion(proximityID: defaultRegionId, major: 1, minor: 2, rssi: 101) XCTAssertNil(region, "RSSIs over 100 or under -100 dBm should be ignored.") region = ProximityRegion(proximityID: defaultRegionId, major: 1, minor: 2, rssi: -101) XCTAssertNil(region, "RSSIs over 100 or under -100 dBm should be ignored.") } } ================================================ FILE: Airship/AirshipCore/Tests/RateAppActionTest.swift ================================================ import XCTest @testable import AirshipCore class RateAppActionTest: XCTestCase { private let testAppRater = TestAppRater() private var configItunesID: String? = nil private var action: RateAppAction! override func setUpWithError() throws { self.action = RateAppAction( appRater: self.testAppRater ) { return self.configItunesID } } func testShowPrompt() async throws { let args: [String: Any] = [ "show_link_prompt": true, "itunes_id": "test id", ] let result = try await action.perform(arguments: ActionArguments( value: try AirshipJSON.wrap(args), situation: .manualInvocation ) ) XCTAssertNil(result) XCTAssertTrue(testAppRater.showPromptCalled) XCTAssertNil(testAppRater.openStoreItunesID) } func testOpenAppStore() async throws { let args: [String: Any] = [ "itunes_id": "test id" ] let result = try await action.perform(arguments: ActionArguments( value: try AirshipJSON.wrap(args), situation: .manualInvocation ) ) XCTAssertNil(result) XCTAssertFalse(testAppRater.showPromptCalled) XCTAssertEqual("test id", testAppRater.openStoreItunesID) } func testOpenAppStoreFallbackItunesID() async throws { self.configItunesID = "config iTunes ID" let args: [String: Any] = [:] let result = try await action.perform(arguments: ActionArguments( value: try AirshipJSON.wrap(args), situation: .manualInvocation ) ) XCTAssertNil(result) XCTAssertFalse(testAppRater.showPromptCalled) XCTAssertEqual(configItunesID, testAppRater.openStoreItunesID) } func testNilConfig() async throws { self.configItunesID = "config iTunes ID" let result = try await action.perform(arguments: ActionArguments( value: AirshipJSON.null, situation: .manualInvocation ) ) XCTAssertNil(result) XCTAssertFalse(testAppRater.showPromptCalled) XCTAssertEqual(configItunesID, testAppRater.openStoreItunesID) } func testNoItunesID() async throws { self.configItunesID = nil do { _ = try await action.perform(arguments: ActionArguments( value: AirshipJSON.null, situation: .manualInvocation ) ) XCTFail("should throw") } catch {} XCTAssertFalse(testAppRater.showPromptCalled) XCTAssertNil(testAppRater.openStoreItunesID) } func testInvalidArgs() async throws { self.configItunesID = "config id" do { _ = try await action.perform(arguments: ActionArguments( string: "invalid" ) ) XCTFail("should throw") } catch {} XCTAssertFalse(testAppRater.showPromptCalled) XCTAssertNil(testAppRater.openStoreItunesID) } func testAcceptsArguments() async throws { let validSituations: [ActionSituation] = [ .manualInvocation, .automation, .foregroundPush, .foregroundInteractiveButton, .webViewInvocation, .launchedFromPush, ] for situation in validSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } } func testRejectsArguments() async throws { let invalidSituations: [ActionSituation] = [ .backgroundPush, .backgroundInteractiveButton, ] for situation in invalidSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } fileprivate class TestAppRater: AppRaterProtocol, @unchecked Sendable { var showPromptCalled = false var openStoreItunesID: String? = nil func openStore(itunesID: String) async throws { openStoreItunesID = itunesID } func showPrompt() throws { showPromptCalled = true } } } ================================================ FILE: Airship/AirshipCore/Tests/RegionEventTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class RegionEventTest: XCTestCase { private var coordinates: (latitude: Double, longitude: Double) = (45.5200, 122.6819) private var validRegionId: String { return "".padding(toLength: 255, withPad: "REGION_ID", startingAt: 0) } private var validSource: String { return "".padding(toLength: 255, withPad: "SOURCE", startingAt: 0) } /** * Test region event data directly. */ func testRegionEventData() { let circular = CircularRegion(radius: 11, latitude: coordinates.latitude, longitude: coordinates.longitude) let proximity = ProximityRegion(proximityID: "proximity_id", major: 1, minor: 11, rssi: -59, latitude: coordinates.latitude, longitude: coordinates.longitude) let event = RegionEvent(regionID: "region_id", source: "source", boundaryEvent: .enter, circularRegion: circular, proximityRegion: proximity) let expected: [String: Any] = [ "action": "enter", "region_id": "region_id", "source": "source", "circular_region": [ "latitude": "45.5200000", "longitude": "122.6819000", "radius": "11.0" ], "proximity": [ "minor": 11, "rssi": -59, "major": 1, "proximity_id": "proximity_id", "latitude": "45.5200000", "longitude": "122.6819000" ] ] XCTAssertEqual(try! AirshipJSON.wrap(expected), try! event?.eventBody(stringifyFields: true)) } /** * Test setting a region event ID. */ func testSetRegionEventID() { var event = RegionEvent(regionID: self.validRegionId, source: self.validSource, boundaryEvent: .enter) XCTAssertEqual(self.validRegionId, event?.regionID) let invalidRegionId = "".padding(toLength: 256, withPad: "REGION_ID", startingAt: 0) event = RegionEvent(regionID: invalidRegionId, source: self.validSource, boundaryEvent: .enter) XCTAssertNil(event, "Region IDs larger than 255 characters should be ignored") event = RegionEvent(regionID: "", source: self.validSource, boundaryEvent: .enter) XCTAssertNil(event, "Region IDs less than 1 character should be ignored") } /** * Test setting a region event source. */ func testSetSource() { var event = RegionEvent(regionID: self.validRegionId, source: self.validSource, boundaryEvent: .enter) XCTAssertEqual(event?.source, validSource, "255 character source should be valid") let invalidSource = "".padding(toLength: 256, withPad: "source", startingAt: 0) event = RegionEvent(regionID: self.validRegionId, source: invalidSource, boundaryEvent: .enter) XCTAssertNil(event, "Sources larger than 255 characters should be ignored") event = RegionEvent(regionID: self.validRegionId, source: "", boundaryEvent: .enter) XCTAssertNil(event, "Sources less than 1 character should be ignored") event = RegionEvent(regionID: self.validRegionId, source: self.validSource, boundaryEvent: .enter) XCTAssertEqual(event?.source, validSource, "255 character source should be valid") } /** * Test creating a region event without a proximity or circular region */ func testRegionEvent() { let event = RegionEvent(regionID: self.validRegionId, source: self.validSource, boundaryEvent: .enter) let expected: [String: Any] = [ "action": "enter", "region_id": "\(self.validRegionId)", "source": "\(self.validSource)", ] XCTAssertEqual(try! AirshipJSON.wrap(expected), try! event?.eventBody(stringifyFields: true)) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteConfigManagerTest.swift ================================================ import XCTest @testable import AirshipCore class RemoteConfigManagerTest: XCTestCase { private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private let testRemoteData = TestRemoteData() private let notificationCenter = AirshipNotificationCenter.shared private var privacyManager: TestPrivacyManager! private var remoteConfigManager: RemoteConfigManager! private var config: RuntimeConfig = RuntimeConfig.testConfig() override func setUp() async throws { self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: self.config, defaultEnabledFeatures: .all, notificationCenter: self.notificationCenter ) self.remoteConfigManager = RemoteConfigManager( config: config, remoteData: self.testRemoteData, privacyManager: self.privacyManager, notificationCenter: self.notificationCenter, appVersion: "0.0.0" ) self.remoteConfigManager.airshipReady() } @MainActor func testEmptyConfig() throws { self.config.updateRemoteConfig( RemoteConfig( airshipConfig: RemoteConfig.AirshipConfig( remoteDataURL: "cool://remote", deviceAPIURL: "cool://devices", analyticsURL: "cool://analytics", meteredUsageURL: "cool://meteredUsage" ) ) ) let payload = RemoteDataPayload( type: "app_config", timestamp: Date(), data: AirshipJSON.null, remoteDataInfo: nil ) let expectation = expectation(description: "config updated") self.config.addRemoteConfigListener(notifyCurrent: false) { _, new in XCTAssertEqual(RemoteConfig(), new) expectation.fulfill() } self.testRemoteData.payloads = [payload] wait(for: [expectation], timeout: 10.0) } @MainActor func testRemoteConfig() throws { let remoteConfig = RemoteConfig( airshipConfig: RemoteConfig.AirshipConfig( remoteDataURL: "cool://remote", deviceAPIURL: "cool://devices", analyticsURL: "cool://analytics", meteredUsageURL: "cool://meteredUsage" ), meteredUsageConfig: RemoteConfig.MeteredUsageConfig( isEnabled: true, initialDelayMilliseconds: nil, intervalMilliseconds: nil ) ) let payload = RemoteDataPayload( type: "app_config", timestamp: Date(), data: try! AirshipJSON.wrap(remoteConfig), remoteDataInfo: nil ) let expectation = expectation(description: "config updated") self.config.addRemoteConfigListener(notifyCurrent: false) { _, new in XCTAssertEqual(remoteConfig, new) expectation.fulfill() } self.testRemoteData.payloads = [payload] wait(for: [expectation], timeout: 10.0) } @MainActor func testCombienConfig() throws { let iosConfig = RemoteConfig( airshipConfig: RemoteConfig.AirshipConfig( remoteDataURL: "ios://remote", deviceAPIURL: "ios://devices", analyticsURL: "ios://analytics", meteredUsageURL: "ios://meteredUsage" ) ) let commonConfig = RemoteConfig( airshipConfig: RemoteConfig.AirshipConfig( remoteDataURL: "common://remote", deviceAPIURL: "common://devices", analyticsURL: "common://analytics", meteredUsageURL: "common://meteredUsage" ), meteredUsageConfig: RemoteConfig.MeteredUsageConfig( isEnabled: true, initialDelayMilliseconds: nil, intervalMilliseconds: nil ) ) let expectedConfig = RemoteConfig( airshipConfig: iosConfig.airshipConfig, meteredUsageConfig: commonConfig.meteredUsageConfig ) let platformPayload = RemoteDataPayload( type: "app_config:ios", timestamp: Date(), data: try! AirshipJSON.wrap(iosConfig), remoteDataInfo: nil ) let commonPayload = RemoteDataPayload( type: "app_config", timestamp: Date(), data: try! AirshipJSON.wrap(commonConfig), remoteDataInfo: nil ) let expectation = expectation(description: "config updated") self.config.addRemoteConfigListener(notifyCurrent: false) { _, new in XCTAssertEqual(expectedConfig, new) expectation.fulfill() } self.testRemoteData.payloads = [commonPayload, platformPayload] wait(for: [expectation], timeout: 10.0) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteConfigTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class RemoteConfigTest: XCTestCase { private let encoder = JSONEncoder() private let decoder = JSONDecoder() func testParseEmpty() throws { let json = "{}" let emptyConfig = try self.decoder.decode(RemoteConfig.self, from: json.data(using: .utf8)!) XCTAssertEqual(emptyConfig, RemoteConfig()) } func testJson() throws { let json = """ { "metered_usage":{ "initial_delay_ms":100, "interval_ms":200, "enabled":true }, "airship_config":{ "device_api_url":"device-api-url", "analytics_url":"analytics-url", "wallet_url":"wallet-url", "remote_data_url":"remote-data-url", "metered_usage_url":"metered-usage-url" }, "contact_config":{ "max_cra_resolve_age_ms":300, "foreground_resolve_interval_ms":400 }, "fetch_contact_remote_data":true, "disabled_features": ["push", "analytics"], "in_app_config": { "additional_audience_check": { "enabled": true, "context": "json-value", "url": "https://test.url" } } } """ let expected = RemoteConfig( airshipConfig: .init( remoteDataURL: "remote-data-url", deviceAPIURL: "device-api-url", analyticsURL: "analytics-url", meteredUsageURL: "metered-usage-url" ), meteredUsageConfig: .init( isEnabled: true, initialDelayMilliseconds: 100, intervalMilliseconds: 200 ), fetchContactRemoteData: true, contactConfig: .init( foregroundIntervalMilliseconds: 400, channelRegistrationMaxResolveAgeMilliseconds: 300 ), disabledFeatures: [.push, .analytics], iaaConfig: .init( retryingQueue: nil, additionalAudienceConfig: .init(isEnabled: true, context: "json-value", url: "https://test.url")) ) let config = try self.decoder.decode(RemoteConfig.self, from: json.data(using: .utf8)!) XCTAssertEqual(config, expected) let roundTrip = try self.decoder.decode(RemoteConfig.self, from: try self.encoder.encode(expected)) XCTAssertEqual(roundTrip, expected) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteDataAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class RemoteDataAPIClientTest: AirshipBaseTest { var remoteDataAPIClient: RemoteDataAPIClient! private let testSession: TestAirshipRequestSession = TestAirshipRequestSession() private static let validData = """ { "message_center":{ "background_color":"0000FF", "font":"Comic Sans" } } """ private static let validResponse = """ { "ok":true, "payloads":[ { "type":"test_data_type", "timestamp":"2017-01-01T12:00:00", "data":\(validData) } ] } """ private let exampleURL: URL = URL(string: "exampleurl://")! override func setUpWithError() throws { self.remoteDataAPIClient = RemoteDataAPIClient( config: self.config, session: self.testSession ) } func testFetch() async throws { self.testSession.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: ["Last-Modified": "new last modified"] ) self.testSession.data = RemoteDataAPIClientTest.validResponse.data(using: .utf8) let exampleURL = self.exampleURL let response = try await self.remoteDataAPIClient.fetchRemoteData( url: exampleURL, auth: .contactAuthToken(identifier: "some contact ID"), lastModified: "current last modified" ) { lastModified in XCTAssertEqual(lastModified, "new last modified") return RemoteDataInfo(url: exampleURL, lastModifiedTime: lastModified, source: .contact) } let expectedResult = RemoteDataResult( payloads: [ RemoteDataPayload( type: "test_data_type", timestamp: AirshipDateFormatter.date(fromISOString: "2017-01-01T12:00:00")!, data: try! AirshipJSON.from(json: RemoteDataAPIClientTest.validData), remoteDataInfo: RemoteDataInfo( url: self.exampleURL, lastModifiedTime: "new last modified", source: .contact ) ) ], remoteDataInfo: RemoteDataInfo( url: self.exampleURL, lastModifiedTime: "new last modified", source: .contact ) ) let expectedHeaders = [ "X-UA-Appkey": "\(config.appCredentials.appKey)", "If-Modified-Since": "current last modified", "Accept": "application/vnd.urbanairship+json; version=3;" ] XCTAssertEqual(200, response.statusCode) XCTAssertEqual(expectedResult, response.result) XCTAssertEqual("GET", self.testSession.lastRequest?.method) XCTAssertEqual(self.exampleURL, self.testSession.lastRequest?.url) XCTAssertEqual(expectedHeaders, self.testSession.lastRequest?.headers) XCTAssertEqual(AirshipRequestAuth.contactAuthToken(identifier: "some contact ID"), self.testSession.lastRequest?.auth) } func testFetch304() async throws { self.testSession.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 304, httpVersion: "", headerFields: ["Last-Modified": "new last modified"] ) let exampleURL = self.exampleURL let response = try await self.remoteDataAPIClient.fetchRemoteData( url: self.exampleURL, auth: .contactAuthToken(identifier: "some contact ID"), lastModified: "current last modified" ) { lastModified in XCTFail("Should not be reached") return RemoteDataInfo(url: exampleURL, lastModifiedTime: lastModified, source: .contact) } XCTAssertEqual(304, response.statusCode) XCTAssertNil(response.result) } func testEmptyResponse() async throws { self.testSession.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: ["Last-Modified": "new last modified"] ) self.testSession.data = "{ \"ok\": true }".data(using: .utf8) let exampleURL = self.exampleURL let response = try await self.remoteDataAPIClient.fetchRemoteData( url: self.exampleURL, auth: .contactAuthToken(identifier: "some contact ID"), lastModified: "current last modified" ) { lastModified in return RemoteDataInfo(url: exampleURL, lastModifiedTime: lastModified, source: .contact) } let expectedResult = RemoteDataResult( payloads: [], remoteDataInfo: RemoteDataInfo( url: self.exampleURL, lastModifiedTime: "new last modified", source: .contact ) ) XCTAssertEqual(200, response.statusCode) XCTAssertEqual(expectedResult, response.result) } func testNoLastModified() async throws { self.testSession.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [:] ) self.testSession.data = "{ \"ok\": true }".data(using: .utf8) let exampleURL = self.exampleURL let response = try await self.remoteDataAPIClient.fetchRemoteData( url: self.exampleURL, auth: .basicAppAuth, lastModified: nil ) { lastModified in XCTAssertNil(lastModified) return RemoteDataInfo(url: exampleURL, lastModifiedTime: lastModified, source: .app) } let expectedResult = RemoteDataResult( payloads: [], remoteDataInfo: RemoteDataInfo( url: self.exampleURL, lastModifiedTime: nil, source: .app ) ) let expectedHeaders = [ "X-UA-Appkey": config.appCredentials.appKey, "Accept": "application/vnd.urbanairship+json; version=3;" ] XCTAssertEqual(200, response.statusCode) XCTAssertEqual(expectedResult, response.result) XCTAssertEqual(expectedHeaders, self.testSession.lastRequest?.headers) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteDataProviderTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class RemoteDataProviderTest: XCTestCase { private let delegate = TestRemoteDataProviderDelegate( source: .app, storeName: "RemoteDataProviderTest" ) private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private var provider: RemoteDataProvider! override func setUpWithError() throws { self.provider = RemoteDataProvider(dataStore: self.dataStore, delegate: self.delegate) } func testRefresh() async throws { let locale = Locale(identifier: "bs") let randomValue = 100 let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: self.delegate.source ) let refreshResult = RemoteDataResult( payloads: [ RemoteDataTestUtils.generatePayload( type: "some type", timestamp: Date(), data: ["cool": "data"], remoteDataInfo: remoteDataInfo ), RemoteDataTestUtils.generatePayload( type: "some other type", timestamp: Date(), data: ["cool": "data"], remoteDataInfo: remoteDataInfo ) ], remoteDataInfo: remoteDataInfo ) self.delegate.fetchRemoteDataCallback = { requestLocale, requestRandomValue, lastRemoteInfo in XCTAssertNil(lastRemoteInfo) XCTAssertEqual(locale, requestLocale) XCTAssertEqual(randomValue, requestRandomValue) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } let result = await self.provider.refresh( changeToken: "change", locale: locale, randomeValue: randomValue ) XCTAssertEqual(result, .newData) let payloads = await self.provider.payloads(types: ["some type", "some other type"]) XCTAssertEqual(refreshResult.payloads, payloads) } func testRefreshDisabled() async throws { let source = self.delegate.source self.delegate.fetchRemoteDataCallback = { _, _, _ in let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: source ) let refreshResult = RemoteDataResult( payloads: [ RemoteDataTestUtils.generatePayload( type: "foo", timestamp: Date(), data: ["cool": "data"], remoteDataInfo: remoteDataInfo ) ], remoteDataInfo: remoteDataInfo ) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } // Load data var refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .newData) var payloads = await self.provider.payloads(types: ["foo"]) XCTAssertFalse(payloads.isEmpty) _ = await self.provider.setEnabled(false) payloads = await self.provider.payloads(types: ["foo"]) XCTAssertTrue(payloads.isEmpty) // should clear data refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .newData) // should no-op refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .skipped) _ = await self.provider.setEnabled(true) payloads = await self.provider.payloads(types: ["foo"]) XCTAssertTrue(payloads.isEmpty) } func testRefreshSkipped() async throws { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: self.delegate.source ) self.delegate.fetchRemoteDataCallback = { _, _, _ in let refreshResult = RemoteDataResult( payloads: [ RemoteDataTestUtils.generatePayload( type: "foo", timestamp: Date(), data: ["cool": "data"], remoteDataInfo: remoteDataInfo ) ], remoteDataInfo: remoteDataInfo ) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } // Load data var refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .newData) var payloads = await self.provider.payloads(types: ["foo"]) XCTAssertFalse(payloads.isEmpty) // Refresh same data self.delegate.isRemoteDataInfoUpToDateCallback = { info, locale, randomValue in XCTAssertEqual(remoteDataInfo, info) XCTAssertEqual(Locale.current, locale) XCTAssertEqual(200, randomValue) return true } refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 200 ) XCTAssertEqual(refreshResult, .skipped) payloads = await self.provider.payloads(types: ["foo"]) XCTAssertFalse(payloads.isEmpty) // Change token update refreshResult = await self.provider.refresh( changeToken: "new change", locale: Locale.current, randomeValue: 200 ) XCTAssertEqual(refreshResult, .newData) payloads = await self.provider.payloads(types: ["foo"]) XCTAssertFalse(payloads.isEmpty) // Out of date self.delegate.isRemoteDataInfoUpToDateCallback = { info, locale, randomValue in XCTAssertEqual(remoteDataInfo, info) XCTAssertEqual(Locale.current, locale) XCTAssertEqual(200, randomValue) return false } refreshResult = await self.provider.refresh( changeToken: "new change", locale: Locale.current, randomeValue: 200 ) XCTAssertEqual(refreshResult, .newData) payloads = await self.provider.payloads(types: ["foo"]) XCTAssertFalse(payloads.isEmpty) } func testStatus() async throws { let de = Locale(identifier: "de") var status: RemoteDataSourceStatus! status = await self.provider.status(changeToken: "change", locale: de, randomeValue: 100) // No data XCTAssertEqual(status, .outOfDate) let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: self.delegate.source ) // Load data self.delegate.fetchRemoteDataCallback = { _, _, _ in let refreshResult = RemoteDataResult( payloads: [ RemoteDataTestUtils.generatePayload( type: "foo", timestamp: Date(), data: ["cool": "data"], remoteDataInfo: remoteDataInfo ) ], remoteDataInfo: remoteDataInfo ) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } _ = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) // Up to date self.delegate.isRemoteDataInfoUpToDateCallback = { info, locale, randomValue in return true } status = await self.provider.status(changeToken: "change", locale: de, randomeValue: 100) XCTAssertEqual(status, .upToDate) // Stale status = await self.provider.status(changeToken: "some other", locale: de, randomeValue: 100) XCTAssertEqual(status, .stale) self.delegate.isRemoteDataInfoUpToDateCallback = { info, locale, randomValue in return false } // Out of date from random value status = await self.provider.status(changeToken: "change", locale: de, randomeValue: 200) XCTAssertEqual(status, .outOfDate) // Out of date check over stale status = await self.provider.status(changeToken: "some other", locale: de, randomeValue: 100) XCTAssertEqual(status, .outOfDate) } func testRefresh304() async throws { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: self.delegate.source ) self.delegate.fetchRemoteDataCallback = { _, _, _ in let refreshResult = RemoteDataResult( payloads: [ RemoteDataTestUtils.generatePayload( type: "foo", timestamp: Date(), data: ["cool": "data"], remoteDataInfo: remoteDataInfo ) ], remoteDataInfo: remoteDataInfo ) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } // Load data var refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .newData) // 304 self.delegate.fetchRemoteDataCallback = { _, _, _ in return AirshipHTTPResponse(result: nil, statusCode: 304, headers: [:]) } refreshResult = await self.provider.refresh( changeToken: "new change", locale: Locale.current, randomeValue: 200 ) XCTAssertEqual(refreshResult, .skipped) } func testRefresh304WithoutLastModifiedFails() async throws { self.delegate.fetchRemoteDataCallback = { _, _, _ in return AirshipHTTPResponse(result: nil, statusCode: 304, headers: [:]) } let refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .failed) } func testRefreshClientError() async throws { self.delegate.fetchRemoteDataCallback = { _, _, _ in return AirshipHTTPResponse(result: nil, statusCode: 400, headers: [:]) } let refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .failed) } func testRefreshServerError() async throws { self.delegate.fetchRemoteDataCallback = { _, _, _ in return AirshipHTTPResponse(result: nil, statusCode: 500, headers: [:]) } let refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .failed) } func testRefreshThrows() async throws { self.delegate.fetchRemoteDataCallback = { _, _, _ in throw AirshipErrors.error("some error") } let refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .failed) } func testNotifyOutdated() async throws { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: self.delegate.source ) let requestCount = AirshipAtomicValue<Int>(0) self.delegate.fetchRemoteDataCallback = { _, _, _ in requestCount.value += 1 let refreshResult = RemoteDataResult( payloads: [ RemoteDataTestUtils.generatePayload( type: "foo", timestamp: Date(), data: ["cool": "data"], remoteDataInfo: remoteDataInfo ) ], remoteDataInfo: remoteDataInfo ) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } self.delegate.isRemoteDataInfoUpToDateCallback = { _, _, _ in return true } // Load data var refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .newData) XCTAssertEqual(1, requestCount.value) // skipped refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .skipped) XCTAssertEqual(1, requestCount.value) // Notify different outdated remote info _ = await self.provider.notifyOutdated( remoteDataInfo: RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some other last modified", source: self.delegate.source ) ) // still skipped refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .skipped) XCTAssertEqual(1, requestCount.value) // Notify outdated remote info _ = await self.provider.notifyOutdated( remoteDataInfo: remoteDataInfo ) // Refresh refreshResult = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 100 ) XCTAssertEqual(refreshResult, .newData) XCTAssertEqual(2, requestCount.value) } func testIsCurrent() async throws { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: self.delegate.source ) // No data var isCurrent = await self.provider.isCurrent( locale: Locale.current, randomeValue: 100, remoteDataInfo: remoteDataInfo ) XCTAssertFalse(isCurrent) self.delegate.fetchRemoteDataCallback = { _, _, _ in let refreshResult = RemoteDataResult( payloads: [], remoteDataInfo: remoteDataInfo ) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } // Load data _ = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 0 ) self.delegate.isRemoteDataInfoUpToDateCallback = { currentInfo, locale, randomValue in XCTAssertEqual(currentInfo, remoteDataInfo) XCTAssertEqual(Locale.current, locale) XCTAssertEqual(0, randomValue) return true } isCurrent = await self.provider.isCurrent(locale: Locale.current, randomeValue: 0, remoteDataInfo: remoteDataInfo) XCTAssertTrue(isCurrent) self.delegate.isRemoteDataInfoUpToDateCallback = { _, _, _ in return false } isCurrent = await self.provider.isCurrent(locale: Locale.current, randomeValue: 0, remoteDataInfo: remoteDataInfo) XCTAssertFalse(isCurrent) } func testIsCurrentDifferentRemoteDataInfo() async throws { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some last modified", source: self.delegate.source ) self.delegate.fetchRemoteDataCallback = { _, _, _ in let refreshResult = RemoteDataResult( payloads: [], remoteDataInfo: remoteDataInfo ) return AirshipHTTPResponse(result: refreshResult, statusCode: 200, headers: [:]) } // Load data _ = await self.provider.refresh( changeToken: "change", locale: Locale.current, randomeValue: 0 ) self.delegate.isRemoteDataInfoUpToDateCallback = { currentInfo, locale, randomValue in return true } var isCurrent = await self.provider.isCurrent(locale: Locale.current, randomeValue: 0, remoteDataInfo: remoteDataInfo) XCTAssertTrue(isCurrent) let updatedRemoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: "some other last modified", source: self.delegate.source ) isCurrent = await self.provider.isCurrent(locale: Locale.current, randomeValue: 0, remoteDataInfo: updatedRemoteDataInfo) XCTAssertFalse(isCurrent) } } fileprivate class TestRemoteDataProviderDelegate: RemoteDataProviderDelegate, @unchecked Sendable { let source: RemoteDataSource let storeName: String var isRemoteDataInfoUpToDateCallback: (@Sendable (RemoteDataInfo, Locale, Int) async -> Bool)? var fetchRemoteDataCallback: (@Sendable (Locale, Int, RemoteDataInfo?) async throws -> AirshipHTTPResponse<RemoteDataResult>)? init(source: RemoteDataSource, storeName: String) { self.source = source self.storeName = storeName } func isRemoteDataInfoUpToDate( _ remoteDataInfo: RemoteDataInfo, locale: Locale, randomValue: Int ) async -> Bool { return await self.isRemoteDataInfoUpToDateCallback?(remoteDataInfo, locale, randomValue) ?? true } func fetchRemoteData(locale: Locale, randomValue: Int, lastRemoteDataInfo: RemoteDataInfo?) async throws -> AirshipHTTPResponse<RemoteDataResult> { return try await self.fetchRemoteDataCallback!(locale, randomValue, lastRemoteDataInfo) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteDataStoreTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class RemoteDataStoreTest: XCTestCase { private let remoteDataStore: RemoteDataStore = RemoteDataStore( storeName: "RemoteDataStoreTest", inMemory: true ) func testFirstRemoteData() async throws { let testPayload = createRemoteDataPayload() try await self.remoteDataStore.overwriteCachedRemoteData([testPayload]) let remoteDataStorePayloads = try await self.remoteDataStore.fetchRemoteDataFromCache() XCTAssertEqual([testPayload], remoteDataStorePayloads) } func testNewRemoteData() async throws { let testPayloads = [ createRemoteDataPayload(), createRemoteDataPayload(), createRemoteDataPayload() ].sorted(by: { first, second in first.type > second.type }) try await self.remoteDataStore.overwriteCachedRemoteData(testPayloads) var remoteDataStorePayloads = try await self.remoteDataStore.fetchRemoteDataFromCache() .sorted(by: { first, second in first.type > second.type }) XCTAssertEqual(testPayloads, remoteDataStorePayloads) let testPayload = createRemoteDataPayload(withType: testPayloads[1].type) // Sync only the modified message try await self.remoteDataStore.overwriteCachedRemoteData([testPayload]) // Verify we only have the modified message with the updated title remoteDataStorePayloads = try await self.remoteDataStore.fetchRemoteDataFromCache() XCTAssertEqual([testPayload], remoteDataStorePayloads) } func createRemoteDataPayload(withType type: String? = nil) -> RemoteDataPayload { return RemoteDataTestUtils.generatePayload( type: type ?? UUID().uuidString, timestamp: Date(), data: ["random": UUID().uuidString], source: .app ) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteDataTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore import Combine final class RemoteDataTest: AirshipBaseTest { private static let RefreshTask = "RemoteData.refresh" private let contactProvider: TestRemoteDataProvider = TestRemoteDataProvider(source: .contact, enabled: false) private let appProvider: TestRemoteDataProvider = TestRemoteDataProvider(source: .app, enabled: true) private let testDate: UATestDate = UATestDate(offset: 0, dateOverride: Date()) private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter( notificationCenter: NotificationCenter() ) private let testContact: TestContact = TestContact() private let testLocaleManager: TestLocaleManager = TestLocaleManager() private let testWorkManager: TestWorkManager = TestWorkManager() private var remoteData: RemoteData! private var privacyManager: TestPrivacyManager! override func setUp() async throws { self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: self.config, defaultEnabledFeatures: .all ) self.testDate.dateOverride = Date() self.testLocaleManager.currentLocale = Locale(identifier: "en-US") self.remoteData = await RemoteData( config: config, dataStore: self.dataStore, localeManager: self.testLocaleManager, privacyManager: self.privacyManager, contact: self.testContact, providers: [self.appProvider, self.contactProvider], workManager: self.testWorkManager, date: self.testDate, notificationCenter: self.notificationCenter, appVersion: "SomeAppVersion" ) await self.appProvider.setStatusCallback { _, _, _ in return .upToDate } await self.contactProvider.setStatusCallback { _, _, _ in return .upToDate } } func testRemoteConfigUpdatedEnqueuesRefresh() async { XCTAssertEqual(0, testWorkManager.workRequests.count) await self.config.updateRemoteConfig( RemoteConfig( airshipConfig: .init( remoteDataURL: "someURL", deviceAPIURL: "someURL", analyticsURL: "someURL", meteredUsageURL: "someURL" ) ) ) await self.remoteData.serialQueue.waitForCurrentOperations() XCTAssertEqual(1, testWorkManager.workRequests.count) } func testContactUpdateEnqueuesRefresh() { XCTAssertEqual(0, testWorkManager.workRequests.count) self.testContact.contactIDUpdatesSubject.send( ContactIDInfo(contactID: "some id", isStable: true, namedUserID: nil) ) XCTAssertEqual(1, testWorkManager.workRequests.count) } func testLocaleUpdatesEnqueuesRefresh() { XCTAssertEqual(0, testWorkManager.workRequests.count) notificationCenter.post( name: AirshipNotifications.LocaleUpdated.name ) XCTAssertEqual(1, testWorkManager.workRequests.count) } func testForegroundRefreshEnqueuesRefresh() { XCTAssertEqual(0, testWorkManager.workRequests.count) notificationCenter.post( name: AppStateTracker.didTransitionToForeground ) XCTAssertEqual(1, testWorkManager.workRequests.count) notificationCenter.post( name: AppStateTracker.didTransitionToForeground ) XCTAssertEqual(1, testWorkManager.workRequests.count) self.testDate.offset += self.remoteData.refreshInterval notificationCenter.post( name: AppStateTracker.didTransitionToForeground ) XCTAssertEqual(2, testWorkManager.workRequests.count) } func testAirshipReadyEnqueuesRefresh() async { XCTAssertEqual(0, testWorkManager.workRequests.count) await self.remoteData.airshipReady() XCTAssertEqual(1, testWorkManager.workRequests.count) } func testNotifyOutdatedContact() async { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: nil, source: .contact ) let expectation = XCTestExpectation() await self.contactProvider.setNotifyOutdatedCallback { @Sendable info in XCTAssertEqual(remoteDataInfo, info) expectation.fulfill() return true } await self.remoteData.notifyOutdated(remoteDataInfo: remoteDataInfo) await self.fulfillment(of: [expectation], timeout: 10) } func testNotifyOutdatedApp() async { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: nil, source: .app ) let expectation = XCTestExpectation() await self.appProvider.setNotifyOutdatedCallback { @Sendable info in XCTAssertEqual(remoteDataInfo, info) expectation.fulfill() return true } await self.remoteData.notifyOutdated(remoteDataInfo: remoteDataInfo) await self.fulfillment(of: [expectation], timeout: 10) } func testNotifyOutdatedEnqueusRefreshTask() async { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: nil, source: .app ) XCTAssertEqual(0, testWorkManager.workRequests.count) await self.appProvider.setNotifyOutdatedCallback { _ in return false } await self.remoteData.notifyOutdated(remoteDataInfo: remoteDataInfo) XCTAssertEqual(0, testWorkManager.workRequests.count) await self.appProvider.setNotifyOutdatedCallback { _ in return true } await self.remoteData.notifyOutdated(remoteDataInfo: remoteDataInfo) XCTAssertEqual(1, testWorkManager.workRequests.count) } func testIsCurrentContact() async { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: nil, source: .contact ) let expectation = XCTestExpectation() let testLocaleManager = self.testLocaleManager await self.contactProvider.setIsCurrentCallback { @Sendable locale, _, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return false } let result = await self.remoteData.isCurrent(remoteDataInfo: remoteDataInfo) await self.fulfillment(of: [expectation], timeout: 10) XCTAssertFalse(result) } func testIsCurrentApp() async { let remoteDataInfo = RemoteDataInfo( url: URL(string: "example://")!, lastModifiedTime: nil, source: .app ) let expectation = XCTestExpectation() let testLocaleManager = self.testLocaleManager await self.appProvider.setIsCurrentCallback { @Sendable locale, _, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return true } let result = await self.remoteData.isCurrent(remoteDataInfo: remoteDataInfo) await self.fulfillment(of: [expectation], timeout: 10) XCTAssertTrue(result) } func testContactStatus() async { let expectation = XCTestExpectation() let testLocaleManager = self.testLocaleManager await self.contactProvider.setStatusCallback { @Sendable _, locale, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return .upToDate } let result = await self.remoteData.status(source: .contact) await self.fulfillment(of: [expectation], timeout: 10) XCTAssertEqual(.upToDate, result) } func testAppStatus() async { let expectation = XCTestExpectation() let testLocaleManager = self.testLocaleManager await self.appProvider.setStatusCallback { @Sendable _, locale, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return .stale } let result = await self.remoteData.status(source: .app) await self.fulfillment(of: [expectation], timeout: 10) XCTAssertEqual(.stale, result) } @MainActor func testContentAvailableRefresh() async { XCTAssertEqual(0, self.testWorkManager.workRequests.count) let json = try! AirshipJSON.wrap([ "com.urbanairship.remote-data.update": NSNumber(value: true) ]) let result = await self.remoteData.receivedRemoteNotification(json) XCTAssertEqual(.newData, result) XCTAssertEqual(1, testWorkManager.workRequests.count) } @MainActor func testSettingRefreshInterval() { XCTAssertEqual(self.remoteData.refreshInterval, 10) self.config.updateRemoteConfig(RemoteConfig(remoteDataRefreshIntervalMilliseconds: 9999 * 1000)) XCTAssertEqual(self.remoteData.refreshInterval, 9999) } func testPayloads() async { let contactPayloads = [ RemoteDataTestUtils.generatePayload( type: "foo", timestamp: Date(), data: ["cool": "contact"], source: .contact ) ] let appPayloads = [ RemoteDataTestUtils.generatePayload( type: "foo", timestamp: Date(), data: ["cool": "app"], source: .app ), RemoteDataTestUtils.generatePayload( type: "bar", timestamp: Date(), data: ["not cool": "app"], source: .app ) ] await self.contactProvider.setPayloads(contactPayloads) await self.appProvider.setPayloads(appPayloads) let barResult = await self.remoteData.payloads(types: ["bar"]) XCTAssertEqual(barResult, [appPayloads[1]]) let fooResult = await self.remoteData.payloads(types: ["foo"]) XCTAssertEqual(fooResult, [appPayloads[0], contactPayloads[0]]) let barFooResult = await self.remoteData.payloads(types: ["bar", "foo"]) XCTAssertEqual(barFooResult, [appPayloads[1], appPayloads[0], contactPayloads[0]]) let bazResult = await self.remoteData.payloads(types: ["baz"]) XCTAssertEqual(bazResult, []) } func testPayloadUpdates() async { await self.contactProvider.setRefreshCallback { @Sendable _, _, _ in return .newData } await self.appProvider.setRefreshCallback{ @Sendable _, _, _ in return .newData } let expectation = XCTestExpectation() expectation.expectedFulfillmentCount = 2 // baseline + ≥1 refresh expectation.assertForOverFulfill = false // tolerate extra publishes let first = XCTestExpectation() let isFirst = AirshipAtomicValue<Bool>(false) let subscription = self.remoteData.publisher(types: ["foo"]) .sink { payloads in if isFirst.compareAndSet(expected: false, value: true) { first.fulfill() } expectation.fulfill() XCTAssertTrue(payloads.isEmpty) } await self.fulfillment(of: [first], timeout: 10) await self.launchRefreshTask() await self.fulfillment(of: [expectation], timeout: 10) subscription.cancel() } func testForceRefresh() async { await self.contactProvider.setRefreshCallback { @Sendable _, _, _ in return .newData } await self.appProvider.setRefreshCallback{ @Sendable _, _, _ in return .skipped } self.testWorkManager.autoLaunchRequests = true let refreshFinished = expectation(description: "refresh finished") let remoteData = self.remoteData Task.detached { await remoteData?.forceRefresh() refreshFinished.fulfill() } await self.fulfillment(of: [refreshFinished]) XCTAssertEqual(1, testWorkManager.workRequests.count) } func testRefreshProviders() async { let expectation = XCTestExpectation() expectation.expectedFulfillmentCount = 2 let testLocaleManager = self.testLocaleManager await self.contactProvider.setRefreshCallback { @Sendable _, locale, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return .skipped } await self.appProvider.setRefreshCallback{ @Sendable _, locale, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return .newData } let result = await self.launchRefreshTask() await self.fulfillment(of: [expectation], timeout: 10) XCTAssertEqual(result, .success) } func testRefreshProviderFailed() async { let expectation = XCTestExpectation() expectation.expectedFulfillmentCount = 2 let testLocaleManager = self.testLocaleManager await self.contactProvider.setRefreshCallback { @Sendable _, locale, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return .failed } await self.appProvider.setRefreshCallback{ @Sendable _, locale, _ in XCTAssertEqual(testLocaleManager.currentLocale, locale) expectation.fulfill() return .newData } let result = await self.launchRefreshTask() await self.fulfillment(of: [expectation], timeout: 10) XCTAssertEqual(result, .failure) } @MainActor func testChangeTokenBgPush() async { let changeToken = AirshipAtomicValue<String?>(nil) // Capture the change token let testLocaleManager = self.testLocaleManager await self.contactProvider.setRefreshCallback { @Sendable change, locale, _ in changeToken.value = change XCTAssertEqual(testLocaleManager.currentLocale, locale) return .failed } await self.appProvider.setRefreshCallback{ @Sendable _, locale, _ in return .newData } await self.launchRefreshTask() XCTAssertNotNil(changeToken.value) let last = changeToken.value await self.launchRefreshTask() XCTAssertEqual(last, changeToken.value) // Send bg push _ = await self.remoteData.receivedRemoteNotification( try! AirshipJSON.wrap( [ "com.urbanairship.remote-data.update": NSNumber(value: true) ] ) ) await self.launchRefreshTask() XCTAssertNotEqual(last, changeToken.value) } @MainActor func testChangeTokenAppForeground() async { let changeToken = AirshipAtomicValue<String?>(nil) // Capture the change token let testLocaleManager = self.testLocaleManager await self.contactProvider.setRefreshCallback { @Sendable change, locale, _ in changeToken.value = change XCTAssertEqual(testLocaleManager.currentLocale, locale) return .failed } await self.appProvider.setRefreshCallback{ @Sendable _, locale, _ in return .newData } await self.launchRefreshTask() XCTAssertNotNil(changeToken.value) var last = changeToken.value // Foreground notificationCenter.post( name: AppStateTracker.didTransitionToForeground ) await self.launchRefreshTask() XCTAssertNotEqual(last, changeToken.value) // Foreground again without changing clock last = changeToken.value notificationCenter.post( name: AppStateTracker.didTransitionToForeground ) await self.launchRefreshTask() // Should not change XCTAssertEqual(last, changeToken.value) // Foreground after refresh interval self.testDate.offset += self.remoteData.refreshInterval notificationCenter.post( name: AppStateTracker.didTransitionToForeground ) await self.launchRefreshTask() XCTAssertNotEqual(last, changeToken.value) } @MainActor func testWaitForRefresh() async { await self.contactProvider.setRefreshCallback{ _, _, _ in return .failed } await self.appProvider.setRefreshCallback{ _, _, _ in return .failed } let finished = AirshipMainActorValue(false) let task = Task { await self.remoteData.waitRefresh(source: .app) finished.set(true) } await self.launchRefreshTask() var isFinished = finished.value XCTAssertFalse(isFinished) await self.appProvider.setRefreshCallback{ _, _, _ in return .newData } await self.launchRefreshTask() await task.value isFinished = finished.value XCTAssertTrue(isFinished) } @discardableResult private func launchRefreshTask() async -> AirshipWorkResult { return try! await self.testWorkManager.launchTask( request: AirshipWorkRequest( workID: RemoteDataTest.RefreshTask ) )! } } fileprivate actor TestRemoteDataProvider: RemoteDataProviderProtocol { private var statusCallback: ((String, Locale, Int) async -> RemoteDataSourceStatus)? func setStatusCallback(callback: @escaping (String, Locale, Int) async -> RemoteDataSourceStatus) { self.statusCallback = callback } func status(changeToken: String, locale: Locale, randomeValue: Int) async -> RemoteDataSourceStatus { return await self.statusCallback!(changeToken, locale, randomeValue) } let source: RemoteDataSource private var payloads: [RemoteDataPayload] = [] var enabled: Bool private var notifyOutdatedCallback: ((RemoteDataInfo) -> Bool)? func setNotifyOutdatedCallback(callback: @escaping (RemoteDataInfo) -> Bool) { self.notifyOutdatedCallback = callback } private var isCurrentCallback: ((Locale, Int, RemoteDataInfo) async -> Bool)? func setIsCurrentCallback(callback: @escaping (Locale, Int, RemoteDataInfo) async -> Bool) { self.isCurrentCallback = callback } private var refreshCallback: ((String, Locale, Int) async -> RemoteDataRefreshResult)? func setRefreshCallback(callback: @escaping (String, Locale, Int) async -> RemoteDataRefreshResult) { self.refreshCallback = callback } init(source: RemoteDataSource, enabled: Bool) { self.source = source self.enabled = enabled } func setPayloads(_ payloads: [RemoteDataPayload]) { self.payloads = payloads } func payloads(types: [String]) async -> [RemoteDataPayload] { return payloads.filter { types.contains($0.type) }.sortedByType(types) } func notifyOutdated(remoteDataInfo: RemoteDataInfo) -> Bool { return self.notifyOutdatedCallback!(remoteDataInfo) } func isCurrent(locale: Locale, randomeValue: Int, remoteDataInfo: RemoteDataInfo) async -> Bool { return await self.isCurrentCallback!(locale, randomeValue, remoteDataInfo) } func refresh(changeToken: String, locale: Locale, randomeValue: Int) async -> RemoteDataRefreshResult { return await self.refreshCallback!(changeToken, locale, randomeValue) } func setEnabled(_ enabled: Bool) -> Bool { guard self.enabled != enabled else { return false } self.enabled = enabled return true } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteDataTestUtils.swift ================================================ public import Foundation @testable public import AirshipCore public class RemoteDataTestUtils: NSObject { public class func generatePayload( type: String, timestamp: Date, data: [AnyHashable: Any], source: RemoteDataSource ) -> RemoteDataPayload { return RemoteDataPayload( type: type, timestamp: timestamp, data: try! AirshipJSON.wrap(data), remoteDataInfo: RemoteDataInfo(url: URL(string: "someurl")!, lastModifiedTime: nil, source: source) ) } public class func generatePayload( type: String, timestamp: Date, data: [AnyHashable: Any], remoteDataInfo: RemoteDataInfo ) -> RemoteDataPayload { return RemoteDataPayload( type: type, timestamp: timestamp, data: try! AirshipJSON.wrap(data), remoteDataInfo: remoteDataInfo ) } public class func generatePayload( type: String, timestamp: Date, data: [AnyHashable: Any], source: RemoteDataSource, lastModified: String ) -> RemoteDataPayload { return RemoteDataPayload( type: type, timestamp: timestamp, data: try! AirshipJSON.wrap(data), remoteDataInfo: RemoteDataInfo(url: URL(string: "someurl")!, lastModifiedTime: lastModified, source: source) ) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoteDataURLFactoryTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class RemoteDataURLFactoryTest: XCTestCase { private let config: RuntimeConfig = RuntimeConfig.testConfig() func testURL() throws { let remoteDataURL = try! RemoteDataURLFactory.makeURL( config: config, path: "/some-path", locale: Locale(identifier: "en-US"), randomValue: 100 ) let sdkVersion = AirshipVersion.version XCTAssertEqual( "\(config.remoteDataAPIURL)/some-path?language=en&country=US&sdk_version=\(sdkVersion)&random_value=100", remoteDataURL.absoluteString ) } func testURLNoCountry() throws { let remoteDataURL = try! RemoteDataURLFactory.makeURL( config: config, path: "/some-path", locale: Locale(identifier: "br"), randomValue: 100 ) let sdkVersion = AirshipVersion.version XCTAssertEqual( "\(config.remoteDataAPIURL)/some-path?language=br&sdk_version=\(sdkVersion)&random_value=100", remoteDataURL.absoluteString ) } } ================================================ FILE: Airship/AirshipCore/Tests/RemoveTagsActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class RemoveTagsActionTest: XCTestCase { private let simpleValue = ["tag", "another tag"] private let complexValue: [String: AnyHashable] = [ "channel": [ "channel_tag_group": ["channel_tag_1", "channel_tag_2"], "other_channel_tag_group": ["other_channel_tag_1"] ], "named_user": [ "named_user_tag_group": ["named_user_tag_1", "named_user_tag_2"], "other_named_user_tag_group": ["other_named_user_tag_1"] ], "device": [ "tag", "another_tag"] ] private let channel = TestChannel() private let contact = TestContact() private var action: RemoveTagsAction! override func setUp() async throws { action = RemoveTagsAction( channel: { [channel] in return channel }, contact: { [contact] in return contact } ) } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ActionSituation.backgroundInteractiveButton, ] let rejectedSituations = [ ActionSituation.backgroundPush ] for situation in validSituations { let args = ActionArguments(value: try! AirshipJSON.wrap(simpleValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in validSituations { let args = ActionArguments(value: try! AirshipJSON.wrap(complexValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(value: try! AirshipJSON.wrap(simpleValue), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } func testPerformSimple() async throws { self.channel.tags = ["foo", "bar", "tag", "another tag"] let updates = await self.action.tagMutations _ = try await self.action.perform(arguments: ActionArguments( value: try! AirshipJSON.wrap(simpleValue), situation: .manualInvocation ) ) XCTAssertEqual( ["foo", "bar"], channel.tags ) var iterator = updates.makeAsyncIterator() let mutation = await iterator.next() XCTAssertEqual(TagActionMutation.channelTags(["tag", "another tag"]), mutation) } func testPerformComplex() async throws { self.channel.tags = ["foo", "bar", "tag"] let tagGroupsSet = self.expectation(description: "tagGroupsSet") tagGroupsSet.expectedFulfillmentCount = 2 self.channel.tagGroupEditor = TagGroupsEditor { updates in let expected = [ TagGroupUpdate( group: "channel_tag_group", tags: ["channel_tag_1", "channel_tag_2"], type: .remove ), TagGroupUpdate( group: "other_channel_tag_group", tags: ["other_channel_tag_1"], type: .remove ) ] XCTAssertEqual(Set(expected), Set(updates)) tagGroupsSet.fulfill() } self.contact.tagGroupEditor = TagGroupsEditor { updates in let expected = [ TagGroupUpdate( group: "named_user_tag_group", tags: ["named_user_tag_1", "named_user_tag_2"], type: .remove ), TagGroupUpdate( group: "other_named_user_tag_group", tags: ["other_named_user_tag_1"], type: .remove ) ] XCTAssertEqual(Set(expected), Set(updates)) tagGroupsSet.fulfill() } let updates = await self.action.tagMutations _ = try await self.action.perform(arguments: ActionArguments( value: try! AirshipJSON.wrap(complexValue), situation: .manualInvocation ) ) var expected: [TagActionMutation] = [ .channelTagGroups(["channel_tag_group": ["channel_tag_1", "channel_tag_2"], "other_channel_tag_group": ["other_channel_tag_1"]]), .contactTagGroups(["named_user_tag_group": ["named_user_tag_1", "named_user_tag_2"], "other_named_user_tag_group": ["other_named_user_tag_1"]]), .channelTags(["tag", "another_tag"]) ] for await item in updates { XCTAssertEqual(expected.removeFirst(), item) if expected.isEmpty { break } } XCTAssertEqual( ["foo", "bar"], channel.tags ) await self.fulfillment(of: [tagGroupsSet], timeout: 10) } } ================================================ FILE: Airship/AirshipCore/Tests/RetailEventTemplateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class RetailEventTemplateTest: XCTestCase { func testBrowsed() { let event = CustomEvent(retailTemplate: .browsed) XCTAssertEqual("browsed", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testAddedToCart() { let event = CustomEvent(retailTemplate: .addedToCart) XCTAssertEqual("added_to_cart", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testStarred() { let event = CustomEvent(retailTemplate: .starred) XCTAssertEqual("starred_product", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testPurchased() { let event = CustomEvent(retailTemplate: .purchased) XCTAssertEqual("purchased", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testShared() { let event = CustomEvent(retailTemplate: .shared(source: "some source", medium: "some medium")) XCTAssertEqual("shared_product", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = [ "ltv": false, "source": "some source", "medium": "some medium" ] XCTAssertEqual(expectedProperties, event.properties) } func testSharedEmptyDetails() { let event = CustomEvent(retailTemplate: .shared()) XCTAssertEqual("shared_product", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testWishlist() { let event = CustomEvent(retailTemplate: .wishlist(id: "some id", name: "some name")) XCTAssertEqual("wishlist", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = [ "ltv": false, "wishlist_id": "some id", "wishlist_name": "some name" ] XCTAssertEqual(expectedProperties, event.properties) } func testWishlistEmptyDetails() { let event = CustomEvent(retailTemplate: .wishlist()) XCTAssertEqual("wishlist", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testProperties() { let properties = CustomEvent.RetailProperties( id: "some id", category: "some category", type: "some type", eventDescription: "some description", isLTV: true, brand: "some brand", isNewItem: true, currency: "cred" ) let event = CustomEvent(retailTemplate: .wishlist(), properties: properties) XCTAssertEqual("wishlist", event.eventName) XCTAssertEqual("retail", event.templateType) let expectedProperties: [String: AirshipJSON] = [ "id": "some id", "category": "some category", "type": "some type", "description": "some description", "ltv": true, "brand": "some brand", "new_item": true, "currency": "cred", ] XCTAssertEqual(expectedProperties, event.properties) } } ================================================ FILE: Airship/AirshipCore/Tests/RuntimeConfig.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipCore extension RuntimeConfig { class func testConfig( site: CloudSite = .us, useUserPreferredLocale: Bool = false, requireInitialRemoteConfigEnabled: Bool = false, initialConfigURL: String? = nil, notifiaconCenter: NotificationCenter = NotificationCenter() ) -> RuntimeConfig { let credentails = AirshipAppCredentials( appKey: UUID().uuidString, appSecret: UUID().uuidString ) var airshipConfig = AirshipConfig() airshipConfig.site = site airshipConfig.useUserPreferredLocale = useUserPreferredLocale airshipConfig.initialConfigURL = initialConfigURL airshipConfig.requireInitialRemoteConfigEnabled = requireInitialRemoteConfigEnabled return RuntimeConfig( airshipConfig: airshipConfig, appCredentials: credentails, dataStore: PreferenceDataStore(appKey: credentails.appKey), requestSession: TestAirshipRequestSession(), notificationCenter: notifiaconCenter ) } class func testConfig( airshipConfig: AirshipConfig, notifiaconCenter: NotificationCenter = NotificationCenter() ) -> RuntimeConfig { let credentails = AirshipAppCredentials( appKey: UUID().uuidString, appSecret: UUID().uuidString ) return RuntimeConfig( airshipConfig: airshipConfig, appCredentials: credentails, dataStore: PreferenceDataStore(appKey: credentails.appKey), requestSession: TestAirshipRequestSession(), notificationCenter: notifiaconCenter ) } } ================================================ FILE: Airship/AirshipCore/Tests/RuntimeConfigTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class RuntimeConfigTest: XCTestCase { func testUSSiteURLS() throws { let config = RuntimeConfig.testConfig(site: .us) XCTAssertEqual( "https://device-api.urbanairship.com", config.deviceAPIURL ) XCTAssertEqual("https://combine.urbanairship.com", config.analyticsURL) XCTAssertEqual( "https://remote-data.urbanairship.com", config.remoteDataAPIURL ) } func testEUSiteURLS() throws { let config = RuntimeConfig.testConfig(site: .eu) XCTAssertEqual("https://device-api.asnapieu.com", config.deviceAPIURL) XCTAssertEqual("https://combine.asnapieu.com", config.analyticsURL) XCTAssertEqual( "https://remote-data.asnapieu.com", config.remoteDataAPIURL ) } func testInitialConfigURL() throws { let config = RuntimeConfig.testConfig(initialConfigURL: "cool://remote") XCTAssertEqual("cool://remote", config.remoteDataAPIURL) } func testRequireInitialRemoteConfigEnabled() throws { let config = RuntimeConfig.testConfig( requireInitialRemoteConfigEnabled: true ) XCTAssertNil(config.deviceAPIURL) XCTAssertNil(config.analyticsURL) XCTAssertEqual( "https://remote-data.urbanairship.com", config.remoteDataAPIURL ) } func testRemoteConfigOverride() async throws { let notificationCenter = NotificationCenter() let updatedCount = AirshipAtomicValue<Int>(0) notificationCenter.addObserver( forName: RuntimeConfig.configUpdatedEvent, object: nil, queue: nil ) { _ in updatedCount.value += 1 } let config = RuntimeConfig.testConfig(notifiaconCenter: notificationCenter) let airshipConfig = RemoteConfig.AirshipConfig( remoteDataURL: "cool://remote", deviceAPIURL: "cool://devices", analyticsURL: "cool://analytics", meteredUsageURL: "cool://meteredUsage" ) await config.updateRemoteConfig( RemoteConfig(airshipConfig: airshipConfig) ) XCTAssertEqual("cool://devices", config.deviceAPIURL) XCTAssertEqual("cool://analytics", config.analyticsURL) XCTAssertEqual("cool://remote", config.remoteDataAPIURL) XCTAssertEqual("cool://meteredUsage", config.meteredUsageURL) XCTAssertEqual(1, updatedCount.value) await config.updateRemoteConfig( RemoteConfig(airshipConfig: airshipConfig) ) XCTAssertEqual("cool://devices", config.deviceAPIURL) XCTAssertEqual("cool://analytics", config.analyticsURL) XCTAssertEqual("cool://remote", config.remoteDataAPIURL) XCTAssertEqual("cool://meteredUsage", config.meteredUsageURL) XCTAssertEqual(1, updatedCount.value) let differentConfig = RemoteConfig.AirshipConfig( remoteDataURL: "neat://remote", deviceAPIURL: "neat://devices", analyticsURL: "neat://analytics", meteredUsageURL: "neat://meteredUsage" ) await config.updateRemoteConfig( RemoteConfig(airshipConfig: differentConfig) ) XCTAssertEqual("neat://devices", config.deviceAPIURL) XCTAssertEqual("neat://analytics", config.analyticsURL) XCTAssertEqual("neat://remote", config.remoteDataAPIURL) XCTAssertEqual("neat://meteredUsage", config.meteredUsageURL) XCTAssertEqual(2, updatedCount.value) } } ================================================ FILE: Airship/AirshipCore/Tests/SearchEventTemplateTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class SearchEventTemplateTest: XCTestCase { func testSearch() { let event = CustomEvent(searchTemplate: .search) XCTAssertEqual("search", event.eventName) XCTAssertEqual("search", event.templateType) let expectedProperties: [String: AirshipJSON] = ["ltv": false] XCTAssertEqual(expectedProperties, event.properties) } func testProperties() { let event = CustomEvent( searchTemplate: .search, properties: .init( id: "some id", category: "some category", type: "some type", isLTV: true, query: "some query", totalResults: 20 ) ) XCTAssertEqual("search", event.eventName) XCTAssertEqual("search", event.templateType) let expectedProperties: [String: AirshipJSON] = [ "id": "some id", "category": "some category", "type": "some type", "ltv": true, "query": "some query", "total_results": 20 ] XCTAssertEqual(expectedProperties, event.properties) } } ================================================ FILE: Airship/AirshipCore/Tests/SessionTrackerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class SessionTrackerTest: XCTestCase { private let taskSleeper: TestTaskSleeper = TestTaskSleeper() private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter() private let date: UATestDate = UATestDate(offset: 0, dateOverride: Date()) private let appStateTracker: TestAppStateTracker = TestAppStateTracker() var tracker: SessionTracker! var sessionCount = AirshipAtomicValue<Int>(1) @MainActor override func setUp() async throws { self.tracker = SessionTracker( date: date, taskSleeper: taskSleeper, appStateTracker: appStateTracker, sessionStateFactory: { [sessionCount] in let state = SessionState(sessionID: "\(sessionCount.value)") sessionCount.value = sessionCount.value + 1 return state } ) } func testDidBecomeActiveAppInit() async throws { Task { @MainActor [notificationCenter] in notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) } var asyncIterator = self.tracker.events.makeAsyncIterator() let event = await asyncIterator.next()! XCTAssertEqual(.foregroundInit, event.type) XCTAssertEqual(self.date.now, event.date) } func testBackgroundBeforeForegroundEmitsAppInit() async throws { Task { @MainActor [notificationCenter] in notificationCenter.post( name: AppStateTracker.didEnterBackgroundNotification, object: nil ) } await ensureEvents([ SessionEvent( type: .backgroundInit, date: self.date.now, sessionState: SessionState(sessionID: "1") ), ]) XCTAssertEqual(self.tracker.sessionState.sessionID, "1") } func testLaunchFromPushEmitsAppInit() async throws { await self.tracker.launchedFromPush(sendID: "some sendID", metadata: "some metadata") let expectedSessionState = SessionState( sessionID: "1", conversionSendID: "some sendID", conversionMetadata: "some metadata" ) await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: expectedSessionState ) ]) XCTAssertEqual(self.tracker.sessionState, expectedSessionState) } @MainActor func testAirshipReadyEmitsAppInitActiveState() async throws { self.appStateTracker.currentState = .active let expectedSessionState = SessionState( sessionID: "1" ) self.tracker.airshipReady() await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: expectedSessionState ) ]) let sleeps = await self.taskSleeper.sleeps XCTAssertEqual([1.0], sleeps) XCTAssertEqual(self.tracker.sessionState, expectedSessionState) } @MainActor func testAirshipReadyEmitsAppInitInActiveState() async throws { self.appStateTracker.currentState = .inactive let expectedSessionState = SessionState( sessionID: "1" ) self.tracker.airshipReady() await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: expectedSessionState ) ]) let sleeps = await self.taskSleeper.sleeps XCTAssertEqual([1.0], sleeps) XCTAssertEqual(self.tracker.sessionState, expectedSessionState) } @MainActor func testAirshipReadyEmitsAppBackgroundState() async throws { self.appStateTracker.currentState = .background let expectedSessionState = SessionState( sessionID: "1" ) self.tracker.airshipReady() await ensureEvents([ SessionEvent( type: .backgroundInit, date: self.date.now, sessionState: expectedSessionState ) ]) let sleeps = await self.taskSleeper.sleeps XCTAssertEqual([1.0], sleeps) XCTAssertEqual(self.tracker.sessionState, expectedSessionState) } @MainActor func testLaunchFromPushAppBackgroundState() async throws { self.appStateTracker.currentState = .background let expectedSessionState = SessionState( sessionID: "1", conversionSendID: "some sendID", conversionMetadata: "some metadata" ) Task { @MainActor [tracker, notificationCenter] in // launch from push tracker?.launchedFromPush(sendID: "some sendID", metadata: "some metadata") // This would normally be called with a delay, so calling it after tracker?.airshipReady() // Foreground notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) } await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: expectedSessionState ) ]) XCTAssertEqual(self.tracker.sessionState, expectedSessionState) } @MainActor func testLaunchFromPushAppInActiveState() async throws { self.appStateTracker.currentState = .inactive let expectedSessionState = SessionState( sessionID: "1", conversionSendID: "some sendID", conversionMetadata: "some metadata" ) Task { @MainActor [tracker, notificationCenter] in // launch from push tracker?.launchedFromPush(sendID: "some sendID", metadata: "some metadata") // This would normally be called with a delay, so calling it after tracker?.airshipReady() // Foreground notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) } await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: expectedSessionState ) ]) XCTAssertEqual(self.tracker.sessionState, expectedSessionState) } @MainActor func testLaunchAppBackgroundState() async throws { self.appStateTracker.currentState = .background // App init self.tracker.airshipReady() await ensureEvents([ SessionEvent( type: .backgroundInit, date: self.date.now, sessionState: SessionState( sessionID: "1" ) ) ]) Task { @MainActor [notificationCenter] in // Foreground notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) // Background notificationCenter.post( name: AppStateTracker.didEnterBackgroundNotification, object: nil ) } let expectedSessionState = SessionState( sessionID: "2" ) await ensureEvents([ SessionEvent( type: .foreground, date: self.date.now, sessionState: expectedSessionState ), SessionEvent( type: .background, date: self.date.now, sessionState: expectedSessionState ) ]) // Background should reset state XCTAssertEqual(self.tracker.sessionState, SessionState(sessionID: "3")) } @MainActor func testLaunchAppInactiveState() async throws { self.appStateTracker.currentState = .inactive // App init self.tracker.airshipReady() await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: SessionState( sessionID: "1" ) ) ]) Task { @MainActor [notificationCenter] in // Foreground notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) // Background notificationCenter.post( name: AppStateTracker.didEnterBackgroundNotification, object: nil ) } await ensureEvents([ SessionEvent( type: .background, date: self.date.now, sessionState: SessionState( sessionID: "1" ) ) ]) // Background should reset state XCTAssertEqual(self.tracker.sessionState, SessionState(sessionID: "2")) } @MainActor func testLaunchAppActiveState() async throws { appStateTracker.currentState = .active // App init self.tracker.airshipReady() await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: SessionState( sessionID: "1" ) ) ]) Task { @MainActor [notificationCenter] in // Foreground notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) // Background notificationCenter.post( name: AppStateTracker.didEnterBackgroundNotification, object: nil ) } await ensureEvents([ SessionEvent( type: .background, date: self.date.now, sessionState: SessionState( sessionID: "1" ) ) ]) // Background should reset state XCTAssertEqual(self.tracker.sessionState, SessionState(sessionID: "2")) } @MainActor func testLaunchContentAvailablePush() async throws { self.appStateTracker.currentState = .background // App init self.tracker.airshipReady() await ensureEvents([ SessionEvent( type: .backgroundInit, date: self.date.now, sessionState: SessionState( sessionID: "1" ) ) ]) Task { @MainActor [tracker, notificationCenter] in // launch from push tracker?.launchedFromPush(sendID: "some sendID", metadata: "some metadata") // Foreground notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) } let expectedSessionState = SessionState( sessionID: "2", conversionSendID: "some sendID", conversionMetadata: "some metadata" ) await ensureEvents([ SessionEvent( type: .foreground, date: self.date.now, sessionState: expectedSessionState ) ]) // Foreground should generate new session ID XCTAssertEqual(self.tracker.sessionState, expectedSessionState) } @MainActor func testBackgroundClearPush() async throws { self.appStateTracker.currentState = .background self.tracker.launchedFromPush(sendID: "some sendID", metadata: "some metadata") await ensureEvents([ SessionEvent( type: .foregroundInit, date: self.date.now, sessionState: SessionState( sessionID: "1", conversionSendID: "some sendID", conversionMetadata: "some metadata" ) ) ]) Task { @MainActor [notificationCenter] in // Background notificationCenter.post( name: AppStateTracker.didEnterBackgroundNotification, object: nil ) // Foreground notificationCenter.post( name: AppStateTracker.didBecomeActiveNotification, object: nil ) } await ensureEvents([ SessionEvent( type: .background, date: self.date.now, sessionState: SessionState( sessionID: "1", conversionSendID: "some sendID", conversionMetadata: "some metadata" ) ), SessionEvent( type: .foreground, date: self.date.now, sessionState: SessionState( sessionID: "3" ) ) ]) // Foreground should generate new session ID XCTAssertEqual(self.tracker.sessionState, SessionState(sessionID: "3")) } private func ensureEvents(_ events: [SessionEvent], line: UInt = #line) async { let verifyTask = Task { [tracker] in var asyncIterator = tracker!.events.makeAsyncIterator() for expected in events { if Task.isCancelled { break } let next = await asyncIterator.next() XCTAssertEqual(expected, next, line: line) } } let timeoutTask = Task { try? await DefaultAirshipTaskSleeper().sleep(timeInterval:2.0) if Task.isCancelled == false { XCTFail("Failed to get events", line: line) verifyTask.cancel() } } await verifyTask.value timeoutTask.cancel() } } actor TestTaskSleeper : AirshipTaskSleeper { var sleeps : [TimeInterval] = [] private var updates: AirshipAsyncChannel<[TimeInterval]> = AirshipAsyncChannel() var continuations: [CheckedContinuation<Void, Never>] = [] private var isPaused: Bool = false func pause() { self.isPaused = true } func resume() { self.isPaused = false continuations.forEach { $0.resume() } continuations.removeAll() } var sleepUpdates: AsyncStream<[TimeInterval]> { get async { await updates.makeStream() } } func sleep(timeInterval: TimeInterval) async throws { sleeps.append(timeInterval) await updates.send(sleeps) if (isPaused) { await withCheckedContinuation { continuations.append($0) } } } } ================================================ FILE: Airship/AirshipCore/Tests/ShareActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ShareActionTest: XCTestCase { private let action: ShareAction = ShareAction() func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush ] let rejectedSituations = [ ActionSituation.backgroundPush, ActionSituation.backgroundInteractiveButton ] for situation in validSituations { let args = ActionArguments(value: AirshipJSON.string("some valid text"), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(value: AirshipJSON.string("some valid text"), situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } } ================================================ FILE: Airship/AirshipCore/Tests/SubscriptionListAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import XCTest @testable import AirshipCore class SubscriptionListAPIClientTest: XCTestCase { var config: RuntimeConfig = .testConfig() var session: TestAirshipRequestSession = TestAirshipRequestSession() var client: SubscriptionListAPIClient! override func setUpWithError() throws { self.client = SubscriptionListAPIClient( config: self.config, session: self.session ) } func testGet() async throws { let responseBody = """ { "ok" : true, "list_ids": ["example_listId-1","example_listId-2"] } """ self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) self.session.data = responseBody.data(using: .utf8) let response = try await self.client.get(channelID: "some-channel") XCTAssertEqual(response.statusCode, 200) XCTAssertEqual( ["example_listId-1", "example_listId-2"], response.result ) XCTAssertEqual("GET", self.session.lastRequest?.method) XCTAssertEqual( "https://device-api.urbanairship.com/api/subscription_lists/channels/some-channel", self.session.lastRequest?.url?.absoluteString ) } func testGetParseError() async throws { let responseBody = "What?" self.session.response = HTTPURLResponse( url: URL(string: "https://neat")!, statusCode: 200, httpVersion: "", headerFields: [String: String]() ) self.session.data = responseBody.data(using: .utf8) do { _ = try await self.client.get(channelID: "some-channel") XCTFail("Should throw") } catch { } } func testGetError() async throws { let sessionError = AirshipErrors.error("error!") self.session.error = sessionError do { _ = try await self.client.get(channelID: "some-channel") XCTFail("Should throw") } catch { XCTAssertEqual(sessionError as NSError, error as NSError) } } } ================================================ FILE: Airship/AirshipCore/Tests/SubscriptionListActionTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class SubscriptionListActionTests: XCTestCase { private let channel = TestChannel() private let contact = TestContact() private let date = UATestDate(offset: 0, dateOverride: Date()) private var action: SubscriptionListAction! private var channelEdits: [SubscriptionListUpdate] = [] private var contactEdits: [ScopedSubscriptionListUpdate] = [] override func setUp() { self.action = SubscriptionListAction( channel: { [channel] in return channel }, contact: { [contact] in return contact } ) self.channel.subscriptionListEditor = SubscriptionListEditor { updates in self.channelEdits.append(contentsOf: updates) } self.contact.subscriptionListEditor = ScopedSubscriptionListEditor( date: date ) { updates in self.contactEdits.append(contentsOf: updates) } } func testAcceptsArguments() async throws { let validSituations = [ ActionSituation.foregroundInteractiveButton, ActionSituation.launchedFromPush, ActionSituation.manualInvocation, ActionSituation.webViewInvocation, ActionSituation.automation, ActionSituation.foregroundPush, ActionSituation.backgroundInteractiveButton, ] let rejectedSituations = [ ActionSituation.backgroundPush ] for situation in validSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertTrue(result) } for situation in rejectedSituations { let args = ActionArguments(situation: situation) let result = await self.action.accepts(arguments: args) XCTAssertFalse(result) } } func testPerformWithoutArgs() async throws { let args = ActionArguments() do { _ = try await action.perform(arguments: args) XCTFail("should throw") } catch {} } func testPerformWithValidPayload() async throws { let actionValue: [[String: String]] = [ [ "type": "channel", "action": "subscribe", "list": "456", ], [ "type": "contact", "action": "unsubscribe", "list": "4567", "scope": "app", ], ] let args = ActionArguments( value: try AirshipJSON.wrap(actionValue) ) _ = try await action.perform(arguments: args) let expectedChannelEdits = [ SubscriptionListUpdate(listId: "456", type: .subscribe) ] XCTAssertEqual(expectedChannelEdits, self.channelEdits) let expectedContactEdits = [ ScopedSubscriptionListUpdate( listId: "4567", type: .unsubscribe, scope: .app, date: self.date.now ) ] XCTAssertEqual(expectedContactEdits, self.contactEdits) } func testPerformWithAltValidPayload() async throws { let actionValue: [String: Any] = [ "edits": [ [ "type": "channel", "action": "subscribe", "list": "456", ], [ "type": "contact", "action": "unsubscribe", "list": "4567", "scope": "app", ], ] ] let args = ActionArguments( value: try AirshipJSON.wrap(actionValue) ) _ = try await action.perform(arguments: args) let expectedChannelEdits = [ SubscriptionListUpdate(listId: "456", type: .subscribe) ] XCTAssertEqual(expectedChannelEdits, self.channelEdits) let expectedContactEdits = [ ScopedSubscriptionListUpdate( listId: "4567", type: .unsubscribe, scope: .app, date: self.date.now ) ] XCTAssertEqual(expectedContactEdits, self.contactEdits) } func testPerformWithInvalidPayload() async throws { let actionValue: [String: Any] = [ "edits": [ [ "type": "channel", "action": "subscribe", "list": "456", ], [ "type": "contact", "list": "4567", "scope": "app", ], ] ] let args = ActionArguments( value: try AirshipJSON.wrap(actionValue) ) do { _ = try await action.perform(arguments: args) XCTFail("should throw") } catch {} XCTAssertTrue(self.channelEdits.isEmpty) XCTAssertTrue(self.contactEdits.isEmpty) } } ================================================ FILE: Airship/AirshipCore/Tests/Support/AirshipConfig-Valid-Legacy.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>DEVELOPMENT_APP_KEY</key> <string>0A00000000000000000000</string> <key>DEVELOPMENT_APP_SECRET</key> <string>0A00000000000000000000</string> <key>PRODUCTION_APP_KEY</key> <string>0A00000000000000000000</string> <key>PRODUCTION_APP_SECRET</key> <string>0A00000000000000000000</string> <key>LOG_LEVEL</key> <integer>5</integer> <key>APP_STORE_OR_AD_HOC_BUILD</key> <true/> </dict> </plist> ================================================ FILE: Airship/AirshipCore/Tests/Support/AirshipConfig-Valid.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>urlAllowListScopeOpenURL</key> <array> <string>*</string> </array> <key>requireInitialRemoteConfigEnabled</key> <true/> <key>developmentAppKey</key> <string>0A00000000000000000000</string> <key>developmentAppSecret</key> <string>0A00000000000000000000</string> <key>developmentLogLevel</key> <integer>1</integer> <key>developmentLogPrivacyLevel</key> <integer>0</integer> <key>productionAppKey</key> <string>0A00000000000000000000</string> <key>productionAppSecret</key> <string>0A00000000000000000000</string> <key>productionLogLevel</key> <integer>5</integer> <key>productionLogPrivacyLevel</key> <string>public</string> <key>isChannelCreationDelayEnabled</key> <true/> <key>isExtendedBroadcastsEnabled</key> <true/> <key>messageCenterStyleConfig</key> <string>ValidUAMessageCenterDefaultStyle</string> <key>site</key> <integer>1</integer> <key>resetEnabledFeatures</key> <true/> <key>inProduction</key> <true/> <key>enabledFeatures</key> <array> <string>in_app_automation</string> <string>push</string> </array> </dict> </plist> ================================================ FILE: Airship/AirshipCore/Tests/Support/TestAppStateTracker.swift ================================================ public import AirshipCore import Foundation @preconcurrency import Combine public final class TestAppStateTracker: AppStateTrackerProtocol, Sendable { private let stateValue: AirshipMainActorValue<ApplicationState> = AirshipMainActorValue(.background) public var stateUpdates: AsyncStream<ApplicationState> { stateValue.updates } private let stateSubject: PassthroughSubject<ApplicationState, Never> = PassthroughSubject() @MainActor public func waitForActive() async { guard self.currentState != .active else { return } var subscription: AnyCancellable? await withCheckedContinuation { continuation in subscription = stateSubject.eraseToAnyPublisher() .filter { $0 == .active } .first() .sink { _ in continuation.resume() } } subscription?.cancel() } public var state: AirshipCore.ApplicationState { return currentState } @MainActor public var currentState: ApplicationState = .background { didSet { stateSubject.send(currentState) stateValue.set(currentState) } } @MainActor public func updateState(_ state: ApplicationState) async { self.currentState = state } } ================================================ FILE: Airship/AirshipCore/Tests/Support/testMCColorsCatalog.xcassets/Contents.json ================================================ { "info" : { "version" : 1, "author" : "xcode" } } ================================================ FILE: Airship/AirshipCore/Tests/Support/testMCColorsCatalog.xcassets/seapunkTestColor.colorset/Contents.json ================================================ { "info" : { "version" : 1, "author" : "xcode" }, "colors" : [ { "idiom" : "universal", "color" : { "color-space" : "srgb", "components" : { "red" : "0.058", "alpha" : "1.000", "blue" : "0.500", "green" : "0.253" } } }, { "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", "value" : "light" } ], "color" : { "color-space" : "srgb", "components" : { "red" : "0.812", "alpha" : "1.000", "blue" : "0.500", "green" : "0.083" } } }, { "idiom" : "universal", "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "red" : "0.058", "alpha" : "1.000", "blue" : "0.500", "green" : "0.620" } } } ] } ================================================ FILE: Airship/AirshipCore/Tests/TagEditorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class TagEditorTest: XCTestCase { func testEditor() throws { var tags = ["cool", "story"] let editor = TagEditor { tagApplicator in tags = tagApplicator(tags) } editor.add(["dog", "cat"]) editor.remove(["story"]) editor.apply() XCTAssertEqual(tags, ["cool", "dog", "cat"]) editor.set(["what", "cool"]) editor.add(["nice"]) editor.remove(["cool"]) editor.apply() XCTAssertEqual(tags, ["what", "nice"]) } } ================================================ FILE: Airship/AirshipCore/Tests/TagGroupsEditorTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class TagGroupsEditorTest: XCTestCase { func testEditor() throws { var out: [TagGroupUpdate]? let editor = TagGroupsEditor { updates in out = updates } editor.add(["tag one"], group: "some group") editor.remove(["tag one"], group: "some group") editor.apply() XCTAssertEqual(2, out?.count) } func testInvalidTagGroup() throws { var out: [TagGroupUpdate]? let editor = TagGroupsEditor { updates in out = updates } editor.add(["tag one"], group: "") editor.set(["tag one"], group: "") editor.remove(["tag one"], group: "") editor.apply() XCTAssertTrue(out?.isEmpty ?? false) } func testEmptyTags() throws { var out: [TagGroupUpdate]? let editor = TagGroupsEditor { updates in out = updates } editor.add([], group: "group one") editor.set([], group: "group two") editor.remove([], group: "group three") editor.apply() XCTAssertEqual(1, out?.count) XCTAssertEqual(out?.first?.group, "group two") XCTAssertTrue(out?.first?.tags.isEmpty ?? false) } func testNormalizeTags() throws { var out: [TagGroupUpdate]? let editor = TagGroupsEditor { updates in out = updates } editor.add( ["foo ", "bar \n", "neat tag", " cool"], group: " group one " ) editor.apply() XCTAssertEqual(1, out?.count) XCTAssertEqual(out?.first?.group, "group one") let tags = ["foo", "bar", "neat tag", "cool"] XCTAssertEqual(tags, out?.first?.tags) } func testPreventDeviceTagGroup() throws { var out: [TagGroupUpdate]? let editor = TagGroupsEditor(allowDeviceTagGroup: false) { updates in out = updates } editor.add(["cool"], group: "ua_device") editor.apply() XCTAssertTrue(out?.isEmpty ?? false) } func testAllowDeviceTagGroup() throws { var out: [TagGroupUpdate]? let editor = TagGroupsEditor(allowDeviceTagGroup: true) { updates in out = updates } editor.add(["cool"], group: "ua_device") editor.apply() XCTAssertEqual(1, out?.count) } } ================================================ FILE: Airship/AirshipCore/Tests/TestAirshipInstance.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipCore final class TestAirshipInstance: AirshipInstance, @unchecked Sendable { var inputValidator: any AirshipInputValidation.Validator { fatalError("Not implemented") } var _permissionsManager: DefaultAirshipPermissionsManager? var permissionsManager: any AirshipPermissionsManager { return _permissionsManager! } public let preferenceDataStore: AirshipCore.PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var _config: RuntimeConfig? public var config: RuntimeConfig { get { return _config! } set { _config = newValue } } private var _actionRegistry: (any AirshipActionRegistry)? public var actionRegistry: any AirshipActionRegistry { get { return _actionRegistry! } set { _actionRegistry = newValue } } private var _channelCapture: (any AirshipChannelCapture)? public var channelCapture: any AirshipChannelCapture { get { return _channelCapture! } set { _channelCapture = newValue } } private var _urlAllowList: (any AirshipURLAllowList)? public var urlAllowList: any AirshipURLAllowList { get { return _urlAllowList! } set { _urlAllowList = newValue } } private var _localeManager: (any AirshipLocaleManager)? public var localeManager: AirshipLocaleManager { get { return _localeManager! } set { _localeManager = newValue } } private var _privacyManager: (any InternalAirshipPrivacyManager)? public var privacyManager: any InternalAirshipPrivacyManager { get { return _privacyManager! } set { _privacyManager = newValue } } public var javaScriptCommandDelegate: JavaScriptCommandDelegate? public var deepLinkDelegate: DeepLinkDelegate? @MainActor public var onDeepLink: (@MainActor @Sendable (URL) async -> Void)? public let urlOpener: any URLOpenerProtocol = TestURLOpener() public var components: [AirshipComponent] = [] private var componentMap: [String: AirshipComponent] = [:] public func component<E>(ofType componentType: E.Type) -> E? { let key = "Type:\(componentType)" if componentMap[key] == nil { self.componentMap[key] = self.components.first { ($0 as? E) != nil } } return componentMap[key] as? E } public func makeShared() { Airship._shared = Airship(instance: self) } public class func clearShared() { Airship._shared = nil } public func airshipReady() { } @MainActor init() { _permissionsManager = DefaultAirshipPermissionsManager() } } ================================================ FILE: Airship/AirshipCore/Tests/TestAirshipRequestSession.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation @testable public import AirshipCore public final class TestAirshipRequestSession: AirshipRequestSession, @unchecked Sendable { public var previousRequest: AirshipRequest? public var lastRequest: AirshipRequest? public var response: HTTPURLResponse? public var error: (any Error)? public var data: Data? public func performHTTPRequest( _ request: AirshipRequest ) async throws -> AirshipHTTPResponse<Void> { return try await self.performHTTPRequest( request, autoCancel: false, responseParser: nil ) } public func performHTTPRequest( _ request: AirshipRequest, autoCancel: Bool ) async throws -> AirshipHTTPResponse<Void> { return try await self.performHTTPRequest( request, autoCancel: autoCancel, responseParser: nil ) } public func performHTTPRequest<T>( _ request: AirshipRequest, responseParser: (@Sendable (Data?, HTTPURLResponse) throws -> T?)? ) async throws -> AirshipHTTPResponse<T> { return try await self.performHTTPRequest( request, autoCancel: false, responseParser: responseParser ) } @MainActor public func performHTTPRequest<T>( _ request: AirshipRequest, autoCancel: Bool, responseParser: (@Sendable (Data?, HTTPURLResponse) throws -> T?)? ) async throws -> AirshipHTTPResponse<T> { self.previousRequest = self.lastRequest self.lastRequest = request if let error = self.error { throw error } guard let response else { throw AirshipErrors.error("No response") } let result = AirshipHTTPResponse( result: try responseParser?(data, response), statusCode: response.statusCode, headers: response.allHeaderFields as? [String: String] ?? [:] ) return result } } ================================================ FILE: Airship/AirshipCore/Tests/TestAnalytics.swift ================================================ import Foundation @testable public import AirshipCore public import Combine public import UserNotifications public class TestAnalytics: InternalAirshipAnalytics, AirshipComponent, @unchecked Sendable { public var eventFeed: AirshipAnalyticsFeed = AirshipAnalyticsFeed { true } let eventSubject = PassthroughSubject<AirshipEventData, Never>() /// Airship event publisher public var eventPublisher: AnyPublisher<AirshipEventData, Never> { eventSubject.eraseToAnyPublisher() } public func recordCustomEvent(_ event: CustomEvent) { self.customEvents.append(event) } public func recordRegionEvent(_ event: RegionEvent) { } public func trackInstallAttribution(appPurchaseDate: Date?, iAdImpressionDate: Date?) { } public func recordEvent(_ event: AirshipEvent) { self.events.append(event) Task { await eventFeed.notifyEvent(.analytics(eventType: event.eventType, body: event.eventData)) } } private let screen = AirshipMainActorValue<String?>(nil) private let regions = AirshipMainActorValue<Set<String>>(Set()) @MainActor public func setScreen(_ screen: String?) { self.screen.set(screen) } @MainActor public func setRegions(_ regions: Set<String>) { self.regions.set(regions) } public var screenUpdates: AsyncStream<String?> { return self.screen.updates } @MainActor public var currentScreen: String? { return self.screen.value } public var regionUpdates: AsyncStream<Set<String>> { return self.regions.updates } public var currentRegions: Set<String> { return self.regions.value } public func onNotificationResponse(response: UNNotificationResponse, action: UNNotificationAction?) { } public func addHeaderProvider(_ headerProvider: @escaping () async -> [String : String]) { headerBlocks.append(headerProvider) } public var headerBlocks: [() async -> [String: String]] = [] public var headers: [String: String] { get async { var allHeaders: [String: String] = [:] for headerBlock in self.headerBlocks { let headers = await headerBlock() allHeaders.merge(headers) { (_, new) in return new } } return allHeaders } } public var isComponentEnabled: Bool = true public var events: [AirshipEvent] = [] public var customEvents: [CustomEvent] = [] public var conversionSendID: String? public var conversionPushMetadata: String? public var sessionID: String = "" public func addEvent(_ event: AirshipEvent) { events.append(event) } public func associateDeviceIdentifiers( _ associatedIdentifiers: AssociatedIdentifiers ) { } public func currentAssociatedDeviceIdentifiers() -> AssociatedIdentifiers { return AssociatedIdentifiers() } @MainActor public func trackScreen(_ screen: String?) { } public func scheduleUpload() { } public func registerSDKExtension( _ ext: AirshipSDKExtension, version: String ) { } public func launched(fromNotification notification: [AnyHashable: Any]) { } } ================================================ FILE: Airship/AirshipCore/Tests/TestAudienceChecker.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipCore final class TestAudienceChecker: DeviceAudienceChecker, @unchecked Sendable { func evaluate( audienceSelector: CompoundDeviceAudienceSelector?, newUserEvaluationDate: Date, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> AirshipDeviceAudienceResult { guard let audienceSelector else { return .match } return try await self.onEvaluate!(audienceSelector, newUserEvaluationDate, deviceInfoProvider) } var onEvaluate: ((CompoundDeviceAudienceSelector, Date, any AudienceDeviceInfoProvider) async throws -> AirshipDeviceAudienceResult)! } 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<String> = Set() var locale: Locale = Locale.current var appVersion: String? = nil var permissions: [AirshipPermission : AirshipPermissionStatus] = [:] var isUserOptedInPushNotifications: Bool = false var analyticsEnabled: Bool = false var installDate: Date = Date() } ================================================ FILE: Airship/AirshipCore/Tests/TestCache.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipCore struct CacheEntry: Sendable { let data: Data let ttl: TimeInterval } actor TestCache: AirshipCache { func deleteCachedValue(key: String) async { values[key] = nil } private var values: [String: CacheEntry] = [:] func entry(key: String) async -> CacheEntry? { return self.values[key] } func getCachedValue<T>(key: String) async -> T? where T : Decodable, T : Encodable, T : Sendable { guard let value = self.values[key] else { return nil } return try? JSONDecoder().decode(T.self, from: value.data) } func setCachedValue<T>( _ value: T?, key: String, ttl: TimeInterval ) async where T : Decodable, T : Encodable, T : Sendable { guard let value = value, let data = try? JSONEncoder().encode(value) else { return } self.values[key] = CacheEntry(data: data, ttl: ttl) } } ================================================ FILE: Airship/AirshipCore/Tests/TestChannel.swift ================================================ import Foundation import ActivityKit @testable import AirshipCore import Combine class TestChannel: NSObject, AirshipChannel, AirshipComponent, @unchecked Sendable { private var identifierSubject: CurrentValueSubject<String?, Never> = CurrentValueSubject(nil) var identifierUpdates: AsyncStream<String> { return AsyncStream { continuation in let cancellable = identifierSubject .compactMap { $0 } .removeDuplicates() .sink { update in continuation.yield(update) } continuation.onTermination = { _ in cancellable.cancel() } } } private let subscriptionListEditsSubject = PassthroughSubject<SubscriptionListEdit, Never>() public var extenders: [@Sendable (inout ChannelRegistrationPayload) async -> Void] = [] public var channelPayload: ChannelRegistrationPayload { get async { var result: ChannelRegistrationPayload = ChannelRegistrationPayload() for extender in extenders { await extender(&result) } return result } } @objc public var identifier: String? = nil { didSet { identifierSubject.send(identifier) } } public var contactUpdates: [SubscriptionListUpdate] = [] public var updateRegistrationCalled: Bool = false public var isChannelCreationEnabled: Bool = false public var pendingAttributeUpdates: [AttributeUpdate] = [] public var pendingTagGroupUpdates: [TagGroupUpdate] = [] public var tags: [String] = [] public var isChannelTagRegistrationEnabled: Bool = false public var tagGroupEditor: TagGroupsEditor? public var attributeEditor: AttributesEditor? public var subscriptionListEditor: SubscriptionListEditor? public func updateRegistration(forcefully: Bool) { self.updateRegistrationCalled = true } public func editTags() -> TagEditor { return TagEditor { applicator in self.tags = applicator(self.tags) } } public func editTags(_ editorBlock: (TagEditor) -> Void) { let editor = editTags() editorBlock(editor) editor.apply() } public func editTagGroups() -> TagGroupsEditor { return self.tagGroupEditor! } public func editTagGroups(_ editorBlock: (TagGroupsEditor) -> Void) { let editor = editTagGroups() editorBlock(editor) editor.apply() } public func editSubscriptionLists() -> SubscriptionListEditor { return self.subscriptionListEditor! } public func editSubscriptionLists( _ editorBlock: (SubscriptionListEditor) -> Void ) { let editor = editSubscriptionLists() editorBlock(editor) editor.apply() } public func fetchSubscriptionLists() async throws -> [String] { fatalError("Not implemented") } public func editAttributes() -> AttributesEditor { return self.attributeEditor! } public func editAttributes(_ editorBlock: (AttributesEditor) -> Void) { let editor = editAttributes() editorBlock(editor) editor.apply() } public func enableChannelCreation() { self.isChannelCreationEnabled = true } public func updateRegistration() { self.updateRegistrationCalled = true } public override var description: String { return "TestChannel" } public func addRegistrationExtender( _ extender: @Sendable @escaping (inout ChannelRegistrationPayload) async -> Void ) { self.extenders.append(extender) } public func processContactSubscriptionUpdates( _ updates: [SubscriptionListUpdate] ) { self.contactUpdates.append(contentsOf: updates) } public var subscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { subscriptionListEditsSubject.eraseToAnyPublisher() } func liveActivityRegistrationStatusUpdates(name: String) -> LiveActivityRegistrationStatusUpdates { return LiveActivityRegistrationStatusUpdates { _ in return .notTracked } } @available(iOS 16.1, *) func liveActivityRegistrationStatusUpdates<T>(activity: Activity<T>) -> LiveActivityRegistrationStatusUpdates where T : ActivityAttributes { return LiveActivityRegistrationStatusUpdates { _ in return .notTracked } } @available(iOS 16.1, *) func trackLiveActivity<T>(_ activity: Activity<T>, name: String) where T : ActivityAttributes { } @available(iOS 16.1, *) func restoreLiveActivityTracking(callback: @escaping @Sendable (any LiveActivityRestorer) async -> Void) { } } extension TestChannel: InternalAirshipChannel { func clearSubscriptionListsCache() { } } ================================================ FILE: Airship/AirshipCore/Tests/TestChannelAudienceManager.swift ================================================ import Combine import Foundation @testable import AirshipCore class TestChannelAudienceManager: ChannelAudienceManagerProtocol, @unchecked Sendable { var pendingLiveActivityUpdates: [LiveActivityUpdate] = [] let liveActivityUpdates: AsyncStream<[LiveActivityUpdate]> let liveActivityUpdatesContinuation: AsyncStream<[LiveActivityUpdate]>.Continuation private(set) var operations: [ContactOperation] = [] var generateDefaultContactIDCalled: Bool = false init() { ( self.liveActivityUpdates, self.liveActivityUpdatesContinuation ) = AsyncStream<[LiveActivityUpdate]>.airshipMakeStreamWithContinuation() } public let subscriptionListEditsSubject = PassthroughSubject< SubscriptionListEdit, Never >() public var subscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { self.subscriptionListEditsSubject.eraseToAnyPublisher() } public var contactUpdates: [SubscriptionListUpdate] = [] public var pendingAttributeUpdates: [AttributeUpdate] = [] public var pendingTagGroupUpdates: [TagGroupUpdate] = [] public var channelID: String? = nil public var enabled: Bool = false public var tagGroupEditor: TagGroupsEditor? public var attributeEditor: AttributesEditor? public var subscriptionListEditor: SubscriptionListEditor? public var fetchSubscriptionListCallback: (() async throws -> [String])? public func editSubscriptionLists() -> SubscriptionListEditor { return subscriptionListEditor! } public func editTagGroups(allowDeviceGroup: Bool) -> TagGroupsEditor { return tagGroupEditor! } public func editAttributes() -> AttributesEditor { return attributeEditor! } public func fetchSubscriptionLists() async throws -> [String] { return try await fetchSubscriptionListCallback!() } public func processContactSubscriptionUpdates( _ updates: [SubscriptionListUpdate] ) { self.contactUpdates.append(contentsOf: updates) } public func addLiveActivityUpdate(_ update: LiveActivityUpdate) { } public func clearSubscriptionListCache() { } } ================================================ FILE: Airship/AirshipCore/Tests/TestChannelAuthTokenAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class TestChannelAuthTokenAPIClient: ChannelAuthTokenAPIClientProtocol, @unchecked Sendable { var handler: ((String) async throws -> AirshipHTTPResponse<ChannelAuthTokenResponse>)? func fetchToken( channelID: String ) async throws -> AirshipHTTPResponse<ChannelAuthTokenResponse> { guard let handler = handler else { throw AirshipErrors.error("Request block not set") } return try await handler(channelID) } } ================================================ FILE: Airship/AirshipCore/Tests/TestChannelBulkUpdateAPIClient.swift ================================================ import Foundation @testable import AirshipCore class TestChannelBulkUpdateAPIClient: ChannelBulkUpdateAPIClientProtocol, @unchecked Sendable { var updateCallback: ((String, AudienceUpdate) async throws -> AirshipHTTPResponse<Void>)? init() {} func update(_ update: AirshipCore.AudienceUpdate, channelID: String) async throws -> AirshipCore.AirshipHTTPResponse<Void> { try await self.updateCallback!(channelID, update) } } ================================================ FILE: Airship/AirshipCore/Tests/TestChannelRegistrar.swift ================================================ import Foundation import Combine @testable import AirshipCore class TestChannelRegistrar: ChannelRegistrarProtocol, @unchecked Sendable { let registrationUpdates: AirshipAsyncChannel<ChannelRegistrationUpdate> = .init() var payloadCreateBlock: (@Sendable () async -> AirshipCore.ChannelRegistrationPayload?)? private var extenders: [@Sendable (inout ChannelRegistrationPayload) async -> Void] = [] public var channelPayload: ChannelRegistrationPayload { get async { let payload = await payloadCreateBlock?() var result: ChannelRegistrationPayload = payload ?? ChannelRegistrationPayload() for extender in extenders { await extender(&result) } return result } } public func addRegistrationExtender(_ extender: @Sendable @escaping (inout ChannelRegistrationPayload) async -> Void) { self.extenders.append(extender) } public var channelID: String? public var registerCalled = false public func register(forcefully: Bool) { registerCalled = true } } ================================================ FILE: Airship/AirshipCore/Tests/TestContact.swift ================================================ import Foundation import Combine @testable import AirshipCore class TestContact: InternalAirshipContact, AirshipComponent, @unchecked Sendable { var contactChannelUpdates: AsyncStream<AirshipCore.ContactChannelsResult> = AsyncStream<ContactChannelsResult>.init { _ in } var contactChannelPublisher: AnyPublisher<AirshipCore.ContactChannelsResult, Never> = 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) { } func resend(_ channel: AirshipCore.ContactChannel) { } func disassociateChannel(_ channel: AirshipCore.ContactChannel) { } func associateChannel(_ channelID: String, type: AirshipCore.ChannelType) { } func notifyRemoteLogin() { } var contactIDInfo: AirshipCore.ContactIDInfo? = nil let contactIDUpdatesSubject = PassthroughSubject<ContactIDInfo, Never>() var contactIDUpdates: AnyPublisher<ContactIDInfo, Never> { contactIDUpdatesSubject.eraseToAnyPublisher() } var contactID: String? = nil var authTokenProvider: AuthTokenProvider = TestAuthTokenProvider { id in return "" } func getStableContactID() async -> String { return contactID ?? "" } public static let contactConflictEvent = NSNotification.Name( "com.urbanairship.contact_conflict" ) public static let contactConflictEventKey = "event" public static let maxNamedUserIDLength = 128 private let conflictEventSubject = PassthroughSubject<ContactConflictEvent, Never>() public var conflictEventPublisher: AnyPublisher<ContactConflictEvent, Never> { conflictEventSubject.eraseToAnyPublisher() } private let namedUserUpdatesSubject = PassthroughSubject<String?, Never>() public var namedUserIDPublisher: AnyPublisher<String?, Never> { namedUserUpdatesSubject .prepend(namedUserID) .removeDuplicates() .eraseToAnyPublisher() } public var subscriptionListEdits: AnyPublisher<AirshipCore.ScopedSubscriptionListEdit, Never> { subscriptionListEditsSubject.eraseToAnyPublisher() } private let subscriptionListEditsSubject = PassthroughSubject<ScopedSubscriptionListEdit, Never>() public var isComponentEnabled: Bool = true public var namedUserID: String? public var pendingAttributeUpdates: [AttributeUpdate] = [] public var pendingTagGroupUpdates: [TagGroupUpdate] = [] public var tagGroupEditor: TagGroupsEditor? public var attributeEditor: AttributesEditor? public var subscriptionListEditor: ScopedSubscriptionListEditor? public func identify(_ namedUserID: String) { self.namedUserID = namedUserID } public func reset() { self.namedUserID = nil } public func editTagGroups() -> TagGroupsEditor { return tagGroupEditor! } public func editAttributes() -> AttributesEditor { return attributeEditor! } public func editTagGroups(_ editorBlock: (TagGroupsEditor) -> Void) { let editor = editTagGroups() editorBlock(editor) editor.apply() } public func editAttributes(_ editorBlock: (AttributesEditor) -> Void) { let editor = editAttributes() editorBlock(editor) editor.apply() } public func editSubscriptionLists() -> ScopedSubscriptionListEditor { return subscriptionListEditor! } public func editSubscriptionLists( _ editorBlock: (ScopedSubscriptionListEditor) -> Void ) { let editor = editSubscriptionLists() editorBlock(editor) editor.apply() } public func fetchSubscriptionLists() async throws -> [String: [ChannelScope]] { return [:] } } ================================================ FILE: Airship/AirshipCore/Tests/TestContactAPIClient.swift ================================================ import Foundation @testable import AirshipCore class TestContactAPIClient: ContactsAPIClientProtocol, @unchecked Sendable { var resolveCallback: ((String, String?, String?) async throws -> AirshipHTTPResponse<ContactIdentifyResult>)? var identifyCallback: ((String, String, String?, String?) async throws -> AirshipHTTPResponse<ContactIdentifyResult>)? var resetCallback: ((String, String?) async throws -> AirshipHTTPResponse<ContactIdentifyResult>)? var resendCallback: ((ResendOptions) async throws -> AirshipHTTPResponse<Bool>)? var updateCallback: ((String, [TagGroupUpdate]?, [AttributeUpdate]?, [ScopedSubscriptionListUpdate]?) async throws -> AirshipHTTPResponse<Void>)? var associateChannelCallback: ((String, String, ChannelType) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult>)? var disassociateChannelCallback: ((Bool, String, ChannelType) async throws -> AirshipHTTPResponse<ContactDisassociateChannelResult>)? var disassociateEmailCallback: ((Bool, String, String) async throws -> AirshipHTTPResponse<ContactDisassociateChannelResult>)? var disassociateSMSCallback: ((Bool, String, String, String) async throws -> AirshipHTTPResponse<ContactDisassociateChannelResult>)? var registerEmailCallback: ((String, String, EmailRegistrationOptions, Locale) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult>)? var registerSMSCallback: ((String, String, SMSRegistrationOptions, Locale) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult>)? var registerOpenCallback: ((String, String, OpenRegistrationOptions, Locale) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult>)? init() {} public func resolve( channelID: String, contactID: String?, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> { return try await resolveCallback!(channelID, contactID, possiblyOrphanedContactID) } public func identify( channelID: String, namedUserID: String, contactID: String?, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> { return try await identifyCallback!(channelID, namedUserID, contactID, possiblyOrphanedContactID) } public func reset( channelID: String, possiblyOrphanedContactID: String? ) async throws -> AirshipHTTPResponse<ContactIdentifyResult> { return try await resetCallback!(channelID, possiblyOrphanedContactID) } public func update( contactID: String, tagGroupUpdates: [TagGroupUpdate]?, attributeUpdates: [AttributeUpdate]?, subscriptionListUpdates: [ScopedSubscriptionListUpdate]? ) async throws -> AirshipHTTPResponse<Void> { return try await updateCallback!(contactID, tagGroupUpdates, attributeUpdates, subscriptionListUpdates) } func associateChannel(contactID: String, channelID: String, channelType: ChannelType ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { return try await associateChannelCallback!(contactID, channelID, channelType) } public func registerEmail( contactID: String, address: String, options: EmailRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { return try await registerEmailCallback!(contactID, address, options, locale) } public func registerSMS( contactID: String, msisdn: String, options: SMSRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { return try await registerSMSCallback!(contactID, msisdn, options, locale) } public func registerOpen( contactID: String, address: String, options: OpenRegistrationOptions, locale: Locale ) async throws -> AirshipHTTPResponse<ContactAssociateChannelResult> { return try await registerOpenCallback!(contactID, address, options, locale) } func resend(resendOptions: ResendOptions) async throws -> AirshipHTTPResponse<Bool> { return try await resendCallback!(resendOptions) } func disassociateChannel( contactID: String, disassociateOptions: DisassociateOptions ) async throws -> AirshipHTTPResponse<ContactDisassociateChannelResult> { 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) } } } ================================================ FILE: Airship/AirshipCore/Tests/TestContactSubscriptionListAPIClient.swift ================================================ import Foundation @testable import AirshipCore class TestContactSubscriptionListAPIClient: ContactSubscriptionListAPIClientProtocol, @unchecked Sendable { var fetchSubscriptionListsCallback: ((String) async throws -> AirshipHTTPResponse<[String: [ChannelScope]]>)? init() {} func fetchSubscriptionLists( contactID: String ) async throws -> AirshipHTTPResponse<[String: [ChannelScope]]> { return try await fetchSubscriptionListsCallback!(contactID) } } final class TestContactChannelsProvider: ContactChannelsProviderProtocol, @unchecked Sendable { func contactChannels(stableContactIDUpdates: AsyncStream<String>) -> AsyncStream<ContactChannelsResult> { return AsyncStream<ContactChannelsResult> { _ in } } func contactUpdates(contactID: String) async throws -> AsyncStream<[ContactChannel]> { return AsyncStream<[ContactChannel]> { _ in } } func refresh() async { refreshedCalled = true } var refreshedCalled = false func refreshAsync() { refreshedCalled = true } init() {} } //ContactChannelsProviderProtocol //ContactChannelsAPIClientProtocol class TestChannelsListAPIClient: ContactChannelsAPIClientProtocol, @unchecked Sendable { func fetchAssociatedChannelsList( contactID: String ) async throws -> AirshipHTTPResponse<[ContactChannel]> { return AirshipHTTPResponse(result: nil, statusCode: 200, headers: [:]) } init() {} } ================================================ FILE: Airship/AirshipCore/Tests/TestDate.swift ================================================ /* Copyright Airship and Contributors */ @testable public import AirshipCore public import Foundation public class UATestDate: @unchecked Sendable, AirshipDateProtocol { public init(offset: TimeInterval = 0, dateOverride: Date? = nil) { self._offSet = AirshipAtomicValue(offset) self.dateOverride = dateOverride } private var _offSet: AirshipAtomicValue<TimeInterval> public func advance(by: TimeInterval) { self._offSet.value += by } public var offset: TimeInterval { get { return self._offSet.value } set { self._offSet.value = newValue } } public var dateOverride: Date? public var now: Date { let date = dateOverride ?? Date() return date.advanced(by: offset) } } ================================================ FILE: Airship/AirshipCore/Tests/TestDeferredResolver.swift ================================================ /* Copyright Airship and Contributors */ @testable import AirshipCore import Foundation final actor TestDeferredResolver: AirshipDeferredResolverProtocol { var dataCallback: ((DeferredRequest) async -> AirshipDeferredResult<Data>)? func onData(_ onData: @escaping (DeferredRequest) async -> AirshipDeferredResult<Data>) { self.dataCallback = onData } func resolve<T>( request: DeferredRequest, resultParser: @escaping @Sendable (Data) async throws -> T ) async -> AirshipDeferredResult<T> where T : Sendable { switch(await dataCallback?(request) ?? .timedOut) { case .success(let data): do { let value = try await resultParser(data) return .success(value) } catch { return .retriableError(statusCode: 200) } case .timedOut: return .timedOut case .outOfDate: return .outOfDate case .notFound: return .notFound case .retriableError(retryAfter: let retryAfter, statusCode: let statusCode): return .retriableError(retryAfter: retryAfter, statusCode:statusCode) } } } ================================================ FILE: Airship/AirshipCore/Tests/TestDispatcher.swift ================================================ /* Copyright Airship and Contributors */ @testable import AirshipCore final class TestDispatcher: UADispatcher { func dispatchAsync(_ block: @escaping @Sendable () -> Void) { block() } func doSync(_ block: @escaping @Sendable () -> Void) { block() } func dispatchAsyncIfNecessary(_ block: @escaping @Sendable () -> Void) { block() } } ================================================ FILE: Airship/AirshipCore/Tests/TestExperimentDataProvider.swift ================================================ /* Copyright Airship and Contributors */ @testable import AirshipCore import Foundation final class TestExperimentDataProvider: ExperimentDataProvider, @unchecked Sendable { var onEvaluate: ((MessageInfo, AudienceDeviceInfoProvider) async throws -> ExperimentResult?)? = nil func evaluateExperiments( info: MessageInfo, deviceInfoProvider: AudienceDeviceInfoProvider ) async throws -> ExperimentResult? { return try await onEvaluate?(info, deviceInfoProvider) } } ================================================ FILE: Airship/AirshipCore/Tests/TestKeychainAccess.swift ================================================ /* Copyright Airship and Contributors */ @testable import AirshipCore import Foundation // Test keychain that performs its in the same queueing as the real AirshipKeyChainAccess final class TestKeyChainAccess: AirshipKeychainAccessProtocol, @unchecked Sendable { var storedCredentials: [String: AirshipKeychainCredentials] = [:] private let dispatchQueue: DispatchQueue = DispatchQueue( label: "com.urbanairship.dispatcher.keychain", qos: .utility ) public func writeCredentials( _ credentials: AirshipKeychainCredentials, identifier: String, appKey: String ) async -> Bool { let key = "\(appKey).\(identifier)" return await self.dispatch { self.storedCredentials[key] = credentials return true } } public func deleteCredentials(identifier: String, appKey: String) async { let key = "\(appKey).\(identifier)" return await self.dispatch { self.storedCredentials[key] = nil } } public func readCredentails( identifier: String, appKey: String ) async -> AirshipKeychainCredentials? { let key = "\(appKey).\(identifier)" return await self.dispatch { return self.storedCredentials[key] } } // Not really needed for this class but it matches the behavior of the real // keychain access private func dispatch<T>(block: @escaping @Sendable () -> T) async -> T { return await withCheckedContinuation { continuation in dispatchQueue.async { continuation.resume(returning: block()) } } } } ================================================ FILE: Airship/AirshipCore/Tests/TestLocaleManager.swift ================================================ @testable public import AirshipCore public import Foundation public class TestLocaleManager: AirshipLocaleManager, @unchecked Sendable { public var _locale: Locale? = nil public func clearLocale() { self._locale = nil } public var currentLocale: Locale { get { return self._locale ?? Locale.autoupdatingCurrent } set { self._locale = newValue } } } ================================================ FILE: Airship/AirshipCore/Tests/TestNetworkMonitor.swift ================================================ import AirshipCore import Foundation actor TestNetworkChecker: AirshipNetworkCheckerProtocol { private let _isConnected = AirshipMainActorValue(false) @MainActor var connectionUpdates: AsyncStream<Bool> { return _isConnected.updates } init() {} @MainActor public func setConnected(_ connected: Bool) { self._isConnected.set(connected) } @MainActor var isConnected: Bool { return _isConnected.value } } ================================================ FILE: Airship/AirshipCore/Tests/TestPermissionPrompter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipCore final class TestPermissionPrompter: PermissionPrompter, @unchecked Sendable { var onPrompt: ( ( AirshipPermission, Bool, Bool ) -> AirshipPermissionResult )? init() {} func prompt( permission: AirshipPermission, enableAirshipUsage: Bool, fallbackSystemSettings: Bool) async -> AirshipPermissionResult { if let onPrompt = self.onPrompt { return onPrompt( permission, enableAirshipUsage, fallbackSystemSettings ) } else { return AirshipPermissionResult.notDetermined } } } ================================================ FILE: Airship/AirshipCore/Tests/TestPrivacyManager.swift ================================================ @testable import AirshipCore import Foundation final class TestPrivacyManager: InternalAirshipPrivacyManager, @unchecked Sendable { func isAnyFeatureEnabled() -> Bool { return isAnyFeatureEnabled(ignoringRemoteConfig: false) } private static let enabledFeaturesKey = "com.urbanairship.privacymanager.enabledfeatures" private let dataStore: PreferenceDataStore private let config: RuntimeConfig let notificationCenter: AirshipNotificationCenter private let defaultEnabledFeatures: AirshipFeature private let lock: AirshipLock = AirshipLock() private var lastUpdated: AirshipFeature = [] private var _enabledFeatures: AirshipFeature = [] private var localEnabledFeatures: AirshipFeature { get { guard let fromStore = self.dataStore.unsignedInteger(forKey: TestPrivacyManager.enabledFeaturesKey) else { return self.defaultEnabledFeatures } return AirshipFeature( rawValue:(fromStore & AirshipFeature.all.rawValue) ) } set { self.dataStore.setValue( newValue.rawValue, forKey: TestPrivacyManager.enabledFeaturesKey ) } } init( dataStore: PreferenceDataStore, config: RuntimeConfig, defaultEnabledFeatures: AirshipFeature = [], notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter() ) { self.dataStore = PreferenceDataStore(appKey: UUID().uuidString) self.config = RuntimeConfig.testConfig() self.defaultEnabledFeatures = defaultEnabledFeatures self.notificationCenter = notificationCenter self.notifyUpdate() } /// The current set of enabled features. public var enabledFeatures: AirshipFeature { get { self.localEnabledFeatures.subtracting(self.config.remoteConfig.disabledFeatures ?? []) } set { lock.sync { self.localEnabledFeatures = newValue notifyUpdate() } } } func enableFeatures(_ features: AirshipFeature) { self.enabledFeatures.insert(features) } func disableFeatures(_ features: AirshipFeature) { self.enabledFeatures.remove(features) } func isEnabled(_ feature: AirshipFeature) -> Bool { guard feature == [] else { return (enabledFeatures.rawValue & feature.rawValue) == feature.rawValue } return enabledFeatures == [] } func isAnyFeatureEnabled(ignoringRemoteConfig: Bool) -> Bool { if ignoringRemoteConfig { return localEnabledFeatures != [] } else { return enabledFeatures != [] } } private func notifyUpdate() { lock.sync { let enabledFeatures = self.enabledFeatures guard enabledFeatures != lastUpdated else { return } self.lastUpdated = enabledFeatures self.notificationCenter.postOnMain( name: AirshipNotifications.PrivacyManagerUpdated.name ) } } } ================================================ FILE: Airship/AirshipCore/Tests/TestPush.swift ================================================ /* Copyright Airship and Contributors */ @testable import AirshipCore import UserNotifications import UIKit import Foundation import Combine final class TestPush: NSObject, InternalAirshipPush, AirshipPush, AirshipComponent, @unchecked Sendable { var onAPNSRegistrationFinished: (@MainActor @Sendable (AirshipCore.APNSRegistrationResult) -> Void)? var onNotificationRegistrationFinished: (@MainActor @Sendable (AirshipCore.NotificationRegistrationResult) -> Void)? var onNotificationAuthorizedSettingsDidChange: (@MainActor @Sendable (AirshipCore.AirshipAuthorizedNotificationSettings) -> Void)? func enableUserPushNotifications(fallback: AirshipCore.PromptPermissionFallback) async -> Bool { return true } override init() { (self.notificationStatusUpdates, self.statusUpdateContinuation) = AsyncStream<AirshipNotificationStatus>.airshipMakeStreamWithContinuation() super.init() } var quietTime: QuietTimeSettings? func enableUserPushNotifications() async -> Bool { return true } func setBadgeNumber(_ newBadgeNumber: Int) async { } func resetBadge() async { } var autobadgeEnabled: Bool = false var timeZone: NSTimeZone? var quietTimeEnabled: Bool = false func setQuietTimeStartHour(_ startHour: Int, startMinute: Int, endHour: Int, endMinute: Int) { } let notificationStatusSubject: PassthroughSubject<AirshipNotificationStatus, Never> = PassthroughSubject() let notificationStatusUpdates: AsyncStream<AirshipNotificationStatus> let statusUpdateContinuation: AsyncStream<AirshipNotificationStatus>.Continuation var notificationStatusPublisher: AnyPublisher<AirshipNotificationStatus, Never> { notificationStatusSubject.removeDuplicates().eraseToAnyPublisher() } var notificationStatus: AirshipNotificationStatus = AirshipNotificationStatus( isUserNotificationsEnabled: false, areNotificationsAllowed: false, isPushPrivacyFeatureEnabled: false, isPushTokenRegistered: false, displayNotificationStatus: .denied ) var isPushNotificationsOptedIn: Bool = false var backgroundPushNotificationsEnabled: Bool = false var userPushNotificationsEnabled: Bool = false var extendedPushNotificationPermissionEnabled: Bool = false var requestExplicitPermissionWhenEphemeral: Bool = false var notificationOptions: UNAuthorizationOptions = [] var customCategories: Set<UNNotificationCategory> = Set() var accengageCategories: Set<UNNotificationCategory> = Set() var requireAuthorizationForDefaultCategories: Bool = false var pushNotificationDelegate: PushNotificationDelegate? var registrationDelegate: RegistrationDelegate? var launchNotificationResponse: UNNotificationResponse? var authorizedNotificationSettings: AirshipAuthorizedNotificationSettings = [] var authorizationStatus: UNAuthorizationStatus = .notDetermined var userPromptedForNotifications: Bool = false var defaultPresentationOptions: UNNotificationPresentationOptions = [] var badgeNumber: Int = 0 var deviceToken: String? // Notification callbacks var onForegroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> Void)? #if !os(watchOS) var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> UIBackgroundFetchResult)? #else var onBackgroundNotificationReceived: (@MainActor @Sendable ([AnyHashable: Any]) async -> WKBackgroundFetchResult)? #endif #if !os(tvOS) var onNotificationResponseReceived: (@MainActor @Sendable (UNNotificationResponse) async -> Void)? #endif var onExtendPresentationOptions: (@MainActor @Sendable (UNNotificationPresentationOptions, UNNotification) async -> UNNotificationPresentationOptions)? var updateAuthorizedNotificationTypesCalled = false var registrationError: Error? var didReceiveRemoteNotificationCallback: ( ([AnyHashable: Any], Bool) -> UIBackgroundFetchResult )? var combinedCategories: Set<UNNotificationCategory> = Set() func dispatchUpdateAuthorizedNotificationTypes() { self.updateAuthorizedNotificationTypesCalled = true } func didRegisterForRemoteNotifications(_ deviceToken: Data) { self.deviceToken = String(data: deviceToken, encoding: .utf8) } func didFailToRegisterForRemoteNotifications(_ error: Error) { self.registrationError = error } func didReceiveRemoteNotification( _ userInfo: [AnyHashable: Any], isForeground: Bool ) async -> UABackgroundFetchResult { let result = self.didReceiveRemoteNotificationCallback!( userInfo, isForeground ) return .init(from: result) } func presentationOptionsForNotification(_ notification: UNNotification) async -> UNNotificationPresentationOptions { return [] } func didReceiveNotificationResponse(_ response: UNNotificationResponse) async { assertionFailure("Unable to create UNNotificationResponse in tests.") } } ================================================ FILE: Airship/AirshipCore/Tests/TestRemoteData.swift ================================================ @testable import AirshipCore import Foundation import Combine final class TestRemoteData: NSObject, RemoteDataProtocol, @unchecked Sendable { func statusUpdates<T>(sources: [AirshipCore.RemoteDataSource], map: @escaping (@Sendable ([AirshipCore.RemoteDataSource : AirshipCore.RemoteDataSourceStatus]) -> T)) -> AsyncStream<T> where T : Sendable { return AsyncStream<T> { _ in } } func forceRefresh() async { } var waitForRefreshAttemptBlock: ((RemoteDataSource, TimeInterval?) -> Void)? var waitForRefreshBlock: ((RemoteDataSource, TimeInterval?) -> Void)? var notifiedOutdatedInfos: [RemoteDataInfo] = [] let updatesSubject = PassthroughSubject<[RemoteDataPayload], Never>() var isCurrent = true var payloads: [RemoteDataPayload] = [] { didSet { updatesSubject.send(payloads) } } var status: [RemoteDataSource : RemoteDataSourceStatus] = [:] var remoteDataRefreshInterval: TimeInterval = 0 var isContactSourceEnabled: Bool = false func setContactSourceEnabled(enabled: Bool) { isContactSourceEnabled = enabled } func isCurrent(remoteDataInfo: RemoteDataInfo) async -> Bool { return isCurrent } func notifyOutdated(remoteDataInfo: RemoteDataInfo) async { self.notifiedOutdatedInfos.append(remoteDataInfo) } func status(source: RemoteDataSource) async -> RemoteDataSourceStatus { return self.status[source] ?? .outOfDate } func publisher(types: [String]) -> AnyPublisher<[RemoteDataPayload], Never> { updatesSubject .prepend(payloads) .map{ payloads in return payloads.filter { payload in types.contains(payload.type) } .sorted { first, second in let firstIndex = types.firstIndex(of: first.type) ?? 0 let secondIndex = types.firstIndex(of: second.type) ?? 0 return firstIndex < secondIndex } } .eraseToAnyPublisher() } func payloads(types: [String]) async -> [RemoteDataPayload] { return payloads.filter { payload in types.contains(payload.type) } .sorted { first, second in let firstIndex = types.firstIndex(of: first.type) ?? 0 let secondIndex = types.firstIndex(of: second.type) ?? 0 return firstIndex < secondIndex } } func waitRefresh( source: AirshipCore.RemoteDataSource, maxTime: TimeInterval? ) async { self.waitForRefreshBlock?(source, maxTime) } func waitRefreshAttempt( source: AirshipCore.RemoteDataSource, maxTime: TimeInterval? ) async { self.waitForRefreshAttemptBlock?(source, maxTime) } func waitRefresh(source: AirshipCore.RemoteDataSource) async { await self.waitRefresh(source: source, maxTime: nil) } func waitRefreshAttempt(source: AirshipCore.RemoteDataSource) async { await self.waitRefreshAttempt(source: source, maxTime: nil) } } ================================================ FILE: Airship/AirshipCore/Tests/TestRemoteDataAPIClient.swift ================================================ import Foundation @testable import AirshipCore final class TestRemoteDataAPIClient: RemoteDataAPIClientProtocol, @unchecked Sendable { public var fetchData: ( (URL, AirshipRequestAuth, String?, RemoteDataInfo) async throws -> AirshipHTTPResponse<RemoteDataResult> )? public var lastModified: String? = nil func fetchRemoteData( url: URL, auth: AirshipRequestAuth, lastModified: String?, remoteDataInfoBlock: @escaping @Sendable (String?) throws -> AirshipCore.RemoteDataInfo ) async throws -> AirshipCore.AirshipHTTPResponse<AirshipCore.RemoteDataResult> { try await fetchData!(url, auth, lastModified, try remoteDataInfoBlock(self.lastModified)) } } ================================================ FILE: Airship/AirshipCore/Tests/TestSubscriptionListAPIClient.swift ================================================ import Foundation @testable public import AirshipCore public class TestSubscriptionListAPIClient: SubscriptionListAPIClientProtocol, @unchecked Sendable { var getCallback: ((String) async throws -> AirshipHTTPResponse<[String]>)? init() {} public func get( channelID: String ) async throws -> AirshipHTTPResponse<[String]> { return try await getCallback!(channelID) } } ================================================ FILE: Airship/AirshipCore/Tests/TestThomasLayoutEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @testable import AirshipCore struct TestThomasLayoutEvent: ThomasLayoutEvent { var name: EventType var data: (any Encodable & Sendable)? init(name: EventType = .customEvent, data: (Encodable & Sendable)? = nil) { self.name = name self.data = data } } ================================================ FILE: Airship/AirshipCore/Tests/TestURLAllowList.swift ================================================ /* Copyright Airship and Contributors */ public import AirshipCore public import Foundation public final class TestURLAllowList: AirshipURLAllowList, @unchecked Sendable { public var delegate: (any URLAllowListDelegate)? public var onAllowURL: (@MainActor @Sendable (URL, URLAllowListScope) -> Bool)? public var isAllowedReturnValue: Bool = true public var addEntryReturnValue: Bool = true public func isAllowed(_ url: URL?) -> Bool { return isAllowedReturnValue } public func isAllowed(_ url: URL?, scope: URLAllowListScope) -> Bool { return isAllowedReturnValue } public func addEntry(_ patternString: String) -> Bool { return addEntryReturnValue } public func addEntry( _ patternString: String, scope: URLAllowListScope ) -> Bool { return addEntryReturnValue } } ================================================ FILE: Airship/AirshipCore/Tests/TestURLOpener.swift ================================================ import Foundation @testable import AirshipCore final class TestURLOpener: URLOpenerProtocol, @unchecked Sendable { @MainActor var returnValue: Bool = true @MainActor var lastURL: URL? @MainActor var lastOpenSettingsCalled: Bool = false @MainActor func reset() { lastURL = nil lastOpenSettingsCalled = false } @MainActor func openURL(_ url: URL) async -> Bool { lastURL = url return returnValue } @MainActor func openURL(_ url: URL, completionHandler: (@MainActor @Sendable (Bool) -> Void)?) { lastURL = url completionHandler?(returnValue) } @MainActor func openSettings() async -> Bool { lastOpenSettingsCalled = true return returnValue } } ================================================ FILE: Airship/AirshipCore/Tests/TestWorkManager.swift ================================================ import Combine import Foundation @testable import AirshipCore class TestWorkManager: AirshipWorkManagerProtocol, @unchecked Sendable { struct Worker { let workID: String let workHandler: (AirshipWorkRequest) async throws -> AirshipWorkResult } var backgroundWorkRequests: [AirshipWorkRequest] = [] var rateLimits: [String: RateLimit] = [:] var workRequests: [AirshipWorkRequest] = [] private var workHandler: ((AirshipWorkRequest) async throws -> AirshipWorkResult?)? = nil var onNewWorkRequestAdded: ((AirshipWorkRequest) -> Void)? = nil var autoLaunchRequests: Bool = false var workers: [Worker] = [] func registerWorker( _ workID: String, workHandler: @escaping (AirshipWorkRequest) async throws -> AirshipWorkResult ) { self.workers.append( Worker( workID: workID, workHandler: workHandler ) ) self.workHandler = workHandler } func setRateLimit(_ limitID: String, rate: Int, timeInterval: TimeInterval) { rateLimits[limitID] = RateLimit(rate: rate, timeInterval: timeInterval) } func dispatchWorkRequest(_ request: AirshipWorkRequest) { workRequests.append(request) onNewWorkRequestAdded?(request) if (autoLaunchRequests) { Task { try await workHandler?(request) } } } func autoDispatchWorkRequestOnBackground(_ request: AirshipWorkRequest) { backgroundWorkRequests.append(request) } func launchTask(request: AirshipWorkRequest) async throws -> AirshipWorkResult? { return try await workHandler?(request) } struct RateLimit { let rate: Int let timeInterval: TimeInterval } } ================================================ FILE: Airship/AirshipCore/Tests/TestWorkRateLimiterActor.swift ================================================ import Foundation @testable import AirshipCore actor TestWorkRateLimiter { struct RateLimitRule { let rate: Int let timeInterval: TimeInterval } enum Status { case overLimit(TimeInterval) case withinLimit(Int) } var hits: [String: [Date]] = [:] var rules: [String: RateLimitRule] = [:] private let date: AirshipDateProtocol init(date: AirshipDate = AirshipDate()) { self.date = date } func set(_ key: String, rate: Int, timeInterval: TimeInterval) throws { guard rate > 0, timeInterval > 0 else { throw AirshipErrors.error( "Rate and time interval must be greater than 0" ) } self.rules[key] = RateLimitRule(rate: rate, timeInterval: timeInterval) self.hits[key] = [] } func nextAvailable(_ keys: [String]) -> TimeInterval { return keys.map { key in guard case .overLimit(let delay) = status(key) else { return 0.0 } return delay } .max() ?? 0.0 } func trackIfWithinLimit(_ keys: [String]) -> Bool { let overLimit = keys.contains { if let status = status($0) { if case .overLimit(_) = status { return true } } return false } guard !overLimit else { return false } keys.forEach { track($0) } return true } private func status(_ key: String) -> Status? { guard let rule = rules[key] else { AirshipLogger.debug("No rule for key \(key)") return nil } let date = date.now let filtered = filter(self.hits[key], rule: rule, date: date) ?? [] let count = filtered.count guard count >= rule.rate else { return .withinLimit(rule.rate - count) } let nextAvailable = rule.timeInterval - date.timeIntervalSince(filtered[count - rule.rate]) return .overLimit(nextAvailable) } private func track(_ key: String) { guard let rule = rules[key] else { AirshipLogger.debug("No rule for key \(key)") return } var keyHits = hits[key] ?? [] keyHits.append(self.date.now) hits[key] = filter(keyHits, rule: rule, date: self.date.now) } private func filter(_ hits: [Date]?, rule: RateLimitRule, date: Date) -> [Date]? { guard let hits = hits else { return nil } return hits.filter { hit in return hit.advanced(by: rule.timeInterval) > date } } } ================================================ FILE: Airship/AirshipCore/Tests/ThomasPresentationModelCodingTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ThomasPresentationModelCodingTest: XCTestCase { private let simpleContentViewJson = """ { "type":"label", "text":"Sup Buddy", "text_appearance":{ "font_size":14, "color":{ "type": "hex", "default":{ "hex":"#333333" } }, "alignment":"start", "styles":[ "italic" ], "font_families":[ "permanent_marker", "casual" ] } } """ func testBannerPresentationModelCodable() throws { let json = """ { "type": "banner", "placement_selectors": [ { "orientation": "landscape", "placement": { "position": "top", "size": { "width": "50%", "height": 500 }, "border": { "radius": 20 } } } ], "default_placement": { "size": { "width": "100%", "height": 500 }, "position": "top" } } """ try decodeEncodeCompare(source: json, type: ThomasPresentationInfo.self) } func testModalPresentationModelCodable() throws { let json = """ { "default_placement": { "size": { "min_width": "100%", "min_height": "100%", "max_height": "100%", "height": "100%", "width": "100%", "max_width": "100%" }, "device": { "lock_orientation": "portrait" }, "shade_color": { "default": { "type": "hex", "alpha": 0.2, "hex": "#000000" } }, "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "top" } }, "type": "modal", "dismiss_on_touch_outside": false } """ try decodeEncodeCompare(source: json, type: ThomasPresentationInfo.self) } func testEmbeddedPresentationModelCodable() throws { let json = """ { "type": "embedded", "embedded_id": "home_banner", "default_placement": { "size": { "width": "100%", "height": "100%" }, "margin": { "top": 16, "bottom": 16, "start": 16, "end": 16 } } } """ try decodeEncodeCompare(source: json, type: ThomasPresentationInfo.Embedded.self) } func testPresentationModelCodable() throws { let json = """ { "default_placement": { "size": { "min_width": "100%", "min_height": "100%", "max_height": "100%", "height": "100%", "width": "100%", "max_width": "100%" }, "device": { "lock_orientation": "portrait" }, "shade_color": { "default": { "type": "hex", "alpha": 0.2, "hex": "#000000" } }, "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "top" } }, "type": "modal", "dismiss_on_touch_outside": false } """ try decodeEncodeCompare(source: json, type: ThomasPresentationInfo.self) } func testAirshipLayoutCodable() throws { let json = """ { "version": 1, "presentation": { "type": "embedded", "embedded_id": "home_banner", "default_placement": { "size": { "width": "50%", "height": "50%" }, "margin": { "top": 16, "bottom": 16, "start": 16, "end": 16 } } }, "view": { "type": "container", "border": { "stroke_color": { "default": { "type": "hex", "hex": "#FF0D49", "alpha": 1 } }, "stroke_width": 2 }, "background_color": { "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ], "default": { "type": "hex", "hex": "#FF00FF", "alpha": 1 } }, "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "auto" }, "view": { "type": "label", "text": "50% x 50%.", "text_appearance": { "font_size": 14, "color": { "selectors": [ { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ], "default": { "type": "hex", "hex": "#FF00FF", "alpha": 1 } } } } } ] } } """ try decodeEncodeCompare(source: json, type: AirshipLayout.self) } // MARK: - KeyboardAvoidanceMethod Tests func testModalPresentationWithKeyboardAvoidanceSafeArea() throws { let json = """ { "default_placement": { "size": { "width": "100%", "height": "100%" } }, "type": "modal", "ios": { "keyboard_avoidance": "safe_area" } } """ try decodeEncodeCompare(source: json, type: ThomasPresentationInfo.self) let decoded = try JSONDecoder().decode(ThomasPresentationInfo.self, from: json.data(using: .utf8)!) if case .modal(let modal) = decoded { XCTAssertEqual(modal.ios?.keyboardAvoidance, .safeArea) } else { XCTFail("Expected modal presentation") } } private func decodeEncodeCompare<T: Codable & Equatable>(source: String, type: T.Type) throws { let decoder = JSONDecoder() let encoder = JSONEncoder() let decoded = try decoder.decode(type, from: source.data(using: .utf8)!) let json = try encoder.encode(decoded) let restored = try decoder.decode(type, from: json) XCTAssertEqual(restored, decoded) let inputJson = try JSONSerialization.jsonObject(with: source.data(using: .utf8)!) as! [String: Any] let encodedJson = try JSONSerialization.jsonObject(with: json) as! [String: Any] XCTAssertEqual(try AirshipJSON.wrap(inputJson), try AirshipJSON.wrap(encodedJson)) } } ================================================ FILE: Airship/AirshipCore/Tests/ThomasValidationTests.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class ThomasValidationTests: XCTestCase { func testValidVersions() throws { try (AirshipLayout.minLayoutVersion...AirshipLayout.maxLayoutVersion) .map { self.layout(version: $0).data(using: .utf8)! } .forEach { let layout = try JSONDecoder().decode(AirshipLayout.self, from: $0) XCTAssertTrue( layout.validate() ) } } func testInvalidVersions() throws { try ([AirshipLayout.minLayoutVersion - 1, AirshipLayout.maxLayoutVersion + 1]) .map { self.layout(version: $0).data(using: .utf8)! } .forEach { let layout = try JSONDecoder().decode(AirshipLayout.self, from: $0) XCTAssertFalse( layout.validate() ) } } func layout(version: Int) -> String { """ { "presentation": { "type": "modal", "default_placement": { "size": { "width": "60%", "height": "60%" }, "placement": { "horizontal": "center", "vertical": "center" } } }, "version": \(version), "view": { "type": "empty_view", } } """ } } ================================================ FILE: Airship/AirshipCore/Tests/ThomasViewModelTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore final class ThomasViewModelTest: XCTestCase { func testShapeModelCoding() throws { let rectangle = """ { "type": "rectangle", "scale": 0.5, "aspect_ratio": 1, "color": { "default": { "type": "hex", "hex": "#66FF66", "alpha": 1 } }, "border": { "stroke_width": 2, "radius": 5, "stroke_color": { "default": { "type": "hex", "hex": "#333333", "alpha": 1 } } } } """ try decodeEncodeCompare(source: rectangle, type: ThomasShapeInfo.self) let ellipse = """ { "border": { "radius": 2, "stroke_color": { "default": { "type": "hex", "alpha": 1, "hex": "#000000", } }, "stroke_width": 1 }, "color": { "default": { "type": "hex", "alpha": 1, "hex": "#DDDDDD", } }, "scale": 1, "type": "ellipse" } """ try decodeEncodeCompare(source: ellipse, type: ThomasShapeInfo.self) } func testLabelInfo() throws { let json = """ { "type": "label", "text": "You'll love these", "content_description": "Love it", "border": { "radius": 15, "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 0 } } }, "text_appearance": { "font_size": 44, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "alignment": "start", "styles": [], "font_families": ["sans-serif"] }, "view_overrides": { "background_color": [{ }], "text": [{ "when_state_matches": { "scope": ["some-id:error"], "value": { "equals": true } }, "value": "neat" }] } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.Label.self) } func testSizeCoding() throws { let autoPercent = "{\"width\": \"auto\", \"height\":\"101%\"}" let percentToPoints = "{\"width\":\"101%\", \"height\":45}" try decodeEncodeCompare(source: autoPercent, type: ThomasSize.self) try decodeEncodeCompare(source: percentToPoints, type: ThomasSize.self) } func testVisibilityInfoCoding() throws { let json = """ { "default": false, "invert_when_state_matches": { "or": [ { "key": "neat", "value": { "equals": "dissatisfied" } }, { "key": "neat", "value": { "equals": "very_dissatisfied" } } ] } } """ try decodeEncodeCompare(source: json, type: ThomasVisibilityInfo.self) } func testEventHandlerCodable() throws { let json = """ { "type": "form_input", "state_actions": [ { "type": "clear", }, { "type": "set_form_value", "key": "neat" }, { "type": "set", "key": "label_tapped", "value": true } ] } """ try decodeEncodeCompare(source: json, type: ThomasEventHandler.self) } func testWebViewModelCodable() throws { let json = """ { "type": "web_view", "url": "https://example.com", "event_handlers": [ { "type": "tap", "state_actions": [ { "type": "set", "key": "web_view_tapped", "value": true } ] } ] } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testCustomViewModelCodable() throws { let json = """ { "type": "custom_view", "name": "ad_custom_view", "properties": { "ad_type": "fashion" }, "background_color": { "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ], "default": { "type": "hex", "hex": "#FF00FF", "alpha": 1 } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testMediaViewModelCodable() throws { let image = """ { "media_fit": "center_inside", "media_type": "image", "type": "media", "url": "https://example.com" } """ try decodeEncodeCompare(source: image, type: ThomasViewInfo.self) let video = """ { "type": "media", "media_type": "video", "video": { "aspect_ratio": 0.56, "show_controls": true, "autoplay": true, "muted": true, "loop": true }, "media_fit": "center_inside", "url": "https://hangar-dl.urbanairshi.com" } """ try decodeEncodeCompare(source: video, type: ThomasViewInfo.self) let youtube = """ { "media_fit": "center_inside", "media_type": "youtube", "type": "media", "url": "https://www.youtube.com/embed/xUOQZeN8A7o", "video": { "aspect_ratio": 1.77777777777778, "autoplay": false, "loop": true, "muted": true, "show_controls": true } } """ try decodeEncodeCompare(source: youtube, type: ThomasViewInfo.self) } func testLabelModelCodable() throws { let json = """ { "type": "label", "text": "Sup Buddy", "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#333333" } }, "alignment": "start", "styles": [ "italic" ], "font_families": [ "permanent_marker", "casual" ] } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testLabelButtonModelCodable() throws { let json = """ { "type": "label_button", "identifier": "button1", "background_color": { "default": { "type": "hex", "hex": "#D32F2F", "alpha": 1 } }, "label": { "type": "label", "text": "start|top", "text_appearance": { "font_size": 10, "alignment": "center", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testButtonImageModelCodable() throws { let icon = """ { "scale": 0.4, "type": "icon", "icon": "close", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } } ] } } """ try decodeEncodeCompare(source: icon, type: ThomasViewInfo.ImageButton.ButtonImage.self) let url = """ { "type": "url", "url": "https://upload.wikimedia.org/wikipedia/en/thumb/8/8b/Airship_2019_logo.png" } """ try decodeEncodeCompare(source: url, type: ThomasViewInfo.ImageButton.ButtonImage.self) } func testValidationInfoCoding() throws { let json = """ { "required": true, "on_error": { "state_actions": [ { "type": "set", "key": "is_valid", "value": false } ] }, "on_edit": { "state_actions": [ { "type": "clear" } ] }, "on_valid": { "state_actions": [ { "type": "set", "key": "is_valid", "value": false } ] } } """ try decodeEncodeCompare(source: json, type: ThomasValidationInfo.self) // Test optional fields let minimalJson = """ { } """ try decodeEncodeCompare(source: minimalJson, type: ThomasValidationInfo.self) } func testImageButtonCodable() throws { let json = """ { "type": "image_button", "image": { "scale": 0.4, "type": "icon", "icon": "close", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } } ] } }, "identifier": "dismiss_button", "button_click": [ "dismiss" ] } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testEmptyViewModelCodable() throws { let json = """ { "type": "empty_view", "background_color": { "default": { "type": "hex", "hex": "#00FF00", "alpha": 0.5 } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testPagerGestureModelCodable() throws { let swipe = """ { "identifier": "63a41161-9322-4425-a940-fa928665459e_swipe_up", "type": "swipe", "direction": "up", "behavior": { "behaviors": [ "dismiss" ] } } """ try decodeEncodeCompare(source: swipe, type: ThomasViewInfo.Pager.Gesture.self) let tap = """ { "identifier": "63a41161-9322-4425-a940-fa928665459e_tap_start", "type": "tap", "location": "start", "behavior": { "behaviors": [ "pager_previous" ] } } """ try decodeEncodeCompare(source: tap, type: ThomasViewInfo.Pager.Gesture.self) let hold = """ { "type": "hold", "identifier": "hold-gesture-any-id", "press_behavior": { "behaviors": [ "pager_pause" ] }, "release_behavior": { "behaviors": [ "pager_resume" ] } } """ try decodeEncodeCompare(source: hold, type: ThomasViewInfo.Pager.Gesture.self) } func testPagerIndicatorModelCodable() throws { let json = """ { "type": "pager_indicator", "border": { "radius": 8 }, "spacing": 4, "bindings": { "selected": { "shapes": [ { "type": "ellipse", "aspect_ratio": 1, "scale": 0.75, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } } ] }, "unselected": { "shapes": [ { "type": "ellipse", "aspect_ratio": 1, "scale": 0.75, "border": { "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#333333", "alpha": 1 } } }, "color": { "default": { "type": "hex", "hex": "#ffffff", "alpha": 1 } } } ] } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testStoryIndicatorModelCodable() throws { let json = """ { "type": "story_indicator", "source": { "type": "pager" }, "style": { "type": "linear_progress", "direction": "horizontal", "sizing": "equal", "spacing": 4, "progress_color": { "default": { "type": "hex", "hex": "#AAAAAA", "alpha": 1 } }, "track_color": { "default": { "type": "hex", "hex": "#AAAAAA", "alpha": 0.5 } } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testToggleStyleModelCodable() throws { let switchStyle = """ { "type": "switch", "toggle_colors": { "on": { "default": { "type": "hex", "hex": "#00FF00", "alpha": 1 } }, "off": { "default": { "type": "hex", "hex": "#FF0000", "alpha": 1 } } } } """ try decodeEncodeCompare(source: switchStyle, type: ThomasToggleStyleInfo.self) let checkbox = """ { "bindings": { "selected": { "shapes": [ { "border": { "radius": 2, "stroke_color": { "default": { "type": "hex", "alpha": 1, "hex": "#000000", } }, "stroke_width": 1 }, "color": { "default": { "type": "hex", "alpha": 1, "hex": "#DDDDDD", } }, "scale": 1, "type": "ellipse" } ] }, "unselected": { "shapes": [ { "border": { "radius": 2, "stroke_color": { "default": { "type": "hex", "alpha": 1, "hex": "#000000", } }, "stroke_width": 1 }, "color": { "default": { "type": "hex", "alpha": 1, "hex": "#FFFFFF", } }, "scale": 1, "type": "ellipse" } ] } }, "type": "checkbox" } """ try decodeEncodeCompare(source: checkbox, type: ThomasToggleStyleInfo.self) } func testCheckboxModelCodable() throws { let json = """ { "type": "checkbox", "reporting_value": "moving boxes", "style": { "type": "checkbox", "bindings": { "selected": { "shapes": [ { "type": "rectangle", "scale": 0.5, "aspect_ratio": 1, "color": { "default": { "type": "hex", "hex": "#66FF66", "alpha": 1 } }, "border": { "stroke_width": 2, "radius": 5, "stroke_color": { "default": { "type": "hex", "hex": "#333333", "alpha": 1 } } } } ], "icon": { "type": "icon", "icon": "checkmark", "color": { "default": { "type": "hex", "hex": "#333333", "alpha": 1 } }, "scale": 0.4 } }, "unselected": { "shapes": [ { "type": "rectangle", "scale": 0.5, "aspect_ratio": 1, "color": { "default": { "type": "hex", "hex": "#FF6666", "alpha": 1 } }, "border": { "stroke_width": 2, "radius": 5, "stroke_color": { "default": { "type": "hex", "hex": "#333333", "alpha": 1 } } } } ], "icon": { "type": "icon", "icon": "close", "color": { "default": { "type": "hex", "hex": "#333333", "alpha": 1 } }, "scale": 0.4 } } } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testRadioModelCodable() throws { let json = """ { "reporting_value": "very_satisfied", "attribute_value": "VerySatisfied", "style": { "bindings": { "selected": { "shapes": [ { "border": { "radius": 2, "stroke_color": { "default": { "type": "hex", "alpha": 1, "hex": "#000000", } }, "stroke_width": 1 }, "color": { "default": { "type": "hex", "alpha": 1, "hex": "#DDDDDD", } }, "scale": 1, "type": "ellipse" } ] }, "unselected": { "shapes": [ { "border": { "radius": 2, "stroke_color": { "default": { "type": "hex", "alpha": 1, "hex": "#000000", } }, "stroke_width": 1 }, "color": { "default": { "type": "hex", "alpha": 1, "hex": "#FFFFFF", } }, "scale": 1, "type": "ellipse" } ] } }, "type": "checkbox" }, "type": "radio_input" } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testTextInputModelCodable() throws { let json = """ { "background_color": { "default": { "type": "hex", "hex": "#eae9e9", "alpha": 1 } }, "border": { "radius": 2, "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#63656b", "alpha": 1 } } }, "type": "text_input", "text_appearance": { "alignment": "start", "font_size": 14, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } }, "identifier": "4e1a5c5f-a4cb-4599-a612-199e06aeaebd", "input_type": "text_multiline", "required": false } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testTextInputModelWithRegistrationCodable() throws { let json = """ { "background_color": { "default": { "type": "hex", "hex": "#eae9e9", "alpha": 1 } }, "border": { "radius": 2, "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#63656b", "alpha": 1 } } }, "type": "text_input", "text_appearance": { "alignment": "start", "font_size": 14, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } }, "identifier": "4e1a5c5f-a4cb-4599-a612-199e06aeaebd", "input_type": "email", "email_registration": { "type": "double_opt_in", "properties": { "from": "iax" } }, "required": false } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testScoreStyleModelCodable() throws { let json = """ { "type": "number_range", "start": 0, "end": 10, "spacing": 4, "bindings": { "selected": { "shapes": [ { "type": "rectangle", "aspect_ratio": 1, "scale": 1, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } }, { "type": "ellipse", "aspect_ratio": 1.5, "scale": 1, "border": { "stroke_width": 2, "stroke_color": { "default": { "type": "hex", "hex": "#999999", "alpha": 1 } } }, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 0 } } } ], "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, "font_families": [ "permanent_marker" ] } }, "unselected": { "shapes": [ { "type": "ellipse", "aspect_ratio": 1.5, "scale": 1, "border": { "stroke_width": 2, "stroke_color": { "default": { "type": "hex", "hex": "#999999", "alpha": 1 } } }, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } } } ], "text_appearance": { "font_size": 14, "styles": [ "bold" ], "color": { "default": { "type": "hex", "hex": "#333333", "alpha": 1 } } } } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.Score.ScoreStyle.self) } func testScoreModelCodable() throws { let json = """ { "type": "score", "identifier": "nps_zero_to_ten", "required": true, "style": { "type": "number_range", "spacing": 2, "start": 0, "end": 10, "bindings": { "selected": { "shapes": [ { "type": "rectangle", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } } ], "text_appearance": { "font_size": 12, "styles": [ "bold" ], "color": { "default": { "type": "hex", "hex": "#ffffff", "alpha": 1 } } } }, "unselected": { "shapes": [ { "type": "rectangle", "border": { "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#999999", "alpha": 1 } } }, "color": { "default": { "type": "hex", "hex": "#dedede", "alpha": 1 } } } ], "text_appearance": { "font_size": 12, "color": { "default": { "type": "hex", "hex": "#666666", "alpha": 1 } } } } } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.Score.self) } func testToggleModelCodable() throws { let json = """ { "type": "toggle", "identifier": "hide", "event_handlers": [ { "type": "form_input", "state_actions": [ { "type": "set_form_value", "key": "hide" } ] } ], "style": { "type": "switch", "toggle_colors": { "on": { "default": { "type": "hex", "hex": "#00FF00", "alpha": 1 } }, "off": { "default": { "type": "hex", "hex": "#FF0000", "alpha": 1 } } } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.Toggle.self) } func testContainerModelCodable() throws { let json = """ { "type": "container", "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "auto" }, "margin": { "top": 75, "bottom": 50, "start": 50, "end": 50 }, "view": { "type": "label", "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In arcu cursus euismod quis viverra nibh. Lobortis feugiat vivamus at augue eget arcu dictum. Imperdiet dui accumsan sit amet nulla. Ultrices neque ornare aenean euismod elementum. Tincidunt id aliquet risus feugiat in ante metus dictum.", "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#333333" } }, "alignment": "start", "styles": [ "italic" ], "font_families": [ "permanent_marker", "casual" ] } } } ] } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.Container.self) } func testLinearLayoutModelCodable() throws { let json = """ { "type": "linear_layout", "items": [ { "margin": { "start": 0, "end": 0, "top": 0, "bottom": 0 }, "size": { "width": "100%", "height": "auto" }, "view": { "media_type": "image", "url": "https://media3.giphy.com/media/tBvPFCFQHSpEI/giphy.gif", "media_fit": "center_inside", "type": "media" } }, { "margin": { "bottom": 0, "end": 0, "top": 0, "start": 0 }, "view": { "media_fit": "center_inside", "type": "media", "video": { "muted": true, "aspect_ratio": 1.7777777777777777, "autoplay": false, "show_controls": true, "loop": false }, "url": "https://www.youtube.com/embed/a3ICNMQW7Ok/?autoplay=0&controls=1&loop=0&mute=1", "media_type": "youtube" }, "size": { "width": "100%", "height": "auto" } }, { "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "items": [], "direction": "horizontal" } } ], "direction": "vertical" } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testScrollLayoutModelCodable() throws { let json = """ { "type": "scroll_layout", "direction": "vertical", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "height": "auto", "width": "100%" }, "view": { "type": "media", "media_fit": "fit_crop", "position": { "horizontal": "center", "vertical": "center" }, "url": "https://hangar-dl.urbanairshi.com/binary/public/Hx7SIqHqQDmFj6aruaAFcQ/34be6e8d-31d0-499b-886e-2b29459cb472", "media_type": "image" } } ] } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testPagerModelCodable() throws { let json = """ { "type": "pager", "items": [ { "identifier": "page1", "view": { "type": "empty_view", "background_color": { "default": { "type": "hex", "hex": "#00FF00", "alpha": 0.5 } } } }, { "identifier": "page2", "view": { "type": "empty_view", "background_color": { "default": { "type": "hex", "hex": "#FFFF00", "alpha": 0.5 } } } }, { "identifier": "page2", "view": { "type": "empty_view", "background_color": { "default": { "type": "hex", "hex": "#FF00FF", "alpha": 0.5 } } } } ] } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testPagerControllerModelCodable() throws { let json = """ { "type": "pager_controller", "identifier": "6ab1531a-fcb3-44b4-91d7-52db73ae7cd9", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "height": "100%", "width": "100%" }, "view": { "type": "container", "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "pager", "disable_swipe": true, "items": [ { "identifier": "c36a5103-0a8d-4e34-b7b7-331ec1cbc87e", "view": { "type": "container", "items": [ { "size": { "width": "100%", "height": "100%" }, "position": { "horizontal": "center", "vertical": "center" }, "view": { "type": "container", "items": [ { "margin": { "bottom": 16 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "width": "100%", "height": "100%" }, "view": { "type": "scroll_layout", "direction": "vertical", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "width": "100%", "height": "auto" }, "margin": { "top": 48, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "label", "text": "This is test", "text_appearance": { "font_size": 30, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } } ] }, "alignment": "center", "styles": [], "font_families": [ "serif" ] } } }, { "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "horizontal", "items": [] } } ] } } } ] } } ] } } ] } } ] }, "ignore_safe_area": false }, { "position": { "horizontal": "end", "vertical": "top" }, "size": { "width": 48, "height": 48 }, "view": { "type": "image_button", "image": { "scale": 0.4, "type": "icon", "icon": "close", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } } ] } }, "identifier": "dismiss_button", "button_click": [ "dismiss" ] } } ] } } ] } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testFormControllerModelCodable() throws { let json = """ { "type": "form_controller", "identifier": "parent_form", "submit": "submit_event", "view": { "type": "linear_layout", "direction": "vertical", "background_color": { "default": { "type": "hex", "hex": "#ffffff", "alpha": 1 } }, "items": [ { "size": { "width": "auto", "height": 40 }, "margin": { "top": 8, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "nps_form_controller", "identifier": "nps_zero_to_ten_form", "nps_identifier": "nps_zero_to_ten", "view": { "type": "score", "identifier": "nps_zero_to_ten", "required": true, "style": { "type": "number_range", "spacing": 2, "start": 0, "end": 10, "bindings": { "selected": { "shapes": [ { "type": "rectangle", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } } ], "text_appearance": { "font_size": 12, "styles": [ "bold" ], "color": { "default": { "type": "hex", "hex": "#ffffff", "alpha": 1 } } } }, "unselected": { "shapes": [ { "type": "rectangle", "border": { "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#999999", "alpha": 1 } } }, "color": { "default": { "type": "hex", "hex": "#dedede", "alpha": 1 } } } ], "text_appearance": { "font_size": 12, "color": { "default": { "type": "hex", "hex": "#666666", "alpha": 1 } } } } } } } } }, { "size": { "width": "auto", "height": 24 }, "margin": { "top": 8, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "nps_form_controller", "identifier": "nps_zero_to_ten_form", "nps_identifier": "nps_zero_to_ten", "view": { "type": "score", "identifier": "nps_zero_to_ten", "required": true, "style": { "type": "number_range", "spacing": 8, "start": 1, "end": 5, "bindings": { "selected": { "shapes": [ { "type": "ellipse", "color": { "default": { "type": "hex", "hex": "#FFDD33", "alpha": 1 } } } ], "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } } }, "unselected": { "shapes": [ { "type": "ellipse", "color": { "default": { "type": "hex", "hex": "#3333ff", "alpha": 1 } } } ], "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#ffffff", "alpha": 1 } } } } } } } } }, { "size": { "width": "auto", "height": 32 }, "margin": { "top": 8, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "nps_form_controller", "identifier": "nps_zero_to_ten_form", "nps_identifier": "nps_zero_to_ten", "view": { "type": "score", "identifier": "nps_zero_to_ten", "required": true, "style": { "type": "number_range", "spacing": 8, "start": 97, "end": 105, "bindings": { "selected": { "shapes": [ { "type": "ellipse", "color": { "default": { "type": "hex", "hex": "#FF0000", "alpha": 1 } } } ], "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } } } }, "unselected": { "shapes": [ { "type": "ellipse", "color": { "default": { "type": "hex", "hex": "#0000FF", "alpha": 1 } } } ], "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#ffffff", "alpha": 1 } } } } } } } } }, { "size": { "width": "100%", "height": "auto" }, "margin": { "top": 16, "bottom": 16, "start": 16, "end": 16 }, "view": { "type": "label_button", "identifier": "SUBMIT_BUTTON", "background_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "button_click": [ "form_submit", "cancel" ], "enabled": [ "form_validation" ], "label": { "type": "label", "text": "SEND IT!", "text_appearance": { "font_size": 14, "alignment": "center", "color": { "default": { "type": "hex", "hex": "#ffffff", "alpha": 1 } } } } } } ] } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testCheckboxControllerModelCodable() throws { let json = """ { "type": "checkbox_controller", "identifier": "checkboxes", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "width": "100%", "height": "auto" }, "view": { "type": "linear_layout", "direction": "horizontal", "items": [ { "size": { "width": "auto", "height": "auto" }, "margin": { "top": 0 }, "view": { "type": "checkbox", "reporting_value": "check_cyan", "event_handlers": [ { "type": "tap", "state_actions": [ { "type": "set", "key": "last_check", "value": "cyan" } ] } ], "style": { "type": "checkbox", "bindings": { "selected": { "icon": { "type": "icon", "icon": "checkmark", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "scale": 0.5 }, "shapes": [ { "border": { "radius": 5, "stroke_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "stroke_width": 2 }, "color": { "default": { "type": "hex", "hex": "#00ffff", "alpha": 1 } }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "border": { "radius": 5, "stroke_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 0.5 } }, "stroke_width": 1 }, "color": { "default": { "type": "hex", "hex": "#00ffff", "alpha": 0.5 } }, "scale": 1, "type": "rectangle" } ] } } } } }, { "size": { "width": "auto", "height": "auto" }, "margin": { "start": 8 }, "view": { "type": "label", "text": "<-- Check it", "text_appearance": { "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "font_size": 14, "alignment": "start" }, "visibility": { "default": true, "invert_when_state_matches": { "key": "last_check", "value": { "is_present": true } } } } }, { "size": { "width": "auto", "height": "auto" }, "margin": { "start": 8 }, "view": { "type": "label", "text": "<-- Tapped last", "text_appearance": { "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "font_size": 14, "alignment": "start" }, "visibility": { "default": false, "invert_when_state_matches": { "key": "last_check", "value": { "equals": "cyan" } } } } } ] } }, { "size": { "width": "100%", "height": "auto" }, "margin": { "top": 4 }, "view": { "type": "linear_layout", "direction": "horizontal", "items": [ { "size": { "width": "auto", "height": "auto" }, "view": { "type": "checkbox", "reporting_value": "check_magenta", "event_handlers": [ { "type": "tap", "state_actions": [ { "type": "set", "key": "last_check", "value": "magenta" } ] } ], "style": { "type": "checkbox", "bindings": { "selected": { "icon": { "type": "icon", "icon": "checkmark", "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "scale": 0.5 }, "shapes": [ { "border": { "radius": 5, "stroke_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "stroke_width": 2 }, "color": { "default": { "type": "hex", "hex": "#ff00ff", "alpha": 1 } }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "border": { "radius": 5, "stroke_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 0.5 } }, "stroke_width": 1 }, "color": { "default": { "type": "hex", "hex": "#ff00ff", "alpha": 0.5 } }, "scale": 1, "type": "rectangle" } ] } } } } }, { "size": { "width": "auto", "height": "auto" }, "margin": { "start": 8 }, "view": { "type": "label", "text": "<-- Check it", "text_appearance": { "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "font_size": 14, "alignment": "start" }, "visibility": { "default": true, "invert_when_state_matches": { "key": "last_check", "value": { "is_present": true } } } } }, { "size": { "width": "auto", "height": "auto" }, "margin": { "start": 8 }, "view": { "type": "label", "text": "<-- Tapped last", "text_appearance": { "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 } }, "font_size": 14, "alignment": "start" }, "visibility": { "default": false, "invert_when_state_matches": { "key": "last_check", "value": { "equals": "magenta" } } } } } ] } } ] } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testRadioInputControllerModelCodable() throws { let json = """ { "identifier": "52fd50d9-c899-4887-8210-9669cb27188c", "type": "radio_input_controller", "attribute_name": { "channel": "HowSatisfiedAreYou" }, "event_handlers": [ { "type": "form_input", "state_actions": [ { "type": "set_form_value", "key": "neat" } ] } ], "view": { "type": "label", "text": "Sup Buddy", "text_appearance": { "font_size": 14, "color": { "default": { "type": "hex", "hex": "#333333" } }, "alignment": "start", "styles": [ "italic" ], "font_families": [ "permanent_marker", "casual" ] } } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testStateControllerModelCodable() throws { let json = """ { "type":"state_controller", "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "size": { "width": "auto", "height": "auto" }, "view":{ "type":"label", "text":"Sup Buddy", "text_appearance":{ "font_size":14, "color":{ "default":{ "type": "hex", "hex":"#333333" } }, "alignment":"start", "styles":[ "italic" ], "font_families":[ "permanent_marker", "casual" ] } } } ] } } """ try decodeEncodeCompare(source: json, type: ThomasViewInfo.self) } func testActionPayload() throws { let payload = ThomasActionsPayload(value: try AirshipJSON.wrap(["foo": "bar"])) let encoded = try JSONEncoder().encode(payload) let decoded = try JSONDecoder().decode(ThomasActionsPayload.self, from: encoded) XCTAssertEqual(payload, decoded) } func testActionPayloadPlatformOverrides() throws { let payload = ThomasActionsPayload(value: try AirshipJSON.wrap([ "foo": "bar", "shouldnt_change": "value", "platform_action_overrides": [ "ios": [ "foo": "bar2", "added": "override" ] ] ])) let encoded = try JSONEncoder().encode(payload) let decoded = try JSONDecoder().decode(ThomasActionsPayload.self, from: encoded) XCTAssertEqual(payload, decoded) XCTAssertEqual("bar2", payload.value.object?["foo"]?.string) XCTAssertEqual("value", payload.value.object?["shouldnt_change"]?.string) XCTAssertEqual("override", payload.value.object?["added"]?.string) } private func decodeEncodeCompare<T: Codable & Equatable>(source: String, type: T.Type) throws { let decoder = JSONDecoder() let encoder = JSONEncoder() let decoded = try decoder.decode(type, from: source.data(using: .utf8)!) let json = try encoder.encode(decoded) let restored = try decoder.decode(type, from: json) XCTAssertEqual(restored, decoded) let inputJson = try JSONSerialization.jsonObject(with: source.data(using: .utf8)!) as! [String: Any] let encodedJson = try JSONSerialization.jsonObject(with: json) as! [String: Any] XCTAssertEqual(try AirshipJSON.wrap(inputJson), try AirshipJSON.wrap(encodedJson)) } } ================================================ FILE: Airship/AirshipCore/Tests/Types/PagerDisableSwipeSelectorTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore import Foundation struct PagerDisableSwipeSelectorTest { @Test("Parsing test") func testParsing() async throws { let json = """ { "directions": { "type": "horizontal" }, "when_state_matches": { "scope": [ "test" ], "value": { "equals": [ "is-complete" ] } } } """ let expected = try AirshipJSON.from(json: json) let decoded: ThomasViewInfo.Pager.DisableSwipeSelector = try expected.decode() #expect(decoded.predicate != nil) #expect(decoded.direction == .horizontal) let encodedData = try JSONEncoder().encode(decoded) let encoded = try AirshipJSON.from(data: encodedData) #expect(expected == encoded) } } ================================================ FILE: Airship/AirshipCore/Tests/Types/ThomasEmailRegistrationOptionsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore class ThomasEmailRegistrationOptionsTest: XCTestCase { private let date: Date = Date.now func testCommercialFromJSON() throws { let json = """ { "type": "commercial", "commercial_opted_in": true, "properties": { "cool": "prop" } } """ let options = try JSONDecoder().decode(ThomasEmailRegistrationOption.self, from: json.data(using: .utf8)!) let expected: ThomasEmailRegistrationOption = .commercial( ThomasEmailRegistrationOption.Commercial( optedIn: true, properties: try AirshipJSON.wrap(["cool": "prop"]) ) ) XCTAssertEqual(expected, options) } func testCommercialNoPropertiesFromJSON() throws { let json = """ { "type": "commercial", "commercial_opted_in": false } """ let options = try JSONDecoder().decode(ThomasEmailRegistrationOption.self, from: json.data(using: .utf8)!) let expected: ThomasEmailRegistrationOption = .commercial( ThomasEmailRegistrationOption.Commercial( optedIn: false, properties: nil ) ) XCTAssertEqual(expected, options) } func testTransactionalFromJSON() throws { let json = """ { "type": "transactional", "properties": { "cool": "prop" } } """ let options = try JSONDecoder().decode(ThomasEmailRegistrationOption.self, from: json.data(using: .utf8)!) let expected: ThomasEmailRegistrationOption = .transactional( ThomasEmailRegistrationOption.Transactional( properties: try AirshipJSON.wrap(["cool": "prop"]) ) ) XCTAssertEqual(expected, options) } func testTransactionalNoPropertiesFromJSON() throws { let json = """ { "type": "transactional" } """ let options = try JSONDecoder().decode(ThomasEmailRegistrationOption.self, from: json.data(using: .utf8)!) let expected: ThomasEmailRegistrationOption = .transactional( ThomasEmailRegistrationOption.Transactional( properties: nil ) ) XCTAssertEqual(expected, options) } func testDoubleOptInFromJSON() throws { let json = """ { "type": "double_opt_in", "properties": { "cool": "prop" } } """ let options = try JSONDecoder().decode(ThomasEmailRegistrationOption.self, from: json.data(using: .utf8)!) let expected: ThomasEmailRegistrationOption = .doubleOptIn( ThomasEmailRegistrationOption.DoubleOptIn( properties: try AirshipJSON.wrap(["cool": "prop"]) ) ) XCTAssertEqual(expected, options) } func testDoubleOptInNoPropertiesFromJSON() throws { let json = """ { "type": "double_opt_in" } """ let options = try JSONDecoder().decode(ThomasEmailRegistrationOption.self, from: json.data(using: .utf8)!) let expected: ThomasEmailRegistrationOption = .doubleOptIn( ThomasEmailRegistrationOption.DoubleOptIn( properties: nil ) ) XCTAssertEqual(expected, options) } func testCommercialToContactOptions() throws { let options: ThomasEmailRegistrationOption = .commercial( ThomasEmailRegistrationOption.Commercial( optedIn: true, properties: try AirshipJSON.wrap(["cool": "prop"]) ) ) let expected = EmailRegistrationOptions.commercialOptions( transactionalOptedIn: nil, commercialOptedIn: date, properties: ["cool": "prop"] ) XCTAssertEqual(options.makeContactOptions(date: date), expected) } func testCommercialNoPropertiesToContactOptions() { let options: ThomasEmailRegistrationOption = .commercial( ThomasEmailRegistrationOption.Commercial( optedIn: false, properties: nil ) ) let expected = EmailRegistrationOptions.commercialOptions( transactionalOptedIn: nil, commercialOptedIn: nil, properties: nil ) XCTAssertEqual(options.makeContactOptions(date: date), expected) } func testTransactionalToContactOptions() throws { let options: ThomasEmailRegistrationOption = .transactional( ThomasEmailRegistrationOption.Transactional( properties: try AirshipJSON.wrap(["cool": "prop"]) ) ) let expected = EmailRegistrationOptions.options( transactionalOptedIn: nil, properties: ["cool": "prop"], doubleOptIn: false ) XCTAssertEqual(options.makeContactOptions(date: date), expected) } func testTransactionalNoPropertiesToContactOptions() { let options: ThomasEmailRegistrationOption = .transactional( ThomasEmailRegistrationOption.Transactional( properties: nil ) ) let expected = EmailRegistrationOptions.options( transactionalOptedIn: nil, properties: nil, doubleOptIn: false ) XCTAssertEqual(options.makeContactOptions(date: date), expected) } func testDoubleOptInToContactOptions() throws { let options: ThomasEmailRegistrationOption = .doubleOptIn( ThomasEmailRegistrationOption.DoubleOptIn( properties: try AirshipJSON.wrap(["cool": "prop"]) ) ) let expected = EmailRegistrationOptions.options(properties: ["cool": "prop"], doubleOptIn: true) XCTAssertEqual(options.makeContactOptions(date: date), expected) } func testDoubleOptInNoPropertiesToContactOptions() { let options: ThomasEmailRegistrationOption = .doubleOptIn( ThomasEmailRegistrationOption.DoubleOptIn( properties: nil ) ) let expected = EmailRegistrationOptions.options(properties: nil, doubleOptIn: true) XCTAssertEqual(options.makeContactOptions(date: date), expected) } } ================================================ FILE: Airship/AirshipCore/Tests/VideoMediaWebViewTests.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(watchOS) import Testing @testable import AirshipCore @Suite @MainActor struct VideoMediaWebViewTests { // MARK: - Standard embed URLs @Test func testExtractsIDFromStandardEmbedURL() { let url = "https://www.youtube.com/embed/dQw4w9WgXcQ" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == "dQw4w9WgXcQ") } @Test func testExtractsIDFromEmbedURLWithQueryParams() { let url = "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1&mute=1" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == "dQw4w9WgXcQ") } @Test func testExtractsIDWithHyphensAndUnderscores() { let url = "https://www.youtube.com/embed/a1B2-c3_D4e" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == "a1B2-c3_D4e") } // MARK: - Edge cases @Test func testReturnsNilForNonEmbedURL() { let url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == nil) } @Test func testReturnsNilForEmptyString() { #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: "") == nil) } @Test func testReturnsNilForUnrelatedURL() { let url = "https://vimeo.com/123456789" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == nil) } @Test func testReturnsNilForEmbedWithNoID() { let url = "https://www.youtube.com/embed/" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == nil) } @Test func testExtractsIDFromEmbedWithTrailingSlash() { let url = "https://www.youtube.com/embed/dQw4w9WgXcQ/" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == "dQw4w9WgXcQ") } @Test func testExtractsIDFromEmbedWithFragment() { let url = "https://www.youtube.com/embed/dQw4w9WgXcQ#t=30" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == "dQw4w9WgXcQ") } @Test func testExtractsIDFromEmbedWithTrailingSlashAndQueryParams() { let url = "https://www.youtube.com/embed/7sxVHYZ_PnA/?autoplay=1&controls=0&loop=1&mute=1" #expect(VideoMediaWebView.retrieveYoutubeVideoID(url: url) == "7sxVHYZ_PnA") } } #endif ================================================ FILE: Airship/AirshipCore/Tests/WorkManager/WorkRateLimiterTests.swift ================================================ import Foundation import Testing @testable import AirshipCore @Suite("WorkRateLimiter") struct WorkRateLimiterTests { // Helper to make a limiter with a test clock private func makeLimiter(start: TimeInterval = 1_000_000) -> (WorkRateLimiter, UATestDate) { let clock = UATestDate(dateOverride: Date(timeIntervalSince1970: start)) let limiter = WorkRateLimiter(date: clock) return (limiter, clock) } @Test("First N hits succeed, (N+1)th blocks") func blocksAfterRate() async throws { let (limiter, _) = makeLimiter() try await limiter.set("foo", rate: 2, timeInterval: 10) #expect(await limiter.trackIfWithinLimit(["foo"])) #expect(await limiter.trackIfWithinLimit(["foo"])) #expect(!(await limiter.trackIfWithinLimit(["foo"]))) // third should block let wait = await limiter.nextAvailable(["foo"]) #expect(wait > 0 && wait <= 10) } @Test("nextAvailable is the max across keys") func nextAvailableMaxAcrossKeys() async throws { let (limiter, clock) = makeLimiter() try await limiter.set("foo", rate: 2, timeInterval: 10) try await limiter.set("bar", rate: 1, timeInterval: 30) #expect(await limiter.trackIfWithinLimit(["foo"])) #expect(await limiter.trackIfWithinLimit(["foo"])) #expect(await limiter.trackIfWithinLimit(["bar"])) // Immediately, bar drives the wait (~30) let w0 = await limiter.nextAvailable(["foo", "bar"]) #expect((29.999...30.001).contains(w0), "Expected ~30s, got \(w0)") // After 25s, max should be ~5s clock.advance(by: 25) let w1 = await limiter.nextAvailable(["foo", "bar"]) #expect((4.999...5.001).contains(w1), "Expected ~5s, got \(w1)") } @Test("Pruning on read/write unblocks after window") func pruningUnblocks() async throws { let (limiter, clock) = makeLimiter() try await limiter.set("k", rate: 3, timeInterval: 5) #expect(await limiter.trackIfWithinLimit(["k"])) #expect(await limiter.trackIfWithinLimit(["k"])) #expect(await limiter.trackIfWithinLimit(["k"])) #expect(!(await limiter.trackIfWithinLimit(["k"]))) // blocked clock.advance(by: 6) #expect(await limiter.nextAvailable(["k"]) == 0) #expect(await limiter.trackIfWithinLimit(["k"])) } @Test("Negative waits clamp to 0") func clampNegativeToZero() async throws { let (limiter, clock) = makeLimiter() try await limiter.set("k", rate: 1, timeInterval: 10) #expect(await limiter.trackIfWithinLimit(["k"])) // consume 1 clock.advance(by: 12) let w = await limiter.nextAvailable(["k"]) #expect(w >= 0) #expect((0.0...0.001).contains(w)) } @Test("Empty key set is a no-op success") func emptyKeys() async throws { let (limiter, _) = makeLimiter() #expect(await limiter.trackIfWithinLimit([])) #expect(await limiter.nextAvailable([]) == 0) } @Test("Unknown key behaves as within limit (no rule)") func unknownKeyNoRule() async throws { let (limiter, _) = makeLimiter() #expect(await limiter.trackIfWithinLimit(["unknown"])) #expect(await limiter.nextAvailable(["unknown"]) == 0) } @Test("Set<String> API does not double-count") func setKeysNoDoubleCount() async throws { let (limiter, _) = makeLimiter() try await limiter.set("foo", rate: 1, timeInterval: 10) #expect(await limiter.trackIfWithinLimit(Set(["foo"]))) #expect(!(await limiter.trackIfWithinLimit(Set(["foo"])))) } @Test("All-or-nothing tracking across multiple keys") func allOrNothingAcrossKeys() async throws { let (limiter, _) = makeLimiter() try await limiter.set("a", rate: 1, timeInterval: 10) try await limiter.set("b", rate: 1, timeInterval: 10) #expect(await limiter.trackIfWithinLimit(["a"])) // Should fail for (a,b) because a is already at limit; and must not track either key #expect(!(await limiter.trackIfWithinLimit(["a","b"]))) // b should still be free to use #expect(await limiter.trackIfWithinLimit(["b"])) } } ================================================ FILE: Airship/AirshipDebug/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundlePackageType</key> <string>FMWK</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>$(CURRENT_PROJECT_VERSION)</string> </dict> </plist> ================================================ FILE: Airship/AirshipDebug/Resources/AirshipDebugEventData.xcdatamodeld/AirshipEventData.xcdatamodel/contents ================================================ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21G83" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="EventData" representedClassName="EventData" syncable="YES"> <attribute name="eventBody" attributeType="String"/> <attribute name="eventDate" attributeType="Date" usesScalarValueType="NO"/> <attribute name="eventID" attributeType="String"/> <attribute name="eventType" attributeType="String"/> </entity> <elements> <element name="EventData" positionX="2555.89453125" positionY="-5715.18359375" width="128" height="89"/> </elements> </model> ================================================ FILE: Airship/AirshipDebug/Resources/AirshipDebugPushData.xcdatamodeld/AirshipDebugPushData.xcdatamodel/contents ================================================ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="23H124" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="PushData" representedClassName="PushData" syncable="YES"> <attribute name="alert" optional="YES" attributeType="String"/> <attribute name="data" optional="YES" attributeType="String"/> <attribute name="pushID" optional="YES" attributeType="String"/> <attribute name="time" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> </entity> </model> ================================================ FILE: Airship/AirshipDebug/Source/AirshipDebugManager.swift ================================================ /* Copyright Airship and Contributors */ @preconcurrency import Combine import SwiftUI public import AirshipCore import UserNotifications /// A protocol that provides access to Airship's debug interface functionality. /// /// The `AirshipDebugManager` allows developers to display a comprehensive debug interface /// that provides insights into various Airship SDK components including push notifications, /// analytics events, channel information, contact data, and more. /// /// ## Usage /// /// ```swift /// // Display the debug interface /// Airship.debugManager.display() /// ``` /// /// The debug interface will be presented as an overlay window that can be dismissed /// by the user. It provides real-time monitoring and debugging capabilities for: /// - Push notification history and details /// - Analytics events and associated identifiers /// - Channel tags, attributes, and subscription lists /// - Contact information and channel management /// - In-app experiences and automations /// - Feature flags and experiments /// - Preference centers /// - App and SDK information /// - Privacy manager settings /// /// - Note: This protocol is thread-safe and can be called from any thread. /// - Important: `Airship.takeOff` must be called before accessing the debug manager. public protocol AirshipDebugManager: Sendable { /// Displays the Airship debug interface as an overlay window. /// /// This method presents a comprehensive debug interface that allows developers /// to inspect and monitor various aspects of the Airship SDK in real-time. /// The interface includes navigation to different debug sections and provides /// detailed information about push notifications, analytics events, channel /// data, and other SDK components. /// /// The debug interface will be displayed as a modal overlay window that can /// be dismissed by the user. If a debug interface is already displayed, /// calling this method will replace the current interface. /// /// - Note: This method must be called from the main thread. /// - Important: The debug interface requires an active scene to display properly. /// If no active scene is available, an error will be logged and the interface /// will not be displayed. @MainActor func display() } protocol InternalAirshipDebugManager: AirshipDebugManager { var preferenceFormsPublisher: AnyPublisher<[String], Never> { get } var inAppAutomationsPublisher: AnyPublisher<[[String: AnyHashable]], Never> { get } var experimentsPublisher: AnyPublisher<[[String: AnyHashable]], Never> { get } var featureFlagPublisher: AnyPublisher<[[String: AnyHashable]], Never> { get } var pushNotificationReceivedPublisher: AnyPublisher<PushNotification, Never> { get } var eventReceivedPublisher: AnyPublisher<AirshipEvent, Never> { get } func pushNotifications() async -> [PushNotification] func events(searchString: String?) async -> [AirshipEvent] func events() async -> [AirshipEvent] @MainActor func receivedRemoteNotification( _ notification: AirshipJSON ) async -> UABackgroundFetchResult #if !os(tvOS) func receivedNotificationResponse( _ response: UNNotificationResponse ) async #endif } final class DefaultAirshipDebugManager: InternalAirshipDebugManager { @MainActor private var currentDisplay: (any AirshipMainActorCancellable)? private let pushDataManager: PushDataManager private let eventDataManager: EventDataManager private let remoteData: any RemoteDataProtocol @MainActor private var eventUpdates: AnyCancellable? = nil var preferenceFormsPublisher: AnyPublisher<[String], Never> { self.remoteData.publisher(types: ["preference_forms"]) .map { payloads -> [String] in return payloads.compactMap { payload in if let data = payload.data(key: "preference_forms") as? [[String: Any]] { return data.compactMap { $0["form"] as? [String: Any] } .compactMap { $0["id"] as? String } } else { return [] } }.reduce([], +) } .removeDuplicates() .eraseToAnyPublisher() } var inAppAutomationsPublisher: AnyPublisher<[[String: AnyHashable]], Never> { self.remoteData.publisher(types: ["in_app_messages"]) .map { payloads -> [[String: AnyHashable]] in return payloads.compactMap { payload in payload.data(key: "in_app_messages") as? [[String: AnyHashable]] }.reduce([], +) } .removeDuplicates() .eraseToAnyPublisher() } var experimentsPublisher: AnyPublisher<[[String: AnyHashable]], Never> { self.remoteData.publisher(types: ["experiments"]) .map { payloads -> [[String: AnyHashable]] in return payloads.compactMap { payload in payload.data(key: "experiments") as? [[String: AnyHashable]] }.reduce([], +) } .removeDuplicates() .eraseToAnyPublisher() } var featureFlagPublisher: AnyPublisher<[[String: AnyHashable]], Never> { self.remoteData.publisher(types: ["feature_flags"]) .map { payloads -> [[String: AnyHashable]] in return payloads.compactMap { payload in payload.data(key: "feature_flags") as? [[String: AnyHashable]] }.reduce([], +) } .removeDuplicates() .eraseToAnyPublisher() } private let pushNotificationReceivedSubject = PassthroughSubject<PushNotification, Never>() var pushNotificationReceivedPublisher: AnyPublisher<PushNotification, Never> { return pushNotificationReceivedSubject.eraseToAnyPublisher() } private let eventReceivedSubject = PassthroughSubject<AirshipEvent, Never>() var eventReceivedPublisher: AnyPublisher<AirshipEvent, Never> { return eventReceivedSubject.eraseToAnyPublisher() } private let isEnabled: Bool @MainActor init( config: RuntimeConfig, analytics: any AirshipAnalytics, remoteData: any RemoteDataProtocol ) { self.remoteData = remoteData self.pushDataManager = PushDataManager(appKey: config.appCredentials.appKey) self.eventDataManager = EventDataManager(appKey: config.appCredentials.appKey) self.isEnabled = config.airshipConfig.isAirshipDebugEnabled guard self.isEnabled else { return } self.eventUpdates = analytics.eventPublisher .sink { incoming in let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted guard let body = try? incoming.body.toString( encoder: encoder ) else { return } let airshipEvent = AirshipEvent( identifier: incoming.id, type: incoming.type.reportingName, date: incoming.date, body: body ) Task { @MainActor in await self.eventDataManager.saveEvent(airshipEvent) self.eventReceivedSubject.send(airshipEvent) } } } func pushNotifications() async -> [PushNotification] { return await self.pushDataManager.pushNotifications() } func events(searchString: String?) async -> [AirshipEvent] { return await self.eventDataManager.events(searchString: searchString) } func events() async -> [AirshipEvent] { return await events(searchString: nil) } @MainActor public func display() { let displayable = AirshipDisplayTarget().prepareDisplay(for: .modal) currentDisplay?.cancel() let rootView = AirshipDebugView { displayable.dismiss() } do { try displayable.display { _ in return AirshipNativeHostingController(rootView: rootView) } self.currentDisplay = AirshipMainActorCancellableBlock(block: { displayable.dismiss() }) } catch { AirshipLogger.error("Unable to display AirshipDebug \(error)") } } @MainActor func receivedRemoteNotification( _ notification: AirshipJSON ) async -> UABackgroundFetchResult { guard self.isEnabled else { return .noData } do { let push = try PushNotification(userInfo: notification) try await savePush(push) } catch { AirshipLogger.error("Failed to save push \(error)") } return .noData } #if !os(tvOS) func receivedNotificationResponse( _ response: UNNotificationResponse ) async { guard self.isEnabled else { return } do { let push = try PushNotification( userInfo: try AirshipJSON.wrap( response.notification.request.content.userInfo ) ) try await savePush(push) } catch { AirshipLogger.error("Failed to save push \(error)") } } #endif private func savePush( _ push: PushNotification ) async throws { await self.pushDataManager.savePushNotification(push) Task { @MainActor in self.pushNotificationReceivedSubject.send(push) } } } public extension Airship { /// The shared AirshipDebugManager instance. /// /// This property provides access to the Airship debug interface functionality, /// allowing developers to display a comprehensive debug UI for monitoring /// and debugging various aspects of the Airship SDK. /// /// ## Usage /// /// ```swift /// // Display the debug interface /// Airship.debugManager.display() /// ``` /// /// The debug manager provides access to: /// - Push notification history and details /// - Analytics events and associated identifiers /// - Channel tags, attributes, and subscription lists /// - Contact information and channel management /// - In-app experiences and automations /// - Feature flags and experiments /// - Preference centers /// - App and SDK information /// - Privacy manager settings /// /// - Note: `Airship.takeOff` must be called before accessing this instance. /// - Important: This property will crash if accessed before Airship initialization. static var debugManager: any AirshipDebugManager { return Airship.requireComponent(ofType: DebugComponent.self).debugManager } } extension Airship { static var internalDebugManager: any InternalAirshipDebugManager { return Airship.requireComponent(ofType: DebugComponent.self).debugManager } } ================================================ FILE: Airship/AirshipDebug/Source/AirshipDebugResources.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Resources for AirshipDebug public final class AirshipDebugResources { /// Module bundle public static let bundle: Bundle = resolveBundle() private static func resolveBundle() -> Bundle { #if SWIFT_PACKAGE AirshipLogger.trace("Using Bundle.module for AirshipDebug") let bundle = Bundle.module #if DEBUG if bundle.resourceURL == nil { assertionFailure(""" AirshipDebug module was built with SWIFT_PACKAGE but no resources were found. Check your build configuration. """) } #endif return bundle #endif return Bundle.airshipFindModule( moduleName: "AirshipDebug", sourceBundle: Bundle(for: Self.self) ) } } extension String { func localized( bundle: Bundle = AirshipDebugResources.bundle, tableName: String = "AirshipDebug", comment: String = "" ) -> String { return NSLocalizedString( self, tableName: tableName, bundle: bundle, comment: comment ) } func localizedWithFormat(count: Int) -> String { return String.localizedStringWithFormat(localized(), count) } } ================================================ FILE: Airship/AirshipDebug/Source/DebugComponent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @preconcurrency import UserNotifications import AirshipCore /// Actual airship component for AirshipDebugManager. Used to hide AirshipComponent methods. final class DebugComponent : AirshipComponent, AirshipPushableComponent { final let debugManager: any InternalAirshipDebugManager init(debugManager: any InternalAirshipDebugManager) { self.debugManager = debugManager } @MainActor func receivedRemoteNotification( _ notification: AirshipCore.AirshipJSON ) async -> AirshipCore.UABackgroundFetchResult { return await self.debugManager.receivedRemoteNotification(notification) } #if !os(tvOS) @MainActor func receivedNotificationResponse( _ response: UNNotificationResponse ) async { return await self.debugManager.receivedNotificationResponse(response) } #endif } ================================================ FILE: Airship/AirshipDebug/Source/DebugSDKModule.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public import AirshipCore /// - Note: For internal use only. :nodoc: @objc(UADebugSDKModule) public class DebugSDKModule: NSObject, AirshipSDKModule { public var actionsManifest: (any ActionsManifest)? = nil public let components: [any AirshipComponent] public static func load(_ args: AirshiopModuleLoaderArgs) -> (any AirshipSDKModule)? { let debugManager = DefaultAirshipDebugManager( config: args.config, analytics: args.analytics, remoteData: args.remoteData ) return DebugSDKModule(debugManager) } private init(_ debugManager: any InternalAirshipDebugManager) { self.components = [DebugComponent(debugManager: debugManager)] } } ================================================ FILE: Airship/AirshipDebug/Source/Events/AirshipEvent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import AirshipCore struct AirshipEvent: Equatable, Hashable, Sendable { /// The unique identifier of the event. var identifier: String /// The type of the event (e.g., "custom_event", "app_foreground"). var type: String /// The date and time when the event occurred. var date: Date /// The JSON body of the event as a formatted string. var body: String } ================================================ FILE: Airship/AirshipDebug/Source/Events/EventData.swift ================================================ /* Copyright Airship and Contributors */ public import CoreData import Foundation /// - Note: For internal use only. :nodoc: @objc(EventData) public class EventData: NSManagedObject { @nonobjc public class func fetchRequest() -> NSFetchRequest<EventData> { return NSFetchRequest<EventData>(entityName: "EventData") } @NSManaged public var eventBody: String? @NSManaged public var eventID: String? @NSManaged public var eventType: String? @NSManaged public var eventDate: Date? } ================================================ FILE: Airship/AirshipDebug/Source/Events/EventDataManager.swift ================================================ /* Copyright Airship and Contributors */ import CoreData import AirshipCore final class EventDataManager: Sendable { private let maxAge = TimeInterval(172800) // 2 days private let appKey: String private let coreData: UACoreData public init(appKey: String) { self.appKey = appKey self.coreData = UACoreData( name: "AirshipDebugEventData", modelURL: AirshipDebugResources.bundle .url( forResource: "AirshipDebugEventData", withExtension: "momd" )!, inMemory: false, stores: ["AirshipDebugEventData-\(appKey).sqlite"] ) Task { await self.trimDatabase() } } private func trimDatabase() async { let cutOffDate = Date().advanced(by: -self.maxAge) do { try await coreData.perform(skipIfStoreNotCreated: true) { context in let fetchRequest: NSFetchRequest<any NSFetchRequestResult> = EventData.fetchRequest() fetchRequest.predicate = NSPredicate(format: "eventDate < %@", cutOffDate as NSDate) let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) try context.execute(batchDeleteRequest) } } catch { print("Failed to execute request: \(error)") } } func saveEvent(_ event: AirshipEvent) async { do { try await self.coreData.perform { context in let persistedEvent = EventData( entity: EventData.entity(), insertInto: context ) persistedEvent.eventBody = event.body persistedEvent.eventType = event.type persistedEvent.eventDate = event.date persistedEvent.eventID = event.identifier } } catch { print("Failed to save event: \(error)") } } func events(searchString: String? = nil) async -> [AirshipEvent] { do { return try await coreData.performWithResult { context in let fetchRequest: NSFetchRequest = EventData.fetchRequest() fetchRequest.fetchLimit = 200 fetchRequest.sortDescriptors = [ NSSortDescriptor( key: #keyPath(EventData.eventDate), ascending: false ) ] if let searchString = searchString, !searchString.isEmpty { fetchRequest.predicate = NSPredicate( format: "eventID CONTAINS[cd] %@ OR eventType CONTAINS[cd] %@", searchString, searchString ) } let result = try context.fetch(fetchRequest) let events = result.compactMap { data -> AirshipEvent? in if let eventType = data.eventType, let eventBody = data.eventBody, let eventDate = data.eventDate, let eventID = data.eventID { return AirshipEvent( identifier: eventID, type: eventType, date: eventDate, body: eventBody ) } return nil } return events } } catch { print( "ERROR: error fetching events list - \(error)" ) return [] } } } ================================================ FILE: Airship/AirshipDebug/Source/Push/PushData+CoreDataClass.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import CoreData /// - Note: For internal use only. :nodoc: @objc(PushData) public class PushData: NSManagedObject {} ================================================ FILE: Airship/AirshipDebug/Source/Push/PushData+CoreDataProperties.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import CoreData //Had to generate it manually as coredata codegen doesn't work well with swift 6 extension PushData { @nonobjc public class func fetchRequest() -> NSFetchRequest<PushData> { return NSFetchRequest<PushData>(entityName: "PushData") } @NSManaged public var alert: String? @NSManaged public var data: String? @NSManaged public var pushID: String? @NSManaged public var time: Double } ================================================ FILE: Airship/AirshipDebug/Source/Push/PushDataManager.swift ================================================ /* Copyright Airship and Contributors */ import CoreData #if canImport(AirshipCore) import AirshipCore #elseif canImport(AirshipKit) import AirshipKit #endif final class PushDataManager: Sendable { private let maxAge = TimeInterval(172800) // 2 days private let appKey: String private let coreData: UACoreData public init(appKey: String) { self.appKey = appKey self.coreData = UACoreData( name: "AirshipDebugPushData", modelURL: AirshipDebugResources.bundle .url( forResource: "AirshipDebugPushData", withExtension: "momd" )!, inMemory: false, stores: ["AirshipDebugPushData-\(appKey).sqlite"] ) Task { await self.trimDatabase() } } private func trimDatabase() async { let storageDaysInterval = Date() .advanced(by: -self.maxAge) .timeIntervalSince1970 do { try await coreData.perform(skipIfStoreNotCreated: true) { context in let fetchRequest: NSFetchRequest<any NSFetchRequestResult> = PushData.fetchRequest() fetchRequest.predicate = NSPredicate(format: "time < %f", storageDaysInterval) let batchDeleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) try context.execute(batchDeleteRequest) } } catch { print("Failed to execute request: \(error)") } } public func savePushNotification(_ push: PushNotification) async { try? await coreData.perform { context in guard !self.pushExists(id: push.pushID, context: context) else { return } let persistedPush = PushData( entity: PushData.entity(), insertInto: context ) persistedPush.pushID = push.pushID persistedPush.alert = push.alert persistedPush.data = push.description persistedPush.time = push.time } } private func pushExists(id: String, context: NSManagedObjectContext) -> Bool { let fetchRequest: NSFetchRequest = PushData.fetchRequest() fetchRequest.predicate = NSPredicate(format: "pushID = %@", id) var results: [NSManagedObject] = [] do { results = try context.fetch(fetchRequest) } catch { print("error executing fetch request: \(error)") } return results.count > 0 } func pushNotifications() async -> [PushNotification] { let result = try? await coreData.performWithResult { (context) -> [PushNotification] in let fetchRequest: NSFetchRequest = PushData.fetchRequest() fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "time", ascending: false) ] let result = try context.fetch(fetchRequest) let notifications = result.map { PushNotification(pushData: $0) } return notifications } return result ?? [] } } ================================================ FILE: Airship/AirshipDebug/Source/Push/PushNotification.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Foundation /// A wrapper for representing an Airship push notification in the Debug UI. /// /// `PushNotification` encapsulates push notification data for display in the debug interface. /// It provides a simplified representation of push notifications with the essential /// information needed for debugging and monitoring. /// /// ## Usage /// /// ```swift /// // Access push notifications through the debug manager /// let pushes = await Airship.internalDebugManager.pushNotifications() /// for push in pushes { /// print("Push: \(push.alert ?? "No alert") at \(Date(timeIntervalSince1970: push.time))") /// } /// ``` /// /// - Note: This struct is thread-safe and can be used across different threads. struct PushNotification: Equatable, Hashable, CustomStringConvertible, Sendable { /// The unique push ID. var pushID: String /// The push alert message. var alert: String? /// The time the push was created (as a TimeInterval since 1970). var time: TimeInterval /// The push data description as a JSON string. public var description: String var payload: AirshipJSON { return (try? AirshipJSON.from(json: self.description)) ?? AirshipJSON.string(description) } init(userInfo: AirshipJSON) throws { self.description = try userInfo.toString() self.time = Date().timeIntervalSince1970 self.alert = PushNotification.parseAlert(userInfo) self.pushID = userInfo.object?["_"]?.string ?? "MISSING_PUSH_ID" } init(pushData: PushData) { self.description = pushData.data ?? "" self.time = pushData.time self.alert = pushData.alert self.pushID = pushData.pushID ?? "" } private static func parseAlert(_ push: AirshipJSON) -> String? { guard let alert = push.object?["aps"]?.object?["alert"] else { return nil } if let string = alert.string { return string } return alert.object?["body"]?.string } } ================================================ FILE: Airship/AirshipDebug/Source/ShakeUtils.swift ================================================ ================================================ FILE: Airship/AirshipDebug/Source/View/AirshipDebugContentView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI import AirshipCore import AirshipAutomation /// A SwiftUI view that provides the main content of the Airship debug interface. /// /// `AirshipDebugContentView` contains the primary debug interface content without /// navigation wrapper, making it suitable for embedding in existing navigation contexts. /// It displays the main menu of debug sections that users can navigate to. /// /// ## Usage /// /// When embedding in existing navigation, you must handle the navigation destinations /// using the `navigationDestination` helper from `AirshipDebugRoute`: /// /// ```swift /// // Embed in existing navigation with proper destination handling /// NavigationStack(path: $path) { /// AirshipDebugContentView() /// .navigationDestination(for: AirshipDebugRoute.self) { route in /// route.navigationDestination /// } /// } /// ``` /// /// - Important: When using `AirshipDebugContentView` in a `NavigationStack`, you must /// add the `.navigationDestination(for: AirshipDebugRoute.self)` modifier to handle /// navigation to debug sections. Use `route.navigationDestination` to get the /// appropriate view for each route. @MainActor public struct AirshipDebugContentView: View { private static let sections = [ DebugSection( icon: "hand.raised.square.fill", title: "Privacy Manager", route: .privacyManager ), DebugSection( icon: "arrow.left.arrow.right.square.fill", title: "Channel", route: .channel ), DebugSection( icon: "person.crop.square.fill", title: "Contacts", route: .contact ), DebugSection( icon: "checkmark.bubble.fill", title: "Push", route: .push ), DebugSection( icon: "calendar.badge.checkmark", title: "Analytics", route: .analytics ), DebugSection( icon: "bolt.square.fill", title: "In-App Experiences", route: .inAppExperience ), DebugSection( icon: "flag.square.fill", title: "Feature Flags", route: .featureFlags ), DebugSection( icon: "list.bullet.rectangle.fill", title: "Preference Centers", route: .preferenceCenters ), DebugSection( icon: "iphone.homebutton", title: "App Info", route: .appInfo ) ] public init() { } @ViewBuilder public var body: some View { Form { ForEach(Self.sections, id: \.self) { item in NavigationLink(value: item.route) { HStack { Image(systemName: item.icon) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 24, height: 24) .foregroundColor(.blue) Text(item.title.localized()) .foregroundColor(.primary) Spacer() } } } } } } fileprivate struct DebugSection: Sendable, Hashable { let icon: String let title: String let route: AirshipDebugRoute } #Preview { AirshipDebugContentView() } ================================================ FILE: Airship/AirshipDebug/Source/View/AirshipDebugRoute.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI import AirshipCore import AirshipPreferenceCenter /// An enum that defines all possible navigation routes within the Airship debug interface. /// /// `AirshipDebugRoute` provides type-safe navigation for the debug interface. /// Each route case maps to a specific SwiftUI view through the `navigationDestination` /// computed property, enabling seamless navigation between different debug sections. /// /// ## Usage /// /// Routes are used internally by the `AirshipDebugView` for navigation. The /// `navigationDestination` property automatically resolves each route to its /// corresponding SwiftUI view. /// /// ## Route Categories /// /// The routes are organized into main categories with optional sub-routes: /// - **Privacy Manager**: Privacy settings and controls /// - **Channel**: Channel management with sub-routes for tags, attributes, etc. /// - **Contact**: Contact management with sub-routes for different channel types /// - **Push**: Push notification management with sub-routes for details /// - **Analytics**: Analytics data with sub-routes for events and identifiers /// - **In-App Experience**: In-app experiences with sub-routes for automations and experiments /// - **Feature Flags**: Feature flag management with sub-routes for details /// - **Preference Centers**: Preference center management with sub-routes for specific centers /// - **App Info**: General app and SDK information /// /// - Note: This enum is thread-safe and can be used across different threads. public enum AirshipDebugRoute: Sendable, Equatable, Hashable { /// Navigate to the privacy manager section. case privacyManager /// Navigate to the main channel section. case channel /// Navigate to a specific channel sub-section. case channelSub(ChannelRoute) /// Navigate to the main contact section. case contact /// Navigate to a specific contact sub-section. case contactSub(ContactRoute) /// Navigate to the main push notifications section. case push /// Navigate to a specific push notifications sub-section. case pushSub(PushRoute) /// Navigate to the main analytics section. case analytics /// Navigate to a specific analytics sub-section. case analyticsSub(AnalyticsRoute) /// Navigate to the main in-app experiences section. case inAppExperience /// Navigate to a specific in-app experiences sub-section. case inAppExperienceSub(InAppExperienceRoute) /// Navigate to the main feature flags section. case featureFlags /// Navigate to a specific feature flag sub-section. case featureFlagsSub(FeatureFlagRoute) /// Navigate to the main preference centers section. case preferenceCenters /// Navigate to a specific preference center sub-section. case preferenceCentersSub(PrefenceCenterRoute) /// Navigate to the app information section. case appInfo /// Sub-routes for the channel management section. public enum ChannelRoute: Sendable, Equatable, Hashable { /// Navigate to the channel tags management view. case tags /// Navigate to the channel tag groups management view. case tagGroups /// Navigate to the channel attributes management view. case attributes /// Navigate to the channel subscription lists management view. case subscriptionLists } /// Sub-routes for the contact management section. public enum ContactRoute: Sendable, Equatable, Hashable { /// Navigate to the contact tag groups management view. case tagGroups /// Navigate to the contact attributes management view. case attributes /// Navigate to the contact subscription lists management view. case subscriptionLists /// Navigate to the add open channel view. case addOpenChannel /// Navigate to the add SMS channel view. case addSMSChannel /// Navigate to the add email channel view. case addEmailChannel /// Navigate to the named user ID management view. case namedUserID } /// Sub-routes for the analytics section. public enum AnalyticsRoute: Sendable, Equatable, Hashable { /// Navigate to the analytics events list view. case events /// Navigate to the details view for a specific analytics event. /// - Parameter identifier: The unique identifier of the event to display. case eventDetails(identifier: String) /// Navigate to the add analytics event view. case addEvent /// Navigate to the associated identifiers management view. case associatedIdentifiers } /// Sub-routes for the in-app experiences section. public enum InAppExperienceRoute: Sendable, Equatable, Hashable { /// Navigate to the in-app automations view. case automations /// Navigate to the experiments view. case experiments } /// Sub-routes for the feature flags section. public enum FeatureFlagRoute: Sendable, Equatable, Hashable { /// Navigate to the details view for a specific feature flag. /// - Parameter name: The name of the feature flag to display. case featureFlagDetails(name: String) } /// Sub-routes for the preference centers section. public enum PrefenceCenterRoute: Sendable, Equatable, Hashable { /// Navigate to the details view for a specific preference center. /// - Parameter identifier: The identifier of the preference center to display. case preferenceCenter(identifier: String) } /// Sub-routes for the push notifications section. public enum PushRoute: Sendable, Equatable, Hashable { /// Navigate to the received push notifications list view. case recievedPushes /// Navigate to the details view for a specific push notification. /// - Parameter identifier: The unique identifier of the push notification to display. case pushDetails(identifier: String) } } public extension AirshipDebugRoute { /// Returns the SwiftUI view that corresponds to this route. /// /// This computed property provides the view that should be displayed when /// navigating to this route. It uses a switch statement to map each route /// case to its corresponding SwiftUI view. /// /// - Returns: The SwiftUI view that should be displayed for this route. @ViewBuilder @MainActor var navigationDestiation: some View { switch(self) { case .privacyManager: AirshipDebugPrivacyManagerView() case .channel: AirshipDebugChannelView() case .channelSub(let subRoute): switch(subRoute) { case .tags: AirshipDebugChannelTagView() case .attributes: AirshipDebugAttributesEditorView(for: .channel) case .subscriptionLists: AirshipDebugChannelSubscriptionsView() case .tagGroups: AirshipDebugTagGroupsEditorView(for: .channel) } case .contact: AirshipDebugContactsView() case .contactSub(let subRoute): switch(subRoute) { case .attributes: AirshipDebugAttributesEditorView(for: .contact) case .subscriptionLists: AirshipDebugContactSubscriptionEditorView() case .tagGroups: AirshipDebugTagGroupsEditorView(for: .contact) case .addSMSChannel: AirshipDebugAddSMSChannelView() case .addOpenChannel: AirshipDebugAddOpenChannelView() case .addEmailChannel: AirshipDebugAddEmailChannelView() case .namedUserID: AirshipDebugNamedUserView() } case .push: AirshipDebugPushView() case .pushSub(let subRoute): switch(subRoute) { case .recievedPushes: AirshipDebugReceivedPushView() case .pushDetails(let identifier): AirshipDebugPushDetailsView(identifier: identifier) } case .analytics: AirshipDebugAnalyticsView() case .analyticsSub(let subRoute): switch(subRoute) { case .associatedIdentifiers: AirshipDebugAnalyticIdentifierEditorView() case .events: AirshipDebugEventsView() case .addEvent: AirshipDebugAddEventView() case .eventDetails(let identifier): AirshipDebugEventDetailsView(identifier: identifier) } case .inAppExperience: AirshipDebugInAppExperiencesView() case .inAppExperienceSub(let subRoute): switch(subRoute) { case .experiments: AirshipDebugExperimentsView() case .automations: AirshipDebugAutomationsView() } case .featureFlags: AirshipDebugFeatureFlagView() case .featureFlagsSub(let subRoute): switch(subRoute) { case .featureFlagDetails(let name): AirshipDebugFeatureFlagDetailsView(name: name) } case .preferenceCenters: AirshipDebugPreferenceCentersView() case .preferenceCentersSub(let subRoute): switch(subRoute) { case .preferenceCenter(let identifier): AirshipDebugPreferencCenterItemView(preferenceCenterID: identifier) } case .appInfo: AirshipDebugAppInfoView() } } } ================================================ FILE: Airship/AirshipDebug/Source/View/AirshipDebugView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI import AirshipCore /// A SwiftUI view that provides the main Airship debug interface. /// /// `AirshipDebugView` presents a comprehensive debug interface for monitoring /// and debugging various aspects of the Airship SDK. The view uses a navigation /// stack to organize different debug sections and provides real-time access to /// push notifications, analytics events, channel data, and other SDK components. /// /// ## Usage /// /// ```swift /// // Basic usage /// AirshipDebugView() /// /// // With custom dismissal handling /// AirshipDebugView { /// // Handle dismissal /// print("Debug view dismissed") /// } /// ``` /// /// ## Features /// /// The debug view provides access to: /// - **Privacy Manager**: Privacy settings and controls /// - **Channel**: Channel tags, attributes, and subscription lists /// - **Contacts**: Contact information and channel management /// - **Push**: Push notification history and details /// - **Analytics**: Analytics events and associated identifiers /// - **In-App Experiences**: Automations and experiments /// - **Feature Flags**: Feature flag details and status /// - **Preference Centers**: Preference center management /// - **App Info**: General app and SDK information /// /// - Note: This view must be used on the main thread. @MainActor public struct AirshipDebugView: View { private let onDismiss: (@MainActor () -> Void)? @State private var path: [AirshipDebugRoute] = [] /// Creates a new AirshipDebugView. /// /// - Parameter onDismiss: Optional callback that will be called when the /// debug view is dismissed. The callback will be executed on the main thread. public init(onDismiss: (@MainActor () -> Void)? = nil) { self.onDismiss = onDismiss } @ViewBuilder public var body: some View { NavigationStack(path: self.$path) { AirshipDebugContentView() .toolbar { if let onDismiss { ToolbarItem(placement: .cancellationAction) { Button(action: { onDismiss() }) { Image(systemName: "chevron.backward") .scaleEffect(0.68) .font(Font.title.weight(.medium)) } } } } .navigationTitle("Airship Debug") .navigationDestination(for: AirshipDebugRoute.self) { route in route.navigationDestiation } } } } #Preview { AirshipDebugView() } ================================================ FILE: Airship/AirshipDebug/Source/View/AirshipoDebugTriggers.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI import AirshipCore #if canImport(UIKit) import UIKit #endif /// Defines the triggers available for the Airship Debug interface. public struct AirshipDebugTrigger: OptionSet, Sendable { public let rawValue: Int public init(rawValue: Int) { self.rawValue = rawValue } /// Detects device shake (iOS only). public static let shake = AirshipDebugTrigger(rawValue: 1 << 0) /// Detects Cmd + Shift + D (iOS with hardware keyboard & macOS). public static let cmdShiftD = AirshipDebugTrigger(rawValue: 1 << 1) } public extension View { /// Enables the Airship Debug interface based on specified interaction triggers. /// /// This modifier provides a unified way to access the Airship Debug console across platforms. /// /// The debug interface will only be attached if `Airship.isFlying` is true and /// `isAirshipDebugEnabled` is set to `true` in the Airship configuration. /// /// ### Usage /// ```swift /// // Standard behavior (Shake & Hotkey) /// ContentView() /// .airshipDebug(triggers: [.shake, .cmdShiftD) /// /// // Discrete mode (keyboard only) /// ContentView() /// .airshipDebug(triggers: [.cmdShiftD]) /// ``` /// /// - Parameter triggers: A set of ``AirshipDebugTrigger`` options determining how the /// debug interface is invoked. Defaults to ``AirshipDebugTrigger/defaultTriggers``. /// - Returns: A view modified to detect the specified debug triggers. @ViewBuilder func airshipDebug(triggers: AirshipDebugTrigger) -> some View { if Airship.isFlying, Airship.config.airshipConfig.isAirshipDebugEnabled { self.modifier(AirshipDebugModifier(triggers: triggers)) } else { self } } /// Enables the Airship Debug interface via a device shake gesture. /// /// This is a legacy convenience method for iOS that specifically enables the /// shake gesture trigger. For more control, use ``airshipDebug(triggers:)``. @available(*, deprecated, renamed: "airshipDebug(triggers:)", message: "Use airshipDebug(triggers: .shake) instead.") func airshipDebugOnShake() -> some View { self.airshipDebug(triggers: .shake) } } struct AirshipDebugModifier: ViewModifier { let triggers: AirshipDebugTrigger func body(content: Content) -> some View { content // --- Gestures (iOS) --- #if canImport(UIKit) .onReceive(NotificationCenter.default.publisher(for: UIDevice.airshipDeviceDidShakeNotification)) { _ in if triggers.contains(.shake) { displayDebug() } } #endif .airshipApplyIf(triggers.contains(.cmdShiftD)) { view in view.background { Button("") { displayDebug() } .keyboardShortcut("d", modifiers: [.command, .shift]) .opacity(0) .allowsHitTesting(false) .accessibilityHidden(true) } } } private func displayDebug() { Airship.debugManager.display() } } // MARK: - iOS Specific Shake Logic #if canImport(UIKit) extension UIDevice { static let airshipDeviceDidShakeNotification = Notification.Name(rawValue: "AirshipDeviceDidShakeNotification") } extension UIWindow { open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { if motion == .motionShake { NotificationCenter.default.post(name: UIDevice.airshipDeviceDidShakeNotification, object: nil) } super.motionEnded(motion, with: event) } } #endif ================================================ FILE: Airship/AirshipDebug/Source/View/Analytics/AirshipDebugAddEventView.swift ================================================ import AirshipCore import Foundation import SwiftUI import Combine struct AirshipDebugAddEventView: View { @StateObject private var viewModel = ViewModel() @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> @State var shouldPresentPropetySheet = false public init() {} @ViewBuilder func makeTextInput(title: String, binding: Binding<String>) -> some View { HStack { Text(title.lowercased()) Spacer() TextField(title.lowercased(), text: binding.preventWhiteSpace()) .freeInput() } } @ViewBuilder func makeNumberInput(title: String, binding: Binding<Double>) -> some View { HStack { Text(title.lowercased()) Spacer() TextField( title.lowercased(), value: binding, formatter: NumberFormatter() ) #if !os(macOS) .keyboardType(.numberPad) #endif } } var body: some View { Form { Section(header: Text("Event Properties".localized())) { makeTextInput( title: "Event Name", binding: self.$viewModel.eventName ) makeNumberInput( title: "Event Value", binding: self.$viewModel.eventValue ) makeTextInput( title: "Transaction ID", binding: self.$viewModel.transactionID ) makeTextInput( title: "Interaction ID", binding: self.$viewModel.interactionID ) makeTextInput( title: "Interaction Type", binding: self.$viewModel.interactionType ) } Section(header: Text("Properties".localized())) { Button("Add Property".localized()) { self.shouldPresentPropetySheet = true } .sheet(isPresented: self.$shouldPresentPropetySheet) { NavigationStack { AirshipDebugAddPropertyView { self.viewModel.properties[$0] = $1 } .navigationTitle("New Property") #if !os(tvOS) && !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif } .presentationDetents([.medium]) } List { let keys = [String](self.viewModel.properties.keys) ForEach(keys, id: \.self) { key in HStack { Text("\(key):") Text( self.viewModel.properties[key]?.prettyString ?? "-" ) } } .onDelete { $0.forEach { index in self.viewModel.properties[keys[index]] = nil } } } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { self.viewModel.createEvent() presentationMode.wrappedValue.dismiss() } label: { Text("Create".localized()) } .disabled(!self.viewModel.isEnabled) } } .navigationTitle("Custom Event".localized()) } @MainActor fileprivate class ViewModel: ObservableObject { @Published var eventName: String = "" @Published var eventValue: Double = 1.0 @Published var interactionID: String = "" @Published var interactionType: String = "" @Published var transactionID: String = "" var isEnabled: Bool { return !self.eventName.isEmpty && Airship.isFlying } var properties: [String: AirshipJSON] = [:] func createEvent() { guard Airship.isFlying, !self.eventName.isEmpty else { return } var event = CustomEvent( name: self.eventName, value: self.eventValue ) if !self.transactionID.isEmpty { event.transactionID = self.transactionID } if !self.interactionID.isEmpty && !self.interactionType.isEmpty { event.interactionID = self.interactionID event.interactionType = self.interactionType } try? event.setProperties(self.properties) event.track() } } } #Preview { AirshipDebugAddEventView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Analytics/AirshipDebugAnalyticIdentifierEditorView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine import AirshipCore struct AirshipDebugAnalyticIdentifierEditorView: View { @StateObject private var viewModel = ViewModel() @State private var shouldPresentPropetySheet: Bool = false var body: some View { Form { Section(header: Text("Identifiers".localized())) { Button("Add Identifier".localized()) { self.shouldPresentPropetySheet = true } .sheet(isPresented: self.$shouldPresentPropetySheet) { NavigationStack { AirshipDebugAddStringPropertyView { self.viewModel.identifiers[$0] = $1 } .navigationTitle("New Identifier") #if !os(tvOS) && !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif } .presentationDetents([.medium]) } List { let keys = [String](self.viewModel.identifiers.keys) ForEach(keys, id: \.self) { key in HStack { Text("\(key):") Text(self.viewModel.identifiers[key] ?? "") } } .onDelete { $0.forEach { index in self.viewModel.identifiers[keys[index]] = nil } } } } } .navigationTitle("Analytic Identifiers".localized()) } @MainActor class ViewModel: ObservableObject { @Published var identifiers: [String: String] { didSet { save() } } init() { if Airship.isFlying { self.identifiers = Airship.analytics.currentAssociatedDeviceIdentifiers().allIDs } else { self.identifiers = [:] } } func save() { guard Airship.isFlying else { return } Airship.analytics.associateDeviceIdentifiers( AssociatedIdentifiers(identifiers: self.identifiers) ) } } } private struct AddIdentifierView: View { @State private var key: String = "" @State private var value: String = "" let onAdd: (String, String) -> Void @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> var body: some View { Form { HStack { Text("Key".localized()) Spacer() TextField( "Key".localized(), text: self.$key.preventWhiteSpace() ) .freeInput() } HStack { Text("Value".localized()) Spacer() TextField( "Value".localized(), text: self.$value.preventWhiteSpace() ) .freeInput() } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { onAdd(key, value) presentationMode.wrappedValue.dismiss() } label: { Text("Add".localized()) } .disabled(key.isEmpty || value.isEmpty) } } .navigationTitle("Identifier".localized()) } } #Preview { AirshipDebugAnalyticIdentifierEditorView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Analytics/AirshipDebugAnalyticsView.swift ================================================ // Copyright Airship and Contributors import SwiftUI struct AirshipDebugAnalyticsView: View { var body: some View { Form { CommonItems.navigationLink( title: "Events".localized(), route: .analyticsSub(.events) ) CommonItems.navigationLink( title: "Add Custom Event".localized(), route: .analyticsSub(.addEvent) ) CommonItems.navigationLink( title: "Associated Identifiers".localized(), route: .analyticsSub(.associatedIdentifiers) ) } .navigationTitle("Analytics".localized()) } } #Preview { AirshipDebugAnalyticsView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Analytics/AirshipDebugEventDetailsView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore struct AirshipDebugEventDetailsView: View { @State private var toastMessage: AirshipToast.Message? = nil @StateObject private var viewModel: ViewModel public init(identifier: String) { _viewModel = .init(wrappedValue: .init(identifier: identifier)) } @ViewBuilder var body: some View { Form { if let event = self.viewModel.event { Section(header: Text("Event Details".localized())) { makeInfoItem("Type", event.type) makeInfoItem("ID", event.identifier) makeInfoItem( "Date", AirshipDateFormatter.string(fromDate: event.date, format: .iso) ) } Section(header: Text("Event body".localized())) { Button(action: { copyToClipboard(value: event.body) }) { Text(event.body) .foregroundColor(.primary) } } } else { ProgressView() } } .overlay(AirshipToast(message: self.$toastMessage)) .navigationTitle("Event".localized()) } @ViewBuilder func makeInfoItem(_ title: String, _ value: String?) -> some View { Button(action: { if let value = value { copyToClipboard(value: value) } }) { HStack { Text(title) .foregroundColor(.primary) Spacer() Text(value ?? "") .foregroundColor(.secondary) } } } func copyToClipboard(value: String?) { guard let value = value else { return } value.pastleboard() self.toastMessage = AirshipToast.Message( id: UUID().uuidString, text: "Copied to pasteboard!".localized(), duration: 1.0 ) } @MainActor class ViewModel: ObservableObject { @Published private(set) var event: AirshipEvent? init(identifier: String) { Task { @MainActor [weak self] in self?.event = await Airship.internalDebugManager.events().first( where: { event in event.identifier == identifier } ) } } } } ================================================ FILE: Airship/AirshipDebug/Source/View/Analytics/AirshipDebugEventsView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore struct AirshipDebugEventsView: View { @StateObject private var viewModel = ViewModel() var body: some View { Form { Section { ForEach(self.viewModel.events, id: \.identifier) { event in NavigationLink( value: AirshipDebugRoute.analyticsSub(.eventDetails(identifier: event.identifier)) ) { VStack(alignment: .leading) { Text(event.type) Text(event.identifier) HStack { Text(event.date, style: .date) Text(event.date, style: .time) } } } } } } .navigationTitle("Events".localized()) } @MainActor class ViewModel: ObservableObject { @Published private(set) var events: [AirshipEvent] = [] @Published var searchString: String = "" { didSet { refreshEvents() } } private var cancellable: AnyCancellable? = nil init() { if Airship.isFlying { self.cancellable = Airship.internalDebugManager .eventReceivedPublisher .sink { [weak self] incoming in self?.refreshEvents() } } refreshEvents() } private func refreshEvents() { if !Airship.isFlying { return } Task { @MainActor in let events = await Airship.internalDebugManager.events( searchString: self.searchString ) self.events = events } } } } #Preview { AirshipDebugEventsView() } ================================================ FILE: Airship/AirshipDebug/Source/View/AppInfo/AirshipDebugAppInfoView.swift ================================================ // Copyright Airship and Contributors import SwiftUI import Combine import AirshipCore fileprivate struct AppInfo: Sendable { let bundleId: String let timeZone: String let sdkVersion: String let appVersion: String let appCodeVersion: String let applicationLocale: String } @MainActor struct AirshipDebugAppInfoView: View { @StateObject private var viewModel = ViewModel() @State private var toast: AirshipToast.Message? = nil var body: some View { Form { CommonItems.infoRow( title: "Airship SDK Version".localized(), value: viewModel.appInfo.sdkVersion, onTap: { copyValue(viewModel.appInfo.sdkVersion) } ) CommonItems.infoRow( title: "App Version".localized(), value: viewModel.appInfo.appVersion, onTap: { copyValue(viewModel.appInfo.appVersion) } ) CommonItems.infoRow( title: "App Code Version".localized(), value: viewModel.appInfo.appCodeVersion, onTap: { copyValue(viewModel.appInfo.appCodeVersion) } ) CommonItems.infoRow( title: "Model".localized(), value: AirshipDevice.modelIdentifier, onTap: { copyValue(AirshipDevice.modelIdentifier) } ) CommonItems.infoRow( title: "Bundle ID".localized(), value: viewModel.appInfo.bundleId, onTap: { copyValue(viewModel.appInfo.bundleId) } ) CommonItems.infoRow( title: "Time Zone".localized(), value: viewModel.appInfo.timeZone, onTap: { copyValue(viewModel.appInfo.timeZone) } ) CommonItems.infoRow( title: "App Locale".localized(), value: viewModel.appInfo.applicationLocale, onTap: { copyValue(viewModel.appInfo.applicationLocale) } ) Picker( selection: self.$viewModel.airshipLocaleIdentifier, label: Text("Airship Locale".localized()) ) { let allIDs = Locale.availableIdentifiers ForEach(allIDs, id: \.self) { localeID in // <1> Text(localeID) } } .foregroundColor(.primary) .frame(height: CommonItems.rowHeight) Button( "Clear Locale Override".localized(), role: .destructive ) { [weak viewModel] in viewModel?.clearLocaleOverride() } .frame(height: CommonItems.rowHeight) } .toastable($toast) .navigationTitle("App Info".localized()) } private func copyValue(_ value: String) { value.pastleboard() toast = .init(text: "Copied to clipboard") } @MainActor fileprivate final class ViewModel: ObservableObject { @Published var appInfo: AppInfo @Published var airshipLocaleIdentifier: String { didSet { guard Airship.isFlying else { return } Airship.localeManager.currentLocale = Locale( identifier: airshipLocaleIdentifier ) } } @MainActor init() { self.appInfo = .init( bundleId: Bundle.main.bundleIdentifier ?? "", timeZone: TimeZone.autoupdatingCurrent.identifier, sdkVersion: AirshipVersion.version, appVersion: (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "", appCodeVersion: (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "", applicationLocale: Locale.autoupdatingCurrent.identifier ) self.airshipLocaleIdentifier = "" Airship.onReady { [weak self] in self?.airshipLocaleIdentifier = Airship.localeManager.currentLocale.identifier } } func clearLocaleOverride() { if Airship.isFlying { self.airshipLocaleIdentifier = Locale.autoupdatingCurrent.identifier Airship.localeManager.clearLocale() } } } } #Preview { AirshipDebugAppInfoView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Automations/AirshipDebugAutomationsView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore struct AirshipDebugAutomationsView: View { @StateObject private var viewModel = ViewModel() var body: some View { Form { Section(header: Text("")) { List(self.viewModel.messagePayloads, id: \.self) { payload in let title = parseTitle(payload: payload) VStack(alignment: .leading) { Text(title) Text(parseID(payload: payload)) } } } } .navigationTitle("Automations".localized()) } func parseTitle(payload: [String: AnyHashable]) -> String { let message = payload["message"] as? [String: AnyHashable] return message?["name"] as? String ?? parseType(payload: payload) } func parseType(payload: [String: AnyHashable]) -> String { return payload["type"] as? String ?? "Unknown" } func parseID(payload: [String: AnyHashable]) -> String { return payload["id"] as? String ?? "MISSING_ID" } @MainActor class ViewModel: ObservableObject { @Published private(set) var messagePayloads: [[String: AnyHashable]] = [] private var cancellable: AnyCancellable? = nil init() { Airship.onReady { [weak self] in self?.cancellable = Airship.internalDebugManager .inAppAutomationsPublisher .receive(on: RunLoop.main) .sink { incoming in self?.messagePayloads = incoming } } } } } #Preview { AirshipDebugAutomationsView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Automations/AirshipDebugExperimentsView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore struct AirshipDebugExperimentsView: View { @StateObject private var viewModel = ViewModel() var body: some View { Form { Section(header: Text("")) { ForEach(self.viewModel.payloads, id: \.self) { payload in VStack(alignment: .leading) { Text(parseID(payload: payload)) } } } } .navigationTitle("Experiments".localized()) } func parseID(payload: [String: AnyHashable]) -> String { return payload["experiment_id"] as? String ?? "MISSING_ID" } @MainActor class ViewModel: ObservableObject { @Published private(set) var payloads: [[String: AnyHashable]] = [] private var cancellable: AnyCancellable? = nil init() { if Airship.isFlying { self.cancellable = Airship.internalDebugManager .experimentsPublisher .receive(on: RunLoop.main) .sink { incoming in self.payloads = incoming } } } } } #Preview { AirshipDebugExperimentsView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Automations/AirshipDebugInAppExperiencesView.swift ================================================ // Copyright Airship and Contributors import SwiftUI import Combine import AirshipCore import AirshipAutomation struct AirshipDebugInAppExperiencesView: View { @StateObject private var viewModel: ViewModel = .init() var body: some View { Form { CommonItems.navigationLink( title: "Automations".localized(), route: .inAppExperienceSub(.automations) ) CommonItems.navigationLink( title: "Experiments".localized(), route: .inAppExperienceSub(.experiments) ) displayIntervalRow() } .navigationTitle("In-App Experiences".localized()) } private var displayIntervalString: String { viewModel.displayInterval.formatted(.number.precision(.fractionLength(1))) } @ViewBuilder private func displayIntervalRow() -> some View { VStack { HStack { Text("Display Interval".localized()) .foregroundColor(.primary) Spacer() Text("\(displayIntervalString) seconds") .foregroundColor(.secondary) } #if os(tvOS) TVSlider( displayInterval: self.$viewModel.displayInterval, range: 0.0...200.0, step: 1.0 ) #else Slider( value: self.$viewModel.displayInterval, in: 0.0...200.0, step: 1.0 ) #endif } } @MainActor fileprivate final class ViewModel: ObservableObject { @Published var displayInterval: TimeInterval = 0.0 { didSet { guard Airship.isFlying else { return } Airship.inAppAutomation.inAppMessaging.displayInterval = self.displayInterval } } @MainActor init() { Airship.onReady { [weak self] in self?.displayInterval = Airship.inAppAutomation.inAppMessaging.displayInterval } } } } #Preview { AirshipDebugInAppExperiencesView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Channel/AirshipDebugChannelSubscriptionsView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import AirshipCore struct AirshipDebugChannelSubscriptionsView: View { private enum SubscriptionListAction: String, Equatable, CaseIterable { case subscribe = "Subscribe" case unsubscribe = "Unsubscribe" } @State private var listID: String = "" @State private var action: SubscriptionListAction = .subscribe @ViewBuilder var body: some View { Form { Section( header: Text("Subscription Info".localized()) ) { Picker("Action".localized(), selection: $action) { ForEach(SubscriptionListAction.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) HStack { Text("List ID".localized()) Spacer() TextField("", text: self.$listID.preventWhiteSpace()) .freeInput() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { apply() } label: { Text("Apply".localized()) } .disabled(listID.isEmpty) } } .navigationTitle("Subscription Lists".localized()) } private func apply() { defer { self.listID = "" } guard Airship.isFlying else { return } Airship.channel.editSubscriptionLists { editor in switch self.action { case .subscribe: editor.subscribe(self.listID) case .unsubscribe: editor.unsubscribe(self.listID) } } } } #Preview { AirshipDebugChannelSubscriptionsView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Channel/AirshipDebugChannelTagView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine import AirshipCore struct AirshipDebugChannelTagView: View { @StateObject private var viewModel = ViewModel() @State private var tag: String = "" var body: some View { Form { Section { HStack { Text("New Tag".localized()) TextField("", text: self.$tag.preventWhiteSpace()).freeInput() .frame(maxWidth: .infinity) Button { self.viewModel.addTag(self.tag) self.tag = "" } label: { Text("Save".localized()) } .disabled(tag.isEmpty) } } Section("Current Tags".localized()) { List { ForEach(self.viewModel.tags, id: \.self) { tag in Text(tag) } .onDelete { $0.forEach { index in let tag = self.viewModel.tags[index] self.viewModel.removeTag(tag) } } } } } .navigationTitle("Tags".localized()) } @MainActor class ViewModel: ObservableObject { @Published private(set) var tags: [String] init() { if Airship.isFlying { self.tags = Airship.channel.tags } else { self.tags = [] } } func addTag(_ tag: String) { if Airship.isFlying { Airship.channel.editTags { $0.add(tag) } self.tags = Airship.channel.tags } } func removeTag(_ tag: String) { if Airship.isFlying { Airship.channel.editTags { $0.remove(tag) } self.tags = Airship.channel.tags } } } } #Preview { AirshipDebugChannelTagView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Channel/AirshipDebugChannelView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine import AirshipCore struct AirshipDebugChannelView: View { @State private var toastMessage: AirshipToast.Message? = nil @StateObject private var viewModel = ViewModel() var body: some View { Form { let channelID = viewModel.channelID CommonItems.infoRow( title: "Channel ID".localized(), value: channelID, onTap: { copyChananelId(channelID) } ) CommonItems.navigationLink( title: "Tags".localized(), route: .channelSub(.tags) ) CommonItems.navigationLink( title: "Tag Groups".localized(), route: .channelSub(.tagGroups) ) CommonItems.navigationLink( title: "Attributes".localized(), route: .channelSub(.attributes) ) CommonItems.navigationLink( title: "Subscription Lists".localized(), route: .channelSub(.subscriptionLists) ) } .toastable($toastMessage) .navigationTitle("Channel".localized()) } private func copyChananelId(_ channelId: String?) { guard let channelId else { return } channelId.pastleboard() self.toastMessage = .init(text: "Channel ID copied to clipboard") } @MainActor fileprivate final class ViewModel: ObservableObject { @Published var channelID: String? private var task: Task<Void, Never>? = nil @MainActor init() { self.task = Task { [weak self] in await Airship.waitForReady() for await _ in Airship.channel.identifierUpdates { self?.updateChannelID() } } updateChannelID() } deinit { task?.cancel() } private func updateChannelID() { guard Airship.isFlying else { return } if self.channelID != Airship.channel.identifier { channelID = Airship.channel.identifier } } } } #Preview { AirshipDebugChannelView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipDebugAddPropertyView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore struct AirshipDebugAddPropertyView: View { enum PropertyType: String, Equatable, CaseIterable { case bool = "Bool" case string = "String" case number = "Number" case json = "JSON" } @State private var key: String = "" @State private var boolValue: Bool = false @State private var numberValue: Double = 0 @State private var stringValue: String = "" @State private var jsonValue: String = "" @State private var propertyType: PropertyType = .string let onAdd: (String, AirshipJSON) -> Void @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> var body: some View { Form { HStack { Text("Name".localized()) Spacer() TextField( "Name".localized(), text: self.$key.preventWhiteSpace() ) .freeInput() } Picker("Type".localized(), selection: self.$propertyType) { ForEach(PropertyType.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) makeValue() } .toolbar { ToolbarItem(placement: .cancellationAction) { Button { presentationMode.wrappedValue.dismiss() } label: { Image(systemName: "xmark") } } ToolbarItem(placement: .confirmationAction) { Button("Add".localized()) { guard let value = self.value else { return } onAdd(self.key, value) presentationMode.wrappedValue.dismiss() } .disabled(!self.isValid) } } } private var value: AirshipJSON? { return switch self.propertyType { case .bool: .bool(self.boolValue) case .number: .number(self.numberValue) case .string: .string(self.stringValue) case .json: try? AirshipJSON.from(json: self.jsonValue) } } private var isValid: Bool { guard !self.key.isEmpty else { return false } return switch self.propertyType { case .bool: true case .number: true case .string: !self.stringValue.isEmpty case .json: self.value != nil } } @ViewBuilder private func makeValue() -> some View { switch self.propertyType { case .bool: Toggle("\(self.boolValue ? "true": "false")", isOn: self.$boolValue) case .string: HStack { Text("String".localized()) Spacer() TextField( "String".localized(), text: self.$stringValue.preventWhiteSpace() ) .freeInput() } case .json: VStack { Text("JSON".localized()) Spacer() #if !os(tvOS) TextEditor(text: self.$jsonValue.preventWhiteSpace()) .frame(maxWidth: .infinity, minHeight: 300) #endif } case .number: HStack { Text("Number".localized()) Spacer() TextField( "Number".localized(), value: self.$numberValue, formatter: NumberFormatter() ) #if !os(macOS) .keyboardType(.numberPad) #endif } } } } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipDebugAddStringPropertyView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore struct AirshipDebugAddStringPropertyView: View { @State private var key: String = "" @State private var value: String = "" let onAdd: (String, String) -> Void private var isValid: Bool { return !key.isEmpty && !value.isEmpty } @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> var body: some View { Form { HStack { Text("Key".localized()) Spacer() TextField( "Key".localized(), text: self.$key.preventWhiteSpace() ) .freeInput() } HStack { Text("Value".localized()) Spacer() TextField( "Value".localized(), text: self.$value.preventWhiteSpace() ) .freeInput() } } .toolbar { ToolbarItem(placement: .cancellationAction) { Button { presentationMode.wrappedValue.dismiss() } label: { Image(systemName: "xmark") } } ToolbarItem(placement: .confirmationAction) { Button("Add".localized()) { onAdd(self.key, value) presentationMode.wrappedValue.dismiss() } .disabled(!self.isValid) } } } } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipDebugAttributesEditorView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import AirshipCore struct AirshipDebugAttributesEditorView: View { private enum AttributeAction: String, Equatable, CaseIterable { case add = "Add" case remove = "Remove" } private enum AttributeType: String, Equatable, CaseIterable { case text = "Text" case number = "Number" case date = "Date" case json = "JSON" } private let subject: AirshipDebugAudienceSubject? init() { self.subject = nil } public init(for subject: AirshipDebugAudienceSubject) { self.subject = subject } @State private var attribute: String = "" @State private var action: AttributeAction = .add @State private var type: AttributeType = .text @State private var date = Date() @State private var text: String = "" @State private var number: Double = 0.0 @State private var jsonText: String = "" @State private var instanceID: String = "" @State private var expiryEnabled: Bool = false @State private var expiryDate = Date() @ViewBuilder func makeValue() -> some View { switch self.type { case .date: #if os(tvOS) TVDatePicker( "Date".localized(), selection: $date, displayedComponents: .all ) #else DatePicker( "Date".localized(), selection: $date, displayedComponents: [.date, .hourAndMinute] ) #endif case .text: HStack { Text("Text") Spacer() TextField( "Value".localized(), text: self.$text.preventWhiteSpace() ) .freeInput() } case .number: HStack { Text("Number") Spacer() TextField( "Value".localized(), value: self.$number, formatter: NumberFormatter() ) #if !os(macOS) .keyboardType(.numberPad) #endif } case .json: VStack(alignment: .leading, spacing: 8) { Text("JSON") Group { #if !os(tvOS) TextEditor(text: $jsonText) .font(.system(.caption, design: .monospaced)) .frame(height: 100) #else MultilineTextView(text: $jsonText) .frame(height: 100) #endif }.overlay( RoundedRectangle(cornerRadius: 4) .stroke( jsonText.isEmpty || jsonIsValid ? Color.secondary.opacity(0.5) : Color.red ) ) .airshipOnChangeOf(jsonText) { newValue in guard let data = newValue.data(using: .utf8) else { return } do { let obj = try JSONSerialization.jsonObject(with: data) let prettyData = try JSONSerialization.data( withJSONObject: obj, options: .prettyPrinted ) if let prettyString = String(data: prettyData, encoding: .utf8), prettyString != newValue { jsonText = prettyString } } catch {} } HStack { Text("Instance ID") Spacer() TextField("ID".localized(), text: $instanceID.preventWhiteSpace()) .freeInput() } Toggle("Expiry".localized(), isOn: $expiryEnabled) if expiryEnabled { #if os(tvOS) TVDatePicker( "Date".localized(), selection: $expiryDate, displayedComponents: .all ) #else DatePicker( "Date".localized(), selection: $expiryDate, displayedComponents: [.date, .hourAndMinute] ) #endif } } } } var body: some View { Form { Section(header: Text("Attribute Info".localized())) { Picker("Action".localized(), selection: $action) { ForEach(AttributeAction.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) HStack { Text("Attribute".localized()) Spacer() TextField( "Attribute Name".localized(), text: self.$attribute.preventWhiteSpace() ) .freeInput() } if self.action == .add { Picker("Type".localized(), selection: $type) { ForEach(AttributeType.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) makeValue() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { apply() } label: { Text("Apply".localized()) } .disabled(!isValid()) } } .navigationTitle("Attributes".localized()) } private func isValid() -> Bool { guard !attribute.isEmpty else { return false } switch self.action { case .add: switch self.type { case .number: return true case .text: return !self.text.isEmpty case .date: return true case .json: return !jsonText.isEmpty && !instanceID.isEmpty } case .remove: return true } } private func apply() { defer { attribute = "" text = "" number = 0 jsonText = "" instanceID = "" expiryEnabled = false expiryDate = Date() } guard Airship.isFlying, let subject else { return } subject.editAttributes { editor in switch self.action { case .add: switch self.type { case .number: editor.set(double: self.number, attribute: self.attribute) case .text: editor.set(string: self.text, attribute: self.attribute) case .date: editor.set(date: self.date, attribute: self.attribute) case .json: do { let root = try AirshipJSON.from(json: jsonText) guard case let .object(dict) = root else { throw AirshipErrors.error("Top‑level JSON must be an object") } if expiryEnabled { try editor.set( json: dict, attribute: attribute, instanceID: instanceID, expiration: expiryDate ) } else { try editor.set( json: dict, attribute: attribute, instanceID: instanceID ) } } catch { AirshipLogger.error("JSON attribute error: \(error)") } } case .remove: editor.remove(self.attribute) } } } private var jsonIsValid: Bool { guard let data = jsonText.data(using: .utf8) else { return false } return (try? JSONSerialization.jsonObject(with: data, options: [])) != nil } } /// For tvOS #if os(tvOS) struct MultilineTextView: UIViewRepresentable { @Binding var text: String class Coordinator: NSObject, UITextViewDelegate { var parent: MultilineTextView init(_ parent: MultilineTextView) { self.parent = parent } func textViewDidChange(_ textView: UITextView) { parent.text = textView.text } } func makeCoordinator() -> Coordinator { Coordinator(self) } func makeUIView(context: Context) -> UITextView { let tv = UITextView() tv.font = .monospacedSystemFont(ofSize: 14, weight: .regular) tv.delegate = context.coordinator tv.textColor = .label tv.backgroundColor = .clear tv.isScrollEnabled = true tv.text = text return tv } func updateUIView(_ uiView: UITextView, context: Context) { if uiView.text != text { uiView.text = text } } } #endif #Preview { AirshipDebugAttributesEditorView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipDebugAudienceSubject.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore enum AirshipDebugAudienceSubject { case channel case contact func editTagGroups(_ editorBlock: (TagGroupsEditor) -> Void) { switch(self) { case .channel: Airship.channel.editTagGroups(editorBlock) case .contact: Airship.channel.editTagGroups(editorBlock) } } func editAttributes(_ editorBlock: (AttributesEditor) -> Void) { switch(self) { case .channel: Airship.channel.editAttributes(editorBlock) case .contact: Airship.channel.editAttributes(editorBlock) } } } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipDebugExtensions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI extension Binding where Value == String { func preventWhiteSpace() -> Binding<String> { return Binding<String>( get: { self.wrappedValue }, set: { self.wrappedValue = $0.trimmingCharacters( in: .whitespacesAndNewlines ) } ) } } extension View { @ViewBuilder func freeInput() -> some View { #if os(macOS) self.disableAutocorrection(true) #else self.textInputAutocapitalization(.never) .disableAutocorrection(true) #endif } } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipDebugTagGroupsEditorView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import AirshipCore import Combine struct AirshipDebugTagGroupsEditorView: View { enum TagAction: String, Equatable, CaseIterable { case add = "Add" case remove = "Remove" } @State private var tag: String = "" @State private var group: String = "" @State private var action: TagAction = .add private let subject: AirshipDebugAudienceSubject? init() { self.subject = nil } init(for subject: AirshipDebugAudienceSubject) { self.subject = subject } @ViewBuilder var body: some View { Form { Section(header: Text("Tag Info".localized())) { Picker("Action".localized(), selection: $action) { ForEach(TagAction.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) HStack { Text("Tag") Spacer() TextField("", text: self.$tag.preventWhiteSpace()) .freeInput() } HStack { Text("Group") Spacer() TextField("", text: self.$group.preventWhiteSpace()) .freeInput() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { apply() } label: { Text("Apply".localized()) } .disabled(tag.isEmpty || group.isEmpty) } } .navigationTitle("Tag Groups".localized()) } private func apply() { defer { self.tag = "" self.group = "" } guard Airship.isFlying, let subject else { return } subject.editTagGroups { editor in switch self.action { case .add: editor.add([tag], group: group) case .remove: editor.remove([tag], group: group) } } } } #Preview { AirshipDebugTagGroupsEditorView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipJSONDetailsView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import AirshipCore struct AirshipJSONDetailsView: View { let payload: AirshipJSON let title: String @State private var toastMessage: AirshipToast.Message? = nil @ViewBuilder var body: some View { Form { Section(header: Text("Details".localized())) { AirshipJSONView(json: payload) } } .navigationTitle(title) .toolbar { ToolbarItem(placement: .primaryAction) { Button("Copy") { copyToClipboard(value: payload.prettyString) } } } .toastable($toastMessage) } func copyToClipboard(value: String?) { guard let value = value else { return } value.pastleboard() self.toastMessage = AirshipToast.Message( id: UUID().uuidString, text: "Copied to pasteboard!".localized(), duration: 1.0 ) } } #Preview { AirshipJSONDetailsView( payload: ["key": "value"], title: "Preview") } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipJSONView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Foundation import AirshipCore struct AirshipJSONView: View { let json: AirshipJSON init(json: AirshipJSON) { self.json = json } @ViewBuilder public var body: some View { switch (json) { case .object(let object): List(Array(object.keys), id: \.self) { key in if let value = object[key] { ObjectEntry(key: key, value: value) } } default: Text(json.prettyString) } } } extension String { var quoted: String { return "\"\(self)\"" } } extension AirshipJSON { static let prettyEncoder = { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted return encoder }() static func wrapSafe(_ any: Any?) -> AirshipJSON { do { return try AirshipJSON.wrap(any) } catch { return AirshipJSON.string("Error \(error)") } } var prettyString: String { do { return try self.toString(encoder: AirshipJSON.prettyEncoder) } catch { return "Error: \(error)" } } var typeString: String { switch(self) { case .object: return "object" case .string: return "string" case .number: return "number" case .array: return "array" case .bool: return "boolean" case .null: return "null" @unknown default: return "unknown" } } } fileprivate struct ObjectEntry: View { let key: String let value: AirshipJSON @State private var collapsed: Bool = false var body: some View { VStack(alignment: .leading) { Button( action: { self.collapsed.toggle() }, label: { HStack { Text(key.quoted) + Text(": ") + Text(value.typeString).italic().font(.system(size: 12)) Spacer() Image(systemName: self.collapsed ? "chevron.down" : "chevron.up") } .contentShape(Rectangle()) } ) .buttonStyle(PlainButtonStyle()) .frame(minHeight: 36) VStack(alignment: .leading) { Text(value.prettyString) .font(.subheadline) .frame(maxWidth: .infinity, alignment: .leading) } .padding(8) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: collapsed ? 0 : .none) .clipped() .animation(.easeOut, value: self.collapsed) .transition(.slide) } } } #Preview { AirshipJSONView(json: ["preview": true]) } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/AirshipToast.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore struct AirshipToast: View { struct Message: Equatable { let id: String let text: String let duration: TimeInterval init(id: String = UUID().uuidString, text: String, duration: TimeInterval = 1) { self.id = id self.text = text self.duration = duration } } @Binding var message: Message? @State private var toastTask: Task<(), Never>? = nil @State private var toastVisible: Bool = false @ViewBuilder private func makeView() -> some View { Text(message?.text ?? "") .padding() .background( .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous) ) } @ViewBuilder var body: some View { makeView() .airshipOnChangeOf( self.message) { incoming in if incoming != nil { showToast() } } .hideOpt(self.toastVisible == false || self.message == nil) } private func showToast() { self.toastTask?.cancel() guard let message = message else { return } let waitTask = Task { try? await Task.sleep( nanoseconds: UInt64(message.duration * 1_000_000_000) ) return } Task { let _ = await waitTask.result await MainActor.run { if !waitTask.isCancelled { self.toastVisible = false self.message = nil } } } self.toastTask = waitTask self.toastVisible = true } } extension View { @ViewBuilder func hideOpt(_ shouldHide: Bool) -> some View { if shouldHide { self.hidden() } else { self } } } ================================================ FILE: Airship/AirshipDebug/Source/View/Common/Extensions.swift ================================================ // Copyright Airship and Contributors import AirshipCore import SwiftUI private struct ToastModifier: ViewModifier { @Binding var message: AirshipToast.Message? func body(content: Content) -> some View { content.overlay(AirshipToast(message: $message), alignment: .bottom) } } extension View { func toastable(_ message: Binding<AirshipToast.Message?>) -> some View { modifier(ToastModifier(message: message)) } } extension String { func pastleboard() { #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.declareTypes([.string], owner: nil) pasteboard.setString(self, forType: .string) #elseif !os(tvOS) UIPasteboard.general.string = self #endif } } struct CommonItems { static let rowHeight = 34.0 @ViewBuilder @MainActor static func navigationLink( title: String, trailingView: (() -> some View) = { EmptyView() }, showDivider: Bool = false, route: AirshipDebugRoute ) -> some View { NavigationLink(value: route) { VStack { HStack { Text(title) .foregroundColor(.primary) Spacer() trailingView() } .frame(height: Self.rowHeight) if showDivider { Divider() } } } } @ViewBuilder @MainActor static func infoRow( title: String, value: String?, onTap: (() -> Void)? = nil, showDivider: Bool = false ) -> some View { VStack(alignment: .leading) { HStack { Text(title) .foregroundColor(.primary) Spacer() if let value { Text(value) .foregroundColor(.secondary) } } if showDivider { Divider() } } .frame(height: Self.rowHeight) .onTapGesture { onTap?() } } } ================================================ FILE: Airship/AirshipDebug/Source/View/Contact/AirshipDebugAddEmailChannelView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine import AirshipCore struct AirshipDebugAddEmailChannelView: View { enum RegistrationType: String, Equatable, CaseIterable { case transactional = "Transactional" case commercial = "Commercial" } public init() {} @StateObject private var viewModel = ViewModel() @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> @State private var shouldPresentPropetySheet: Bool = false var body: some View { Form { Section(header: Text("Channel Info".localized())) { HStack { Text("Email") Spacer() TextField( "Email", text: self.$viewModel.emailAddress.preventWhiteSpace() ) .freeInput() } Picker( "Registration Type".localized(), selection: self.$viewModel.registrationType ) { ForEach(RegistrationType.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) } if self.viewModel.registrationType == .commercial { Section(header: Text("Commercial Options".localized())) { Toggle("Double Opt-In", isOn: self.$viewModel.doubleOptIn) } } Section(header: Text("Properties".localized())) { Button("Add Property".localized()) { self.shouldPresentPropetySheet = true } .sheet(isPresented: self.$shouldPresentPropetySheet) { NavigationStack { AirshipDebugAddPropertyView { self.viewModel.properties[$0] = $1 } .navigationTitle("New Property") #if !os(tvOS) && !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif } .presentationDetents([.medium]) } List { let keys = [String](self.viewModel.properties.keys) ForEach(keys, id: \.self) { key in HStack { Text("\(key):") Text( self.viewModel.properties[key]?.prettyString ?? "" ) } } .onDelete { $0.forEach { index in self.viewModel.properties[keys[index]] = nil } } } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { self.viewModel.createChannel() presentationMode.wrappedValue.dismiss() } label: { Text("Create".localized()) } .disabled(self.viewModel.emailAddress.isEmpty) } } .navigationTitle("Email Channel".localized()) } @MainActor fileprivate class ViewModel: ObservableObject { @Published var emailAddress: String = "" @Published var commercial: Bool = false @Published var registrationType: RegistrationType = .transactional @Published var doubleOptIn: Bool = false @Published var properties: [String: AirshipJSON] = [:] func createChannel() { guard Airship.isFlying, !self.emailAddress.isEmpty else { return } var options: EmailRegistrationOptions! let date = Date() switch self.registrationType { case .commercial: if doubleOptIn { options = EmailRegistrationOptions.options( transactionalOptedIn: date, properties: properties, doubleOptIn: true ) } else { options = EmailRegistrationOptions.commercialOptions( transactionalOptedIn: date, commercialOptedIn: date, properties: properties, ) } case .transactional: options = EmailRegistrationOptions.options( transactionalOptedIn: date, properties: properties, doubleOptIn: false ) } Airship.contact.registerEmail( self.emailAddress, options: options ) } } } #Preview { AirshipDebugAddEmailChannelView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Contact/AirshipDebugAddOpenChannelView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore import Combine struct AirshipDebugAddOpenChannelView: View { @StateObject private var viewModel = ViewModel() @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> @State private var shouldPresentPropetySheet: Bool = false var body: some View { Form { Section(header: Text("Channel Info".localized())) { HStack { Text("Platform") Spacer() TextField( "Platform", text: self.$viewModel.platformName.preventWhiteSpace() ) .freeInput() } HStack { Text("Address") Spacer() TextField( "Address", text: self.$viewModel.address.preventWhiteSpace() ) .freeInput() } } Section(header: Text("Identifiers".localized())) { Button("Add Identifier".localized()) { self.shouldPresentPropetySheet = true } .sheet(isPresented: self.$shouldPresentPropetySheet) { NavigationStack { AirshipDebugAddStringPropertyView { self.viewModel.identifiers[$0] = $1 } .navigationTitle("New Identifier") #if !os(tvOS) && !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif } .presentationDetents([.medium]) } List { let keys = [String](self.viewModel.identifiers.keys) ForEach(keys, id: \.self) { key in HStack { Text("\(key):") Text(self.viewModel.identifiers[key] ?? "") } } .onDelete { $0.forEach { index in self.viewModel.identifiers[keys[index]] = nil } } } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { self.viewModel.createChannel() presentationMode.wrappedValue.dismiss() } label: { Text("Create".localized()) } .disabled( self.viewModel.address.isEmpty || self.viewModel.platformName.isEmpty ) } } .navigationTitle("Open Channel".localized()) } @MainActor class ViewModel: ObservableObject { @Published var identifiers: [String: String] = [:] @Published var address: String = "" @Published var platformName: String = "" func createChannel() { guard Airship.isFlying, !self.address.isEmpty, !self.platformName.isEmpty else { return } let options = OpenRegistrationOptions.optIn( platformName: self.platformName, identifiers: identifiers ) Airship.contact.registerOpen( self.address, options: options ) } } } private struct AddIdentifierView: View { @State private var key: String = "" @State private var value: String = "" let onAdd: (String, String) -> Void @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> var body: some View { Form { HStack { Text("Key".localized()) Spacer() TextField( "Key".localized(), text: self.$key.preventWhiteSpace() ) .freeInput() } HStack { Text("Value".localized()) Spacer() TextField( "Value".localized(), text: self.$value.preventWhiteSpace() ) .freeInput() } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { onAdd(key, value) presentationMode.wrappedValue.dismiss() } label: { Text("Add".localized()) } .disabled(key.isEmpty || value.isEmpty) } } .navigationTitle("Identifier".localized()) } } #Preview { AirshipDebugAddOpenChannelView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Contact/AirshipDebugAddSMSChannelView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore struct AirshipDebugAddSMSChannelView: View { @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> @State private var phoneNumber: String = "" @State private var senderID: String = "" var body: some View { Form { Section(header: Text("Channel Info".localized())) { HStack { Text("MSISDN".localized()) Spacer() TextField( "MSISDN".localized(), text: self.$phoneNumber ) #if !os(macOS) .keyboardType(.phonePad) #endif .freeInput() } HStack { Text("Sender ID") Spacer() TextField( "Sender ID", text: self.$senderID.preventWhiteSpace() ) .freeInput() } } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { createChannel() } label: { Text("Create".localized()) } .disabled(senderID.isEmpty || phoneNumber.isEmpty) } } .navigationTitle("SMS Channel".localized()) } func createChannel() { guard Airship.isFlying, !senderID.isEmpty, !phoneNumber.isEmpty else { return } let options = SMSRegistrationOptions.optIn(senderID: senderID) Airship.contact.registerSMS(phoneNumber, options: options) } } #Preview { AirshipDebugAddSMSChannelView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Contact/AirshipDebugContactSubscriptionEditorView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import AirshipCore struct AirshipDebugContactSubscriptionEditorView: View { private enum SubscriptionListAction: String, Equatable, CaseIterable { case subscribe = "Subscribe" case unsubscribe = "Unsubscribe" } private enum Scope: String, Equatable, CaseIterable { case app = "App" case web = "Web" case email = "Email" case sms = "SMS" var channelScope: ChannelScope { switch self { case .app: return .app case .web: return .web case .email: return .email case .sms: return .sms } } } @State private var listID: String = "" @State private var action: SubscriptionListAction = .subscribe @State private var scope: Scope = .app @ViewBuilder public var body: some View { Form { Section(header: Text("Subscription Info".localized())) { HStack { Text("List ID".localized()) Spacer() TextField("", text: self.$listID.preventWhiteSpace()) .freeInput() } Picker("Scope".localized(), selection: $scope) { ForEach(Scope.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) Picker("Action".localized(), selection: $action) { ForEach(SubscriptionListAction.allCases, id: \.self) { value in Text(value.rawValue.localized()) } } .pickerStyle(.segmented) } } .toolbar { ToolbarItem(placement: .confirmationAction) { Button { apply() } label: { Text("Apply".localized()) } .disabled(listID.isEmpty) } } .navigationTitle("Subscription Lists".localized()) } private func apply() { defer { self.listID = "" } guard Airship.isFlying else { return } Airship.contact.editSubscriptionLists { editor in switch self.action { case .subscribe: editor.subscribe(self.listID, scope: self.scope.channelScope) case .unsubscribe: editor.unsubscribe(self.listID, scope: self.scope.channelScope) } } } } #Preview { AirshipDebugContactSubscriptionEditorView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Contact/AirshipDebugContactView.swift ================================================ // Copyright Airship and Contributors import Combine import SwiftUI import AirshipCore struct AirshipDebugContactsView: View { @StateObject private var viewModel = ViewModel() @ViewBuilder var body: some View { Form { Section { CommonItems.navigationLink( title: "Named User".localized(), trailingView: { HStack { if let namedUserID = viewModel.namedUserID { Text(namedUserID) .foregroundColor(.secondary) } } }, route: .contactSub(.namedUserID) ) CommonItems.navigationLink( title: "Tag Groups".localized(), route: .contactSub(.tagGroups) ) CommonItems.navigationLink( title: "Attributes".localized(), route: .contactSub(.attributes) ) CommonItems.navigationLink( title: "Subscription Lists".localized(), route: .contactSub(.subscriptionLists) ) } Section("Channels".localized()) { CommonItems.navigationLink( title: "Add Email Channel".localized(), route: .contactSub(.addEmailChannel) ) CommonItems.navigationLink( title: "Add SMS Channel".localized(), route: .contactSub(.addSMSChannel) ) CommonItems.navigationLink( title: "Add Open Channel".localized(), route: .contactSub(.addOpenChannel) ) } } .navigationTitle("Contact".localized()) } @MainActor fileprivate final class ViewModel: ObservableObject { @Published var namedUserID: String? private var task: Task<Void, Never>? = nil private var subscription: AnyCancellable? = nil @MainActor init() { self.task = Task { [weak self] in await Airship.waitForReady() self?.update( namedUserID: await Airship.contact.namedUserID ) for await namedUserID in Airship.contact.namedUserIDPublisher.values { self?.update(namedUserID: namedUserID) } } } deinit { task?.cancel() } private func update(namedUserID: String?) { if self.namedUserID != namedUserID { self.namedUserID = namedUserID } } } } #Preview { AirshipDebugContactsView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Contact/AirshipDebugNamedUserView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine import AirshipCore struct AirshipDebugNamedUserView: View { @StateObject private var viewModel: ViewModel = ViewModel() private func updateNamedUser() { let normalized = self.viewModel.namedUserID.trimmingCharacters( in: .whitespacesAndNewlines ) if !normalized.isEmpty { Airship.contact.identify(normalized) } else { Airship.contact.reset() } } var body: some View { let title = "Named User".localized() Form { Section( header: Text(""), footer: Text( "An empty value does not indicate the device does not have a named user. The SDK only knows about the Named User ID if set through the SDK." .localized() ) ) { TextField(title, text: self.$viewModel.namedUserID) .onSubmit { updateNamedUser() } .freeInput() } } .navigationTitle(title) } @MainActor private class ViewModel: ObservableObject { @Published public var namedUserID: String = "" init() { Task { @MainActor in if !Airship.isFlying { return } self.namedUserID = await Airship.contact.namedUserID ?? "" } } } } #Preview { AirshipDebugNamedUserView() } ================================================ FILE: Airship/AirshipDebug/Source/View/FeatureFlags/AirshipDebugFeatureFlagDetailsView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore import AirshipFeatureFlags struct AirshipDebugFeatureFlagDetailsView: View { @StateObject private var viewModel: ViewModel @State private var toastMessage: AirshipToast.Message? = nil init(name: String) { _viewModel = .init(wrappedValue: .init(name: name)) } @ViewBuilder var body: some View { Form { Section(header: Text("Details".localized())) { makeInfoItem("Name".localized(), viewModel.name) } Section( content: { if let result = self.viewModel.result { makeInfoItem("Elegible".localized(), result.isEligible ? "true" : "false") makeInfoItem("Exists".localized(), result.exists ? "true" : "false") if let variables = result.variables { Section(header: Text("Variables: ".localized())) { AirshipJSONView(json: variables) .padding(.leading, 8) } } Button("Track Interaction") { Airship.featureFlagManager.trackInteraction(flag: result) }.frame(maxWidth: .infinity, alignment: .center) } else { VStack { ProgressView() .padding() Text("Resolving flag") }.frame(maxWidth: .infinity) } }, header: { HStack { Text("Result".localized()) Spacer() if let result = self.viewModel.result { makeShareLink(AirshipJSON.wrapSafe(result).prettyString) } if self.viewModel.result != nil || self.viewModel.error != nil { Button { self.viewModel.evaluateFlag() } label: { Image(systemName: "arrow.clockwise") } } } } ) } .navigationTitle(viewModel.name) .onAppear { self.viewModel.evaluateFlag() } .overlay(AirshipToast(message: self.$toastMessage)) } @ViewBuilder func makeShareLink(_ string: String) -> some View { #if !os(tvOS) ShareLink(item: string) { Image(systemName: "square.and.arrow.up") } #else Button { copyToClipboard(value: string) } label: { Image(systemName: "doc.on.clipboard.fill") } #endif } @ViewBuilder func makeInfoItem(_ title: String, _ value: String?) -> some View { HStack { Text(title) .foregroundColor(.primary) Spacer() Text(value ?? "") .foregroundColor(.secondary) } } func copyToClipboard(value: String?) { guard let value = value else { return } value.pastleboard() self.toastMessage = AirshipToast.Message( id: UUID().uuidString, text: "Copied to pasteboard!".localized(), duration: 1.0 ) } @MainActor class ViewModel: ObservableObject { @Published private(set) var result: FeatureFlag? @Published private(set) var error: String? let name: String init(name: String) { self.name = name } func evaluateFlag() { self.result = nil self.error = nil Task { @MainActor in do { self.result = try await Airship.featureFlagManager.flag(name: name) } catch { self.error = error.localizedDescription } } } } } #Preview { AirshipDebugFeatureFlagDetailsView(name: "some flag name") } ================================================ FILE: Airship/AirshipDebug/Source/View/FeatureFlags/AirshipDebugFeatureFlagView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore import AirshipFeatureFlags struct AirshipDebugFeatureFlagView: View { @StateObject private var viewModel = ViewModel() var body: some View { Form { Section { ForEach(self.viewModel.entries, id: \.self) { name in CommonItems.navigationLink( title: name, route: .featureFlagsSub(.featureFlagDetails(name: name)) ) } } } .navigationTitle("Feature Flags".localized()) } @MainActor class ViewModel: ObservableObject { @Published private(set) var entries: [String] = [] private var cancellable: AnyCancellable? = nil init() { if Airship.isFlying { self.cancellable = Airship.internalDebugManager .featureFlagPublisher .receive(on: RunLoop.main) .map { result in return result.compactMap { element in let flag = element["flag"] as? [String : AnyHashable] return flag?["name"] as? String } } .sink { incoming in self.entries = Array(Set(incoming)).sorted() } } } } } #Preview { AirshipDebugFeatureFlagView() } ================================================ FILE: Airship/AirshipDebug/Source/View/PreferenceCenter/AirshipDebugPreferencCenterItemView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine import AirshipCore import AirshipPreferenceCenter struct AirshipDebugPreferencCenterItemView: View { private let preferenceCenterID: String @State private var title: String? = nil init(preferenceCenterID: String) { self.preferenceCenterID = preferenceCenterID } @ViewBuilder public var body: some View { PreferenceCenterContent( preferenceCenterID: preferenceCenterID, onPhaseChange: { phase in guard case .loaded(let state) = phase else { return } let title = state.config.display?.title if let title, title.isEmpty == false { self.title = title } } ) .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle(self.title ?? preferenceCenterID) } } ================================================ FILE: Airship/AirshipDebug/Source/View/PreferenceCenter/AirshipDebugPreferenceCenterView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore import AirshipPreferenceCenter struct AirshipDebugPreferenceCentersView: View { @StateObject private var viewModel = ViewModel() var body: some View { Form { ForEach(self.viewModel.identifiers, id: \.self) { identifier in CommonItems.navigationLink( title: identifier, route: .preferenceCentersSub(.preferenceCenter(identifier: identifier)) ) } } .navigationTitle("Preference Centers".localized()) } @MainActor class ViewModel: ObservableObject { @Published private(set) var identifiers: [String] = [] private var cancellable: AnyCancellable? = nil init() { if Airship.isFlying { self.cancellable = Airship.internalDebugManager .preferenceFormsPublisher .receive(on: RunLoop.main) .sink { incoming in self.identifiers = incoming.sorted() } } } } } #Preview { AirshipDebugPreferenceCentersView() } ================================================ FILE: Airship/AirshipDebug/Source/View/PrivacyManager/AirshipDebugPrivacyManagerView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine import AirshipCore struct AirshipDebugPrivacyManagerView: View { @StateObject private var viewModel = ViewModel() @ViewBuilder private func makeFeatureToggle( _ title: String, _ isOn: Binding<Bool> ) -> some View { Toggle(title.localized(), isOn: isOn).frame(height: CommonItems.rowHeight) } var body: some View { Form { makeFeatureToggle("Contacts", self.$viewModel.contactsEnabled) makeFeatureToggle( "Tags & Attributes", self.$viewModel.tagsAndAttributesEnabled ) makeFeatureToggle("Analytics", self.$viewModel.analyticsEnabled) makeFeatureToggle("Push", self.$viewModel.pushEnabled) makeFeatureToggle( "In App Automation", self.$viewModel.iaaEnabled ) makeFeatureToggle( "Message Center", self.$viewModel.messageCenterEnabled ) makeFeatureToggle( "Feature Flags", self.$viewModel.featureFlagEnabled ) } .navigationTitle("Privacy Manager".localized()) } @MainActor class ViewModel: ObservableObject { @Published public var iaaEnabled: Bool { didSet { update(.inAppAutomation, enable: self.iaaEnabled) } } @Published public var messageCenterEnabled: Bool { didSet { update(.messageCenter, enable: self.messageCenterEnabled) } } @Published public var featureFlagEnabled: Bool { didSet { update(.featureFlags, enable: self.featureFlagEnabled) } } @Published public var pushEnabled: Bool { didSet { update(.push, enable: self.pushEnabled) } } @Published public var analyticsEnabled: Bool { didSet { update(.analytics, enable: self.analyticsEnabled) } } @Published public var tagsAndAttributesEnabled: Bool { didSet { update( .tagsAndAttributes, enable: self.tagsAndAttributesEnabled ) } } @Published public var contactsEnabled: Bool { didSet { update(.contacts, enable: self.contactsEnabled) } } init() { if Airship.isFlying { let privacyManager = Airship.privacyManager self.iaaEnabled = privacyManager.isEnabled(.inAppAutomation) self.messageCenterEnabled = privacyManager.isEnabled( .messageCenter ) self.pushEnabled = privacyManager.isEnabled(.push) self.analyticsEnabled = privacyManager.isEnabled(.analytics) self.contactsEnabled = privacyManager.isEnabled(.contacts) self.tagsAndAttributesEnabled = privacyManager.isEnabled( .tagsAndAttributes) self.featureFlagEnabled = privacyManager.isEnabled(.featureFlags) } else { self.iaaEnabled = false self.messageCenterEnabled = false self.pushEnabled = false self.analyticsEnabled = false self.contactsEnabled = false self.tagsAndAttributesEnabled = false self.featureFlagEnabled = false } } private func update(_ features: AirshipFeature, enable: Bool) { guard Airship.isFlying else { return } if enable { Airship.privacyManager.enableFeatures(features) } else { Airship.privacyManager.disableFeatures(features) } } } } #Preview { AirshipDebugPrivacyManagerView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Push/AirshipDebugPushDetailsView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine import AirshipCore struct AirshipDebugPushDetailsView: View { @StateObject private var viewModel: ViewModel init(identifier: String) { _viewModel = .init(wrappedValue: .init(identifier: identifier)) } var body: some View { if let push = viewModel.pushNotification { AirshipJSONDetailsView(payload: push.payload, title: push.alert ?? "Silent Push".localized()) } else { ProgressView() } } @MainActor class ViewModel: ObservableObject { @Published private(set) var pushNotification: PushNotification? init(identifier: String) { Task { @MainActor [weak self] in self?.pushNotification = await Airship.internalDebugManager.pushNotifications().first(where: { $0.pushID == identifier }) } } } } ================================================ FILE: Airship/AirshipDebug/Source/View/Push/AirshipDebugPushView.swift ================================================ // Copyright Airship and Contributors import SwiftUI import Combine import AirshipCore import AirshipAutomation struct AirshipDebugPushView: View { @State private var toast: AirshipToast.Message? = nil @StateObject private var viewModel = ViewModel() @ViewBuilder var body: some View { Form { Section { Toggle( "User Notifications".localized(), isOn: $viewModel.isPushNotificationsOptedIn ) .frame(height: 34) Toggle( "Background Push Enabled".localized(), isOn: $viewModel.backgroundPushNotificationsEnabled ) .frame(height: 34) CommonItems.navigationLink( title: "Received Pushes", route: .pushSub(.recievedPushes) ) } Section("Notification Opt-In Status") { if let status = self.viewModel.notificationStatus { CommonItems.infoRow( title: "Channel Opted-In".localized(), value: status.isOptedIn.description ) CommonItems.infoRow( title: "Airship Enabled".localized(), value: status.isUserNotificationsEnabled.description ) CommonItems.infoRow( title: "Push PrivacyManager Enabled".localized(), value: status.isPushPrivacyFeatureEnabled.description ) CommonItems.infoRow( title: "Permission Status".localized(), value: status.displayNotificationStatus.rawValue ) CommonItems.infoRow( title: "Push Token".localized(), value: self.viewModel.deviceToken ?? "Not Available", onTap: { if let token = self.viewModel.deviceToken { copyToClipboard(token) } } ) } else { ProgressView() } } } .toastable($toast) .navigationTitle("Push".localized()) } private func copyToClipboard(_ value: String?) { guard let value else { return } value.pastleboard() self.toast = .init(text: "Copied to clipboard".localized()) } @MainActor fileprivate final class ViewModel: ObservableObject { @Published private(set) var deviceToken: String? = nil @Published private(set) var notificationStatus: AirshipNotificationStatus? = nil @Published public var isPushNotificationsOptedIn: Bool = false { didSet { guard Airship.isFlying, Airship.push.isPushNotificationsOptedIn != self.isPushNotificationsOptedIn else { return } self.enableNotificationsTask?.cancel() if self.isPushNotificationsOptedIn { Airship.privacyManager.enableFeatures(.push) self.enableNotificationsTask = Task { [weak self] in await Airship.push.enableUserPushNotifications(fallback: .systemSettings) guard !Task.isCancelled else { return } self?.isPushNotificationsOptedIn = Airship.push.isPushNotificationsOptedIn } } else { Airship.push.userPushNotificationsEnabled = false } } } @Published public var backgroundPushNotificationsEnabled: Bool = false { didSet { guard Airship.isFlying, Airship.push.backgroundPushNotificationsEnabled != self.backgroundPushNotificationsEnabled else { return } Airship.push.backgroundPushNotificationsEnabled = self.backgroundPushNotificationsEnabled } } private var task: Task<Void, Never>? = nil private var enableNotificationsTask: Task<Void, Never>? = nil deinit { self.task?.cancel() self.enableNotificationsTask?.cancel() } @MainActor init() { if Airship.isFlying { self.deviceToken = Airship.push.deviceToken self.isPushNotificationsOptedIn = Airship.push.isPushNotificationsOptedIn self.backgroundPushNotificationsEnabled = Airship.push.backgroundPushNotificationsEnabled } self.task = Task { @MainActor [weak self] in await Airship.waitForReady() self?.deviceToken = Airship.push.deviceToken self?.isPushNotificationsOptedIn = Airship.push.isPushNotificationsOptedIn self?.backgroundPushNotificationsEnabled = Airship.push.backgroundPushNotificationsEnabled self?.notificationStatus = await Airship.push.notificationStatus for await update in await Airship.push.notificationStatusUpdates { self?.notificationStatus = update self?.deviceToken = Airship.push.deviceToken self?.isPushNotificationsOptedIn = Airship.push.isPushNotificationsOptedIn } } } } } #Preview { AirshipDebugPushView() } ================================================ FILE: Airship/AirshipDebug/Source/View/Push/AirshipDebugReceivedPushView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipCore struct AirshipDebugReceivedPushView: View { @StateObject private var viewModel = ViewModel() var body: some View { Form { ForEach(self.viewModel.pushNotifications, id: \.self) { push in CommonItems.navigationLink( title: push.alert ?? "Silent Push".localized(), route: .pushSub(.pushDetails(identifier: push.pushID)) ) } } .navigationTitle("Received Notifications".localized()) } @MainActor class ViewModel: ObservableObject { @Published private(set) var pushNotifications: [PushNotification] = [] private var cancellable: AnyCancellable? = nil init() { if Airship.isFlying { self.refreshPush() self.cancellable = Airship.internalDebugManager .pushNotificationReceivedPublisher .sink { [weak self] _ in self?.refreshPush() } } } private func refreshPush() { Task { @MainActor in self.pushNotifications = await Airship.internalDebugManager.pushNotifications() } } } } #Preview { AirshipDebugReceivedPushView() } ================================================ FILE: Airship/AirshipDebug/Source/View/TvOSComponents/TVDatePicker.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore /// Represents the available components of the tv date picker view. struct TvDatePickerComponents: OptionSet { /// Displays day, month, and year based on the locale static var date: TvDatePickerComponents { TvDatePickerComponents(rawValue: 1 << 3) } /// Displays hour and minute components based on the locale static var hourAndMinute: TvDatePickerComponents { TvDatePickerComponents(rawValue: 1) } /// Displays all components based on the locale static var all: TvDatePickerComponents { [.date, .hourAndMinute] } var rawValue: Int8 init(rawValue: Int8) { self.rawValue = rawValue } } #if os(tvOS) /// A `SwiftUI` tvOS date picker view struct TVDatePicker: View { var titleKey: String var displayedComponents: TvDatePickerComponents private let pickerStyle = SegmentedPickerStyle() private var minimumDate = Date.distantPast private var calendar: Calendar = .current @State private var isSheetPresented = false @Binding var selection: Date @State private var selectedYear: Int = 0 @State private var selectedMonth: Int = 0 @State private var selectedDay: Int = 0 @State private var selectedHour: Int = 0 @State private var selectedMinute: Int = 0 private var currentSelectedYear: Int { calendar.component(.year, from: selection) } private var minimumYear: Int { calendar.component(.year, from: minimumDate) } private var months: Range<Int> { calendar.range(of: .month, in: .year, for: minimumDate) ?? Range(0...0) } private var daysInSelectedMonth: Range<Int> { calendar.range(of: .day, in: .month, for: selection) ?? Range(0...0) } private var hours: Range<Int> { calendar.range(of: .hour, in: .day, for: minimumDate) ?? Range(0...0) } private var minutes: Array<Int> { Array(stride(from: 0, to: 60, by: 5)) } /// Initializes the date picker view with the given values. /// /// - Parameters: /// - titleKey: The key for the localized title of self, describing its purpose. /// - selection: The date value being displayed and selected. /// - displayedComponents: The date components that user is able to view and edit. Defaults to [.hourAndMinute, .date]. init<S>( _ titleKey: S, selection: Binding<Date>, displayedComponents: TvDatePickerComponents = [.hourAndMinute, .date] ) where S : StringProtocol { self.titleKey = titleKey as! String self.displayedComponents = displayedComponents _selection = selection } var body: some View { Button { isSheetPresented = true } label: { HStack { Spacer() Text(AirshipDateFormatter.string( fromDate: selection, format: .relativeShort )) Image(systemName: "chevron.right") } } .background( EmptyView() .sheet( isPresented: $isSheetPresented, onDismiss: { isSheetPresented = false }) { NavigationStack { VStack( alignment: .leading, content: content ) .onAppear(perform: { updateSelectedDateComponents() }) .navigationTitle(.init(titleKey)) .navigationBarItems( trailing: dismissButton() ) } } ) .airshipOnChangeOf(selection, initial: true, { _ in updateSelectedDateComponents() }) } } private extension TVDatePicker { @ViewBuilder func content() -> some View { HStack { Spacer() Text(AirshipDateFormatter.string( fromDate: selection, format: .relativeShort )) .font(.largeTitle) Spacer() } Divider() Text("MM/DD/YYYY") .foregroundColor(.secondary) .font(.subheadline) if displayedComponents.contains(.date) { Picker( selection: $selectedYear, label: Text("Year") ) { let lowerBound = max(minimumYear, (currentSelectedYear - 5)) let upperBound = max(minimumYear + 10, (currentSelectedYear + 5)) ForEach(lowerBound...upperBound, id: \.self) { year in Text(String(year)) .tag(year) } } .pickerStyle(pickerStyle) .airshipOnChangeOf(selectedYear) { value in updateComponent(.year, value: value) } } if displayedComponents.contains(.date) { Picker( selection: $selectedMonth, label: Text("Month") ) { ForEach(months, id: \.self) { month in Text(DateFormatter().shortMonthSymbols[month - 1]) .tag(month) } } .pickerStyle(pickerStyle) .airshipOnChangeOf(selectedMonth) { value in updateComponent(.month, value: value) } } if displayedComponents.contains(.date) { Picker( selection: $selectedDay, label: Text("Day") ) { ForEach(daysInSelectedMonth, id: \.self) { day in Text("\(day)") .tag(day) } } .pickerStyle(pickerStyle) .airshipOnChangeOf(selectedDay) { value in updateComponent(.day, value: value) } } if displayedComponents.contains(.hourAndMinute) { Text("HH:mm") .foregroundColor(.secondary) .font(.subheadline) Picker( selection: $selectedHour, label: Text("Hour") ) { ForEach(hours, id: \.self) { hour in Text("\(hour)") .tag(hour) } } .pickerStyle(pickerStyle) .airshipOnChangeOf(selectedHour) { value in updateComponent(.hour, value: value) } Picker( selection: $selectedMinute, label: Text("Minute") ) { ForEach(Array(minutes), id: \.self) { minute in Text("\(minute)") .tag(minute) } } .pickerStyle(pickerStyle) .airshipOnChangeOf(selectedMinute) { value in updateComponent(.minute, value: value) } } } func dismissButton() -> some View { Button(action: { isSheetPresented = false }, label: { Image(systemName: "xmark") }) } func updateComponent(_ component: Calendar.Component, value: Int) { let dateComponents = DateComponents( calendar: calendar, year: (component == .year) ? value : selectedYear, month: (component == .month) ? value : selectedMonth, day: (component == .day) ? value : selectedDay, hour: (component == .hour) ? value : selectedHour, minute: (component == .minute) ? value : selectedMinute ) if let selectedDate = calendar.date(from: dateComponents) { self.selection = selectedDate updateSelectedDateComponents() } } func updateSelectedDateComponents() { selectedYear = calendar.component(.year, from: selection) selectedMonth = calendar.component(.month, from: selection) selectedDay = calendar.component(.day, from: selection) selectedHour = calendar.component(.hour, from: selection) let currentMinute = calendar.component(.minute, from: selection) selectedMinute = currentMinute - (currentMinute % 5) } } @available(iOS 17.0, *) #Preview { @Previewable @State var date = Date.now TVDatePicker( "Date".localized(), selection: $date, displayedComponents: .all ) } #endif ================================================ FILE: Airship/AirshipDebug/Source/View/TvOSComponents/TVSlider.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI struct TVSlider: View { @Binding var displayInterval: Double var range: ClosedRange<Double> var step: Double = 1.0 static private let height = 50.0 static private let width = 500.0 var body: some View { HStack { Button("-") { guard self.$displayInterval.wrappedValue - step >= range.upperBound else { return } self.$displayInterval.wrappedValue -= step } ZStack(alignment: .leading) { Rectangle() .fill(Color.accentColor.opacity(0.5)) .frame(width: TVSlider.width, height: TVSlider.height) Rectangle() .fill(Color.accentColor) .frame(width: TVSlider.width, height: TVSlider.height) HStack { Spacer() Text("\(Int($displayInterval.wrappedValue)) / \(Int(range.upperBound))") .padding() } .foregroundStyle(.white) } .frame(width: TVSlider.width, height: TVSlider.height) .clipShape( RoundedRectangle(cornerRadius: 10) .size(CGSize(width: TVSlider.width, height: TVSlider.height)) ) Button("+") { guard self.$displayInterval.wrappedValue + step <= range.upperBound else { return } self.$displayInterval.wrappedValue += step } } .padding() } } @available(iOS 17.0, *) #Preview { @Previewable @State var interval: Double = 50.0 TVSlider( displayInterval: $interval, range: 0.0...200.0, step: 1.0 ) } ================================================ FILE: Airship/AirshipFeatureFlags/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleSignature</key> <string>????</string> </dict> </plist> ================================================ FILE: Airship/AirshipFeatureFlags/Source/AirshipFeatureFlagsSDKModule.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) public import AirshipCore #endif public import Foundation /// AirshipFeatureFlags module loader. /// @note For internal use only. :nodoc: @objc(UAFeatureFlagsSDKModule) public class AirshipFeatureFlagsSDKModule: NSObject, AirshipSDKModule { public let actionsManifest: (any ActionsManifest)? = nil public let components: [any AirshipComponent] public static func load(_ args: AirshiopModuleLoaderArgs) -> (any AirshipSDKModule)? { let manager = DefaultFeatureFlagManager( dataStore: args.dataStore, remoteDataAccess: FeatureFlagRemoteDataAccess(remoteData: args.remoteData), remoteData: args.remoteData, analytics: FeatureFlagAnalytics(airshipAnalytics: args.analytics), audienceChecker: args.audienceChecker, deferredResolver: FeatureFlagDeferredResolver( cache: args.cache, deferredResolver: args.deferredResolver ), privacyManager: args.privacyManager, resultCache: DefaultFeatureFlagResultCache(cache: args.cache) ) let component = FeatureFlagComponent(featureFlagManager: manager) return AirshipFeatureFlagsSDKModule(components: [component]) } init(components: [any AirshipComponent]) { self.components = components } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/DeferredFlagResolver.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol FeatureFlagDeferredResolverProtocol: Actor { func resolve( request: DeferredRequest, flagInfo: FeatureFlagInfo ) async throws -> DeferredFlagResponse } actor FeatureFlagDeferredResolver: FeatureFlagDeferredResolverProtocol { private static let minCacheTime: TimeInterval = 60.0 private static let defaultBackoff: TimeInterval = 30.0 private static let immediateBackoffRetryCutoff: TimeInterval = 5.0 private let cache: any AirshipCache private let deferredResolver: any AirshipDeferredResolverProtocol private let date: any AirshipDateProtocol private let taskSleeper: any AirshipTaskSleeper private var pendingTasks: [String: Task<DeferredFlagResponse, any Error>] = [:] private var backOffDates: [String: Date] = [:] init( cache: any AirshipCache, deferredResolver: any AirshipDeferredResolverProtocol, date: any AirshipDateProtocol = AirshipDate.shared, taskSleeper: any AirshipTaskSleeper = .shared ) { self.cache = cache self.deferredResolver = deferredResolver self.date = date self.taskSleeper = taskSleeper } func resolve( request: DeferredRequest, flagInfo: FeatureFlagInfo ) async throws -> DeferredFlagResponse { let requestID = [ flagInfo.name, flagInfo.id, "\(flagInfo.lastUpdated.timeIntervalSince1970)", request.contactID ?? "", request.url.absoluteString, ].joined(separator: ":") _ = try? await pendingTasks[requestID]?.value let task = Task { if let cached: DeferredFlagResponse = await self.cache.getCachedValue(key: requestID) { return cached } let result = try await self.fetchFlag( request: request, requestID: requestID, flagInfo: flagInfo, allowRetry: true ) var ttl: TimeInterval = FeatureFlagDeferredResolver.minCacheTime if let ttlMs = flagInfo.evaluationOptions?.ttlMS { let ttlSeconds = Double(ttlMs)/1000 ttl = max(ttl, ttlSeconds) } await self.cache.setCachedValue(result, key: requestID, ttl: ttl) return result } pendingTasks[requestID] = task return try await task.value } private func fetchFlag( request: DeferredRequest, requestID: String, flagInfo: FeatureFlagInfo, allowRetry: Bool ) 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 JSONDecoder().decode(DeferredFlag.self, from: data) } switch(result) { case .success(let flag): return .found(flag) case .notFound: return .notFound case .retriableError(let retryAfter, let statusCode): let backoff = retryAfter ?? FeatureFlagDeferredResolver.defaultBackoff guard allowRetry, backoff <= FeatureFlagDeferredResolver.immediateBackoffRetryCutoff else { backOffDates[requestID] = self.date.now.advanced(by: backoff) if let statusCode = statusCode { throw FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag. Status code: \(statusCode)") } throw FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag.") } if (backoff > 0) { AirshipLogger.debug(statusCode == nil ? "Backing off deferred flag request \(requestID) for \(backoff) seconds" : "Backing off deferred flag request \(requestID) for \(backoff) seconds with status code: \(statusCode!)") try await self.taskSleeper.sleep(timeInterval: backoff) } AirshipLogger.error(statusCode == nil ? "Retrying deferred flag request \(requestID)" : "Retrying deferred flag request \(requestID) with status code: \(statusCode!)") return try await self.fetchFlag( request: request, requestID: requestID, flagInfo: flagInfo, allowRetry: false ) case .outOfDate: throw FeatureFlagEvaluationError.outOfDate default: throw FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag.") } } } enum DeferredFlagResponse: Codable, Equatable { case notFound case found(DeferredFlag) } struct DeferredFlag: Codable, Equatable { let isEligible: Bool let variables: FeatureFlagVariables? let reportingMetadata: AirshipJSON enum CodingKeys: String, CodingKey { case isEligible = "is_eligible" case variables = "variables" case reportingMetadata = "reporting_metadata" } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlag.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Feature flag public struct FeatureFlag: Equatable, Sendable, Codable { /// The name of the flag public let name: String /// If the device is elegible or not for the flag. public var isEligible: Bool /// If the flag exists in the current flag listing or not public let exists: Bool /// Optional variables associated with the flag public var variables: AirshipJSON? var reportingInfo: ReportingInfo? init( name: String, isEligible: Bool, exists: Bool, variables: AirshipJSON? = nil, reportingInfo: ReportingInfo? = nil, supersededReportingMetadata: AirshipJSON? = nil ) { self.name = name self.isEligible = isEligible self.exists = exists self.variables = variables self.reportingInfo = reportingInfo } enum CodingKeys: String, CodingKey { case name case isEligible = "is_eligible" case exists case variables case reportingInfo = "_reporting_info" } struct ReportingInfo: Codable, Sendable, Equatable { // Reporting info var reportingMetadata: AirshipJSON var supersededReportingMetadata: [AirshipJSON]? // Evaluated contact ID let contactID: String? // Evaluated channel ID let channelID: String? init( reportingMetadata: AirshipJSON, supersededReportingMetadata: [AirshipJSON]? = nil, contactID: String? = nil, channelID: String? = nil) { self.reportingMetadata = reportingMetadata self.supersededReportingMetadata = supersededReportingMetadata self.contactID = contactID self.channelID = channelID } enum CodingKeys: String, CodingKey { case reportingMetadata = "reporting_metadata" case supersededReportingMetadata = "superseded_reporting_metadata" case contactID = "contact_id" case channelID = "channel_id" } mutating func addSuperseded(metadata: AirshipJSON?) { guard let metadata = metadata else { return } var mutable = supersededReportingMetadata ?? [] mutable.append(metadata) supersededReportingMetadata = mutable } } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagAnalytics.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol FeatureFlagAnalyticsProtocol: Sendable { func trackInteraction(flag: FeatureFlag) } final class FeatureFlagAnalytics: FeatureFlagAnalyticsProtocol { private let airshipAnalytics: any InternalAirshipAnalytics private enum FlagKeys { static let name = "flag_name" static let metadata = "reporting_metadata" static let supersededMetadata = "superseded_reporting_metadata" static let eligible = "eligible" static let device = "device" } private enum DeviceKeys { static let channelID = "channel_id" static let contactID = "contact_id" } init( airshipAnalytics: any InternalAirshipAnalytics ) { self.airshipAnalytics = airshipAnalytics } func trackInteraction(flag: FeatureFlag) { guard flag.exists else { return } guard let reportingInfo = flag.reportingInfo else { AirshipLogger.error("Missing reportingInfo, unable to track flag interaction \(flag)") return } let eventBody = AirshipJSON.makeObject{ object in object.set(string: flag.name, key: FlagKeys.name) object.set(json: reportingInfo.reportingMetadata, key: FlagKeys.metadata) object.set(bool: flag.isEligible, key: FlagKeys.eligible) if let superseded = reportingInfo.supersededReportingMetadata { object.set(json: .array(superseded), key: FlagKeys.supersededMetadata) } let device = AirshipJSON.makeObject { object in object.set(string: reportingInfo.channelID, key: DeviceKeys.channelID) object.set(string: reportingInfo.contactID, key: DeviceKeys.contactID) } if (device.object?.isEmpty != true) { object.set(json: device, key: FlagKeys.device) } } let airshipEvent = AirshipEvent( eventType: .featureFlagInteraction, eventData: eventBody ) airshipAnalytics.recordEvent(airshipEvent) } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagComponent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Actual airship component for FeatureFlags. Used to hide AirshipComponent methods. final class FeatureFlagComponent : AirshipComponent { final let featureFlagManager: DefaultFeatureFlagManager init(featureFlagManager: DefaultFeatureFlagManager) { self.featureFlagManager = featureFlagManager } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagManager.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif enum FeatureFlagEvaluationError: Error, Equatable { case outOfDate case connectionError(errorMessage: String) } /// Airship feature flag manager final class DefaultFeatureFlagManager: FeatureFlagManager { private let remoteDataAccess: any FeatureFlagRemoteDataAccessProtocol private let audienceChecker: any DeviceAudienceChecker private let analytics: any FeatureFlagAnalyticsProtocol private let deviceInfoProviderFactory: @Sendable () -> any AudienceDeviceInfoProvider private let deferredResolver: any FeatureFlagDeferredResolverProtocol private let remoteData: any RemoteDataProtocol private let privacyManager: any AirshipPrivacyManager let resultCache: any FeatureFlagResultCache var featureFlagStatusUpdates: AsyncStream<any Sendable> { get async { return await self.remoteData.statusUpdates(sources: [RemoteDataSource.app]) { statuses in return self.toFeatureFlagUpdateStatus(status: statuses.values.first ?? .upToDate) } } } var featureFlagStatus: FeatureFlagUpdateStatus { get async { return await self.toFeatureFlagUpdateStatus(status: self.remoteDataAccess.status) } } private var enabled: Bool { return self.privacyManager.isEnabled(.featureFlags) } init( dataStore: PreferenceDataStore, remoteDataAccess: any FeatureFlagRemoteDataAccessProtocol, remoteData: any RemoteDataProtocol, analytics: any FeatureFlagAnalyticsProtocol, audienceChecker: any DeviceAudienceChecker, deviceInfoProviderFactory: @escaping @Sendable () -> any AudienceDeviceInfoProvider = { CachingAudienceDeviceInfoProvider() }, deferredResolver: any FeatureFlagDeferredResolverProtocol, privacyManager: any AirshipPrivacyManager, resultCache: any FeatureFlagResultCache ) { self.remoteDataAccess = remoteDataAccess self.audienceChecker = audienceChecker self.analytics = analytics self.deviceInfoProviderFactory = deviceInfoProviderFactory self.deferredResolver = deferredResolver self.privacyManager = privacyManager self.resultCache = resultCache self.remoteData = remoteData } func trackInteraction(flag: FeatureFlag) { guard self.enabled else { AirshipLogger.warn("Feature flags disabled.") return } analytics.trackInteraction(flag: flag) } func flag(name: String) async throws -> FeatureFlag { try await self.flag(name: name, useResultCache: true) } func flag(name: String, useResultCache: Bool) async throws -> FeatureFlag { guard self.enabled else { throw AirshipErrors.error("Feature flags disabled.") } do { let flag = try await resolveFlag(name: name) if !flag.exists, useResultCache { if let fromCache = await resultCache.flag(name: name) { return fromCache } } return flag } catch { guard useResultCache, let fromCache = await resultCache.flag(name: name) else { throw error } return fromCache } } func waitRefresh(maxTime: TimeInterval) async { await self.remoteData.waitRefresh(source: RemoteDataSource.app, maxTime: maxTime) } func waitRefresh() async { await self.remoteData.waitRefresh(source: RemoteDataSource.app, maxTime: nil) } func resolveFlag(name: String) async throws -> FeatureFlag { let remoteDataFeatureFlagInfo = try await remoteDataFeatureFlagInfo(name: name) do { // Attempt to evaluate return try await self.evaluate( remoteDataFeatureFlagInfo: remoteDataFeatureFlagInfo ) } catch { // If it's not an outOfDate evaluation error, throw the error guard case FeatureFlagEvaluationError.outOfDate = error else { throw mapError(error) } // Notify out of date await self.remoteDataAccess.notifyOutdated( remoteDateInfo: remoteDataFeatureFlagInfo.remoteDataInfo ) // Best effort refresh again await remoteDataAccess.bestEffortRefresh() // Only continue if we actually have updated the status guard await remoteDataAccess.status == .upToDate else { throw mapError(error) } // Try one more time do { return try await self.evaluate( remoteDataFeatureFlagInfo: remoteDataFeatureFlagInfo ) } catch { throw mapError(error) } } } private func remoteDataFeatureFlagInfo( name: String ) async throws -> RemoteDataFeatureFlagInfo { switch(await remoteDataAccess.status) { case .upToDate: return await self.remoteDataAccess.remoteDataFlagInfo(name: name) case .stale, .outOfDate: let info = await self.remoteDataAccess.remoteDataFlagInfo(name: name) if info.disallowStale || info.flagInfos.isEmpty { await self.remoteDataAccess.bestEffortRefresh() let updatedStatus = await self.remoteDataAccess.status switch(updatedStatus) { case .upToDate: return await self.remoteDataAccess.remoteDataFlagInfo(name: name) case .outOfDate: throw FeatureFlagError.outOfDate case .stale: throw FeatureFlagError.staleData @unknown default: throw AirshipErrors.error("Unexpected state") } } else { return info } @unknown default: throw AirshipErrors.error("Unexpected state") } } private func mapError(_ error: any Error) -> any Error { return switch (error) { case FeatureFlagEvaluationError.connectionError(let errorMessage): FeatureFlagError.connectionError(errorMessage: errorMessage) case FeatureFlagEvaluationError.outOfDate: FeatureFlagError.outOfDate default: FeatureFlagError.failedToFetchData } } private func evaluate( remoteDataFeatureFlagInfo: RemoteDataFeatureFlagInfo ) async throws -> FeatureFlag { let name = remoteDataFeatureFlagInfo.name let flagInfos = remoteDataFeatureFlagInfo.flagInfos let deviceInfoProvider = deviceInfoProviderFactory() for (index, flagInfo) in flagInfos.enumerated() { let isLast = index == (flagInfos.count - 1) let isLocallyEligible = try await self.isLocallyEligible( flagInfo: flagInfo, deviceInfoProvider: deviceInfoProvider ) // We are not locally eligible and have other flags skip if !isLast, !isLocallyEligible { continue } let flag: FeatureFlag = switch (flagInfo.flagPayload) { case .deferredPayload(let deferredInfo): try await evaluateDeferred( flagInfo: flagInfo, isLocallyEligible: isLocallyEligible, deferredInfo: deferredInfo, deviceInfoProvider: deviceInfoProvider ) case .staticPayload(let staticInfo): try await evaluateStatic( flagInfo: flagInfo, isLocallyEligible: isLocallyEligible, staticInfo: staticInfo, deviceInfoProvider: deviceInfoProvider ) } /// If the flag is eligible or the last flag return if flag.isEligible || isLast { return try await self.flag(flag, applyingControlFrom: flagInfo, deviceInfoProvider: deviceInfoProvider) } } return FeatureFlag.makeNotFound(name: name) } private func flag( _ flag: FeatureFlag, applyingControlFrom info: FeatureFlagInfo, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> FeatureFlag { guard flag.isEligible, let control = info.controlOptins else { return flag } let isAudienceMatch = try await self.audienceChecker.evaluate( audienceSelector: control.compoundAudience?.selector, newUserEvaluationDate: info.created, deviceInfoProvider: deviceInfoProvider ) if !isAudienceMatch.isMatch { return flag } var result = flag switch control.controlType { case .flag: result.isEligible = false case .variables(let override): result.variables = override } guard var reportingInfo = flag.reportingInfo else { return result } reportingInfo.addSuperseded(metadata: reportingInfo.reportingMetadata) reportingInfo.reportingMetadata = control.reportingMetadata result.reportingInfo = reportingInfo return result } private func isLocallyEligible( flagInfo: FeatureFlagInfo, deviceInfoProvider: any AudienceDeviceInfoProvider ) async throws -> Bool { let result = try await self.audienceChecker.evaluate( audienceSelector: .combine( compoundSelector: flagInfo.compoundAudience?.selector, deviceSelector: flagInfo.audienceSelector ), newUserEvaluationDate: flagInfo.created, deviceInfoProvider: deviceInfoProvider ) return result.isMatch } private func evaluateDeferred( flagInfo: FeatureFlagInfo, isLocallyEligible: Bool, deferredInfo: FeatureFlagPayload.DeferredInfo, deviceInfoProvider: any 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: any 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: any AudienceDeviceInfoProvider ) async -> VariableResult? { guard let variables = variables, isEligible else { return nil } switch (variables) { case .fixed(let data): return VariableResult(data: data, reportingMetadata: nil) case .variant(let variants): for variant in variants { let result = try? await self.audienceChecker.evaluate( audienceSelector: .combine( compoundSelector: variant.compoundAudience?.selector, deviceSelector: variant.audienceSelector ), newUserEvaluationDate: flagInfo.created, deviceInfoProvider: deviceInfoProvider ) if (result?.isMatch != true) { continue } return VariableResult( data: variant.data, reportingMetadata: variant.reportingMetadata ) } return nil } } private func toFeatureFlagUpdateStatus(status: RemoteDataSourceStatus) -> FeatureFlagUpdateStatus { switch(status) { case .upToDate: return FeatureFlagUpdateStatus.upToDate case .stale: return FeatureFlagUpdateStatus.stale case .outOfDate: return FeatureFlagUpdateStatus.outOfDate @unknown default: return FeatureFlagUpdateStatus.upToDate } } } 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: any 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 ) ) } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagManagerProtocol.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Feature flag errors public enum FeatureFlagError: Error, Equatable { case failedToFetchData case staleData case outOfDate case connectionError(errorMessage: String) } /// Airship feature flag manager public protocol FeatureFlagManager: AnyObject, Sendable { /// Feature flag result cache. This can be used to return a cached result for `flag(name:useResultCache:)` /// if the flag fails to resolve or it does not exist. var resultCache: any FeatureFlagResultCache { get } /// Feature flag status updates. Possible values are upToDate, stale and outOfDate. var featureFlagStatusUpdates: AsyncStream<any Sendable> { get async } /// Current feature flag status. Possible values are upToDate, stale and outOfDate. var featureFlagStatus: FeatureFlagUpdateStatus { get async } /// Tracks a feature flag interaction event. /// - Parameter flag: The flag. func trackInteraction(flag: FeatureFlag) /// Gets and evaluates a feature flag. /// - Parameters /// - name: The flag name /// - useResultCache: `true` to use the `FeatureFlagResultCache` if the flag fails to resolve or if the resolved flag does not exist,`false` to ignore the result cache. /// - Returns: The feature flag. /// - Throws: Throws `FeatureFlagError` if the flag fails to resolve. func flag(name: String, useResultCache: Bool ) async throws -> FeatureFlag /// Gets and evaluates a feature flag using a result cache. /// - Parameters /// - name: The flag name /// - useResultCache: `true` to use the `FeatureFlagResultCache` if the flag fails to resolve or if the resolved flag does not exist,`false` to ignore the result cache. /// - Returns: The feature flag. /// - Throws: Throws `FeatureFlagError` if the flag fails to resolve. func flag(name: String) async throws -> FeatureFlag /// Waits for the refresh of the Feature Flag rules. func waitRefresh() async /// Waits for the refresh of the Feature Flag rules. /// - Parameters maxTime: Timeout in seconds. func waitRefresh(maxTime: TimeInterval) async } public extension Airship { /// The shared `FeatureFlagManager` instance. `Airship.takeOff` must be called before accessing this instance. static var featureFlagManager: any FeatureFlagManager { return Airship.requireComponent( ofType: FeatureFlagComponent.self ).featureFlagManager } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagPayload.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct FeatureFlagCompoundAudience: Codable, Sendable, Equatable { var selector: CompoundDeviceAudienceSelector init(selector: CompoundDeviceAudienceSelector) { self.selector = selector } } struct FeatureFlagInfo: Decodable, Equatable { /** * Unique id of the flag, a UUID */ let id: String /** * Date of the object's creation */ let created: Date /** * Date of the last update to the flag definition */ let lastUpdated: Date /** * The flag name */ let name: String /** * The flag name */ let reportingMetadata: AirshipJSON /** * Optional audience selector */ let audienceSelector: DeviceAudienceSelector? /// Optional compound audience let compoundAudience: FeatureFlagCompoundAudience? /** * Optional time span in which the flag should be active */ let timeCriteria: AirshipTimeCriteria? /** * Flag payload */ let flagPayload: FeatureFlagPayload /** * Evaluation options. */ let evaluationOptions: EvaluationOptions? /** * Control options */ let controlOptins: ControlOptions? private enum FeatureFlagObjectCodingKeys: String, CodingKey { case id = "flag_id" case created case lastUpdated = "last_updated" case flagPayload = "flag" } private enum FlagPayloadKeys: String, CodingKey { case type case reportingMetadata = "reporting_metadata" case audienceSelector = "audience_selector" case compoundAudience = "compound_audience" case timeCriteria = "time_criteria" case variables case evaluationOptions = "evaluation_options" case control case name } private enum CompoundAudienceKeys: String, CodingKey { case selector } init( id: String, created: Date, lastUpdated: Date, name: String, reportingMetadata: AirshipJSON, audienceSelector: DeviceAudienceSelector? = nil, compoundAudience: FeatureFlagCompoundAudience? = nil, timeCriteria: AirshipTimeCriteria? = nil, flagPayload: FeatureFlagPayload, evaluationOptions: EvaluationOptions? = nil, controlOptions: ControlOptions? = nil ) { self.id = id self.created = created self.lastUpdated = lastUpdated self.name = name self.reportingMetadata = reportingMetadata self.audienceSelector = audienceSelector self.compoundAudience = compoundAudience self.timeCriteria = timeCriteria self.flagPayload = flagPayload self.evaluationOptions = evaluationOptions self.controlOptins = controlOptions } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: FeatureFlagObjectCodingKeys.self) self.id = try container.decode(String.self, forKey: .id) guard let created = AirshipDateFormatter.date(fromISOString: try container.decode(String.self, forKey: .created)) else { throw DecodingError.typeMismatch( FeatureFlagInfo.self, DecodingError.Context( codingPath: container.codingPath, debugDescription: "Invalid created date string.", underlyingError: nil ) ) } self.created = created guard let lastUpdated = AirshipDateFormatter.date(fromISOString: try container.decode(String.self, forKey: .lastUpdated)) else { throw DecodingError.typeMismatch( FeatureFlagInfo.self, DecodingError.Context( codingPath: container.codingPath, debugDescription: "Invalid updated date string.", underlyingError: nil ) ) } self.lastUpdated = lastUpdated self.flagPayload = try container.decode(FeatureFlagPayload.self, forKey: .flagPayload) let payloadContainer = try container.nestedContainer(keyedBy: FlagPayloadKeys.self, forKey: .flagPayload) self.name = try payloadContainer.decode(String.self, forKey: .name) self.audienceSelector = try payloadContainer.decodeIfPresent(DeviceAudienceSelector.self, forKey: .audienceSelector) self.compoundAudience = try payloadContainer.decodeIfPresent(FeatureFlagCompoundAudience.self, forKey: .compoundAudience) self.timeCriteria = try payloadContainer.decodeIfPresent(AirshipTimeCriteria.self, forKey: .timeCriteria) self.reportingMetadata = try payloadContainer.decode(AirshipJSON.self, forKey: .reportingMetadata) self.evaluationOptions = try payloadContainer.decodeIfPresent(EvaluationOptions.self, forKey: .evaluationOptions) self.controlOptins = try payloadContainer.decodeIfPresent(ControlOptions.self, forKey: .control) } } struct EvaluationOptions: Decodable, Equatable { let disallowStaleValue: Bool? let ttlMS: UInt64? init(disallowStaleValue: Bool? = nil, ttlMS: UInt64? = nil) { self.disallowStaleValue = disallowStaleValue self.ttlMS = ttlMS } enum CodingKeys: String, CodingKey { case disallowStaleValue = "disallow_stale_value" case ttlMS = "ttl" } } enum FeatureFlagPayload: Decodable, Equatable { case staticPayload(StaticInfo) case deferredPayload(DeferredInfo) struct DeferredInfo: Decodable, Equatable { let deferred: Deferred } struct Deferred: Decodable, Equatable { let url: URL enum CodingKeys: String, CodingKey { case url } } struct StaticInfo: Decodable, Equatable { let variables: FeatureFlagVariables? } private enum CodingKeys: CodingKey { case type } private enum FeatureFlagPayloadType: String, Decodable { case staticType = "static" case deferredType = "deferred" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(FeatureFlagPayloadType.self, forKey: .type) let singleValueContainer = try decoder.singleValueContainer() switch type { case .staticType: self = .staticPayload( try singleValueContainer.decode(StaticInfo.self) ) case .deferredType: self = .deferredPayload( try singleValueContainer.decode(DeferredInfo.self) ) } } } enum FeatureFlagVariables: Codable, Equatable { case fixed(AirshipJSON?) case variant([VariablesVariant]) struct VariablesVariant: Codable, Equatable { let id: String let audienceSelector: DeviceAudienceSelector? let compoundAudience: FeatureFlagCompoundAudience? let reportingMetadata: AirshipJSON let data: AirshipJSON? enum CodingKeys: String, CodingKey { case id case audienceSelector = "audience_selector" case compoundAudience = "compound_audience" case reportingMetadata = "reporting_metadata" case data } init( id: String, audienceSelector: DeviceAudienceSelector? = nil, compoundAudience: FeatureFlagCompoundAudience? = nil, reportingMetadata: AirshipJSON, data: AirshipJSON? ) { self.id = id self.audienceSelector = audienceSelector self.compoundAudience = compoundAudience self.reportingMetadata = reportingMetadata self.data = data } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) self.audienceSelector = try container.decodeIfPresent(DeviceAudienceSelector.self, forKey: .audienceSelector) self.compoundAudience = try container.decodeIfPresent(FeatureFlagCompoundAudience.self, forKey: .compoundAudience) self.reportingMetadata = try container.decode(AirshipJSON.self, forKey: .reportingMetadata) self.data = try container.decodeIfPresent(AirshipJSON.self, forKey: .data) } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encodeIfPresent(audienceSelector, forKey: .audienceSelector) try container.encodeIfPresent(compoundAudience, forKey: .compoundAudience) try container.encode(reportingMetadata, forKey: .reportingMetadata) try container.encodeIfPresent(data, forKey: .data) } } private enum FeatureFlagVariableType: String, Codable { case fixed case variant } private enum CodingKeys: CodingKey { case type case variants case data } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(FeatureFlagVariableType.self, forKey: .type) switch type { case .fixed: self = .fixed( try container.decodeIfPresent(AirshipJSON.self, forKey: .data) ) case .variant: self = .variant( try container.decode([VariablesVariant].self, forKey: .variants) ) } } 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) } } } extension FeatureFlagInfo { var isDeferred: Bool { if case .deferredPayload(_) = self.flagPayload { return true } return false } } struct ControlOptions: Codable, Equatable { let compoundAudience: FeatureFlagCompoundAudience? let reportingMetadata: AirshipJSON let controlType: ControlType private enum CodingKeys: String, CodingKey { case compoundAudience = "compound_audience" case reportintMetadata = "reporting_metadata" } init( compoundAudience: FeatureFlagCompoundAudience?, reportingMetadata: AirshipJSON, controlType: ControlType ) { self.compoundAudience = compoundAudience self.reportingMetadata = reportingMetadata self.controlType = controlType } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.compoundAudience = try container.decodeIfPresent(FeatureFlagCompoundAudience.self, forKey: .compoundAudience) self.reportingMetadata = try container.decode(AirshipJSON.self, forKey: .reportintMetadata) self.controlType = try ControlType(from: decoder) } func encode(to encoder: any Encoder) throws { try controlType.encode(to: encoder) var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(compoundAudience, forKey: .compoundAudience) try container.encode(reportingMetadata, forKey: .reportintMetadata) } enum ControlType: Codable, Equatable { case flag case variables(AirshipJSON?) private enum CodingKeys: CodingKey { case type case data } enum OptionType: String, Codable { case flag case variables } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(OptionType.self, forKey: .type) switch type { case .flag: self = .flag case .variables: self = .variables( try container.decodeIfPresent(AirshipJSON.self, forKey: .data) ) } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .flag: try container.encode(OptionType.flag, forKey: .type) case .variables(let variables): try container.encode(OptionType.variables, forKey: .type) try container.encodeIfPresent(variables, forKey: .data) } } } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagResultCache.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Feature Flag result cache public protocol FeatureFlagResultCache: Actor { /// Caches a flag for the given cachTTL. /// - Parameters: /// - flag: The flag to cache /// - ttl: The time to cache the value for. func cache(flag: FeatureFlag, ttl: TimeInterval) async /// Gets a flag from the cache. /// - Parameters: /// - name: The flag name. /// - Returns: The flag if its in the cache, otherwise nil. func flag(name: String) async -> FeatureFlag? /// Removes a flag from the cache. /// - Parameters: /// - name: The flag name. func removeCachedFlag(name: String) async } actor DefaultFeatureFlagResultCache: FeatureFlagResultCache { private static let cacheKeyPrefix: String = "FeatureFlagResultCache:" private let airshipCache: any AirshipCache init(cache: any AirshipCache) { self.airshipCache = cache } func cache(flag: FeatureFlag, ttl: TimeInterval) async { guard let key = Self.makeKey(flag.name) else { return } await airshipCache.setCachedValue(flag, key: key, ttl: ttl) } func flag(name: String) async -> FeatureFlag? { guard let key = Self.makeKey(name) else { return nil } return await airshipCache.getCachedValue(key: key) } func removeCachedFlag(name: String) async { guard let key = Self.makeKey(name) else { return } return await airshipCache.deleteCachedValue(key: key) } private static func makeKey(_ name: String) -> String? { guard !name.isEmpty else { AirshipLogger.error("Flag cache key is empty.") return nil } return "\(cacheKeyPrefix)\(name)" } } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagUpdateStatus.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Feature flag status. Possible values are upToDate, stale and outOfDate. public enum FeatureFlagUpdateStatus: Sendable { case upToDate case stale case outOfDate } ================================================ FILE: Airship/AirshipFeatureFlags/Source/FeatureFlagsRemoteDataAccess.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol FeatureFlagRemoteDataAccessProtocol: Sendable { func remoteDataFlagInfo(name: String) async -> RemoteDataFeatureFlagInfo var status: RemoteDataSourceStatus { get async } func bestEffortRefresh() async func notifyOutdated(remoteDateInfo: RemoteDataInfo?) async } final class FeatureFlagRemoteDataAccess: FeatureFlagRemoteDataAccessProtocol { private let remoteData: any RemoteDataProtocol private let date: any AirshipDateProtocol init( remoteData: any RemoteDataProtocol, date: any AirshipDateProtocol = AirshipDate.shared ) { self.remoteData = remoteData self.date = date } var status: RemoteDataSourceStatus { get async { return await remoteData.status(source: RemoteDataSource.app) } } func bestEffortRefresh() async { await remoteData.waitRefresh(source: RemoteDataSource.app, maxTime: 15.0) } func notifyOutdated(remoteDateInfo: RemoteDataInfo?) async { if let remoteDateInfo = remoteDateInfo { await remoteData.notifyOutdated(remoteDataInfo: remoteDateInfo) } } func remoteDataFlagInfo(name: String) async -> RemoteDataFeatureFlagInfo { let appPayload: RemoteDataPayload? = await remoteData.payloads(types: ["feature_flags"]) .first { $0.remoteDataInfo?.source == .app } let parsedFlagInfo: [FeatureFlagInfo] = appPayload?.data.object?["feature_flags"]?.array?.compactMap { json in do { let flag: FeatureFlagInfo = try json.decode() return flag } catch { AirshipLogger.error("Unable to parse feature flag \(json), error: \(error)") return nil } } ?? [] let flagInfos: [FeatureFlagInfo] = parsedFlagInfo .filter { $0.name == name } .filter { $0.timeCriteria?.isActive(date: self.date.now) ?? true } return RemoteDataFeatureFlagInfo( name: name, flagInfos: flagInfos, remoteDataInfo: appPayload?.remoteDataInfo ) } } struct RemoteDataFeatureFlagInfo { let name: String let flagInfos: [FeatureFlagInfo] let remoteDataInfo: RemoteDataInfo? var disallowStale: Bool { return flagInfos.contains { flagInfo in flagInfo.evaluationOptions?.disallowStaleValue == true } } } ================================================ FILE: Airship/AirshipFeatureFlags/Tests/FeatureFlagAnalyticsTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipFeatureFlags @testable import AirshipCore final class FeatureFlagAnalyticsTest: XCTestCase { private let airshipAnalytics: TestAnalytics = TestAnalytics() private var analytics: FeatureFlagAnalytics! override func setUp() { self.analytics = FeatureFlagAnalytics(airshipAnalytics: airshipAnalytics) } func testTrackInteractionDoesNotExist() { let flag = FeatureFlag( name: "foo", isEligible: false, exists: false, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting two", contactID: "some contactID", channelID: "some channel ID" ) ) self.analytics.trackInteraction(flag: flag) XCTAssertEqual(0, self.airshipAnalytics.events.count) } func testTrackInteractionNoReportingInfo() { let flag = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: nil ) self.analytics.trackInteraction(flag: flag) XCTAssertEqual(0, self.airshipAnalytics.events.count) } func testTrackInteraction() throws { let flag = FeatureFlag( name: "some_flag", isEligible: true, exists: true, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reportingMetadata", contactID: "some_contact", channelID: "some_channel" ) ) let expectedBody = """ { "flag_name": "some_flag", "reporting_metadata": "reportingMetadata", "eligible": true, "device": { "channel_id": "some_channel", "contact_id": "some_contact" } } """ self.analytics.trackInteraction(flag: flag) XCTAssertEqual(1, self.airshipAnalytics.events.count) let event = self.airshipAnalytics.events.first! XCTAssertEqual("feature_flag_interaction", event.eventType.reportingName) XCTAssertEqual(AirshipEventPriority.normal, event.priority) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } func testTrackInteractionSupersede() throws { let flag = FeatureFlag( name: "some_flag", isEligible: true, exists: true, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reportingMetadata", supersededReportingMetadata: ["supersede"], contactID: "some_contact", channelID: "some_channel" ) ) let expectedBody = """ { "flag_name": "some_flag", "reporting_metadata": "reportingMetadata", "superseded_reporting_metadata": ["supersede"], "eligible": true, "device": { "channel_id": "some_channel", "contact_id": "some_contact" } } """ self.analytics.trackInteraction(flag: flag) XCTAssertEqual(1, self.airshipAnalytics.events.count) let event = self.airshipAnalytics.events.first! XCTAssertEqual("feature_flag_interaction", event.eventType.reportingName) XCTAssertEqual(AirshipEventPriority.normal, event.priority) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } func testTrackInteractionNoDeviceInfo() throws { let flag = FeatureFlag( name: "some_flag", isEligible: true, exists: true, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reportingMetadata" ) ) let expectedBody = """ { "flag_name": "some_flag", "reporting_metadata": "reportingMetadata", "eligible": true } """ self.analytics.trackInteraction(flag: flag) XCTAssertEqual(1, self.airshipAnalytics.events.count) let event = self.airshipAnalytics.events.first! XCTAssertEqual("feature_flag_interaction", event.eventType.reportingName) XCTAssertEqual(AirshipEventPriority.normal, event.priority) XCTAssertEqual(try AirshipJSON.from(json: expectedBody), event.eventData) } func testTrackInteractionEventFeed() async throws { let flag = FeatureFlag( name: "some_flag", isEligible: true, exists: true, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reportingMetadata" ) ) 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, .analytics(eventType: .featureFlagInteraction, body: event.eventData)) } } ================================================ FILE: Airship/AirshipFeatureFlags/Tests/FeatureFlagDeferredResolverTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipFeatureFlags import AirshipCore final class FeatureFlagDeferredResolverTest: XCTestCase { private let cache: TestCache = TestCache() private let deferredResolver: TestDeferredResolver = TestDeferredResolver() private let date: UATestDate = UATestDate(offset: 0, dateOverride: Date()) private let sleeper: TestTaskSleeper = TestTaskSleeper() private var resolver: FeatureFlagDeferredResolver! private let request = DeferredRequest( url: URL(string: "example://example")!, channelID: "some channel id", contactID: "some contact id", locale: Locale.current, notificationOptIn: true ) private let flagInfo = FeatureFlagInfo( id: "some-id", created: Date(), lastUpdated: Date(), name: "flag name", reportingMetadata: "reporting", flagPayload: .deferredPayload( .init( deferred: .init(url: URL(string: "example://example")!) ) ) ) override func setUpWithError() throws { resolver = FeatureFlagDeferredResolver( cache: cache, deferredResolver: deferredResolver, date: date, taskSleeper: sleeper ) } 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": false, "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) } let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) let expected = DeferredFlagResponse.found( DeferredFlag( isEligible: false, variables: nil, reportingMetadata: try! AirshipJSON.wrap(["reporting": "reporting"]) ) ) XCTAssertEqual(expected, result) await fulfillment(of: [expectation]) } 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": true, "variables": [ "type": "fixed", "data": [ "var": "one" ] ], "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) } let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) let expected = DeferredFlagResponse.found( DeferredFlag( isEligible: true, variables: .fixed(try! AirshipJSON.wrap(["var": "one"])), reportingMetadata: try! AirshipJSON.wrap(["reporting": "reporting"]) ) ) XCTAssertEqual(expected, result) await fulfillment(of: [expectation]) } func testResolveNotFound() async throws { let expectation = expectation(description: "flag resolved") self.deferredResolver.onData = { _ in expectation.fulfill() return .notFound } let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTAssertEqual(DeferredFlagResponse.notFound, result) await fulfillment(of: [expectation]) } func testResolveOutOfDate() async throws { let expectation = expectation(description: "flag resolved") self.deferredResolver.onData = { _ in expectation.fulfill() return .outOfDate } do { _ = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTFail() } catch { XCTAssertEqual(FeatureFlagEvaluationError.outOfDate, error as! FeatureFlagEvaluationError) } await fulfillment(of: [expectation]) } func testResolveTimedOut() async throws { let expectation = expectation(description: "flag resolved") self.deferredResolver.onData = { _ in expectation.fulfill() return .timedOut } do { _ = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTFail() } catch { XCTAssertEqual(FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag."), error as! FeatureFlagEvaluationError) } await fulfillment(of: [expectation]) } func testResolveConnectionErrorNoRetryAfter() async throws { let expectation = expectation(description: "flag resolved") self.deferredResolver.onData = { _ in expectation.fulfill() return .retriableError(statusCode: nil) } do { _ = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTFail() } catch { XCTAssertEqual(FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag."), error as! FeatureFlagEvaluationError) } await fulfillment(of: [expectation]) XCTAssertTrue(sleeper.sleeps.isEmpty) } func testResolveConnectionErrorShortRetryAfter() async throws { let expectation = expectation(description: "flag resolved") expectation.expectedFulfillmentCount = 2 self.deferredResolver.onData = { _ in expectation.fulfill() return .retriableError(retryAfter: 5) } do { _ = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTFail() } catch { XCTAssertEqual(FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag."), error as! FeatureFlagEvaluationError) } await fulfillment(of: [expectation]) XCTAssertEqual(sleeper.sleeps, [5]) } func testResolveConnectionErrorLongRetryAfter() async throws { let expecation = expectation(description: "flag resolved") self.deferredResolver.onData = { _ in expecation.fulfill() return .retriableError(retryAfter: 6) } do { _ = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTFail() } catch { XCTAssertEqual(FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag."), error as! FeatureFlagEvaluationError) } await fulfillment(of: [expecation]) XCTAssertEqual(sleeper.sleeps, []) self.date.offset += 1 self.deferredResolver.onData = { _ in XCTAssertEqual(self.sleeper.sleeps, [5]) return .notFound } let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTAssertEqual(DeferredFlagResponse.notFound, result) } func testCache() async throws { self.deferredResolver.onData = { _ in let data = try! AirshipJSON.wrap([ "is_eligible": true, "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) } let flag = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) let expectedKey = [ flagInfo.name, flagInfo.id, "\(flagInfo.lastUpdated.timeIntervalSince1970)", request.contactID ?? "", request.url.absoluteString, ].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(DeferredFlagResponse.self, from: entry.data), expectedValue ) XCTAssertEqual(entry.ttl, 60.0) self.deferredResolver.onData = { _ in return .notFound } let cached = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) XCTAssertEqual(cached, flag) } func testCacheTTL() async throws { self.deferredResolver.onData = { _ in let data = try! AirshipJSON.wrap([ "is_eligible": true, "reporting_metadata": ["reporting": "reporting"] ]).toData() return .success(data) } let flagInfo = FeatureFlagInfo( id: "some-id", created: Date(), lastUpdated: Date(), name: "flag name", reportingMetadata: "reporting", flagPayload: .deferredPayload( .init( deferred: .init(url: URL(string: "example://example")!) ) ), evaluationOptions: EvaluationOptions(ttlMS: 120000) ) let result = try await self.resolver.resolve( request: request, flagInfo: flagInfo ) let expectedKey = [ flagInfo.name, flagInfo.id, "\(flagInfo.lastUpdated.timeIntervalSince1970)", request.contactID ?? "", request.url.absoluteString, ].joined(separator: ":") let entry = await self.cache.entry(key: expectedKey)! XCTAssertEqual( try JSONDecoder().decode(DeferredFlagResponse.self, from: entry.data), result ) XCTAssertEqual(entry.ttl, 120.0) } } fileprivate final class TestDeferredResolver: AirshipDeferredResolverProtocol, @unchecked Sendable { var onData: ((DeferredRequest) async -> AirshipDeferredResult<Data>)? func resolve<T>( request: DeferredRequest, resultParser: @escaping @Sendable (Data) async throws -> T ) async -> AirshipDeferredResult<T> where T : Sendable { switch(await onData?(request) ?? .timedOut) { case .success(let data): do { let value = try await resultParser(data) return .success(value) } catch { return .retriableError() } case .timedOut: return .timedOut case .outOfDate: return .outOfDate case .notFound: return .notFound case .retriableError(retryAfter: let retryAfter, statusCode: let statusCode): return .retriableError(retryAfter: retryAfter, statusCode: statusCode) @unknown default: fatalError() } } } fileprivate final class TestTaskSleeper : AirshipTaskSleeper, @unchecked Sendable { var sleeps : [TimeInterval] = [] func sleep(timeInterval: TimeInterval) async throws { sleeps.append(timeInterval) } } ================================================ FILE: Airship/AirshipFeatureFlags/Tests/FeatureFlagInfoTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @testable import AirshipFeatureFlags final class FeatureFlagInfoTest: XCTestCase { func testDecode() throws { let json = """ { "flag_id": "27f26d85-0550-4df5-85f0-7022fa7a5925", "created": "2023-07-10T18:10:46.203", "last_updated": "2023-07-10T18:10:46.203", "platforms": [ "web" ], "flag": { "name": "cool_flag", "type": "static", "reporting_metadata": { "flag_id": "27f26d85-0550-4df5-85f0-7022fa7a5925" }, "audience_selector": { "app_version": { "value": { "version_matches": "1.6.0+" } }, "hash": { "audience_hash": { "hash_prefix": "27f26d85-0550-4df5-85f0-7022fa7a5925:", "num_hash_buckets": 16384, "hash_identifier": "contact", "hash_algorithm": "farm_hash" }, "audience_subset": { "min_hash_bucket": 0, "max_hash_bucket": 1637 } } }, "control": { "reporting_metadata": "superseded", "type": "flag" }, "variables": { "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: FeatureFlagInfo = try JSONDecoder().decode( FeatureFlagInfo.self, from: json.data(using: .utf8)! ) let expected = FeatureFlagInfo( id: "27f26d85-0550-4df5-85f0-7022fa7a5925", created: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, lastUpdated: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, name: "cool_flag", reportingMetadata: try! AirshipJSON.wrap(["flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925"]), audienceSelector: DeviceAudienceSelector( versionPredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: .matcherWithVersionConstraint("1.6.0+")! ) ), hashSelector: AudienceHashSelector( hash: .init( prefix: "27f26d85-0550-4df5-85f0-7022fa7a5925:", property: .contact, algorithm: .farm, seed: nil, numberOfBuckets: 16384, overrides: nil ), bucket: .init(min: 0, max: 1637) ) ), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .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) ) ), compoundAudience: nil, 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, compoundAudience: 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" ] ) ) ] ) ) ), controlOptions: ControlOptions( compoundAudience: nil, reportingMetadata: "superseded", controlType: .flag ) ) XCTAssertEqual(decoded, expected) } func testDecodeWithCompoundAudience() throws { let json = """ { "flag_id": "27f26d85-0550-4df5-85f0-7022fa7a5925", "created": "2023-07-10T18:10:46.203", "last_updated": "2023-07-10T18:10:46.203", "platforms": [ "web" ], "flag": { "name": "cool_flag", "type": "static", "reporting_metadata": { "flag_id": "27f26d85-0550-4df5-85f0-7022fa7a5925" }, "audience_selector": { "app_version": { "value": { "version_matches": "1.6.0+" } }, "hash": { "audience_hash": { "hash_prefix": "27f26d85-0550-4df5-85f0-7022fa7a5925:", "num_hash_buckets": 16384, "hash_identifier": "contact", "hash_algorithm": "farm_hash" }, "audience_subset": { "min_hash_bucket": 0, "max_hash_bucket": 1637 } } }, "compound_audience": { "selector": { "type": "atomic", "audience": { "new_user": true } } }, "control": { "audience_selector": { "app_version": { "value": { "version_matches": "1.6.0+" } } }, "reporting_metadata": "superseded", "type": "flag", "compound_audience": { "selector": { "type": "atomic", "audience": { "new_user": true } } } }, "variables": { "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 } } }, "compound_audience": { "selector": { "type": "atomic", "audience": { "new_user": true } } }, "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: FeatureFlagInfo = try JSONDecoder().decode( FeatureFlagInfo.self, from: json.data(using: .utf8)! ) let expected = FeatureFlagInfo( id: "27f26d85-0550-4df5-85f0-7022fa7a5925", created: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, lastUpdated: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, name: "cool_flag", reportingMetadata: try! AirshipJSON.wrap(["flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925"]), audienceSelector: DeviceAudienceSelector( versionPredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: .matcherWithVersionConstraint("1.6.0+")! ) ), hashSelector: AudienceHashSelector( hash: .init( prefix: "27f26d85-0550-4df5-85f0-7022fa7a5925:", property: .contact, algorithm: .farm, seed: nil, numberOfBuckets: 16384, overrides: nil ), bucket: .init(min: 0, max: 1637) ) ), compoundAudience: .init(selector: .atomic(DeviceAudienceSelector(newUser: true))), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .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) ) ), compoundAudience: .init(selector: .atomic(DeviceAudienceSelector(newUser: true))), 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) ) ), compoundAudience: nil, 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, compoundAudience: 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" ] ) ) ] ) ) ), controlOptions: ControlOptions( compoundAudience: .init(selector: .atomic(DeviceAudienceSelector(newUser: true))), reportingMetadata: "superseded", controlType: .flag ) ) XCTAssertEqual(decoded, expected) } func testDecodeControl() throws { let json = """ { "compound_audience":{ "selector":{ "type":"atomic", "audience":{ "app_version":{ "value":{ "version_matches":"1.6.0+" } } } } }, "reporting_metadata":"superseded", "type":"variables", "data":"variables_override" } """ let decoded = try JSONDecoder().decode( ControlOptions.self, from: json.data(using: .utf8)! ) let expected = ControlOptions( compoundAudience: FeatureFlagCompoundAudience( selector: .atomic( .init( versionPredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: .matcherWithVersionConstraint("1.6.0+")! ) ) ) ) ), reportingMetadata: "superseded", controlType: .variables(.string("variables_override")) ) XCTAssertEqual(expected, decoded) } } ================================================ FILE: Airship/AirshipFeatureFlags/Tests/FeatureFlagManagerTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @testable import AirshipFeatureFlags final class AirshipFeatureFlagsTest: XCTestCase { private let remoteDataAccess: TestFeatureFlagRemoteDataAccess = TestFeatureFlagRemoteDataAccess() private let remoteData: TestRemoteData = TestRemoteData() private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private let networkChecker: TestNetworkChecker = TestNetworkChecker() private let audienceChecker: TestAudienceChecker = TestAudienceChecker() private let analytics: TestFeatureFlagAnalytics = TestFeatureFlagAnalytics() private let deviceInfoProvider: TestDeviceInfoProvider = TestDeviceInfoProvider() private let deferredResolver: TestFeatureFlagResolver = TestFeatureFlagResolver() private var privacyManager: TestPrivacyManager! private let notificationCenter: AirshipNotificationCenter = AirshipNotificationCenter(notificationCenter: NotificationCenter()) private let resultCache: DefaultFeatureFlagResultCache = DefaultFeatureFlagResultCache(cache: TestCache()) private var featureFlagManager: DefaultFeatureFlagManager! override func setUp() async throws { let config: RuntimeConfig = .testConfig() self.privacyManager = TestPrivacyManager( dataStore: dataStore, config: config, defaultEnabledFeatures: .all, notificationCenter: notificationCenter ) self.featureFlagManager = DefaultFeatureFlagManager( dataStore: self.dataStore, remoteDataAccess: self.remoteDataAccess, remoteData: self.remoteData, analytics: self.analytics, audienceChecker: self.audienceChecker, deviceInfoProviderFactory: { self.deviceInfoProvider }, deferredResolver: self.deferredResolver, privacyManager: self.privacyManager, resultCache: self.resultCache ) } func testFlagAccessWaitsForRefreshIfOutOfDateAndStaleNotAllowed() async throws { let expectation = XCTestExpectation() self.remoteDataAccess.bestEffortRefresh = { expectation.fulfill() } self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ), evaluationOptions: EvaluationOptions(disallowStaleValue: true) ) ] self.remoteDataAccess.status = .outOfDate let _ = try? await featureFlagManager.flag(name: "foo") await self.fulfillment(of: [expectation]) } func testFlagAccessWaitsForRefreshIfFlagNotFound() async throws { let expectation = XCTestExpectation() self.remoteDataAccess.bestEffortRefresh = { expectation.fulfill() } self.remoteDataAccess.status = .outOfDate let _ = try? await featureFlagManager.flag(name: "foo") await self.fulfillment(of: [expectation]) } func testFlagAccessWaitsForRefreshIfStaleNotAllowed() async throws { let expectation = XCTestExpectation() self.remoteDataAccess.bestEffortRefresh = { self.remoteDataAccess.status = .upToDate expectation.fulfill() } self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ), evaluationOptions: EvaluationOptions(disallowStaleValue: true) ) ] self.remoteDataAccess.status = .stale let flag = try await featureFlagManager.flag(name: "foo") await self.fulfillment(of: [expectation]) XCTAssertTrue(flag.exists) } func testNoFlags() async throws { self.remoteDataAccess.status = .upToDate let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag(name: "foo", isEligible: false, exists: false, variables: nil) XCTAssertEqual(expected, flag) } func testFlagNoAudience() async throws { self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ) ) ] let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } func testFlagAudienceMatch() async throws { let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", audienceSelector: DeviceAudienceSelector(newUser: true), compoundAudience: .init(selector: .not(.atomic(DeviceAudienceSelector(newUser: false)))), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { selector, newUserDate, _ in XCTAssertEqual(selector, .combine(compoundSelector: flagInfo.compoundAudience?.selector, deviceSelector: flagInfo.audienceSelector)!) XCTAssertEqual(newUserDate, flagInfo.created) return .match } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } func testFlagAudienceNoMatch() async throws { let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { _, _, _ in return .miss } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", 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: "reporting 1", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ) ), FeatureFlagInfo( id: "some other ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting 2", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(.string("some variables")) ) ) ), ] self.audienceChecker.onEvaluate = { _, _, _ in return .miss } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "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: "reporting 1", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ) ), FeatureFlagInfo( id: "some other ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting 2", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ), ] self.audienceChecker.onEvaluate = { _, _, _ in return .miss } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting 2", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } func testMultipleFlags() async throws { let flagInfo1 = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ) ) let flagInfo2 = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", audienceSelector: DeviceAudienceSelector(newUser: false), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(AirshipJSON.string("flagInfo2 variables")) ) ) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo1, flagInfo2 ] self.audienceChecker.onEvaluate = { selector, _, _ in return if selector == .atomic(flagInfo2.audienceSelector!) { .match } else { .miss } } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: AirshipJSON.string("flagInfo2 variables"), reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } func testMultipleFlagsCompound() async throws { let flagInfo1 = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", audienceSelector: nil, compoundAudience: .init(selector: .atomic(DeviceAudienceSelector(newUser: true))), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo(variables: nil) ) ) let flagInfo2 = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", audienceSelector: DeviceAudienceSelector(newUser: false), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(AirshipJSON.string("flagInfo2 variables")) ) ) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo1, flagInfo2 ] self.audienceChecker.onEvaluate = { selector, _, _ in return if selector == flagInfo1.compoundAudience?.selector { .match } else { .miss } } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } func testVariantVariables() 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: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .variant(variables) ) ) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo, ] self.audienceChecker.onEvaluate = { selector, _, _ in // match second variant return if selector == .atomic(variables[1].audienceSelector!) { .match } else { .miss } } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: variables[1].data, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: variables[1].reportingMetadata, contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } func testControlFlag() async throws { let controlAudience = DeviceAudienceSelector( versionPredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: .matcherWithVersionConstraint("1.6.0+")! ) ) ) let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .variant([]) ) ), controlOptions: .init( compoundAudience: .init(selector: .atomic(controlAudience)), reportingMetadata: "supersede", controlType: .flag) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo, ] var audienceMatched = false self.audienceChecker.onEvaluate = { selector, _, _ in return if selector == .atomic(controlAudience), audienceMatched { .match } else { .miss } } let noControlFlag = try await featureFlagManager.flag(name: "foo") var expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, noControlFlag) audienceMatched = true let controlFlag = try await featureFlagManager.flag(name: "foo") expected = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "supersede", supersededReportingMetadata: ["reporting"], contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, controlFlag) } func testControlVariables() async throws { let controlAudience = DeviceAudienceSelector( versionPredicate: JSONPredicate( jsonMatcher: JSONMatcher( valueMatcher: .matcherWithVersionConstraint("1.6.0+")! ) ) ) let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .variant([]) ) ), controlOptions: .init( compoundAudience: .init(selector: .atomic(controlAudience)), reportingMetadata: "supersede", controlType: .variables("variables-overrides")) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo, ] var audienceMatched = false self.audienceChecker.onEvaluate = { selector, _, _ in return if selector == .atomic(controlAudience), audienceMatched { .match } else { .miss } } let noControlFlag = try await featureFlagManager.flag(name: "foo") var expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, noControlFlag) audienceMatched = true let controlFlag = try await featureFlagManager.flag(name: "foo") expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: "variables-overrides", reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "supersede", supersededReportingMetadata: ["reporting"], contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, controlFlag) } 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: "reporting", flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) let deferredResponse = DeferredFlagResponse.found( DeferredFlag(isEligible: true, variables: .variant(variables), reportingMetadata: "reporting two") ) let expectedFlag = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: variables[1].data, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "Variant reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { selector, _, _ in return if selector == .atomic(variables[1].audienceSelector!) { .match } else { .miss } } 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: "reporting", flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) let deferredResponse = DeferredFlagResponse.found( DeferredFlag(isEligible: false, variables: .variant(variables), reportingMetadata: "reporting two") ) let expectedFlag = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting two", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { selector, _, _ in return if selector == .atomic(variables[1].audienceSelector!) { .match } else { .miss } } 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] = [ 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: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .variant(variables) ) ) ) self.remoteDataAccess.status = .upToDate self.remoteDataAccess.flagInfos = [ flagInfo, ] self.audienceChecker.onEvaluate = { _, _, _ in return .miss } let flag = try await featureFlagManager.flag(name: "foo") let expected = FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: flagInfo.reportingMetadata, contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) XCTAssertEqual(expected, flag) } func testStaleNotDefined() async throws { self.remoteDataAccess.status = .stale self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ) ) ] let flag = try await featureFlagManager.flag(name: "foo") XCTAssertEqual( flag, FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) ) } func testStaleAllowed() async throws { self.remoteDataAccess.status = .stale self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ), evaluationOptions: EvaluationOptions(disallowStaleValue: false) ) ] let flag = try await featureFlagManager.flag(name: "foo") XCTAssertEqual( flag, FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) ) } func testStaleNotAllowed() async throws { self.remoteDataAccess.status = .stale self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ), evaluationOptions: EvaluationOptions(disallowStaleValue: true) ) ] do { let _ = try await featureFlagManager.flag(name: "foo") XCTFail("Should throw") } catch FeatureFlagError.staleData { // No-op } catch { XCTFail("Should throw staleData") } } func testStaleNotAllowedMultipleFlags() async throws { self.remoteDataAccess.status = .stale // If one flag does not allow we ignore all self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ), evaluationOptions: EvaluationOptions(disallowStaleValue: false) ), FeatureFlagInfo( id: "some other ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ), evaluationOptions: EvaluationOptions(disallowStaleValue: true) ) ] do { let _ = try await featureFlagManager.flag(name: "foo") XCTFail("Should throw") }catch FeatureFlagError.staleData { // No-op } catch { XCTFail("Should throw staleData") } } func testStaleAllowedOutOfDate() async throws { self.remoteDataAccess.status = .outOfDate self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ), evaluationOptions: EvaluationOptions(disallowStaleValue: false) ) ] let flag = try await featureFlagManager.flag(name: "foo") XCTAssertEqual( flag, FeatureFlag( name: "foo", isEligible: true, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) ) } func testOutOfDate() async throws { self.remoteDataAccess.status = .outOfDate self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ), evaluationOptions: EvaluationOptions(disallowStaleValue: false) ), FeatureFlagInfo( id: "some other ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting", flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ), evaluationOptions: EvaluationOptions(disallowStaleValue: true) ) ] do { let _ = try await featureFlagManager.flag(name: "foo") XCTFail("Should throw") } catch FeatureFlagError.outOfDate { // No-op } catch { XCTFail("Should throw outOfDate") } } func testMultipleFlagsNotEligible() async throws { self.audienceChecker.onEvaluate = { _, _, _ in return .miss } self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ) ), FeatureFlagInfo( id: "some other ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting two", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ) ) ] let flag = try await featureFlagManager.flag(name: "foo") XCTAssertEqual( flag, FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting two", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) ) } func testTrackInteractive() async throws { self.audienceChecker.onEvaluate = { _, _, _ in return .miss } self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ) ), FeatureFlagInfo( id: "some other ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting two", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: .fixed(nil) ) ) ) ] let flag = try await featureFlagManager.flag(name: "foo") XCTAssertEqual( flag, FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting two", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) ) } func testTrackInteraction() { let flag = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting two", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) self.featureFlagManager.trackInteraction(flag: flag) XCTAssertEqual(self.analytics.trackedInteractions, [flag]) } func testDeferred() async throws { let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) let deferredResponse = DeferredFlagResponse.found( DeferredFlag(isEligible: false, variables: nil, reportingMetadata: "reporting two") ) let expectedFlag = FeatureFlag( name: "foo", isEligible: false, exists: true, variables: nil, reportingInfo: FeatureFlag.ReportingInfo( reportingMetadata: "reporting two", contactID: self.deviceInfoProvider.stableContactInfo.contactID, channelID: self.deviceInfoProvider.channelID ) ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { _, _, _ in return .match } await self.deferredResolver.setOnResolve { [deviceInfoProvider] request, info in XCTAssertEqual(request.url, URL(string: "some-url://")) 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 deferredResponse } let result = try await featureFlagManager.flag(name: "foo") XCTAssertEqual(result, expectedFlag) } func testDeferredLocalAudience() async throws { let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { _, _, _ in return .miss } await self.deferredResolver.setOnResolve { _, _ in XCTFail() throw AirshipErrors.error("Failed") } let result = try await featureFlagManager.flag(name: "foo") XCTAssertFalse(result.isEligible) } func testMultipleDeferred() async throws { self.remoteDataAccess.flagInfos = [ FeatureFlagInfo( id: "one", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "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: "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: "reporting three", flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) ] self.audienceChecker.onEvaluate = { selector, _, _ in if selector == .atomic(DeviceAudienceSelector(newUser: true)) { return .match } else { return .miss } } 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: "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( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) self.remoteDataAccess.remoteDataInfo = RemoteDataInfo( url: URL(string: "some://remote-data")!, lastModifiedTime: "last modified", source: .app ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { _, _, _ in return .match } await self.deferredResolver.setOnResolve { _, _ in throw FeatureFlagEvaluationError.outOfDate } do { _ = try await featureFlagManager.flag(name: "foo") } catch { XCTAssertEqual(error as! FeatureFlagError, FeatureFlagError.outOfDate) } XCTAssertEqual(remoteDataAccess.lastOutdatedRemoteInfo, self.remoteDataAccess.remoteDataInfo) } func testDeferredConnectionIssue() async throws { let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) self.remoteDataAccess.remoteDataInfo = RemoteDataInfo( url: URL(string: "some://remote-data")!, lastModifiedTime: "last modified", source: .app ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { _, _, _ in return .miss } await self.deferredResolver.setOnResolve { _, _ in throw FeatureFlagEvaluationError.connectionError(errorMessage: "Failed to resolve flag.") } do { _ = try await featureFlagManager.flag(name: "foo") } catch { XCTAssertEqual(error as! FeatureFlagError, FeatureFlagError.connectionError(errorMessage: "Failed to resolve flag.")) } XCTAssertNil(remoteDataAccess.lastOutdatedRemoteInfo) } func testDeferredOtherError() async throws { let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) self.remoteDataAccess.remoteDataInfo = RemoteDataInfo( url: URL(string: "some://remote-data")!, lastModifiedTime: "last modified", source: .app ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { _, _, _ in return .match } await self.deferredResolver.setOnResolve { _, _ in throw AirshipErrors.error("other!") } do { _ = try await featureFlagManager.flag(name: "foo") } catch { XCTAssertEqual(error as! FeatureFlagError, FeatureFlagError.failedToFetchData) } XCTAssertNil(remoteDataAccess.lastOutdatedRemoteInfo) } func testResultCacheFlagDoesNotExist() async throws { let cachedValue = FeatureFlag( name: "does-not-exist", isEligible: true, exists: false ) await self.deferredResolver.setOnResolve { _, _ in throw AirshipErrors.error("other!") } await featureFlagManager.resultCache.cache(flag: cachedValue, ttl: .infinity) let flag = try await featureFlagManager.flag(name: "does-not-exist") let flagNoCache = try await featureFlagManager.flag(name: "does-not-exist", useResultCache: false) XCTAssertEqual(flag, cachedValue) XCTAssertNotEqual(flagNoCache, cachedValue) } func testResultCacheThrows() async throws { let cachedValue = FeatureFlag( name: "foo", isEligible: true, exists: true ) await featureFlagManager.resultCache.cache(flag: cachedValue, ttl: .infinity) let flagInfo = FeatureFlagInfo( id: "some ID", created: Date(), lastUpdated: Date(), name: "foo", reportingMetadata: "reporting one", audienceSelector: DeviceAudienceSelector(newUser: true), flagPayload: .deferredPayload( FeatureFlagPayload.DeferredInfo( deferred: .init(url: URL(string: "some-url://")!) ) ) ) self.remoteDataAccess.flagInfos = [ flagInfo ] self.audienceChecker.onEvaluate = { _, _, _ in return .match } await self.deferredResolver.setOnResolve { _, _ in throw AirshipErrors.error("other!") } let flag = try await featureFlagManager.flag(name: "foo") do { _ = try await featureFlagManager.flag(name: "foo", useResultCache: false) XCTFail() } catch {} XCTAssertEqual(flag, cachedValue) } } final class TestFeatureFlagRemoteDataAccess: FeatureFlagRemoteDataAccessProtocol, @unchecked Sendable { var lastOutdatedRemoteInfo: RemoteDataInfo? func remoteDataFlagInfo(name: String) async -> RemoteDataFeatureFlagInfo { let flags = flagInfos.filter { info in info.name == name } return RemoteDataFeatureFlagInfo(name: name, flagInfos: flags, remoteDataInfo: self.remoteDataInfo) } func notifyOutdated(remoteDateInfo: RemoteDataInfo?) async { lastOutdatedRemoteInfo = remoteDataInfo; } var bestEffortRefresh: (() -> Void)? func bestEffortRefresh() async { self.bestEffortRefresh?() } var status: RemoteDataSourceStatus = .upToDate var flagInfos: [FeatureFlagInfo] = [] var remoteDataInfo: RemoteDataInfo? } final class TestFeatureFlagAnalytics: FeatureFlagAnalyticsProtocol, @unchecked Sendable { func trackInteraction(flag: FeatureFlag) { trackedInteractions.append(flag) } var trackedInteractions: [FeatureFlag] = [] } final class TestDeviceInfoProvider: AudienceDeviceInfoProvider, @unchecked Sendable { var sdkVersion: String = "1.0.0" var isAirshipReady: Bool = false var tags: Set<String> = Set() var isChannelCreated: Bool = true var channelID: String = UUID().uuidString var locale: Locale = Locale.current var appVersion: String? var permissions: [AirshipCore.AirshipPermission : AirshipCore.AirshipPermissionStatus] = [:] var isUserOptedInPushNotifications: Bool = false var analyticsEnabled: Bool = false var installDate: Date = Date() var stableContactInfo: StableContactInfo = StableContactInfo(contactID: UUID().uuidString) } final actor TestFeatureFlagResolver: FeatureFlagDeferredResolverProtocol { var resolvedFlagInfos: [FeatureFlagInfo] = [] var onResolve: ((DeferredRequest, FeatureFlagInfo) async throws -> DeferredFlagResponse)? func setOnResolve(onResolve: @escaping @Sendable (DeferredRequest, FeatureFlagInfo) async throws -> DeferredFlagResponse) { self.onResolve = onResolve } func resolve(request: DeferredRequest, flagInfo: FeatureFlagInfo) async throws -> DeferredFlagResponse { resolvedFlagInfos.append(flagInfo) return try await self.onResolve!(request, flagInfo) } } ================================================ FILE: Airship/AirshipFeatureFlags/Tests/FeatureFlagRemoteDataAccessTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @testable import AirshipFeatureFlags final class FeatureFlagRemoteDataAccessTest: XCTestCase { private let remoteData: TestRemoteData = TestRemoteData() private let date: UATestDate = UATestDate(offset: 0, dateOverride: Date()) private var remoteDataAccess: FeatureFlagRemoteDataAccess! override func setUp() { self.remoteDataAccess = FeatureFlagRemoteDataAccess( remoteData: self.remoteData, date: date ) } func testBestEffortRefresh() async throws { let expectation = XCTestExpectation() self.remoteData.waitForRefreshBlock = { source, time in XCTAssertEqual(source, .app) XCTAssertEqual(time, 15.0) expectation.fulfill() } await self.remoteDataAccess.bestEffortRefresh() await self.fulfillment(of: [expectation]) } func testFeatureFlags() async throws { let json = """ { "feature_flags":[ { "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925", "created":"2023-07-10T18:10:46.203", "last_updated":"2023-07-10T18:10:46.203", "platforms":[ "web" ], "flag":{ "name":"cool_flag", "type":"static", "reporting_metadata":{ "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925" } } } ] } """ self.remoteData.payloads = [ RemoteDataPayload( type: "feature_flags", timestamp: Date(), data: try! AirshipJSON.from(json: json), remoteDataInfo: RemoteDataInfo( url: URL(string: "some:url")!, lastModifiedTime: nil, source: .app ) ) ] let remoteDataInfo = await self.remoteDataAccess.remoteDataFlagInfo(name: "cool_flag") let expected: [FeatureFlagInfo] = [ FeatureFlagInfo( id: "27f26d85-0550-4df5-85f0-7022fa7a5925", created: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, lastUpdated: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, name: "cool_flag", reportingMetadata: try! AirshipJSON.wrap(["flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925"]), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: nil ) ) ) ] XCTAssertEqual(remoteDataInfo.flagInfos, expected) XCTAssertEqual(remoteDataInfo.remoteDataInfo, self.remoteData.payloads.first?.remoteDataInfo) } func testFeatureFlagsIgnoreInvalid() async throws { let json = """ { "feature_flags":[ { "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925", "created":"2023-07-10T18:10:46.203", "last_updated":"2023-07-10T18:10:46.203", "platforms":[ "web" ], "flag":{ "name":"cool_flag", "type":"static", "reporting_metadata":{ "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925" } } }, { "something": "invalid" } ] } """ self.remoteData.payloads = [ RemoteDataPayload( type: "feature_flags", timestamp: Date(), data: try! AirshipJSON.from(json: json), remoteDataInfo: RemoteDataInfo( url: URL(string: "some:url")!, lastModifiedTime: nil, source: .app ) ) ] let flagInfos = await self.remoteDataAccess.remoteDataFlagInfo(name: "cool_flag").flagInfos let expected: [FeatureFlagInfo] = [ FeatureFlagInfo( id: "27f26d85-0550-4df5-85f0-7022fa7a5925", created: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, lastUpdated: AirshipDateFormatter.date(fromISOString: "2023-07-10T18:10:46.203")!, name: "cool_flag", reportingMetadata: try! AirshipJSON.wrap(["flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925"]), flagPayload: .staticPayload( FeatureFlagPayload.StaticInfo( variables: nil ) ) ) ] XCTAssertEqual(flagInfos, expected) } func testFeatureFlagsIgnoreContact() async throws { let json = """ { "feature_flags":[ { "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925", "created":"2023-07-10T18:10:46.203", "last_updated":"2023-07-10T18:10:46.203", "platforms":[ "web" ], "flag":{ "name":"cool_flag", "type":"static", "reporting_metadata":{ "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925" } } } ] } """ self.remoteData.payloads = [ RemoteDataPayload( type: "feature_flags", timestamp: Date(), data: try! AirshipJSON.from(json: json), remoteDataInfo: RemoteDataInfo( url: URL(string: "some:url")!, lastModifiedTime: nil, source: .contact ) ) ] let flagInfos = await self.remoteDataAccess.remoteDataFlagInfo(name: "cool_flag").flagInfos XCTAssertTrue(flagInfos.isEmpty) } func testFeatureFlagsIgnoreInActive() async throws { let nowMs = self.date.now.millisecondsSince1970 let json = """ { "feature_flags":[ { "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925", "created":"2023-07-10T18:10:46.203", "last_updated":"2023-07-10T18:10:46.203", "flag":{ "name":"cool_flag", "type":"static", "reporting_metadata":{ "flag_id":"27f26d85-0550-4df5-85f0-7022fa7a5925" }, "time_criteria": { "start_timestamp": \(nowMs), "end_timestamp": \(nowMs + 5000) } }, } ] } """ self.remoteData.payloads = [ RemoteDataPayload( type: "feature_flags", timestamp: Date(), data: try! AirshipJSON.from(json: json), remoteDataInfo: RemoteDataInfo( url: URL(string: "some:url")!, lastModifiedTime: nil, source: .app ) ) ] var flagInfos = await self.remoteDataAccess.remoteDataFlagInfo(name: "cool_flag").flagInfos XCTAssertFalse(flagInfos.isEmpty) self.date.offset = 4.9 flagInfos = await self.remoteDataAccess.remoteDataFlagInfo(name: "cool_flag").flagInfos XCTAssertFalse(flagInfos.isEmpty) self.date.offset = 5.0 flagInfos = await self.remoteDataAccess.remoteDataFlagInfo(name: "cool_flag").flagInfos XCTAssertTrue(flagInfos.isEmpty) } } ================================================ FILE: Airship/AirshipFeatureFlags/Tests/FeatureFlagResultCacheTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @testable import AirshipFeatureFlags final class FeatureFlagResultCacheTest: XCTestCase { private let airshipCache: TestCache = TestCache() private var resultCache: DefaultFeatureFlagResultCache! override func setUp() { self.resultCache = DefaultFeatureFlagResultCache(cache: self.airshipCache) } public func testSet() async { let flag = FeatureFlag(name: UUID().uuidString, isEligible: true, exists: true) await resultCache.cache(flag: flag, ttl: 100) let entry = await airshipCache.entry(key: "FeatureFlagResultCache:\(flag.name)")! XCTAssertEqual( try JSONDecoder().decode(FeatureFlag.self, from: entry.data), flag ) XCTAssertEqual(entry.ttl, 100) } public func testUpdate() async { var flag = FeatureFlag(name: UUID().uuidString, isEligible: true, exists: true) await resultCache.cache(flag: flag, ttl: 100) flag.isEligible = false await resultCache.cache(flag: flag, ttl: 99) let entry = await airshipCache.entry(key: "FeatureFlagResultCache:\(flag.name)")! XCTAssertEqual( try JSONDecoder().decode(FeatureFlag.self, from: entry.data), flag ) XCTAssertEqual(entry.ttl, 99) } public func testDeleteDoesNotExist() async { await resultCache.removeCachedFlag(name: "does not exist") } public func testDelete() async { let flag = FeatureFlag(name: UUID().uuidString, isEligible: true, exists: true) await resultCache.cache(flag: flag, ttl: 100) var entry = await airshipCache.entry(key: "FeatureFlagResultCache:\(flag.name)") XCTAssertNotNil(entry) await resultCache.removeCachedFlag(name: flag.name) entry = await airshipCache.entry(key: "FeatureFlagResultCache:\(flag.name)") XCTAssertNil(entry) } } ================================================ FILE: Airship/AirshipFeatureFlags/Tests/FeatureFlagVariablesTest.swift ================================================ /* 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 } } }, "compound_audience": { "selector": { "type": "atomic", "audience": { "new_user": true } } }, "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) ) ), compoundAudience: .init(selector: .atomic(DeviceAudienceSelector(newUser: true))), 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) ) ), compoundAudience: nil, 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, compoundAudience: 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)) } } ================================================ FILE: Airship/AirshipMessageCenter/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>en</string> <key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundlePackageType</key> <string>FMWK</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleSignature</key> <string>????</string> <key>CFBundleVersion</key> <string>$(CURRENT_PROJECT_VERSION)</string> <key>NSPrincipalClass</key> <string></string> </dict> </plist> ================================================ FILE: Airship/AirshipMessageCenter/Resources/TestAssets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Airship/AirshipMessageCenter/Resources/TestAssets.xcassets/testNamedColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.500", "green" : "0.500", "red" : "0.500" } }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "light" } ], "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "1.000", "green" : "1.000", "red" : "1.000" } }, "idiom" : "universal" }, { "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0.000", "green" : "0.000", "red" : "0.000" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/.xccurrentversion ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>_XCCurrentVersionName</key> <string>UAInbox 4.xcdatamodel</string> </dict> </plist> ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 2.xcdatamodel/contents ================================================ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23A344" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="UAInboxMessage" representedClassName="UAInboxMessageData" syncable="YES"> <attribute name="deletedClient" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="extra" optional="YES" attributeType="Transformable" valueTransformerName="UAJSONValueTransformer"/> <attribute name="messageBodyURL" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageID" optional="YES" attributeType="String"/> <attribute name="messageReporting" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageSent" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageURL" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="rawMessageObject" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="title" optional="YES" attributeType="String"/> <attribute name="unread" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> <attribute name="unreadClient" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> </entity> </model> ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 3.xcdatamodel/contents ================================================ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23D56" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="UAInboxMessage" representedClassName="UAInboxMessageData" syncable="YES"> <attribute name="deletedClient" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="extra" optional="YES" attributeType="Binary" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageBodyURL" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageID" optional="YES" attributeType="String"/> <attribute name="messageReporting" optional="YES" attributeType="Binary" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageSent" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageURL" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="rawMessageObject" optional="YES" attributeType="Binary" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="title" optional="YES" attributeType="String"/> <attribute name="unread" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> <attribute name="unreadClient" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> </entity> </model> ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 4.xcdatamodel/contents ================================================ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24299" systemVersion="24G419" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="UAInboxMessage" representedClassName="UAInboxMessageData" syncable="YES"> <attribute name="associatedData" optional="YES" attributeType="Binary"/> <attribute name="contentType" optional="YES" attributeType="String"/> <attribute name="deletedClient" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="extra" optional="YES" attributeType="Binary" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageBodyURL" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageID" optional="YES" attributeType="String"/> <attribute name="messageReporting" optional="YES" attributeType="Binary" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="messageSent" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageURL" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="rawMessageObject" optional="YES" attributeType="Binary" valueTransformerName="NSSecureUnarchiveFromDataTransformer"/> <attribute name="title" optional="YES" attributeType="String"/> <attribute name="unread" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> <attribute name="unreadClient" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> </entity> </model> ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox.xcdatamodel/contents ================================================ <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17192" systemVersion="19H2" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <entity name="UAInboxMessage" representedClassName="UAInboxMessageData" syncable="YES"> <attribute name="deletedClient" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="extra" optional="YES" attributeType="Transformable" valueTransformerName="UAJSONValueTransformer"/> <attribute name="messageBodyURL" optional="YES" attributeType="Transformable" valueTransformerName="UANSURLValueTransformer"/> <attribute name="messageExpiration" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageID" optional="YES" attributeType="String"/> <attribute name="messageSent" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="messageURL" optional="YES" attributeType="Transformable" valueTransformerName="UANSURLValueTransformer"/> <attribute name="rawMessageObject" optional="YES" attributeType="Transformable" valueTransformerName="UANSDictionaryValueTransformer"/> <attribute name="title" optional="YES" attributeType="String"/> <attribute name="unread" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> <attribute name="unreadClient" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/> </entity> <elements> <element name="UAInboxMessage" positionX="-63" positionY="-18" width="128" height="208"/> </elements> </model> ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInboxDataMappingV1toV4.xcmappingmodel/xcmapping.xml ================================================ <?xml version="1.0" standalone="yes"?> <!DOCTYPE database SYSTEM "file:///System/Library/DTDs/CoreData.dtd"> <database> <databaseInfo> <version>134481920</version> <UUID>746F2920-DF24-481B-B805-EFD92FAD9619</UUID> <nextObjectID>116</nextObjectID> <metadata> <plist version="1.0"> <dict> <key>NSPersistenceFrameworkVersion</key> <integer>1518</integer> <key>NSPersistenceMaximumFrameworkVersion</key> <integer>1518</integer> <key>NSStoreModelVersionChecksumKey</key> <string>bMpud663vz0bXQE24C6Rh4MvJ5jVnzsD2sI3njZkKbc=</string> <key>NSStoreModelVersionHashes</key> <dict> <key>XDDevAttributeMapping</key> <data> 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= </data> <key>XDDevEntityMapping</key> <data> qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= </data> <key>XDDevMappingModel</key> <data> EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= </data> <key>XDDevPropertyMapping</key> <data> XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= </data> <key>XDDevRelationshipMapping</key> <data> akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= </data> </dict> <key>NSStoreModelVersionHashesDigest</key> <string>+Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A==</string> <key>NSStoreModelVersionHashesVersion</key> <integer>3</integer> <key>NSStoreModelVersionIdentifiers</key> <array> <string></string> </array> </dict> </plist> </metadata> </databaseInfo> <object type="XDDEVATTRIBUTEMAPPING" id="z102"> <attribute name="name" type="string">messageReporting</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z103"> <attribute name="name" type="string">rawMessageObject</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVENTITYMAPPING" id="z104"> <attribute name="migrationpolicyclassname" type="string">UAInboxDataMappingV1toV4</attribute> <attribute name="sourcename" type="string">UAInboxMessage</attribute> <attribute name="mappingtypename" type="string">Undefined</attribute> <attribute name="mappingnumber" type="int16">1</attribute> <attribute name="destinationname" type="string">UAInboxMessage</attribute> <attribute name="autogenerateexpression" type="bool">1</attribute> <relationship name="mappingmodel" type="1/1" destination="XDDEVMAPPINGMODEL" idrefs="z108"></relationship> <relationship name="attributemappings" type="0/0" destination="XDDEVATTRIBUTEMAPPING" idrefs="z103 z110 z109 z113 z112 z107 z102 z116 z111 z106 z115 z105 z114"></relationship> <relationship name="relationshipmappings" type="0/0" destination="XDDEVRELATIONSHIPMAPPING"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z105"> <attribute name="name" type="string">unread</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z106"> <attribute name="name" type="string">contentType</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z107"> <attribute name="name" type="string">extra</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVMAPPINGMODEL" id="z108"> <attribute name="sourcemodelpath" type="string">AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox.xcdatamodel</attribute> <attribute name="sourcemodeldata" type="binary">YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEBXgALAAwAGQA1ADYANwA/AEAAWwBcAF0AYwBkAHAAhgCHAIgAiQCKAIsAjACNAI4AjwCoAKsAsgC4AMcA1gDZAOgA9wD6AFoBCgEZAR0BIQEwATYBNwE/AU4BTwFYAXIBcwF0AXUBdgF3AXgBeQF6AXsBfAF9AZIBkwGbAZwBnQGpAb0BvgG/AcABwQHCAcMBxAHFAdQB4wHyAfYCBQIUAiMCMgJBAk0CXwJgAmECYgJjAmQCZQJmAnUCdgKFApQCowKkArMCwgLRAtkC7gLvAvcDAwMXAyYDNQNEA0gDVwNmA2cDdgOFA5QDoAOyA8ED0APfA+4D7wP+BA0EHAQxBDIEOgRGBFoEaQR4BIcEiwSaBKkEuATHBNYE4gT0BQMFEgUhBTAFMQVABU8FUAVfBXQFdQV9BYkFnQWsBbsFygXOBd0F7AX7BgoGGQYlBjcGRgZHBlYGZQZ0BoMGkgahBrYGtwa/BssG3wbuBv0HDAcQBx8HLgc9B0wHWwdnB3kHiAeXB6YHtQfEB9MH1AfjB/gH+QgBCA0IIQgwCD8ITghSCGEIcAh/CI4InQipCLsIygjZCOgI9wj4CQcJFgklCToJOwlDCU8JYwlyCYEJkAmUCaMJsgnBCdAJ3wnrCf0KDAobCioKOQpIClcKWApnCnwKfQqFCpEKpQq0CsMK0grWCuUK9AsDCxILIQstCz8LTgtdC2wLewuKC5kLmgupC74LvwvHC9ML5wv2DAUMFAwYDCcMNgxFDFQMYwxvDIEMkAyfDK4MvQzMDNsM6gz/DQANCA0UDSgNNw1GDVUNWQ1oDXcNhg2VDaQNsA3CDdEN4A3vDf4ODQ4cDisOQA5BDkkOVQ5pDngOhw6WDpoOqQ64DscO1g7lDvEPAw8SDyEPMA8/D04PXQ9sD20PcA95D30PgQ+FD40PkA+UD5VVJG51bGzWAA0ADgAPABAAEQASABMAFAAVABYAFwAYXxAPX3hkX3Jvb3RQYWNrYWdlViRjbGFzc11feGRfbW9kZWxOYW1lXF94ZF9jb21tZW50c18QFV9jb25maWd1cmF0aW9uc0J5TmFtZV8QF19tb2RlbFZlcnNpb25JZGVudGlmaWVygAKBAV2AAIEBWoEBW4EBXN4AGgAbABwAHQAeAB8AIAAOACEAIgAjACQAJQAmACcAKAApAAkAJwAVAC0ALgAvADAAMQAnACcAFV8QHFhEQnVja2V0Rm9yQ2xhc3Nlc3dhc0VuY29kZWRfEBpYREJ1Y2tldEZvclBhY2thZ2Vzc3RvcmFnZV8QHFhEQnVja2V0Rm9ySW50ZXJmYWNlc3N0b3JhZ2VfEA9feGRfb3duaW5nTW9kZWxfEB1YREJ1Y2tldEZvclBhY2thZ2Vzd2FzRW5jb2RlZFZfb3duZXJfEBtYREJ1Y2tldEZvckRhdGFUeXBlc3N0b3JhZ2VbX3Zpc2liaWxpdHlfEBlYREJ1Y2tldEZvckNsYXNzZXNzdG9yYWdlVV9uYW1lXxAfWERCdWNrZXRGb3JJbnRlcmZhY2Vzd2FzRW5jb2RlZF8QHlhEQnVja2V0Rm9yRGF0YVR5cGVzd2FzRW5jb2RlZF8QEF91bmlxdWVFbGVtZW50SUSABIEBWIEBVoABgASAAIEBV4EBWRAAgAWAA4AEgASAAFBTWUVT0wA4ADkADgA6ADwAPldOUy5rZXlzWk5TLm9iamVjdHOhADuABqEAPYAHgCVeVUFJbmJveE1lc3NhZ2XfEBAAQQBCAEMARAAfAEUARgAhAEcASAAOACMASQBKACYASwBMAE0AJwAnABMAUQBSAC8AJwBMAFUAOwBMAFgAWQBaXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zZHVwbGljYXRlc18QJFhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNvcmRlcmVkXxAhWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNzdG9yYWdlW19pc0Fic3RyYWN0gAmALYAEgASAAoAKgQFTgASACYEBVYAGgAmBAVSACAgS5f8u2VdvcmRlcmVk0wA4ADkADgBeAGAAPqEAX4ALoQBhgAyAJV5YRF9QU3RlcmVvdHlwZdkAHwAjAGUADgAmAGYAIQBLAGcAPQBfAEwAawAVACcALwBaAG9fECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2WAB4ALgAmALIAAgAQIgA3TADgAOQAOAHEAewA+qQByAHMAdAB1AHYAdwB4AHkAeoAOgA+AEIARgBKAE4AUgBWAFqkAfAB9AH4AfwCAAIEAggCDAISAF4AbgByAHoAfgCGAI4AmgCqAJV8QE1hEUE1Db21wb3VuZEluZGV4ZXNfEBBYRF9QU0tfZWxlbWVudElEXxAZWERQTVVuaXF1ZW5lc3NDb25zdHJhaW50c18QGlhEX1BTS192ZXJzaW9uSGFzaE1vZGlmaWVyXxAZWERfUFNLX2ZldGNoUmVxdWVzdHNBcnJheV8QEVhEX1BTS19pc0Fic3RyYWN0XxAPWERfUFNLX3VzZXJJbmZvXxATWERfUFNLX2NsYXNzTWFwcGluZ18QFlhEX1BTS19lbnRpdHlDbGFzc05hbWXfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQCbABUAYQBaAFoAWgAvAFoAogByAFoAWgAVAFpVX3R5cGVYX2RlZmF1bHRcX2Fzc29jaWF0aW9uW19pc1JlYWRPbmx5WV9pc1N0YXRpY1lfaXNVbmlxdWVaX2lzRGVyaXZlZFpfaXNPcmRlcmVkXF9pc0NvbXBvc2l0ZVdfaXNMZWFmgACAGIAAgAwICAgIgBqADggIgAAI0gA5AA4AqQCqoIAZ0gCsAK0ArgCvWiRjbGFzc25hbWVYJGNsYXNzZXNeTlNNdXRhYmxlQXJyYXmjAK4AsACxV05TQXJyYXlYTlNPYmplY3TSAKwArQCzALRfEBBYRFVNTFByb3BlcnR5SW1wpAC1ALYAtwCxXxAQWERVTUxQcm9wZXJ0eUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUAFQAVAGEAWgBaAFoALwBaAKIAcwBaAFoAFQBagACAAIAAgAwICAgIgBqADwgIgAAI3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUAyQAVAGEAWgBaAFoALwBaAKIAdABaAFoAFQBagACAHYAAgAwICAgIgBqAEAgIgAAI0gA5AA4A1wCqoIAZ3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUAFQAVAGEAWgBaAFoALwBaAKIAdQBaAFoAFQBagACAAIAAgAwICAgIgBqAEQgIgAAI3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUA6gAVAGEAWgBaAFoALwBaAKIAdgBaAFoAFQBagACAIIAAgAwICAgIgBqAEggIgAAI0gA5AA4A+ACqoIAZ3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUA/AAVAGEAWgBaAFoALwBaAKIAdwBaAFoAFQBagACAIoAAgAwICAgIgBqAEwgIgAAICN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAQwAFQBhAFoAWgBaAC8AWgCiAHgAWgBaABUAWoAAgCSAAIAMCAgICIAagBQICIAACNMAOAA5AA4BGgEbAD6goIAl0gCsAK0BHgEfXxATTlNNdXRhYmxlRGljdGlvbmFyeaMBHgEgALFcTlNEaWN0aW9uYXJ53xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUBIwAVAGEAWgBaAFoALwBaAKIAeQBaAFoAFQBagACAJ4AAgAwICAgIgBqAFQgIgAAI1gAjAA4AJgBLAB8AIQExATIAFQBaABUAL4AogCmAAAiAAF8QFFhER2VuZXJpY1JlY29yZENsYXNz0gCsAK0BOAE5XVhEVU1MQ2xhc3NJbXCmAToBOwE8AT0BPgCxXVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUBQQAVAGEAWgBaAFoALwBaAKIAegBaAFoAFQBagACAK4AAgAwICAgIgBqAFggIgAAIXxASVUFJbmJveE1lc3NhZ2VEYXRh0gCsAK0BUAFRXxASWERVTUxTdGVyZW90eXBlSW1wpwFSAVMBVAFVAVYBVwCxXxASWERVTUxTdGVyZW90eXBlSW1wXVhEVU1MQ2xhc3NJbXBfEBJYRFVNTENsYXNzaWZpZXJJbXBfEBFYRFVNTE5hbWVzcGFjZUltcF8QFFhEVU1MTmFtZWRFbGVtZW50SW1wXxAPWERVTUxFbGVtZW50SW1w0wA4ADkADgFZAWUAPqsBWgFbAVwBXQFeAV8BYAFhAWIBYwFkgC6AL4AwgDGAMoAzgDSANYA2gDeAOKsBZgFnAWgBaQFqAWsBbAFtAW4BbwFwgDmAZIB9gJaAroDGgN6A9oEBDoEBJYEBPIAlVnVucmVhZFltZXNzYWdlSURabWVzc2FnZVVSTF1kZWxldGVkQ2xpZW50VWV4dHJhW21lc3NhZ2VTZW50Xm1lc3NhZ2VCb2R5VVJMXxAQcmF3TWVzc2FnZU9iamVjdFV0aXRsZV8QEW1lc3NhZ2VFeHBpcmF0aW9uXHVucmVhZENsaWVudN8QEgCQAJEAkgF+AB8AlACVAX8AIQCTAYAAlgAOACMAlwCYACYAmQAVABUAFQAnAD0AWgBaAYgALwBaAEwAWgGMAVoAWgBaAZAAWl8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAcICIA7CIAJCIBjgC4ICIA6CBMAAAABIXF1TtMAOAA5AA4BlAGXAD6iAZUBloA8gD2iAZgBmYA+gFGAJV8QElhEX1BQcm9wU3RlcmVvdHlwZV8QElhEX1BBdHRfU3RlcmVvdHlwZdkAHwAjAZ4ADgAmAZ8AIQBLAaABZgGVAEwAawAVACcALwBaAahfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECRYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3dhc0VuY29kZWRfECFYREJ1Y2tldEZvck93bmVkQXR0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BBsFGwYbCBsJGw4bGxsgGyIbJBspGysbLRsvG1QbeBufG8MbxRvHG8kbyxvNG88b0BvSG98b8BvyG/Qb9hv4G/ob/Bv+HAAcERwTHBUcFxwZHBscHRwfHCEcIxxiHGQcZhxoHGocaxxsHG0cbhxwHHIccxx0HHYcdxy2HLgcuhy8HL4cvxzAHMEcwhzEHMYcxxzIHMocyx0KHQwdDh0QHRIdEx0UHRUdFh0YHRodGx0cHR4dHx0sHS0dLh0wHW8dcR1zHXUddx14HXkdeh17HX0dfx2AHYEdgx2EHcMdxR3HHckdyx3MHc0dzh3PHdEd0x3UHdUd1x3YHdkeGB4aHhweHh4gHiEeIh4jHiQeJh4oHikeKh4sHi0ebB5uHnAech50HnUedh53Hngeeh58Hn0efh6AHoEewB7CHsQexh7IHskeyh7LHswezh7QHtEe0h7UHtUe+h8eH0UfaR9rH20fbx9xH3MfdR92H3gfhR+UH5YfmB+aH5wfnh+gH6IfsR+zH7Uftx+5H7sfvR+/H8EgACACIAQgBiAIIAkgCiALIAwgDiAQIBEgEiAUIBUgVCBWIFggWiBcIF0gXiBfIGAgYiBkIGUgZiBoIGkgqCCqIKwgriCwILEgsiCzILQgtiC4ILkguiC8IL0g/CD+IQAhAiEEIQUhBiEHIQghCiEMIQ0hDiEQIREhFCFTIVUhVyFZIVshXCFdIV4hXyFhIWMhZCFlIWchaCGnIakhqyGtIa8hsCGxIbIhsyG1IbchuCG5IbshvCH7If0h/yIBIgMiBCIFIgYiByIJIgsiDCINIg8iECJbIn4iniK+IsAiwiLEIsYiyCLJIsoizCLNIs8i0CLSItQi1SLWItgi2SLiIu8i9CL2Ivgi/SL/IwEjAyMoI0wjcyOXI5kjmyOdI58joSOjI6QjpiOzI8QjxiPII8ojzCPOI9Aj0iPUI+Uj5yPpI+sj7SPvI/Ej8yP1I/ckNiQ4JDokPCQ+JD8kQCRBJEIkRCRGJEckSCRKJEskiiSMJI4kkCSSJJMklCSVJJYkmCSaJJsknCSeJJ8k3iTgJOIk5CTmJOck6CTpJOok7CTuJO8k8CTyJPMlACUBJQIlBCVDJUUlRyVJJUslTCVNJU4lTyVRJVMlVCVVJVclWCWXJZklmyWdJZ8loCWhJaIloyWlJaclqCWpJaslrCXrJe0l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JlIpUipSLFItUi9SMVIyUjNSNlI3UjxSSVJOUlBSUlJXUlpSXVJfUoRSqFLPUvNS9lL4UvpS/FL+UwBTAVMEUxFTIlMkUyZTKFMqUyxTLlMwUzJTQ1NGU0lTTFNPU1JTVVNYU1tTXVOcU55ToFOiU6VTplOnU6hTqVOrU61TrlOvU7FTslPxU/NT9VP3U/pT+1P8U/1T/lQAVAJUA1QEVAZUB1RGVEhUS1RNVFBUUVRSVFNUVFRWVFhUWVRaVFxUXVRqVGtUbFRuVK1Ur1SxVLNUtlS3VLhUuVS6VLxUvlS/VMBUwlTDVQJVBFUGVQhVC1UMVQ1VDlUPVRFVE1UUVRVVF1UYVVdVWVVbVV1VYFVhVWJVY1VkVWZVaFVpVWpVbFVtVaxVrlWwVbJVtVW2VbdVuFW5VbtVvVW+Vb9VwVXCVgFWA1YFVgdWClYLVgxWDVYOVhBWElYTVhRWFlYXVjxWYFaHVqtWrlawVrJWtFa2VrhWuVa8VslW2FbaVtxW3lbgVuJW5FbmVvVW+Fb7Vv5XAVcEVwdXClcMV0tXTVdPV1FXVFdVV1ZXV1dYV1pXXFddV15XYFdhV6BXolekV6ZXqVeqV6tXrFetV69XsVeyV7NXtVe2V/VX91f5V/tX/lf/WABYAVgCWARYBlgHWAhYClgLWEpYTFhOWFBYU1hUWFVYVlhXWFlYW1hcWF1YX1hgWJ9YoVijWKVYqFipWKpYq1isWK5YsFixWLJYtFi1WPRY9lj4WPpY/Vj+WP9ZAFkBWQNZBVkGWQdZCVkKWUlZS1lNWU9ZUllTWVRZVVlWWVhZWllbWVxZXllfWapZzVntWg1aD1oRWhNaFVoXWhhaGVocWh1aH1ogWiJaJFolWiZaKVoqWjNaQFpFWkdaSVpOWlFaVFpWWntan1rGWupa7VrvWvFa81r1Wvda+Fr7WwhbGVsbWx1bH1shWyNbJVsnWylbOls9W0BbQ1tGW0lbTFtPW1JbVFuTW5Vbl1uZW5xbnVueW59boFuiW6RbpVumW6hbqVvoW+pb7FvuW/Fb8lvzW/Rb9Vv3W/lb+lv7W/1b/lw9XD9cQlxEXEdcSFxJXEpcS1xNXE9cUFxRXFNcVFxhXGJcY1xlXKRcplyoXKpcrVyuXK9csFyxXLNctVy2XLdcuVy6XPlc+1z9XP9dAl0DXQRdBV0GXQhdCl0LXQxdDl0PXU5dUF1SXVRdV11YXVldWl1bXV1dX11gXWFdY11kXaNdpV2nXaldrF2tXa5dr12wXbJdtF21XbZduF25Xfhd+l38Xf5eAV4CXgNeBF4FXgdeCV4KXgteDV4OXjNeV15+XqJepV6nXqleq16tXq9esF6zXsBez17RXtNe1V7XXtle217dXuxe717yXvVe+F77Xv5fAV8DX0JfRF9GX0hfS19MX01fTl9PX1FfU19UX1VfV19YX5dfmV+bX51foF+hX6Jfo1+kX6ZfqF+pX6pfrF+tX+xf7l/wX/Jf9V/2X/df+F/5X/tf/V/+X/9gAWACYEFgQ2BFYEdgSmBLYExgTWBOYFBgUmBTYFRgVmBXYJZgmGCaYJxgn2CgYKFgomCjYKVgp2CoYKlgq2CsYOtg7WDvYPFg9GD1YPZg92D4YPpg/GD9YP5hAGEBYUBhQmFEYUZhSWFKYUthTGFNYU9hUWFSYVNhVWFWYaFhxGHkYgRiBmIIYgpiDGIOYg9iEGITYhRiFmIXYhliG2IcYh1iIGIhYiZiM2I4YjpiPGJBYkRiR2JJYm5ikmK5Yt1i4GLiYuRi5mLoYupi62LuYvtjDGMOYxBjEmMUYxZjGGMaYxxjLWMwYzNjNmM5YzxjP2NCY0VjR2OGY4hjimOMY49jkGORY5Jjk2OVY5djmGOZY5tjnGPbY91j32PhY+Rj5WPmY+dj6GPqY+xj7WPuY/Bj8WQwZDJkNWQ3ZDpkO2Q8ZD1kPmRAZEJkQ2REZEZkR2RUZFVkVmRYZJdkmWSbZJ1koGShZKJko2SkZKZkqGSpZKpkrGStZOxk7mTwZPJk9WT2ZPdk+GT5ZPtk/WT+ZP9lAWUCZUFlQ2VFZUdlSmVLZUxlTWVOZVBlUmVTZVRlVmVXZZZlmGWaZZxln2WgZaFlomWjZaVlp2WoZallq2WsZetl7WXvZfFl9GX1ZfZl92X4Zfpl/GX9Zf5mAGYBZiZmSmZxZpVmmGaaZpxmnmagZqJmo2amZrNmwmbEZsZmyGbKZsxmzmbQZt9m4mblZuhm62buZvFm9Gb2ZzVnN2c5ZztnPmc/Z0BnQWdCZ0RnRmdHZ0hnSmdLZ4pnjGeOZ5Bnk2eUZ5VnlmeXZ5lnm2ecZ51nn2egZ99n4WfjZ+Vn6GfpZ+pn62fsZ+5n8GfxZ/Jn9Gf1aDRoNmg4aDpoPWg+aD9oQGhBaENoRWhGaEdoSWhKaIloi2iNaI9okmiTaJRolWiWaJhommibaJxonmifaN5o4GjiaORo52joaOlo6mjraO1o72jwaPFo82j0aTNpNWk3aTlpPGk9aT5pP2lAaUJpRGlFaUZpSGlJaVRpXWleaWBpaWl0aYNpjmmcabFpxWncae5p+2n8af1p/2oMag1qDmoQah1qHmofaiFqKmo5akZqVWpnantqkmqkaq1qrmqwar1qvmq/asFqwmrLatVq3AAAAAAAAAICAAAAAAAAD5kAAAAAAAAAAAAAAAAAAGrk </attribute> <attribute name="destinationmodelpath" type="string">AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 4.xcdatamodel</attribute> <attribute name="destinationmodeldata" type="binary">YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEBkAALAAwAGQA1ADYANwA/AEAAWwBcAF0AYwBkAHAAhgCHAIgAiQCKAIsAjACNAI4AjwCoAKsAsgC4AMcA1gDZAOgA9wD6AFoBCgEZAR0BIQEwATYBNwE/AU4BTwFYAXYBdwF4AXkBegF7AXwBfQF+AX8BgAGBAYIBgwGYAZkBoQGiAaMBrwHDAcQBxQHGAccByAHJAcoBywHaAekB+AH8AgsCGgIbAioCOQJIAlQCZgJnAmgCaQJqAmsCbAJtAnwCiwKaAqkCqgK5AsgCyQLYAuAC9QL2Av4DCgMeAy0DPANLA08DXgNtA3wDiwOaA6YDuAPHA9YD5QP0A/UEBAQTBBQEIwQ4BDkEQQRNBGEEcAR/BI4EkgShBLAEvwTOBN0E6QT7BQoFGQUoBTcFOAVHBVYFZQV6BXsFgwWPBaMFsgXBBdAF1AXjBfIGAQYQBh8GKwY9BkwGWwZqBnkGiAaXBpgGpwa8Br0GxQbRBuUG9AcDBxIHFgclBzQHQwdSB2EHbQd/B44HjweeB60HvAe9B8wH2wfqB/8IAAgICBQIKAg3CEYIVQhZCGgIdwiGCJUIpAiwCMII0QjgCO8I/gkNCRwJHQksCUEJQglKCVYJagl5CYgJlwmbCaoJuQnICdcJ5gnyCgQKEwoiCjEKQApBClAKXwpuCoMKhAqMCpgKrAq7CsoK2QrdCuwK+wsKCxkLKAs0C0YLVQtkC3MLgguRC6ALrwvEC8ULzQvZC+0L/AwLDBoMHgwtDDwMSwxaDGkMdQyHDJYMpQy0DMMM0gzhDPANBQ0GDQ4NGg0uDT0NTA1bDV8Nbg19DYwNmw2qDbYNyA3XDeYN9Q4EDhMOIg4xDkYORw5PDlsObw5+Do0OnA6gDq8Ovg7NDtwO6w73DwkPGA8ZDygPNw9GD1UPZA9zD4gPiQ+RD50PsQ/AD88P3g/iD/EQABAPEB4QLRA5EEsQWhBpEHgQhxCWEKUQtBDJEMoQ0hDeEPIRAREQER8RIxEyEUERUBFfEW4RehGMEZsRqhG5EcgR1xHmEecR9hH3EfoSAxIHEgsSDxIXEhoSHhIfVSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgQGPgACBAYyBAY2BAY7eABoAGwAcAB0AHgAfACAADgAhACIAIwAkACUAJgAnACgAKQAJACcAFQAtAC4ALwAwADEAJwAnABVfEBxYREJ1Y2tldEZvckNsYXNzZXN3YXNFbmNvZGVkXxAaWERCdWNrZXRGb3JQYWNrYWdlc3N0b3JhZ2VfEBxYREJ1Y2tldEZvckludGVyZmFjZXNzdG9yYWdlXxAPX3hkX293bmluZ01vZGVsXxAdWERCdWNrZXRGb3JQYWNrYWdlc3dhc0VuY29kZWRWX293bmVyXxAbWERCdWNrZXRGb3JEYXRhVHlwZXNzdG9yYWdlW192aXNpYmlsaXR5XxAZWERCdWNrZXRGb3JDbGFzc2Vzc3RvcmFnZVVfbmFtZV8QH1hEQnVja2V0Rm9ySW50ZXJmYWNlc3dhc0VuY29kZWRfEB5YREJ1Y2tldEZvckRhdGFUeXBlc3dhc0VuY29kZWRfEBBfdW5pcXVlRWxlbWVudElEgASBAYqBAYiAAYAEgACBAYmBAYsQAIAFgAOABIAEgABQU1lFU9MAOAA5AA4AOgA8AD5XTlMua2V5c1pOUy5vYmplY3RzoQA7gAahAD2AB4AlXlVBSW5ib3hNZXNzYWdl3xAQAEEAQgBDAEQAHwBFAEYAIQBHAEgADgAjAEkASgAmAEsATABNACcAJwATAFEAUgAvACcATABVADsATABYAFkAWl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAJgC2ABIAEgAKACoEBhYAEgAmBAYeABoAJgQGGgAgIEwAAAAEuRERbV29yZGVyZWTTADgAOQAOAF4AYAA+oQBfgAuhAGGADIAlXlhEX1BTdGVyZW90eXBl2QAfACMAZQAOACYAZgAhAEsAZwA9AF8ATABrABUAJwAvAFoAb18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYAHgAuACYAsgACABAiADdMAOAA5AA4AcQB7AD6pAHIAcwB0AHUAdgB3AHgAeQB6gA6AD4AQgBGAEoATgBSAFYAWqQB8AH0AfgB/AIAAgQCCAIMAhIAXgBuAHIAegB+AIYAjgCaAKoAlXxATWERQTUNvbXBvdW5kSW5kZXhlc18QEFhEX1BTS19lbGVtZW50SURfEBlYRFBNVW5pcXVlbmVzc0NvbnN0cmFpbnRzXxAaWERfUFNLX3ZlcnNpb25IYXNoTW9kaWZpZXJfEBlYRF9QU0tfZmV0Y2hSZXF1ZXN0c0FycmF5XxARWERfUFNLX2lzQWJzdHJhY3RfEA9YRF9QU0tfdXNlckluZm9fEBNYRF9QU0tfY2xhc3NNYXBwaW5nXxAWWERfUFNLX2VudGl0eUNsYXNzTmFtZd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAJsAFQBhAFoAWgBaAC8AWgCiAHIAWgBaABUAWlVfdHlwZVhfZGVmYXVsdFxfYXNzb2NpYXRpb25bX2lzUmVhZE9ubHlZX2lzU3RhdGljWV9pc1VuaXF1ZVpfaXNEZXJpdmVkWl9pc09yZGVyZWRcX2lzQ29tcG9zaXRlV19pc0xlYWaAAIAYgACADAgICAiAGoAOCAiAAAjSADkADgCpAKqggBnSAKwArQCuAK9aJGNsYXNzbmFtZVgkY2xhc3Nlc15OU011dGFibGVBcnJheaMArgCwALFXTlNBcnJheVhOU09iamVjdNIArACtALMAtF8QEFhEVU1MUHJvcGVydHlJbXCkALUAtgC3ALFfEBBYRFVNTFByb3BlcnR5SW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQAVABUAYQBaAFoAWgAvAFoAogBzAFoAWgAVAFqAAIAAgACADAgICAiAGoAPCAiAAAjfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQDJABUAYQBaAFoAWgAvAFoAogB0AFoAWgAVAFqAAIAdgACADAgICAiAGoAQCAiAAAjSADkADgDXAKqggBnfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQAVABUAYQBaAFoAWgAvAFoAogB1AFoAWgAVAFqAAIAAgACADAgICAiAGoARCAiAAAjfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQDqABUAYQBaAFoAWgAvAFoAogB2AFoAWgAVAFqAAIAggACADAgICAiAGoASCAiAAAjSADkADgD4AKqggBnfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQD8ABUAYQBaAFoAWgAvAFoAogB3AFoAWgAVAFqAAIAigACADAgICAiAGoATCAiAAAgI3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUBDAAVAGEAWgBaAFoALwBaAKIAeABaAFoAFQBagACAJIAAgAwICAgIgBqAFAgIgAAI0wA4ADkADgEaARsAPqCggCXSAKwArQEeAR9fEBNOU011dGFibGVEaWN0aW9uYXJ5owEeASAAsVxOU0RpY3Rpb25hcnnfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQEjABUAYQBaAFoAWgAvAFoAogB5AFoAWgAVAFqAAIAngACADAgICAiAGoAVCAiAAAjWACMADgAmAEsAHwAhATEBMgAVAFoAFQAvgCiAKYAACIAAXxAUWERHZW5lcmljUmVjb3JkQ2xhc3PSAKwArQE4ATldWERVTUxDbGFzc0ltcKYBOgE7ATwBPQE+ALFdWERVTUxDbGFzc0ltcF8QElhEVU1MQ2xhc3NpZmllckltcF8QEVhEVU1MTmFtZXNwYWNlSW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQFBABUAYQBaAFoAWgAvAFoAogB6AFoAWgAVAFqAAIArgACADAgICAiAGoAWCAiAAAhfEBJVQUluYm94TWVzc2FnZURhdGHSAKwArQFQAVFfEBJYRFVNTFN0ZXJlb3R5cGVJbXCnAVIBUwFUAVUBVgFXALFfEBJYRFVNTFN0ZXJlb3R5cGVJbXBdWERVTUxDbGFzc0ltcF8QElhEVU1MQ2xhc3NpZmllckltcF8QEVhEVU1MTmFtZXNwYWNlSW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDTADgAOQAOAVkBZwA+rQFaAVsBXAFdAV4BXwFgAWEBYgFjAWQBZQFmgC6AL4AwgDGAMoAzgDSANYA2gDeAOIA5gDqtAWgBaQFqAWsBbAFtAW4BbwFwAXEBcgFzAXSAO4BngICAmICwgMmA4YD5gQEQgQEngQE+gQFWgQFtgCVVZXh0cmFebWVzc2FnZUJvZHlVUkxfEBFtZXNzYWdlRXhwaXJhdGlvbl8QEG1lc3NhZ2VSZXBvcnRpbmddZGVsZXRlZENsaWVudF8QEHJhd01lc3NhZ2VPYmplY3RZbWVzc2FnZUlEW2NvbnRlbnRUeXBlW21lc3NhZ2VTZW50VXRpdGxlXHVucmVhZENsaWVudFZ1bnJlYWRabWVzc2FnZVVSTN8QEgCQAJEAkgGEAB8AlACVAYUAIQCTAYYAlgAOACMAlwCYACYAmQAVABUAFQAnAD0AWgBaAY4ALwBaAEwAWgGSAVoAWgBaAZYAWl8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAcICIA9CIAJCIBmgC4ICIA8CBJc/IHe0wA4ADkADgGaAZ0APqIBmwGcgD6AP6IBngGfgECAVIAlXxASWERfUFByb3BTdGVyZW90eXBlXxASWERf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QTRDNFA0YTRjNGU0ZzRpNGs0bTRvNHE0gjSENIY0iDSKNIw0jjSQNJI0lDTTNNU01zTZNNs03DTdNN403zThNOM05DTlNOc06DUnNSk1KzUtNS81MDUxNTI1MzU1NTc1ODU5NTs1PDV7NX01fzWBNYM1hDWFNYY1hzWJNYs1jDWNNY81kDWdNZ41nzWhNeA14jXkNeY16DXpNeo16zXsNe418DXxNfI19DX1NjQ2NjY4Njo2PDY9Nj42PzZANkI2RDZFNkY2SDZJNog2ijaMNo42kDaRNpI2kzaUNpY2mDaZNpo2nDadNtw23jbgNuI25DblNuY25zboNuo27DbtNu428DbxNzA3Mjc0NzY3ODc5Nzo3Ozc8Nz43QDdBN0I3RDdFN2o3jje1N9k32zfdN9834TfjN+U35jfoN/U4BDgGOAg4CjgMOA44EDgSOCE4IzglOCc4KTgrOC04LzgxOHA4cjh0OHY4eDh5OHo4ezh8OH44gDiBOII4hDiFOIg4xzjJOMs4zTjPONA40TjSONM41TjXONg42TjbONw5GzkdOR85ITkjOSQ5JTkmOSc5KTkrOSw5LTkvOTA5bzlxOXM5dTl3OXg5eTl6OXs5fTl/OYA5gTmDOYQ5hznGOcg5yjnMOc45zznQOdE50jnUOdY51znYOdo52zoaOhw6HjogOiI6IzokOiU6JjooOio6KzosOi46LzpuOnA6cjp0OnY6dzp4Onk6ejp8On46fzqAOoI6gzrOOvE7ETsxOzM7NTs3Ozk7Ozs8Oz07PztAO0I7QztFO0c7SDtJO0s7TDtVO2I7ZztpO2s7cDtyO3Q7djubO7875jwKPAw8DjwQPBI8FDwWPBc8GTwmPDc8OTw7PD08PzxBPEM8RTxHPFg8WjxcPF48YDxiPGQ8ZjxoPGo8qTyrPK08rzyxPLI8szy0PLU8tzy5PLo8uzy9PL48/Tz/PQE9Az0FPQY9Bz0IPQk9Cz0NPQ49Dz0RPRI9UT1TPVU9Vz1ZPVo9Wz1cPV09Xz1hPWI9Yz1lPWY9cz10PXU9dz22Pbg9uj28Pb49vz3APcE9wj3EPcY9xz3IPco9yz4KPgw+Dj4QPhI+Ez4UPhU+Fj4YPho+Gz4cPh4+Hz5ePmA+Yj5kPmY+Zz5oPmk+aj5sPm4+bz5wPnI+cz6yPrQ+tj64Pro+uz68Pr0+vj7APsI+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GFUZVRpVHFUdVSpVK1UsVS5VbVVvVXFVc1V2VXdVeFV5VXpVfFV+VX9VgFWCVYNVwlXEVcZVyFXLVcxVzVXOVc9V0VXTVdRV1VXXVdhWF1YZVhtWHVYgViFWIlYjViRWJlYoVilWKlYsVi1WbFZuVnBWclZ1VnZWd1Z4VnlWe1Z9Vn5Wf1aBVoJWwVbDVsVWx1bKVstWzFbNVs5W0FbSVtNW1FbWVtdW/FcgV0dXa1duV3BXcld0V3ZXeFd5V3xXiVeYV5pXnFeeV6BXolekV6ZXtVe4V7tXvlfBV8RXx1fKV8xYC1gNWA9YEVgUWBVYFlgXWBhYGlgcWB1YHlggWCFYYFhiWGRYZlhpWGpYa1hsWG1Yb1hxWHJYc1h1WHZYtVi3WLlYu1i+WL9YwFjBWMJYxFjGWMdYyFjKWMtZClkMWQ5ZEFkTWRRZFVkWWRdZGVkbWRxZHVkfWSBZX1lhWWNZZVloWWlZallrWWxZbllwWXFZcll0WXVZtFm2WbhZulm9Wb5Zv1nAWcFZw1nFWcZZx1nJWcpaCVoLWg1aD1oSWhNaFFoVWhZaGFoaWhtaHFoeWh9aalqNWq1azVrPWtFa01rVWtda2FrZWtxa3VrfWuBa4lrkWuVa5lrpWupa71r8WwFbA1sFWwpbDVsQWxJbN1tbW4JbplupW6tbrVuvW7Fbs1u0W7dbxFvVW9db2VvbW91b31vhW+Nb5Vv2W/lb/Fv/XAJcBVwIXAtcDlwQXE9cUVxTXFVcWFxZXFpcW1xcXF5cYFxhXGJcZFxlXKRcplyoXKpcrVyuXK9csFyxXLNctVy2XLdcuVy6XPlc+1z+XQBdA10EXQVdBl0HXQldC10MXQ1dD10QXR1dHl0fXSFdYF1iXWRdZl1pXWpda11sXW1db11xXXJdc111XXZdtV23Xbldu12+Xb9dwF3BXcJdxF3GXcddyF3KXcteCl4MXg5eEF4TXhReFV4WXhdeGV4bXhxeHV4fXiBeX15hXmNeZV5oXmleal5rXmxebl5wXnFecl50XnVetF62Xrheul69Xr5ev17AXsFew17FXsZex17JXspe718TXzpfXl9hX2NfZV9nX2lfa19sX29ffF+LX41fj1+RX5NflV+XX5lfqF+rX65fsV+0X7dful+9X79f/mAAYAJgBGAHYAhgCWAKYAtgDWAPYBBgEWATYBRgU2BVYFdgWWBcYF1gXmBfYGBgYmBkYGVgZmBoYGlgqGCqYKxgrmCxYLJgs2C0YLVgt2C5YLpgu2C9YL5g/WD/YQFhA2EGYQdhCGEJYQphDGEOYQ9hEGESYRNhUmFUYVZhWGFbYVxhXWFeYV9hYWFjYWRhZWFnYWhhp2GpYathrWGwYbFhsmGzYbRhtmG4YblhumG8Yb1h/GH+YgBiAmIFYgZiB2IIYgliC2INYg5iD2IRYhJiXWKAYqBiwGLCYsRixmLIYspiy2LMYs9i0GLSYtNi1WLXYthi2WLcYt1i4mLvYvRi9mL4Yv1jAGMDYwVjKmNOY3VjmWOcY55joGOiY6RjpmOnY6pjt2PIY8pjzGPOY9Bj0mPUY9Zj2GPpY+xj72PyY/Vj+GP7Y/5kAWQDZEJkRGRGZEhkS2RMZE1kTmRPZFFkU2RUZFVkV2RYZJdkmWSbZJ1koGShZKJko2SkZKZkqGSpZKpkrGStZOxk7mTxZPNk9mT3ZPhk+WT6ZPxk/mT/ZQBlAmUDZRBlEWUSZRRlU2VVZVdlWWVcZV1lXmVfZWBlYmVkZWVlZmVoZWllqGWqZaxlrmWxZbJls2W0ZbVlt2W5Zbplu2W9Zb5l/WX/ZgFmA2YGZgdmCGYJZgpmDGYOZg9mEGYSZhNmUmZUZlZmWGZbZlxmXWZeZl9mYWZjZmRmZWZnZmhmp2apZqtmrWawZrFmsmazZrRmtma4Zrlmuma8Zr1m4mcGZy1nUWdUZ1ZnWGdaZ1xnXmdfZ2Jnb2d+Z4BngmeEZ4ZniGeKZ4xnm2eeZ6FnpGenZ6pnrWewZ7Jn8WfzZ/Zn+Gf7Z/xn/Wf+Z/9oAWgDaARoBWgHaAhoDGhLaE1oT2hRaFRoVWhWaFdoWGhaaFxoXWheaGBoYWigaKJopGimaKloqmiraKxorWivaLFosmizaLVotmj1aPdo+Wj7aP5o/2kAaQFpAmkEaQZpB2kIaQppC2lKaUxpTmlQaVNpVGlVaVZpV2lZaVtpXGldaV9pYGmfaaFpo2mlaahpqWmqaatprGmuabBpsWmyabRptWn0afZp+Gn6af1p/mn/agBqAWoDagVqBmoHaglqCmpVanhqmGq4arpqvGq+asBqwmrDasRqx2rIaspqy2rNas9q0GrRatRq1Wraaudq7GruavBq9Wr4avtq/Wsia0ZrbWuRa5RrlmuYa5prnGuea59romuva8BrwmvEa8ZryGvKa8xrzmvQa+Fr5Gvna+pr7Wvwa/Nr9mv5a/tsOmw8bD5sQGxDbERsRWxGbEdsSWxLbExsTWxPbFBsj2yRbJNslWyYbJlsmmybbJxsnmygbKFsomykbKVs5GzmbOls62zubO9s8GzxbPJs9Gz2bPds+Gz6bPttCG0JbQptDG1LbU1tT21RbVRtVW1WbVdtWG1abVxtXW1ebWBtYW2gbaJtpG2mbaltqm2rbaxtrW2vbbFtsm2zbbVttm31bfdt+W37bf5t/24AbgFuAm4EbgZuB24IbgpuC25KbkxuTm5QblNuVG5VblZuV25ZbltuXG5dbl9uYG6fbqFuo26lbqhuqW6qbqturG6ubrBusW6ybrRutW7abv5vJW9Jb0xvTm9Qb1JvVG9Wb1dvWm9nb3ZveG96b3xvfm+Ab4JvhG+Tb5ZvmW+cb59vom+lb6hvqm/pb+tv7m/wb/Nv9G/1b/Zv92/5b/tv/G/9b/9wAHA/cEFwQ3BFcEhwSXBKcEtwTHBOcFBwUXBScFRwVXCUcJZwmHCacJ1wnnCfcKBwoXCjcKVwpnCncKlwqnDpcOtw7XDvcPJw83D0cPVw9nD4cPpw+3D8cP5w/3E+cUBxQnFEcUdxSHFJcUpxS3FNcU9xUHFRcVNxVHGTcZVxl3GZcZxxnXGecZ9xoHGicaRxpXGmcahxqXHocepx7HHucfFx8nHzcfRx9XH3cflx+nH7cf1x/nJJcmxyjHKscq5ysHKycrRytnK3crhyu3K8cr5yv3LBcsNyxHLFcshyyXLOctty4HLicuRy6XLscu9y8XMWczpzYXOFc4hzinOMc45zkHOSc5NzlnOjc7RztnO4c7pzvHO+c8BzwnPEc9Vz2HPbc95z4XPkc+dz6nPtc+90LnQwdDJ0NHQ3dDh0OXQ6dDt0PXQ/dEB0QXRDdER0g3SFdId0iXSMdI10jnSPdJB0knSUdJV0lnSYdJl02HTadN1033TidON05HTldOZ06HTqdOt07HTudO90/HT9dP51AHU/dUF1Q3VFdUh1SXVKdUt1THVOdVB1UXVSdVR1VXWUdZZ1mHWadZ11nnWfdaB1oXWjdaV1pnWndal1qnXpdet17XXvdfJ183X0dfV19nX4dfp1+3X8df51/3Y+dkB2QnZEdkd2SHZJdkp2S3ZNdk92UHZRdlN2VHaTdpV2l3aZdpx2nXaedp92oHaidqR2pXamdqh2qXbOdvJ3GXc9d0B3QndEd0Z3SHdKd0t3Tndbd2p3bHdud3B3cnd0d3Z3eHeHd4p3jXeQd5N3lneZd5x3nnfdd9934Xfjd+Z353fod+l36nfsd+5373fwd/J383gyeDR4Nng4eDt4PHg9eD54P3hBeEN4RHhFeEd4SHiHeIl4i3iNeJB4kXiSeJN4lHiWeJh4mXiaeJx4nXjceN544HjieOV45njneOh46XjreO147njvePF48nkxeTN5NXk3eTp5O3k8eT15PnlAeUJ5Q3lEeUZ5R3mGeYh5i3mNeZB5kXmSeZN5lHmWeZh5mXmaeZx5nXnEegN6BXoHegl6DHoNeg56D3oQehJ6FHoVehZ6GHoZeiR6LXouejB6OXpEelN6XnpseoF6lXqser56y3rMes16z3rcet163nrgeu167nrvevF6+nsJexZ7JXs3e0t7Ynt0e317fnuAe417jnuPe5F7knube6V7rAAAAAAAAAICAAAAAAAAEiMAAAAAAAAAAAAAAAAAAHu0 </attribute> <relationship name="entitymappings" type="0/0" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z109"> <attribute name="name" type="string">messageID</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z110"> <attribute name="name" type="string">messageExpiration</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z111"> <attribute name="name" type="string">deletedClient</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z112"> <attribute name="name" type="string">messageSent</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z113"> <attribute name="name" type="string">title</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z114"> <attribute name="name" type="string">messageURL</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z115"> <attribute name="name" type="string">unreadClient</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z116"> <attribute name="name" type="string">messageBodyURL</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> </database> ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInboxDataMappingV2toV4.xcmappingmodel/xcmapping.xml ================================================ <?xml version="1.0" standalone="yes"?> <!DOCTYPE database SYSTEM "file:///System/Library/DTDs/CoreData.dtd"> <database> <databaseInfo> <version>134481920</version> <UUID>13F60737-FF29-41B8-8B9D-08195AEDDDA3</UUID> <nextObjectID>116</nextObjectID> <metadata> <plist version="1.0"> <dict> <key>NSPersistenceFrameworkVersion</key> <integer>1518</integer> <key>NSPersistenceMaximumFrameworkVersion</key> <integer>1518</integer> <key>NSStoreModelVersionChecksumKey</key> <string>bMpud663vz0bXQE24C6Rh4MvJ5jVnzsD2sI3njZkKbc=</string> <key>NSStoreModelVersionHashes</key> <dict> <key>XDDevAttributeMapping</key> <data> 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= </data> <key>XDDevEntityMapping</key> <data> qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= </data> <key>XDDevMappingModel</key> <data> EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= </data> <key>XDDevPropertyMapping</key> <data> XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= </data> <key>XDDevRelationshipMapping</key> <data> akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= </data> </dict> <key>NSStoreModelVersionHashesDigest</key> <string>+Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A==</string> <key>NSStoreModelVersionHashesVersion</key> <integer>3</integer> <key>NSStoreModelVersionIdentifiers</key> <array> <string></string> </array> </dict> </plist> </metadata> </databaseInfo> <object type="XDDEVATTRIBUTEMAPPING" id="z102"> <attribute name="name" type="string">messageURL</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z103"> <attribute name="name" type="string">extra</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVENTITYMAPPING" id="z104"> <attribute name="migrationpolicyclassname" type="string">UAInboxDataMappingV2toV4</attribute> <attribute name="sourcename" type="string">UAInboxMessage</attribute> <attribute name="mappingtypename" type="string">Undefined</attribute> <attribute name="mappingnumber" type="int16">1</attribute> <attribute name="destinationname" type="string">UAInboxMessage</attribute> <attribute name="autogenerateexpression" type="bool">1</attribute> <relationship name="mappingmodel" type="1/1" destination="XDDEVMAPPINGMODEL" idrefs="z107"></relationship> <relationship name="attributemappings" type="0/0" destination="XDDEVATTRIBUTEMAPPING" idrefs="z103 z106 z110 z113 z105 z114 z108 z115 z109 z112 z102 z111 z116"></relationship> <relationship name="relationshipmappings" type="0/0" destination="XDDEVRELATIONSHIPMAPPING"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z105"> <attribute name="name" type="string">messageSent</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z106"> <attribute name="name" type="string">messageExpiration</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVMAPPINGMODEL" id="z107"> <attribute name="sourcemodelpath" type="string">AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 2.xcdatamodel</attribute> <attribute name="sourcemodeldata" type="binary">YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEBdwALAAwAGQA1ADYANwA/AEAAWwBcAF0AYwBkAHAAhgCHAIgAiQCKAIsAjACNAI4AjwCoAKsAsgC4AMcA1gDZAOgA9wD6AFoBCgEZAR0BIQEwATYBNwE/AU4BTwFYAXQBdQF2AXcBeAF5AXoBewF8AX0BfgF/AYABlQGWAZ4BnwGgAawBwAHBAcIBwwHEAcUBxgHHAcgB1wHmAfUB+QIIAhcCGAInAjYCRQJRAmMCZAJlAmYCZwJoAmkCagJ5AogClwKmAqcCtgLFAsYC1QLdAvIC8wL7AwcDGwMqAzkDSANMA1sDagN5A4gDlwOjA7UDxAPTA+ID8QQABA8EEAQfBDQENQQ9BEkEXQRsBHsEigSOBJ0ErAS7BMoE2QTlBPcFBgUVBSQFMwVCBVEFUgVhBXYFdwV/BYsFnwWuBb0FzAXQBd8F7gX9BgwGGwYnBjkGSAZXBmYGdQaEBpMGlAajBrgGuQbBBs0G4QbwBv8HDgcSByEHMAc/B04HXQdpB3sHigeLB5oHqQe4B7kHyAfXB+YH+wf8CAQIEAgkCDMIQghRCFUIZAhzCIIIkQigCKwIvgjNCNwI6wj6CPsJCgkZCSgJPQk+CUYJUglmCXUJhAmTCZcJpgm1CcQJ0wniCe4KAAoPCh4KLQo8Cj0KTApbCmoKfwqACogKlAqoCrcKxgrVCtkK6Ar3CwYLFQskCzALQgtRC2ALbwt+C40LnAurC8ALwQvJC9UL6Qv4DAcMFgwaDCkMOAxHDFYMZQxxDIMMkgyhDLAMvwzODN0M7A0BDQINCg0WDSoNOQ1IDVcNWw1qDXkNiA2XDaYNsg3EDdMN1A3jDfIOAQ4QDh8OLg5DDkQOTA5YDmwOew6KDpkOnQ6sDrsOyg7ZDugO9A8GDxUPJA8zD0IPUQ9gD28PhA+FD40PmQ+tD7wPyw/aD94P7Q/8EAsQGhApEDUQRxBWEGUQdBCDEJIQoRCiELEQshC1EL4QwhDGEMoQ0hDVENkQ2lUkbnVsbNYADQAOAA8AEAARABIAEwAUABUAFgAXABhfEA9feGRfcm9vdFBhY2thZ2VWJGNsYXNzXV94ZF9tb2RlbE5hbWVcX3hkX2NvbW1lbnRzXxAVX2NvbmZpZ3VyYXRpb25zQnlOYW1lXxAXX21vZGVsVmVyc2lvbklkZW50aWZpZXKAAoEBdoAAgQFzgQF0gQF13gAaABsAHAAdAB4AHwAgAA4AIQAiACMAJAAlACYAJwAoACkACQAnABUALQAuAC8AMAAxACcAJwAVXxAcWERCdWNrZXRGb3JDbGFzc2Vzd2FzRW5jb2RlZF8QGlhEQnVja2V0Rm9yUGFja2FnZXNzdG9yYWdlXxAcWERCdWNrZXRGb3JJbnRlcmZhY2Vzc3RvcmFnZV8QD194ZF9vd25pbmdNb2RlbF8QHVhEQnVja2V0Rm9yUGFja2FnZXN3YXNFbmNvZGVkVl9vd25lcl8QG1hEQnVja2V0Rm9yRGF0YVR5cGVzc3RvcmFnZVtfdmlzaWJpbGl0eV8QGVhEQnVja2V0Rm9yQ2xhc3Nlc3N0b3JhZ2VVX25hbWVfEB9YREJ1Y2tldEZvckludGVyZmFjZXN3YXNFbmNvZGVkXxAeWERCdWNrZXRGb3JEYXRhVHlwZXN3YXNFbmNvZGVkXxAQX3VuaXF1ZUVsZW1lbnRJRIAEgQFxgQFvgAGABIAAgQFwgQFyEACABYADgASABIAAUFNZRVPTADgAOQAOADoAPAA+V05TLmtleXNaTlMub2JqZWN0c6EAO4AGoQA9gAeAJV5VQUluYm94TWVzc2FnZd8QEABBAEIAQwBEAB8ARQBGACEARwBIAA4AIwBJAEoAJgBLAEwATQAnACcAEwBRAFIALwAnAEwAVQA7AEwAWABZAFpfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc29yZGVyZWRfECFYREJ1Y2tldEZvck93bmVkQXR0cmlidXRlc3N0b3JhZ2VfECBYREJ1Y2tldEZvclN0ZXJlb3R5cGVzd2FzRW5jb2RlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNzdG9yYWdlXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnNkdXBsaWNhdGVzXxAkWERCdWNrZXRGb3JHZW5lcmFsaXphdGlvbnN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc29yZGVyZWRfECFYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3N0b3JhZ2VbX2lzQWJzdHJhY3SACYAtgASABIACgAqBAWyABIAJgQFugAaACYEBbYAICBKUfK23V29yZGVyZWTTADgAOQAOAF4AYAA+oQBfgAuhAGGADIAlXlhEX1BTdGVyZW90eXBl2QAfACMAZQAOACYAZgAhAEsAZwA9AF8ATABrABUAJwAvAFoAb18QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QJFhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzd2FzRW5jb2RlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZYAHgAuACYAsgACABAiADdMAOAA5AA4AcQB7AD6pAHIAcwB0AHUAdgB3AHgAeQB6gA6AD4AQgBGAEoATgBSAFYAWqQB8AH0AfgB/AIAAgQCCAIMAhIAXgBuAHIAegB+AIYAjgCaAKoAlXxATWERQTUNvbXBvdW5kSW5kZXhlc18QEFhEX1BTS19lbGVtZW50SURfEBlYRFBNVW5pcXVlbmVzc0NvbnN0cmFpbnRzXxAaWERfUFNLX3ZlcnNpb25IYXNoTW9kaWZpZXJfEBlYRF9QU0tfZmV0Y2hSZXF1ZXN0c0FycmF5XxARWERfUFNLX2lzQWJzdHJhY3RfEA9YRF9QU0tfdXNlckluZm9fEBNYRF9QU0tfY2xhc3NNYXBwaW5nXxAWWERfUFNLX2VudGl0eUNsYXNzTmFtZd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAJsAFQBhAFoAWgBaAC8AWgCiAHIAWgBaABUAWlVfdHlwZVhfZGVmYXVsdFxfYXNzb2NpYXRpb25bX2lzUmVhZE9ubHlZX2lzU3RhdGljWV9pc1VuaXF1ZVpfaXNEZXJpdmVkWl9pc09yZGVyZWRcX2lzQ29tcG9zaXRlV19pc0xlYWaAAIAYgACADAgICAiAGoAOCAiAAAjSADkADgCpAKqggBnSAKwArQCuAK9aJGNsYXNzbmFtZVgkY2xhc3Nlc15OU011dGFibGVBcnJheaMArgCwALFXTlNBcnJheVhOU09iamVjdNIArACtALMAtF8QEFhEVU1MUHJvcGVydHlJbXCkALUAtgC3ALFfEBBYRFVNTFByb3BlcnR5SW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQAVABUAYQBaAFoAWgAvAFoAogBzAFoAWgAVAFqAAIAAgACADAgICAiAGoAPCAiAAAjfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQDJABUAYQBaAFoAWgAvAFoAogB0AFoAWgAVAFqAAIAdgACADAgICAiAGoAQCAiAAAjSADkADgDXAKqggBnfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQAVABUAYQBaAFoAWgAvAFoAogB1AFoAWgAVAFqAAIAAgACADAgICAiAGoARCAiAAAjfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQDqABUAYQBaAFoAWgAvAFoAogB2AFoAWgAVAFqAAIAggACADAgICAiAGoASCAiAAAjSADkADgD4AKqggBnfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQD8ABUAYQBaAFoAWgAvAFoAogB3AFoAWgAVAFqAAIAigACADAgICAiAGoATCAiAAAgI3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUBDAAVAGEAWgBaAFoALwBaAKIAeABaAFoAFQBagACAJIAAgAwICAgIgBqAFAgIgAAI0wA4ADkADgEaARsAPqCggCXSAKwArQEeAR9fEBNOU011dGFibGVEaWN0aW9uYXJ5owEeASAAsVxOU0RpY3Rpb25hcnnfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQEjABUAYQBaAFoAWgAvAFoAogB5AFoAWgAVAFqAAIAngACADAgICAiAGoAVCAiAAAjWACMADgAmAEsAHwAhATEBMgAVAFoAFQAvgCiAKYAACIAAXxAUWERHZW5lcmljUmVjb3JkQ2xhc3PSAKwArQE4ATldWERVTUxDbGFzc0ltcKYBOgE7ATwBPQE+ALFdWERVTUxDbGFzc0ltcF8QElhEVU1MQ2xhc3NpZmllckltcF8QEVhEVU1MTmFtZXNwYWNlSW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQFBABUAYQBaAFoAWgAvAFoAogB6AFoAWgAVAFqAAIArgACADAgICAiAGoAWCAiAAAhfEBJVQUluYm94TWVzc2FnZURhdGHSAKwArQFQAVFfEBJYRFVNTFN0ZXJlb3R5cGVJbXCnAVIBUwFUAVUBVgFXALFfEBJYRFVNTFN0ZXJlb3R5cGVJbXBdWERVTUxDbGFzc0ltcF8QElhEVU1MQ2xhc3NpZmllckltcF8QEVhEVU1MTmFtZXNwYWNlSW1wXxAUWERVTUxOYW1lZEVsZW1lbnRJbXBfEA9YRFVNTEVsZW1lbnRJbXDTADgAOQAOAVkBZgA+rAFaAVsBXAFdAV4BXwFgAWEBYgFjAWQBZYAugC+AMIAxgDKAM4A0gDWANoA3gDiAOawBZwFoAWkBagFrAWwBbQFuAW8BcAFxAXKAOoBmgH6AloCugMeA34D3gQEOgQElgQE9gQFUgCVVZXh0cmFebWVzc2FnZUJvZHlVUkxfEBByYXdNZXNzYWdlT2JqZWN0XxAQbWVzc2FnZVJlcG9ydGluZ11kZWxldGVkQ2xpZW50XxARbWVzc2FnZUV4cGlyYXRpb25ZbWVzc2FnZUlEW21lc3NhZ2VTZW50VXRpdGxlXHVucmVhZENsaWVudFZ1bnJlYWRabWVzc2FnZVVSTN8QEgCQAJEAkgGBAB8AlACVAYIAIQCTAYMAlgAOACMAlwCYACYAmQAVABUAFQAnAD0AWgBaAYsALwBaAEwAWgGPAVoAWgBaAZMAWl8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZIAAgACAAIAEgAcICIA8CIAJCIBlgC4ICIA7CBK1hf9R0wA4ADkADgGXAZoAPqIBmAGZgD2APqIBmwGcgD+AU4AlXxASWERfUFByb3BTdGVyZW90eXBlXxASWERfUEF0dF9TdGVyZW90eXBl2QAfACMBoQAOACYBogAhAEsBowFnAZgATABrABUAJwAvAFoBq18QIVhEQnVja2V0Rm9yT3duZWRBdHRy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FZMVlRWWFZcVmBWZFZsVnRWeFZ8VoRWiFeEV4xXlFecV6RXqFesV7BXtFe8V8RXyFfMV9RX2FhsWPxZmFooWjBaOFpAWkhaUFpYWlxaZFqYWtRa3FrkWuxa9Fr8WwRbDFtIW1BbWFtgW2hbcFt4W4BbiFwIXLRdHF2AXeheaF70X/Bf+GAAYAhgEGAUYBhgHGAgYChgMGA0YDhgQGBEYUBhSGFQYVhhYGFkYWhhbGFwYXhhgGGEYYhhkGGUYpBimGKgYqhisGK0YrhivGLAYshi0GLUYthi4GLkY+Bj6GPwY/hkAGQEZAhkDGQQZBhkIGQkZChkMGQ0ZEBlPGVEZUxlVGVcZWBlZGVoZWxldGV8ZYBlhGWMZZBmjGaUZpxmpGasZrBmtGa4ZrxmxGbMZtBm1GbcZuBnRGhAaEhoUGhYaGBoZGhoaGxocGh4aIBohGiIaJBolGi4aPBpJGlcaZBp3Go4aoBrrGw4bLhtOG1AbUhtUG1YbWBtZG1obXBtdG18bYBtiG2QbZRtmG2gbaRtyG38bhBuGG4gbjRuPG5Ebkxu4G9wcAxwnHCkcKxwtHC8cMRwzHDQcNhxDHFQcVhxYHFocXBxeHGAcYhxkHHUcdxx5HHscfRx/HIEcgxyFHIccxhzIHMoczBzOHM8c0BzRHNIc1BzWHNcc2BzaHNsdGh0cHR4dIB0iHSMdJB0lHSYdKB0qHSsdLB0uHS8dbh1wHXIddB12HXcdeB15HXodfB1+HX8dgB2CHYMdkB2RHZIdlB3THdUd1x3ZHdsd3B3dHd4d3x3hHeMd5B3lHecd6B4nHikeKx4tHi8eMB4xHjIeMx41HjceOB45HjsePB57Hn0efx6BHoMehB6FHoYehx6JHosejB6NHo8ekB7PHtEe0x7VHtce2B7ZHtoe2x7dHt8e4B7hHuMe5B8jHyUfJx8pHysfLB8tHy4fLx8xHzMfNB81HzcfOB9dH4EfqB/MH84f0B/SH9Qf1h/YH9kf2x/oH/cf+R/7H/0f/yABIAMgBSAUIBYgGCAaIBwgHiAgICIgJCBjIGUgZyBpIGsgbCBtIG4gbyBxIHMgdCB1IHcgeCC3ILkguyC9IL8gwCDBIMIgwyDFIMcgyCDJIMsgzCELIQ0hDyERIRMhFCEVIRYhFyEZIRshHCEdIR8hICFfIWEhYyFlIWchaCFpIWohayFt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N9w33jftN+838TfzN/U39zf5N/s3/Tg8OD44QDhCOEQ4RThGOEc4SDhKOEw4TThOOFA4UThUOJM4lTiXOJk4mzicOJ04njifOKE4ozikOKU4pzioOOc46TjrOO047zjwOPE48jjzOPU49zj4OPk4+zj8OTs5PTk/OUE5QzlEOUU5RjlHOUk5SzlMOU05TzlQOVM5kjmUOZY5mDmaOZs5nDmdOZ45oDmiOaM5pDmmOac55jnoOeo57DnuOe858DnxOfI59Dn2Ofc5+Dn6Ofs6Ojo8Oj46QDpCOkM6RDpFOkY6SDpKOks6TDpOOk86mjq9Ot06/Tr/OwE7AzsFOwc7CDsJOws7DDsOOw87ETsTOxQ7FTsXOxg7HTsqOy87MTszOzg7Ojs8Oz47YzuHO6470jvUO9Y72DvaO9w73jvfO+E77jv/PAE8AzwFPAc8CTwLPA08DzwgPCI8JDwmPCg8KjwsPC48MDwyPHE8czx1PHc8eTx6PHs8fDx9PH88gTyCPIM8hTyGPMU8xzzJPMs8zTzOPM880DzRPNM81TzWPNc82TzaPRk9Gz0dPR89IT0iPSM9JD0lPSc9KT0qPSs9LT0uPTs9PD09PT89fj2APYI9hD2GPYc9iD2JPYo9jD2OPY89kD2SPZM90j3UPdY92D3aPds93D3dPd494D3iPeM95D3mPec+Jj4oPio+LD4uPi8+MD4xPjI+ND42Pjc+OD46Pjs+ej58Pn4+gD6CPoM+hD6FPoY+iD6KPos+jD6OPo8+zj7QPtI+1D7WPtc+2D7ZPto+3D7ePt8+4D7iPuM/CD8sP1M/dz95P3s/fT9/P4E/gz+EP4Y/kz+iP6Q/pj+oP6o/rD+uP7A/vz/BP8M/xT/HP8k/yz/NP89ADkAQQBJAFEAWQBdAGEAZQBpAHEAeQB9AIEAiQCNAYkBkQGZAaEBqQGtAbEBtQG5AcEByQHNAdEB2QHdAtkC4QLpAvEC+QL9AwEDBQMJAxEDGQMdAyEDKQMtBCkEMQQ5BEEESQRNBFEEVQRZBGEEaQRtBHEEeQR9BIkFhQWNBZUFnQWlBakFrQWxBbUFvQXFBckFzQXVBdkG1QbdBuUG7Qb1BvkG/QcBBwUHDQcVBxkHHQclBykIJQgtCDUIPQhFCEkITQhRCFUIXQhlCGkIbQh1CHkJpQoxCrELMQs5C0ELSQtRC1kLXQthC2kLbQt1C3kLgQuJC40LkQuZC50LsQvlC/kMAQwJDB0MJQwtDDUMyQ1ZDfUOhQ6NDpUOnQ6lDq0OtQ65DsEO9Q85D0EPSQ9RD1kPYQ9pD3EPeQ+9D8UPzQ/VD90P5Q/tD/UP/RAFEQERCRERERkRIRElESkRLRExETkRQRFFEUkRURFVElESWRJhEmkScRJ1EnkSfRKBEokSkRKVEpkSoRKlE6ETqROxE7kTwRPFE8kTzRPRE9kT4RPlE+kT8RP1FCkULRQxFDkVNRU9FUUVTRVVFVkVXRVhFWUVbRV1FXkVfRWFFYkWhRaNFpUWnRalFqkWrRaxFrUWvRbFFskWzRbVFtkX1RfdF+UX7Rf1F/kX/RgBGAUYDRgVGBkYHRglGCkZJRktGTUZPRlFGUkZTRlRGVUZXRllGWkZbRl1GXkadRp9GoUajRqVGpkanRqhGqUarRq1GrkavRrFGskbXRvtHIkdGR0hHSkdMR05HUEdSR1NHVUdiR3FHc0d1R3dHeUd7R31Hf0eOR5BHkkeUR5ZHmEeaR5xHnkfdR99H4UfjR+VH5kfnR+hH6UfrR+1H7kfvR/FH8kgxSDNINUg3SDlIOkg7SDxIPUg/SEFIQkhDSEVIRkiFSIdIiUiLSI1IjkiPSJBIkUiTSJVIlkiXSJlImkjZSNtI3UjfSOFI4kjjSORI5UjnSOlI6kjrSO1I7kjxSTBJMkk0STZJOEk5STpJO0k8ST5JQElBSUJJRElFSYRJhkmISYpJjEmNSY5Jj0mQSZJJlEmVSZZJmEmZSdhJ2kncSd5J4EnhSeJJ40nkSeZJ6EnpSepJ7EntSjhKW0p7SptKnUqfSqFKo0qlSqZKp0qpSqpKrEqtSq9KsUqySrNKtUq2SrtKyErNSs9K0UrWSthK20rdSwJLJktNS3FLc0t1S3dLeUt7S31LfkuAS41LnkugS6JLpEumS6hLqkusS65Lv0vBS8NLxUvIS8tLzkvRS9RL1kwVTBdMGUwbTB1MHkwfTCBMIUwjTCVMJkwnTClMKkxpTGtMbUxvTHFMckxzTHRMdUx3THlMekx7TH1Mfky9TL9MwUzDTMVMxkzHTMhMyUzLTM1MzkzPTNFM0kzfTOBM4UzjTSJNJE0mTShNKk0rTSxNLU0uTTBNMk0zTTRNNk03TXZNeE16TXxNfk1/TYBNgU2CTYRNhk2HTYhNik2LTcpNzE3OTdBN0k3TTdRN1U3WTdhN2k3bTdxN3k3fTh5OIE4iTiROJk4nTihOKU4qTixOLk4vTjBOMk4zTnJOdE52TnhOek57TnxOfU5+ToBOgk6DToROhk6HTqxO0E73TxtPHU8fTyFPI08lTydPKE8rTzhPR09JT0tPTU9PT1FPU09VT2RPZ09qT21PcE9zT3ZPeU97T7pPvE++T8BPw0/ET8VPxk/HT8lPy0/MT81Pz0/QUA9QEVATUBVQGFAZUBpQG1AcUB5QIFAhUCJQJFAlUGRQZlBoUGpQbVBuUG9QcFBxUHNQdVB2UHdQeVB6ULlQu1C9UL9QwlDDUMRQxVDGUMhQylDLUMxQzlDPUQ5REFESURRRF1EYURlRGlEbUR1RH1EgUSFRI1EkUWNRZVFnUWlRbFFtUW5Rb1FwUXJRdFF1UXZReFF5UbhRulG8Ub5RwVHCUcNRxFHFUcdRyVHKUctRzVHOUhlSPFJcUnxSflKAUoJShFKGUodSiFKLUoxSjlKPUpFSk1KUUpVSmFKZUqJSr1K0UrZSuFK9UsBSw1LFUupTDlM1U1lTXFNeU2BTYlNkU2ZTZ1NqU3dTiFOKU4xTjlOQU5JTlFOWU5hTqVOsU69TslO1U7hTu1O+U8FTw1QCVARUBlQIVAtUDFQNVA5UD1QRVBNUFFQVVBdUGFRXVFlUW1RdVGBUYVRiVGNUZFRmVGhUaVRqVGxUbVSsVK5UsVSzVLZUt1S4VLlUulS8VL5Uv1TAVMJUw1TQVNFU0lTUVRNVFVUXVRlVHFUdVR5VH1UgVSJVJFUlVSZVKFUpVWhValVsVW5VcVVyVXNVdFV1VXdVeVV6VXtVfVV+Vb1Vv1XBVcNVxlXHVchVyVXKVcxVzlXPVdBV0lXTVhJWFFYWVhhWG1YcVh1WHlYfViFWI1YkViVWJ1YoVmdWaVZrVm1WcFZxVnJWc1Z0VnZWeFZ5VnpWfFZ9VqJWxlbtVxFXFFcWVxhXGlccVx5XH1ciVy9XPldAV0JXRFdGV0hXSldMV1tXXldhV2RXZ1dqV21XcFdyV7FXs1e1V7dXule7V7xXvVe+V8BXwlfDV8RXxlfHWAZYCFgKWAxYD1gQWBFYElgTWBVYF1gYWBlYG1gcWFtYXVhfWGFYZFhlWGZYZ1hoWGpYbFhtWG5YcFhxWLBYsli0WLZYuVi6WLtYvFi9WL9YwVjCWMNYxVjGWQVZB1kJWQtZDlkPWRBZEVkSWRRZFlkXWRhZGlkbWVpZXFleWWBZY1lkWWVZZllnWWlZa1lsWW1Zb1lwWa9ZsVmzWbVZuFm5WbpZu1m8Wb5ZwFnBWcJZxFnFWhBaM1pTWnNadVp3Wnlae1p9Wn5af1qCWoNahVqGWohailqLWoxaj1qQWpVaolqnWqlaq1qwWrNatlq4Wt1bAVsoW0xbT1tRW1NbVVtXW1lbWltdW2pbe1t9W39bgVuDW4Vbh1uJW4tbnFufW6JbpVuoW6tbrluxW7Rbtlv1W/db+Vv7W/5b/1wAXAFcAlwEXAZcB1wIXApcC1xKXExcTlxQXFNcVFxVXFZcV1xZXFtcXFxdXF9cYFyfXKFcpFymXKlcqlyrXKxcrVyvXLFcslyzXLVctlzDXMRcxVzHXQZdCF0KXQxdD10QXRFdEl0TXRVdF10YXRldG10cXVtdXV1fXWFdZF1lXWZdZ11oXWpdbF1tXW5dcF1xXbBdsl20XbZduV26XbtdvF29Xb9dwV3CXcNdxV3GXgVeB14JXgteDl4PXhBeEV4SXhReFl4XXhheGl4bXlpeXF5eXmBeY15kXmVeZl5nXmlea15sXm1eb15wXpVeuV7gXwRfB18JXwtfDV8PXxFfEl8VXyJfMV8zXzVfN185XztfPV8/X05fUV9UX1dfWl9dX2BfY19lX6Rfpl+pX6tfrl+vX7BfsV+yX7Rftl+3X7hful+7X79f/mAAYAJgBGAHYAhgCWAKYAtgDWAPYBBgEWATYBRgU2BVYFdgWWBcYF1gXmBfYGBgYmBkYGVgZmBoYGlgqGCqYKxgrmCxYLJgs2C0YLVgt2C5YLpgu2C9YL5g/WD/YQFhA2EGYQdhCGEJYQphDGEOYQ9hEGESYRNhUmFUYVZhWGFbYVxhXWFeYV9hYWFjYWRhZWFnYWhhp2GpYathrWGwYbFhsmGzYbRhtmG4YblhumG8Yb1iCGIrYktia2JtYm9icWJzYnVidmJ3Ynpie2J9Yn5igGKCYoNihGKHYohijWKaYp9ioWKjYqhiq2KuYrBi1WL5YyBjRGNHY0ljS2NNY09jUWNSY1VjYmNzY3Vjd2N5Y3tjfWN/Y4Fjg2OUY5djmmOdY6Bjo2OmY6ljrGOuY+1j72PxY/Nj9mP3Y/hj+WP6Y/xj/mP/ZABkAmQDZEJkRGRGZEhkS2RMZE1kTmRPZFFkU2RUZFVkV2RYZJdkmWScZJ5koWSiZKNkpGSlZKdkqWSqZKtkrWSuZLtkvGS9ZL9k/mUAZQJlBGUHZQhlCWUKZQtlDWUPZRBlEWUTZRRlU2VVZVdlWWVcZV1lXmVfZWBlYmVkZWVlZmVoZWllqGWqZaxlrmWxZbJls2W0ZbVlt2W5Zbplu2W9Zb5l/WX/ZgFmA2YGZgdmCGYJZgpmDGYOZg9mEGYSZhNmUmZUZlZmWGZbZlxmXWZeZl9mYWZjZmRmZWZnZmhmjWaxZthm/Gb/ZwFnA2cFZwdnCWcKZw1nGmcpZytnLWcvZzFnM2c1ZzdnRmdJZ0xnT2dSZ1VnWGdbZ11nnGeeZ6Fno2emZ6dnqGepZ6pnrGeuZ69nsGeyZ7Nn8mf0Z/Zn+Gf7Z/xn/Wf+Z/9oAWgDaARoBWgHaAhoR2hJaEtoTWhQaFFoUmhTaFRoVmhYaFloWmhcaF1onGieaKBoomilaKZop2ioaKloq2itaK5or2ixaLJo8WjzaPVo92j6aPto/Gj9aP5pAGkCaQNpBGkGaQdpRmlIaUppTGlPaVBpUWlSaVNpVWlXaVhpWWlbaVxpm2mdaZ9poWmkaaVppmmnaahpqmmsaa1prmmwabFp/Gofaj9qX2phamNqZWpnamlqampram5qb2pxanJqdGp2andqeGp7anxqhWqSapdqmWqbaqBqo2qmaqhqzWrxaxhrPGs/a0FrQ2tFa0drSWtKa01rWmtra21rb2txa3NrdWt3a3lre2uMa49rkmuVa5hrm2uea6FrpGuma+Vr52vpa+tr7mvva/Br8Wvya/Rr9mv3a/hr+mv7bDpsPGw+bEBsQ2xEbEVsRmxHbElsS2xMbE1sT2xQbI9skWyUbJZsmWyabJtsnGydbJ9soWyibKNspWymbLNstGy1bLds9mz4bPps/Gz/bQBtAW0CbQNtBW0HbQhtCW0LbQxtS21NbU9tUW1UbVVtVm1XbVhtWm1cbV1tXm1gbWFtoG2ibaRtpm2pbaptq22sba1tr22xbbJts221bbZt9W33bflt+23+bf9uAG4BbgJuBG4GbgduCG4KbgtuSm5Mbk5uUG5TblRuVW5WblduWW5bblxuXW5fbmBuhW6pbtBu9G73bvlu+279bv9vAW8CbwVvEm8hbyNvJW8nbylvK28tby9vPm9Bb0RvR29Kb01vUG9Tb1VvlG+Wb5hvmm+db55vn2+gb6Fvo2+lb6Zvp2+pb6pv6W/rb+1v72/yb/Nv9G/1b/Zv+G/6b/tv/G/+b/9wPnBAcEJwRHBHcEhwSXBKcEtwTXBPcFBwUXBTcFRwk3CVcJdwmXCccJ1wnnCfcKBwonCkcKVwpnCocKlw6HDqcOxw7nDxcPJw83D0cPVw93D5cPpw+3D9cP5xPXE/cUJxRHFHcUhxSXFKcUtxTXFPcVBxUXFTcVRxe3G6cbxxvnHAccNxxHHFccZxx3HJcctxzHHNcc9x0HHbceRx5XHncfBx+3IKchVyI3I4ckxyY3J1coJyg3KEcoZyk3KUcpVyl3KkcqVypnKocrFywHLNctxy7nMCcxlzK3M0czVzN3NEc0VzRnNIc0lzUnNcc2MAAAAAAAACAgAAAAAAABDeAAAAAAAAAAAAAAAAAABzaw== </attribute> <attribute name="destinationmodelpath" type="string">AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 4.xcdatamodel</attribute> <attribute name="destinationmodeldata" type="binary">YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEBkAALAAwAGQA1ADYANwA/AEAAWwBcAF0AYwBkAHAAhgCHAIgAiQCKAIsAjACNAI4AjwCoAKsAsgC4AMcA1gDZAOgA9wD6AFoBCgEZAR0BIQEwATYBNwE/AU4BTwFYAXYBdwF4AXkBegF7AXwBfQF+AX8BgAGBAYIBgwGYAZkBoQGiAaMBrwHDAcQBxQHGAccByAHJAcoBywHaAekB+AH8AgsCGgIbAioCOQJIAlQCZgJnAmgCaQJqAmsCbAJtAnwCiwKaAqkCqgK5AsgCyQLYAuAC9QL2Av4DCgMeAy0DPANLA08DXgNtA3wDiwOaA6YDuAPHA9YD5QP0A/UEBAQTBBQEIwQ4BDkEQQRNBGEEcAR/BI4EkgShBLAEvwTOBN0E6QT7BQoFGQUoBTcFOAVHBVYFZQV6BXsFgwWPBaMFsgXBBdAF1AXjBfIGAQYQBh8GKwY9BkwGWwZqBnkGiAaXBpgGpwa8Br0GxQbRBuUG9AcDBxIHFgclBzQHQwdSB2EHbQd/B44HjweeB60HvAe9B8wH2wfqB/8IAAgICBQIKAg3CEYIVQhZCGgIdwiGCJUIpAiwCMII0QjgCO8I/gkNCRwJHQksCUEJQglKCVYJagl5CYgJlwmbCaoJuQnICdcJ5gnyCgQKEwoiCjEKQApBClAKXwpuCoMKhAqMCpgKrAq7CsoK2QrdCuwK+wsKCxkLKAs0C0YLVQtkC3MLgguRC6ALrwvEC8ULzQvZC+0L/AwLDBoMHgwtDDwMSwxaDGkMdQyHDJYMpQy0DMMM0gzhDPANBQ0GDQ4NGg0uDT0NTA1bDV8Nbg19DYwNmw2qDbYNyA3XDeYN9Q4EDhMOIg4xDkYORw5PDlsObw5+Do0OnA6gDq8Ovg7NDtwO6w73DwkPGA8ZDygPNw9GD1UPZA9zD4gPiQ+RD50PsQ/AD88P3g/iD/EQABAPEB4QLRA5EEsQWhBpEHgQhxCWEKUQtBDJEMoQ0hDeEPIRAREQER8RIxEyEUERUBFfEW4RehGMEZsRqhG5EcgR1xHmEecR9hH3EfoSAxIHEgsSDxIXEhoSHhIfVSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgQGPgACBAYyBAY2BAY7eABoAGwAcAB0AHgAfACAADgAhACIAIwAkACUAJgAnACgAKQAJACcAFQAtAC4ALwAwADEAJwAnABVfEBxYREJ1Y2tldEZvckNsYXNzZXN3YXNFbmNvZGVkXxAaWERCdWNrZXRGb3JQYWNrYWdlc3N0b3JhZ2VfEBxYREJ1Y2tldEZvckludGVyZmFjZXNzdG9yYWdlXxAPX3hkX293bmluZ01vZGVsXxAdWERCdWNrZXRGb3JQYWNrYWdlc3dhc0VuY29kZWRWX293bmVyXxAbWERCdWNrZXRGb3JEYXRhVHlwZXNzdG9yYWdlW192aXNpYmlsaXR5XxAZWERCdWNrZXRGb3JDbGFzc2Vzc3RvcmFnZVVfbmFtZV8QH1hEQnVja2V0Rm9ySW50ZXJmYWNlc3dhc0VuY29kZWRfEB5YREJ1Y2tldEZvckRhdGFUeXBlc3dhc0VuY29kZWRfEBBfdW5pcXVlRWxlbWVudElEgASBAYqBAYiAAYAEgACBAYmBAYsQAIAFgAOABIAEgABQU1lFU9MAOAA5AA4AOgA8AD5XTlMua2V5c1pOUy5vYmplY3RzoQA7gAahAD2AB4AlXlVBSW5ib3hNZXNzYWdl3xAQAEEAQgBDAEQAHwBFAEYAIQBHAEgADgAjAEkASgAmAEsATABNACcAJwATAFEAUgAvACcATABVADsATABYAFkAWl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAJgC2ABIAEgAKACoEBhYAEgAmBAYeABoAJgQGGgAgIEmgTRktXb3JkZXJlZNMAOAA5AA4AXgBgAD6hAF+AC6EAYYAMgCVeWERfUFN0ZXJlb3R5cGXZAB8AIwBlAA4AJgBmACEASwBnAD0AXwBMAGsAFQAnAC8AWgBvXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgAeAC4AJgCyAAIAECIAN0wA4ADkADgBxAHsAPqkAcgBzAHQAdQB2AHcAeAB5AHqADoAPgBCAEYASgBOAFIAVgBapAHwAfQB+AH8AgACBAIIAgwCEgBeAG4AcgB6AH4AhgCOAJoAqgCVfEBNYRFBNQ29tcG91bmRJbmRleGVzXxAQWERfUFNLX2VsZW1lbnRJRF8QGVhEUE1VbmlxdWVuZXNzQ29uc3RyYWludHNfEBpYRF9QU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8QGVhEX1BTS19mZXRjaFJlcXVlc3RzQXJyYXlfEBFYRF9QU0tfaXNBYnN0cmFjdF8QD1hEX1BTS191c2VySW5mb18QE1hEX1BTS19jbGFzc01hcHBpbmdfEBZYRF9QU0tfZW50aXR5Q2xhc3NOYW1l3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUAmwAVAGEAWgBaAFoALwBaAKIAcgBaAFoAFQBaVV90eXBlWF9kZWZhdWx0XF9hc3NvY2lhdGlvbltfaXNSZWFkT25seVlfaXNTdGF0aWNZX2lzVW5pcXVlWl9pc0Rlcml2ZWRaX2lzT3JkZXJlZFxfaXNDb21wb3NpdGVXX2lzTGVhZoAAgBiAAIAMCAgICIAagA4ICIAACNIAOQAOAKkAqqCAGdIArACtAK4Ar1okY2xhc3NuYW1lWCRjbGFzc2VzXk5TTXV0YWJsZUFycmF5owCuALAAsVdOU0FycmF5WE5TT2JqZWN00gCsAK0AswC0XxAQWERVTUxQcm9wZXJ0eUltcKQAtQC2ALcAsV8QEFhEVU1MUHJvcGVydHlJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVABUAFQBhAFoAWgBaAC8AWgCiAHMAWgBaABUAWoAAgACAAIAMCAgICIAagA8ICIAACN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAMkAFQBhAFoAWgBaAC8AWgCiAHQAWgBaABUAWoAAgB2AAIAMCAgICIAagBAICIAACNIAOQAOANcAqqCAGd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVABUAFQBhAFoAWgBaAC8AWgCiAHUAWgBaABUAWoAAgACAAIAMCAgICIAagBEICIAACN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAOoAFQBhAFoAWgBaAC8AWgCiAHYAWgBaABUAWoAAgCCAAIAMCAgICIAagBIICIAACNIAOQAOAPgAqqCAGd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAPwAFQBhAFoAWgBaAC8AWgCiAHcAWgBaABUAWoAAgCKAAIAMCAgICIAagBMICIAACAjfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQEMABUAYQBaAFoAWgAvAFoAogB4AFoAWgAVAFqAAIAkgACADAgICAiAGoAUCAiAAAjTADgAOQAOARoBGwA+oKCAJdIArACtAR4BH18QE05TTXV0YWJsZURpY3Rpb25hcnmjAR4BIACxXE5TRGljdGlvbmFyed8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVASMAFQBhAFoAWgBaAC8AWgCiAHkAWgBaABUAWoAAgCeAAIAMCAgICIAagBUICIAACNYAIwAOACYASwAfACEBMQEyABUAWgAVAC+AKIApgAAIgABfEBRYREdlbmVyaWNSZWNvcmRDbGFzc9IArACtATgBOV1YRFVNTENsYXNzSW1wpgE6ATsBPAE9AT4AsV1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAUEAFQBhAFoAWgBaAC8AWgCiAHoAWgBaABUAWoAAgCuAAIAMCAgICIAagBYICIAACF8QElVBSW5ib3hNZXNzYWdlRGF0YdIArACtAVABUV8QElhEVU1MU3RlcmVvdHlwZUltcKcBUgFTAVQBVQFWAVcAsV8QElhEVU1MU3RlcmVvdHlwZUltcF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcNMAOAA5AA4BWQFnAD6tAVoBWwFcAV0BXgFfAWABYQFiAWMBZAFlAWaALoAvgDCAMYAygDOANIA1gDaAN4A4gDmAOq0BaAFpAWoBawFsAW0BbgFvAXABcQFyAXMBdIA7gGeAgICYgLCAyYDhgPmBARCBASeBAT6BAVaBAW2AJVVleHRyYV5tZXNzYWdlQm9keVVSTF8QEW1lc3NhZ2VFeHBpcmF0aW9uXxAQbWVzc2FnZVJlcG9ydGluZ11kZWxldGVkQ2xpZW50XxAQcmF3TWVzc2FnZU9iamVjdFltZXNzYWdlSURbY29udGVudFR5cGVbbWVzc2FnZVNlbnRVdGl0bGVcdW5yZWFkQ2xpZW50VnVucmVhZFptZXNzYWdlVVJM3xASAJAAkQCSAYQAHwCUAJUBhQAhAJMBhgCWAA4AIwCXAJgAJgCZABUAFQAVACcAPQBaAFoBjgAvAFoATABaAZIBWgBaAFoBlgBaXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASABwgIgD0IgAkIgGaALggIgDwIEv8F2wLTADgAOQAOAZoBnQA+ogGbAZyAPoA/ogGeAZ+AQIBUgCVfEBJYRF9QUHJvcFN0ZXJlb3R5cGVfEBJYRF9QQXR0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PTQ/NEw0XTRfNGE0YzRlNGc0aTRrNG00fjSANII0hDSGNIg0ijSMNI40kDTPNNE00zTVNNc02DTZNNo02zTdNN804DThNOM05DUjNSU1JzUpNSs1LDUtNS41LzUxNTM1NDU1NTc1ODV3NXk1ezV9NX81gDWBNYI1gzWFNYc1iDWJNYs1jDWZNZo1mzWdNdw13jXgNeI15DXlNeY15zXoNeo17DXtNe418DXxNjA2MjY0NjY2ODY5Njo2OzY8Nj42QDZBNkI2RDZFNoQ2hjaINoo2jDaNNo42jzaQNpI2lDaVNpY2mDaZNtg22jbcNt424DbhNuI24zbkNuY26DbpNuo27DbtNyw3LjcwNzI3NDc1NzY3Nzc4Nzo3PDc9Nz43QDdBN2Y3ijexN9U31zfZN9s33TffN+E34jfkN/E4ADgCOAQ4BjgIOAo4DDgOOB04HzghOCM4JTgnOCk4KzgtOGw4bjhwOHI4dDh1OHY4dzh4OHo4fDh9OH44gDiBOIQ4wzjFOMc4yTjLOMw4zTjOOM840TjTONQ41TjXONg5FzkZORs5HTkfOSA5ITkiOSM5JTknOSg5KTkrOSw5azltOW85cTlzOXQ5dTl2OXc5eTl7OXw5fTl/OYA5gznCOcQ5xjnIOco5yznMOc05zjnQOdI50znUOdY51zoWOhg6GjocOh46HzogOiE6IjokOiY6JzooOio6KzpqOmw6bjpwOnI6czp0OnU6djp4Ono6ezp8On46fzrKOu07DTstOy87MTszOzU7Nzs4Ozk7Ozs8Oz47PztBO0M7RDtFO0c7SDtNO1o7XzthO2M7aDtqO2w7bjuTO7c73jwCPAQ8BjwIPAo8DDwOPA88ETwePC88MTwzPDU8Nzw5PDs8PTw/PFA8UjxUPFY8WDxaPFw8XjxgPGI8oTyjPKU8pzypPKo8qzysPK08rzyxPLI8szy1PLY89Tz3PPk8+zz9PP48/z0APQE9Az0FPQY9Bz0JPQo9ST1LPU09Tz1RPVI9Uz1UPVU9Vz1ZPVo9Wz1dPV49az1sPW09bz2uPbA9sj20PbY9tz24Pbk9uj28Pb49vz3APcI9wz4CPgQ+Bj4IPgo+Cz4MPg0+Dj4QPhI+Ez4UPhY+Fz5WPlg+Wj5cPl4+Xz5gPmE+Yj5kPmY+Zz5oPmo+az6qPqw+rj6wPrI+sz60PrU+tj64Pro+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EFURVRJVFFUVVSJVI1UkVSZVZVVnVWlVa1VuVW9VcFVxVXJVdFV2VXdVeFV6VXtVulW8Vb5VwFXDVcRVxVXGVcdVyVXLVcxVzVXPVdBWD1YRVhNWFVYYVhlWGlYbVhxWHlYgViFWIlYkViVWZFZmVmhWalZtVm5Wb1ZwVnFWc1Z1VnZWd1Z5VnpWuVa7Vr1Wv1bCVsNWxFbFVsZWyFbKVstWzFbOVs9W9FcYVz9XY1dmV2hXaldsV25XcFdxV3RXgVeQV5JXlFeWV5hXmlecV55XrVewV7NXtle5V7xXv1fCV8RYA1gFWAdYCVgMWA1YDlgPWBBYElgUWBVYFlgYWBlYWFhaWFxYXlhhWGJYY1hkWGVYZ1hpWGpYa1htWG5YrVivWLFYs1i2WLdYuFi5WLpYvFi+WL9YwFjCWMNZAlkEWQZZCFkLWQxZDVkOWQ9ZEVkTWRRZFVkXWRhZV1lZWVtZXVlgWWFZYlljWWRZZlloWWlZallsWW1ZrFmuWbBZslm1WbZZt1m4WblZu1m9Wb5Zv1nBWcJaAVoDWgVaB1oKWgtaDFoNWg5aEFoSWhNaFFoWWhdaYlqFWqVaxVrHWslay1rNWs9a0FrRWtRa1VrXWtha2lrcWt1a3lrhWuJa51r0Wvla+1r9WwJbBVsIWwpbL1tTW3pbnluhW6NbpVunW6lbq1usW69bvFvNW89b0VvTW9Vb11vZW9tb3VvuW/Fb9Fv3W/pb/VwAXANcBlwIXEdcSVxLXE1cUFxRXFJcU1xUXFZcWFxZXFpcXFxdXJxcnlygXKJcpVymXKdcqFypXKtcrVyuXK9csVyyXPFc81z2XPhc+1z8XP1c/lz/XQFdA10EXQVdB10IXRVdFl0XXRldWF1aXVxdXl1hXWJdY11kXWVdZ11pXWpda11tXW5drV2vXbFds122XbdduF25XbpdvF2+Xb9dwF3CXcNeAl4EXgZeCF4LXgxeDV4OXg9eEV4TXhReFV4XXhheV15ZXlteXV5gXmFeYl5jXmReZl5oXmleal5sXm1erF6uXrBesl61XrZet164Xrleu169Xr5ev17BXsJe518LXzJfVl9ZX1tfXV9fX2FfY19kX2dfdF+DX4Vfh1+JX4tfjV+PX5FfoF+jX6ZfqV+sX69fsl+1X7df9l/4X/pf/F//YABgAWACYANgBWAHYAhgCWALYAxgS2BNYE9gUWBUYFVgVmBXYFhgWmBcYF1gXmBgYGFgoGCiYKRgpmCpYKpgq2CsYK1gr2CxYLJgs2C1YLZg9WD3YPlg+2D+YP9hAGEBYQJhBGEGYQdhCGEKYQthSmFMYU5hUGFTYVRhVWFWYVdhWWFbYVxhXWFfYWBhn2GhYaNhpWGoYalhqmGrYaxhrmGwYbFhsmG0YbVh9GH2Yfhh+mH9Yf5h/2IAYgFiA2IFYgZiB2IJYgpiVWJ4YphiuGK6YrxivmLAYsJiw2LEYsdiyGLKYstizWLPYtBi0WLUYtVi3mLrYvBi8mL0Yvli/GL/YwFjJmNKY3FjlWOYY5pjnGOeY6BjomOjY6Zjs2PEY8ZjyGPKY8xjzmPQY9Jj1GPlY+hj62PuY/Fj9GP3Y/pj/WP/ZD5kQGRCZERkR2RIZElkSmRLZE1kT2RQZFFkU2RUZJNklWSXZJlknGSdZJ5kn2SgZKJkpGSlZKZkqGSpZOhk6mTtZO9k8mTzZPRk9WT2ZPhk+mT7ZPxk/mT/ZQxlDWUOZRBlT2VRZVNlVWVYZVllWmVbZVxlXmVgZWFlYmVkZWVlpGWmZahlqmWtZa5lr2WwZbFls2W1ZbZlt2W5Zbpl+WX7Zf1l/2YCZgNmBGYFZgZmCGYKZgtmDGYOZg9mTmZQZlJmVGZXZlhmWWZaZltmXWZfZmBmYWZjZmRmo2alZqdmqWasZq1mrmavZrBmsma0ZrVmtma4Zrlm3mcCZylnTWdQZ1JnVGdWZ1hnWmdbZ15na2d6Z3xnfmeAZ4JnhGeGZ4hnl2eaZ51noGejZ6ZnqWesZ65n7WfvZ/Jn9Gf3Z/hn+Wf6Z/tn/Wf/aABoAWgDaARoCGhHaEloS2hNaFBoUWhSaFNoVGhWaFhoWWhaaFxoXWicaJ5ooGiiaKVopminaKhoqWiraK1ormivaLFosmjxaPNo9Wj3aPpo+2j8aP1o/mkAaQJpA2kEaQZpB2lGaUhpSmlMaU9pUGlRaVJpU2lVaVdpWGlZaVtpXGmbaZ1pn2mhaaRppWmmaadpqGmqaaxprWmuabBpsWnwafJp9Gn2aflp+mn7afxp/Wn/agFqAmoDagVqBmpRanRqlGq0arZquGq6arxqvmq/asBqw2rEasZqx2rJastqzGrNatBq0WrWauNq6Grqauxq8Wr0avdq+Wsea0JraWuNa5BrkmuUa5ZrmGuaa5trnmura7xrvmvAa8JrxGvGa8hrymvMa91r4Gvja+Zr6Wvsa+9r8mv1a/dsNmw4bDpsPGw/bEBsQWxCbENsRWxHbEhsSWxLbExsi2yNbI9skWyUbJVslmyXbJhsmmycbJ1snmygbKFs4GzibOVs52zqbOts7GztbO5s8GzybPNs9Gz2bPdtBG0FbQZtCG1HbUltS21NbVBtUW1SbVNtVG1WbVhtWW1abVxtXW2cbZ5toG2ibaVtpm2nbahtqW2rba1trm2vbbFtsm3xbfNt9W33bfpt+238bf1t/m4AbgJuA24EbgZuB25GbkhuSm5Mbk9uUG5RblJuU25VblduWG5ZbltuXG6bbp1un26hbqRupW6mbqduqG6qbqxurW6ubrBusW7WbvpvIW9Fb0hvSm9Mb05vUG9Sb1NvVm9jb3JvdG92b3hvem98b35vgG+Pb5JvlW+Yb5tvnm+hb6Rvpm/lb+dv6m/sb+9v8G/xb/Jv82/1b/dv+G/5b/tv/HA7cD1wP3BBcERwRXBGcEdwSHBKcExwTXBOcFBwUXCQcJJwlHCWcJlwmnCbcJxwnXCfcKFwonCjcKVwpnDlcOdw6XDrcO5w73DwcPFw8nD0cPZw93D4cPpw+3E6cTxxPnFAcUNxRHFFcUZxR3FJcUtxTHFNcU9xUHGPcZFxk3GVcZhxmXGacZtxnHGecaBxoXGicaRxpXHkceZx6HHqce1x7nHvcfBx8XHzcfVx9nH3cflx+nJFcmhyiHKocqpyrHKucrBysnKzcrRyt3K4crpyu3K9cr9ywHLBcsRyxXLOctty4HLicuRy6XLscu9y8XMWczpzYXOFc4hzinOMc45zkHOSc5NzlnOjc7RztnO4c7pzvHO+c8BzwnPEc9Vz2HPbc95z4XPkc+dz6nPtc+90LnQwdDJ0NHQ3dDh0OXQ6dDt0PXQ/dEB0QXRDdER0g3SFdId0iXSMdI10jnSPdJB0knSUdJV0lnSYdJl02HTadN1033TidON05HTldOZ06HTqdOt07HTudO90/HT9dP51AHU/dUF1Q3VFdUh1SXVKdUt1THVOdVB1UXVSdVR1VXWUdZZ1mHWadZ11nnWfdaB1oXWjdaV1pnWndal1qnXpdet17XXvdfJ183X0dfV19nX4dfp1+3X8df51/3Y+dkB2QnZEdkd2SHZJdkp2S3ZNdk92UHZRdlN2VHaTdpV2l3aZdpx2nXaedp92oHaidqR2pXamdqh2qXbOdvJ3GXc9d0B3QndEd0Z3SHdKd0t3Tndbd2p3bHdud3B3cnd0d3Z3eHeHd4p3jXeQd5N3lneZd5x3nnfdd9934Xfjd+Z353fod+l36nfsd+5373fwd/J383gyeDR4Nng4eDt4PHg9eD54P3hBeEN4RHhFeEd4SHiHeIl4i3iNeJB4kXiSeJN4lHiWeJh4mXiaeJx4nXjceN544HjieOV45njneOh46XjreO147njvePF48nkxeTN5NXk3eTp5O3k8eT15PnlAeUJ5Q3lEeUZ5R3mGeYh5i3mNeZB5kXmSeZN5lHmWeZh5mXmaeZx5nXnEegN6BXoHegl6DHoNeg56D3oQehJ6FHoVehZ6GHoZeiR6LXouejB6OXpEelN6XnpseoF6lXqser56y3rMes16z3rcet163nrgeu167nrvevF6+nsJexZ7JXs3e0t7Ynt0e317fnuAe417jnuPe5F7knube6V7rAAAAAAAAAICAAAAAAAAEiMAAAAAAAAAAAAAAAAAAHu0 </attribute> <relationship name="entitymappings" type="0/0" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z108"> <attribute name="name" type="string">unreadClient</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z109"> <attribute name="name" type="string">contentType</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z110"> <attribute name="name" type="string">title</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z111"> <attribute name="name" type="string">deletedClient</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z112"> <attribute name="name" type="string">messageID</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z113"> <attribute name="name" type="string">messageBodyURL</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z114"> <attribute name="name" type="string">unread</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z115"> <attribute name="name" type="string">rawMessageObject</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z116"> <attribute name="name" type="string">messageReporting</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z104"></relationship> </object> </database> ================================================ FILE: Airship/AirshipMessageCenter/Resources/UAInboxDataMappingV3toV4.xcmappingmodel/xcmapping.xml ================================================ <?xml version="1.0"?> <!DOCTYPE database SYSTEM "file:///System/Library/DTDs/CoreData.dtd"> <database> <databaseInfo> <version>134481920</version> <UUID>9E7BB05D-8284-4C86-94B5-62A27077AAF2</UUID> <nextObjectID>116</nextObjectID> <metadata> <plist version="1.0"> <dict> <key>NSPersistenceFrameworkVersion</key> <integer>1518</integer> <key>NSPersistenceMaximumFrameworkVersion</key> <integer>1518</integer> <key>NSStoreModelVersionChecksumKey</key> <string>bMpud663vz0bXQE24C6Rh4MvJ5jVnzsD2sI3njZkKbc=</string> <key>NSStoreModelVersionHashes</key> <dict> <key>XDDevAttributeMapping</key> <data> 0plcXXRN7XHKl5CcF+fwriFmUpON3ZtcI/AfK748aWc= </data> <key>XDDevEntityMapping</key> <data> qeN1Ym3TkWN1G6dU9RfX6Kd2ccEvcDVWHpd3LpLgboI= </data> <key>XDDevMappingModel</key> <data> EqtMzvRnVZWkXwBHu4VeVGy8UyoOe+bi67KC79kphlQ= </data> <key>XDDevPropertyMapping</key> <data> XN33V44TTGY4JETlMoOB5yyTKxB+u4slvDIinv0rtGA= </data> <key>XDDevRelationshipMapping</key> <data> akYY9LhehVA/mCb4ATLWuI9XGLcjpm14wWL1oEBtIcs= </data> </dict> <key>NSStoreModelVersionHashesDigest</key> <string>+Hmc2uYZK6og+Pvx5GUJ7oW75UG4V/ksQanTjfTKUnxyGWJRMtB5tIRgVwGsrd7lz/QR57++wbvWsr6nxwyS0A==</string> <key>NSStoreModelVersionHashesVersion</key> <integer>3</integer> <key>NSStoreModelVersionIdentifiers</key> <array> <string></string> </array> </dict> </plist> </metadata> </databaseInfo> <object type="XDDEVATTRIBUTEMAPPING" id="z102"> <attribute name="name" type="string">messageSent</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z103"> <attribute name="name" type="string">messageID</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z104"> <attribute name="name" type="string">extra</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z105"> <attribute name="name" type="string">unread</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z106"> <attribute name="name" type="string">unreadClient</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z107"> <attribute name="name" type="string">contentType</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVMAPPINGMODEL" id="z108"> <attribute name="sourcemodelpath" type="string">AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 3.xcdatamodel</attribute> <attribute name="sourcemodeldata" type="binary">YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEBeAALAAwAGQA1ADYANwA/AEAAWwBcAF0AYwBkAHAAhgCHAIgAiQCKAIsAjACNAI4AjwCoAKsAsgC4AMcA1gDZAOgA9wD6AFoBCgEZAR0BIQEwATYBNwE/AU4BTwFYAXQBdQF2AXcBeAF5AXoBewF8AX0BfgF/AYABlQGWAZ4BnwGgAawBwAHBAcIBwwHEAcUBxgHHAcgB1wHmAfUB+QIIAhcCGAInAjYCRQJRAmMCZAJlAmYCZwJoAmkCagJ5AogClwKmAqcCtgLFAsYC1QLdAvIC8wL7AwcDGwMqAzkDSANMA1sDagN5A4gDlwOjA7UDxAPTA+ID8QPyBAEEEAQRBCAENQQ2BD4ESgReBG0EfASLBI8EngStBLwEywTaBOYE+AUHBRYFJQU0BUMFUgVTBWIFdwV4BYAFjAWgBa8FvgXNBdEF4AXvBf4GDQYcBigGOgZJBlgGZwZ2BoUGlAaVBqQGuQa6BsIGzgbiBvEHAAcPBxMHIgcxB0AHTwdeB2oHfAeLB4wHmweqB7kHugfJB9gH5wf8B/0IBQgRCCUINAhDCFIIVghlCHQIgwiSCKEIrQi/CM4I3QjsCPsI/AkLCRoJKQk+CT8JRwlTCWcJdgmFCZQJmAmnCbYJxQnUCeMJ7woBChAKHwouCj0KPgpNClwKawqACoEKiQqVCqkKuArHCtYK2grpCvgLBwsWCyULMQtDC1ILYQtwC38LjgudC6wLwQvCC8oL1gvqC/kMCAwXDBsMKgw5DEgMVwxmDHIMhAyTDKIMsQzADM8M3gztDQINAw0LDRcNKw06DUkNWA1cDWsNeg2JDZgNpw2zDcUN1A3VDeQN8w4CDhEOIA4vDkQORQ5NDlkObQ58DosOmg6eDq0OvA7LDtoO6Q71DwcPFg8lDzQPQw9SD2EPcA+FD4YPjg+aD64PvQ/MD9sP3w/uD/0QDBAbECoQNhBIEFcQZhB1EIQQkxCiEKMQshCzELYQvxDDEMcQyxDTENYQ2hDbVSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgQF3gACBAXSBAXWBAXbeABoAGwAcAB0AHgAfACAADgAhACIAIwAkACUAJgAnACgAKQAJACcAFQAtAC4ALwAwADEAJwAnABVfEBxYREJ1Y2tldEZvckNsYXNzZXN3YXNFbmNvZGVkXxAaWERCdWNrZXRGb3JQYWNrYWdlc3N0b3JhZ2VfEBxYREJ1Y2tldEZvckludGVyZmFjZXNzdG9yYWdlXxAPX3hkX293bmluZ01vZGVsXxAdWERCdWNrZXRGb3JQYWNrYWdlc3dhc0VuY29kZWRWX293bmVyXxAbWERCdWNrZXRGb3JEYXRhVHlwZXNzdG9yYWdlW192aXNpYmlsaXR5XxAZWERCdWNrZXRGb3JDbGFzc2Vzc3RvcmFnZVVfbmFtZV8QH1hEQnVja2V0Rm9ySW50ZXJmYWNlc3dhc0VuY29kZWRfEB5YREJ1Y2tldEZvckRhdGFUeXBlc3dhc0VuY29kZWRfEBBfdW5pcXVlRWxlbWVudElEgASBAXKBAXCAAYAEgACBAXGBAXMQAIAFgAOABIAEgABQU1lFU9MAOAA5AA4AOgA8AD5XTlMua2V5c1pOUy5vYmplY3RzoQA7gAahAD2AB4AlXlVBSW5ib3hNZXNzYWdl3xAQAEEAQgBDAEQAHwBFAEYAIQBHAEgADgAjAEkASgAmAEsATABNACcAJwATAFEAUgAvACcATABVADsATABYAFkAWl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAJgC2ABIAEgAKACoEBbYAEgAmBAW+ABoAJgQFugAgIEqsitt9Xb3JkZXJlZNMAOAA5AA4AXgBgAD6hAF+AC6EAYYAMgCVeWERfUFN0ZXJlb3R5cGXZAB8AIwBlAA4AJgBmACEASwBnAD0AXwBMAGsAFQAnAC8AWgBvXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgAeAC4AJgCyAAIAECIAN0wA4ADkADgBxAHsAPqkAcgBzAHQAdQB2AHcAeAB5AHqADoAPgBCAEYASgBOAFIAVgBapAHwAfQB+AH8AgACBAIIAgwCEgBeAG4AcgB6AH4AhgCOAJoAqgCVfEBNYRFBNQ29tcG91bmRJbmRleGVzXxAQWERfUFNLX2VsZW1lbnRJRF8QGVhEUE1VbmlxdWVuZXNzQ29uc3RyYWludHNfEBpYRF9QU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8QGVhEX1BTS19mZXRjaFJlcXVlc3RzQXJyYXlfEBFYRF9QU0tfaXNBYnN0cmFjdF8QD1hEX1BTS191c2VySW5mb18QE1hEX1BTS19jbGFzc01hcHBpbmdfEBZYRF9QU0tfZW50aXR5Q2xhc3NOYW1l3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUAmwAVAGEAWgBaAFoALwBaAKIAcgBaAFoAFQBaVV90eXBlWF9kZWZhdWx0XF9hc3NvY2lhdGlvbltfaXNSZWFkT25seVlfaXNTdGF0aWNZX2lzVW5pcXVlWl9pc0Rlcml2ZWRaX2lzT3JkZXJlZFxfaXNDb21wb3NpdGVXX2lzTGVhZoAAgBiAAIAMCAgICIAagA4ICIAACNIAOQAOAKkAqqCAGdIArACtAK4Ar1okY2xhc3NuYW1lWCRjbGFzc2VzXk5TTXV0YWJsZUFycmF5owCuALAAsVdOU0FycmF5WE5TT2JqZWN00gCsAK0AswC0XxAQWERVTUxQcm9wZXJ0eUltcKQAtQC2ALcAsV8QEFhEVU1MUHJvcGVydHlJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVABUAFQBhAFoAWgBaAC8AWgCiAHMAWgBaABUAWoAAgACAAIAMCAgICIAagA8ICIAACN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAMkAFQBhAFoAWgBaAC8AWgCiAHQAWgBaABUAWoAAgB2AAIAMCAgICIAagBAICIAACNIAOQAOANcAqqCAGd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVABUAFQBhAFoAWgBaAC8AWgCiAHUAWgBaABUAWoAAgACAAIAMCAgICIAagBEICIAACN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAOoAFQBhAFoAWgBaAC8AWgCiAHYAWgBaABUAWoAAgCCAAIAMCAgICIAagBIICIAACNIAOQAOAPgAqqCAGd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAPwAFQBhAFoAWgBaAC8AWgCiAHcAWgBaABUAWoAAgCKAAIAMCAgICIAagBMICIAACAjfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQEMABUAYQBaAFoAWgAvAFoAogB4AFoAWgAVAFqAAIAkgACADAgICAiAGoAUCAiAAAjTADgAOQAOARoBGwA+oKCAJdIArACtAR4BH18QE05TTXV0YWJsZURpY3Rpb25hcnmjAR4BIACxXE5TRGljdGlvbmFyed8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVASMAFQBhAFoAWgBaAC8AWgCiAHkAWgBaABUAWoAAgCeAAIAMCAgICIAagBUICIAACNYAIwAOACYASwAfACEBMQEyABUAWgAVAC+AKIApgAAIgABfEBRYREdlbmVyaWNSZWNvcmRDbGFzc9IArACtATgBOV1YRFVNTENsYXNzSW1wpgE6ATsBPAE9AT4AsV1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAUEAFQBhAFoAWgBaAC8AWgCiAHoAWgBaABUAWoAAgCuAAIAMCAgICIAagBYICIAACF8QElVBSW5ib3hNZXNzYWdlRGF0YdIArACtAVABUV8QElhEVU1MU3RlcmVvdHlwZUltcKcBUgFTAVQBVQFWAVcAsV8QElhEVU1MU3RlcmVvdHlwZUltcF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcNMAOAA5AA4BWQFmAD6sAVoBWwFcAV0BXgFfAWABYQFiAWMBZAFlgC6AL4AwgDGAMoAzgDSANYA2gDeAOIA5rAFnAWgBaQFqAWsBbAFtAW4BbwFwAXEBcoA6gGaAf4CXgK+AyIDggPiBAQ+BASaBAT6BAVWAJVVleHRyYV5tZXNzYWdlQm9keVVSTF8QEHJhd01lc3NhZ2VPYmplY3RfEBBtZXNzYWdlUmVwb3J0aW5nXWRlbGV0ZWRDbGllbnRfEBFtZXNzYWdlRXhwaXJhdGlvblltZXNzYWdlSURbbWVzc2FnZVNlbnRVdGl0bGVcdW5yZWFkQ2xpZW50VnVucmVhZFptZXNzYWdlVVJM3xASAJAAkQCSAYEAHwCUAJUBggAhAJMBgwCWAA4AIwCXAJgAJgCZABUAFQAVACcAPQBaAFoBiwAvAFoATABaAY8BWgBaAFoBkwBaXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASABwgIgDwIgAkIgGWALggIgDsIEust2O/TADgAOQAOAZcBmgA+ogGYAZmAPYA+ogGbAZyAP4BTgCVfEBJYRF9QUHJvcFN0ZXJlb3R5cGVfEBJYRF9QQXR0X1N0ZXJlb3R5cGXZAB8AIwGhAA4AJgGiACEASwGjAWcBmABMAGsAFQAnAC8AWgGrXxAhWERCdWNrZXRGb3JPd25lZEF0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FUsVTBVNFU8VUBWPFZEVkxWVFZcVmBWZFZoVmxWdFZ8VoBWhFaMVpBXjFeUV5xXpFesV7BXtFe4V7xXxFfMV9BX1FfcV+BYdFkEWaBaMFo4WkBaSFpQWlhaYFpkWmxaoFrcWuRa7Fr0WvxbBFsMWxRbUFtYW2BbaFtwW3hbgFuIW5BcEFy8XSRdiF3wXnBe/F/4YABgCGAQYBhgHGAgYCRgKGAwYDhgPGBAYEhgTGFIYVBhWGFgYWhhbGFwYXRheGGAYYhhjGGQYZhhnGKYYqBiqGKwYrhivGLAYsRiyGLQYthi3GLgYuhi7GPoY/Bj+GQAZAhkDGQQZBRkGGQgZChkLGQwZDhkPGRIZURlTGVUZVxlZGVoZWxlcGV0ZXxlhGWIZYxllGWYZpRmnGakZqxmtGa4ZrxmwGbEZsxm1GbYZtxm5GboZ4RogGiIaJBomGigaKRoqGisaLBouGjAaMRoyGjQaNRo+GkwaWRpnGnQahxqeGrAa+xseGz4bXhtgG2IbZBtmG2gbaRtqG2wbbRtvG3Abcht0G3Ubdht4G3kbfhuLG5AbkhuUG5kbmxudG58bxBvoHA8cMxw1HDccORw7HD0cPxxAHEIcTxxgHGIcZBxmHGgcahxsHG4ccByBHIMchRyHHIkcixyNHI8ckRyTHNIc1BzWHNgc2hzbHNwc3RzeHOAc4hzjHOQc5hznHSYdKB0qHSwdLh0vHTAdMR0yHTQdNh03HTgdOh07HXodfB1+HYAdgh2DHYQdhR2GHYgdih2LHYwdjh2PHZwdnR2eHaAd3x3hHeMd5R3nHegd6R3qHesd7R3vHfAd8R3zHfQeMx41HjceOR47HjwePR4+Hj8eQR5DHkQeRR5HHkgehx6JHosejR6PHpAekR6SHpMelR6XHpgemR6bHpwe2x7dHt8e4R7jHuQe5R7mHuce6R7rHuwe7R7vHvAfLx8xHzMfNR83HzgfOR86HzsfPR8/H0AfQR9DH0QfaR+NH7Qf2B/aH9wf3h/gH+If5B/lH+cf9CADIAUgByAJIAsgDSAPIBEgICAiICQgJiAoICogLCAuIDAgbyBxIHMgdSB3IHggeSB6IHsgfSB/IIAggSCDIIQgwyDFIMcgySDLIMwgzSDOIM8g0SDTINQg1SDXINghFyEZIRshHSEfISAhISEiISMhJSEnISghKSErISwhayFt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N8k3yzfYN+c36TfrN+037zfxN/M39TgEOAY4CDgKOAw4DjgQOBI4FDhTOFU4VzhZOFs4XDhdOF44XzhhOGM4ZDhlOGc4aDhrOKo4rDiuOLA4sjizOLQ4tTi2OLg4uji7OLw4vji/OP45ADkCOQQ5BjkHOQg5CTkKOQw5DjkPORA5EjkTOVI5VDlWOVg5WjlbOVw5XTleOWA5YjljOWQ5ZjlnOWo5qTmrOa05rzmxObI5szm0ObU5tzm5Obo5uzm9Ob45/Tn/OgE6AzoFOgY6BzoIOgk6CzoNOg46DzoROhI6UTpTOlU6VzpZOlo6WzpcOl06XzphOmI6YzplOmY6sTrUOvQ7FDsWOxg7GjscOx47HzsgOyI7IzslOyY7KDsqOys7LDsuOy87NDtBO0Y7SDtKO087UTtTO1U7ejueO8U76TvrO+077zvxO/M79Tv2O/g8BTwWPBg8GjwcPB48IDwiPCQ8Jjw3PDk8Ozw9PD88QTxDPEU8RzxJPIg8ijyMPI48kDyRPJI8kzyUPJY8mDyZPJo8nDydPNw83jzgPOI85DzlPOY85zzoPOo87DztPO488DzxPTA9Mj00PTY9OD05PTo9Oz08PT49QD1BPUI9RD1FPVI9Uz1UPVY9lT2XPZk9mz2dPZ49nz2gPaE9oz2lPaY9pz2pPao96T3rPe097z3xPfI98z30PfU99z35Pfo9+z39Pf4+PT4/PkE+Qz5FPkY+Rz5IPkk+Sz5NPk4+Tz5RPlI+kT6TPpU+lz6ZPpo+mz6cPp0+nz6hPqI+oz6lPqY+5T7nPuk+6z7tPu4+7z7wPvE+8z71PvY+9z75Pvo/Hz9DP2o/jj+QP5I/lD+WP5g/mj+bP50/qj+5P7s/vT+/P8E/wz/FP8c/1j/YP9o/3D/eP+A/4j/kP+ZAJUAnQClAK0AtQC5AL0AwQDFAM0A1QDZAN0A5QDpAeUB7QH1Af0CBQIJAg0CEQIVAh0CJQIpAi0CNQI5AzUDPQNFA00DVQNZA10DYQNlA20DdQN5A30DhQOJBIUEjQSVBJ0EpQSpBK0EsQS1BL0ExQTJBM0E1QTZBOUF4QXpBfEF+QYBBgUGCQYNBhEGGQYhBiUGKQYxBjUHMQc5B0EHSQdRB1UHWQddB2EHaQdxB3UHeQeBB4UIgQiJCJEImQihCKUIqQitCLEIuQjBCMUIyQjRCNUKAQqNCw0LjQuVC50LpQutC7ULuQu9C8ULyQvRC9UL3QvlC+kL7Qv1C/kMDQxBDFUMXQxlDHkMgQyJDJENJQ21DlEO4Q7pDvEO+Q8BDwkPEQ8VDx0PUQ+VD50PpQ+tD7UPvQ/FD80P1RAZECEQKRAxEDkQQRBJEFEQWRBhEV0RZRFtEXURfRGBEYURiRGNEZURnRGhEaURrRGxEq0StRK9EsUSzRLREtUS2RLdEuUS7RLxEvUS/RMBE/0UBRQNFBUUHRQhFCUUKRQtFDUUPRRBFEUUTRRRFIUUiRSNFJUVkRWZFaEVqRWxFbUVuRW9FcEVyRXRFdUV2RXhFeUW4RbpFvEW+RcBFwUXCRcNFxEXGRchFyUXKRcxFzUYMRg5GEEYSRhRGFUYWRhdGGEYaRhxGHUYeRiBGIUZgRmJGZEZmRmhGaUZqRmtGbEZuRnBGcUZyRnRGdUa0RrZGuEa6RrxGvUa+Rr9GwEbCRsRGxUbGRshGyUbuRxJHOUddR19HYUdjR2VHZ0dpR2pHbEd5R4hHikeMR45HkEeSR5RHlkelR6dHqUerR61Hr0exR7NHtUf0R/ZH+Ef6R/xH/Uf+R/9IAEgCSARIBUgGSAhICUhISEpITEhOSFBIUUhSSFNIVEhWSFhIWUhaSFxIXUicSJ5IoEiiSKRIpUimSKdIqEiqSKxIrUiuSLBIsUjwSPJI9Ej2SPhI+Uj6SPtI/Ej+SQBJAUkCSQRJBUkISUdJSUlLSU1JT0lQSVFJUklTSVVJV0lYSVlJW0lcSZtJnUmfSaFJo0mkSaVJpkmnSalJq0msSa1Jr0mwSe9J8UnzSfVJ90n4SflJ+kn7Sf1J/0oASgFKA0oESk9KckqSSrJKtEq2SrhKukq8Sr1KvkrASsFKw0rESsZKyErJSspKzErNStJK30rkSuZK6ErtSu9K8kr0SxlLPUtkS4hLikuMS45LkEuSS5RLlUuXS6RLtUu3S7lLu0u9S79LwUvDS8VL1kvYS9pL3EvfS+JL5UvoS+tL7UwsTC5MMEwyTDRMNUw2TDdMOEw6TDxMPUw+TEBMQUyATIJMhEyGTIhMiUyKTItMjEyOTJBMkUySTJRMlUzUTNZM2UzbTN1M3kzfTOBM4UzjTOVM5kznTOlM6kz3TPhM+Uz7TTpNPE0+TUBNQk1DTURNRU1GTUhNSk1LTUxNTk1PTY5NkE2STZRNlk2XTZhNmU2aTZxNnk2fTaBNok2jTeJN5E3mTehN6k3rTexN7U3uTfBN8k3zTfRN9k33TjZOOE46TjxOPk4/TkBOQU5CTkRORk5HTkhOSk5LTopOjE6OTpBOkk6TTpROlU6WTphOmk6bTpxOnk6fTsRO6E8PTzNPNU83TzlPO089Tz9PQE9DT1BPX09hT2NPZU9nT2lPa09tT3xPf0+CT4VPiE+LT45PkU+TT9JP1E/WT9hP20/cT91P3k/fT+FP40/kT+VP50/oUCdQKVArUC1QMFAxUDJQM1A0UDZQOFA5UDpQPFA9UHxQflCAUIJQhVCGUIdQiFCJUItQjVCOUI9QkVCSUNFQ01DVUNdQ2lDbUNxQ3VDeUOBQ4lDjUORQ5lDnUSZRKFEqUSxRL1EwUTFRMlEzUTVRN1E4UTlRO1E8UXtRfVF/UYFRhFGFUYZRh1GIUYpRjFGNUY5RkFGRUdBR0lHUUdZR2VHaUdtR3FHdUd9R4VHiUeNR5VHmUjFSVFJ0UpRSllKYUppSnFKeUp9SoFKjUqRSplKnUqlSq1KsUq1SsFKxUrZSw1LIUspSzFLRUtRS11LZUv5TIlNJU21TcFNyU3RTdlN4U3pTe1N+U4tTnFOeU6BTolOkU6ZTqFOqU6xTvVPAU8NTxlPJU8xTz1PSU9VT11QWVBhUGlQcVB9UIFQhVCJUI1QlVCdUKFQpVCtULFRrVG1Ub1RxVHRUdVR2VHdUeFR6VHxUfVR+VIBUgVTAVMJUxVTHVMpUy1TMVM1UzlTQVNJU01TUVNZU11TkVOVU5lToVSdVKVUrVS1VMFUxVTJVM1U0VTZVOFU5VTpVPFU9VXxVflWAVYJVhVWGVYdViFWJVYtVjVWOVY9VkVWSVdFV01XVVddV2lXbVdxV3VXeVeBV4lXjVeRV5lXnViZWKFYqVixWL1YwVjFWMlYzVjVWN1Y4VjlWO1Y8VntWfVZ/VoFWhFaFVoZWh1aIVopWjFaNVo5WkFaRVrZW2lcBVyVXKFcqVyxXLlcwVzJXM1c2V0NXUldUV1ZXWFdaV1xXXldgV29Xcld1V3hXe1d+V4FXhFeGV8VXx1fJV8tXzlfPV9BX0VfSV9RX1lfXV9hX2lfbWBpYHFgeWCBYI1gkWCVYJlgnWClYK1gsWC1YL1gwWG9YcVhzWHVYeFh5WHpYe1h8WH5YgFiBWIJYhFiFWMRYxljIWMpYzVjOWM9Y0FjRWNNY1VjWWNdY2VjaWRlZG1kdWR9ZIlkjWSRZJVkmWShZKlkrWSxZLlkvWW5ZcFlyWXRZd1l4WXlZell7WX1Zf1mAWYFZg1mEWcNZxVnHWclZzFnNWc5Zz1nQWdJZ1FnVWdZZ2FnZWiRaR1pnWodaiVqLWo1aj1qRWpJak1qWWpdamVqaWpxanlqfWqBao1qkWqlatlq7Wr1av1rEWsdaylrMWvFbFVs8W2BbY1tlW2dbaVtrW21bbltxW35bj1uRW5NblVuXW5lbm1udW59bsFuzW7ZbuVu8W79bwlvFW8hbylwJXAtcDVwPXBJcE1wUXBVcFlwYXBpcG1wcXB5cH1xeXGBcYlxkXGdcaFxpXGpca1xtXG9ccFxxXHNcdFyzXLVcuFy6XL1cvly/XMBcwVzDXMVcxlzHXMlcylzXXNhc2VzbXRpdHF0eXSBdI10kXSVdJl0nXSldK10sXS1dL10wXW9dcV1zXXVdeF15XXpde118XX5dgF2BXYJdhF2FXcRdxl3IXcpdzV3OXc9d0F3RXdNd1V3WXddd2V3aXhleG14dXh9eIl4jXiReJV4mXiheKl4rXixeLl4vXm5ecF5yXnRed154Xnleel57Xn1ef16AXoFeg16EXqlezV70XxhfG18dXx9fIV8jXyVfJl8pXzZfRV9HX0lfS19NX09fUV9TX2JfZV9oX2tfbl9xX3Rfd195X7hful+9X79fwl/DX8RfxV/GX8hfyl/LX8xfzl/PX9NgEmAUYBZgGGAbYBxgHWAeYB9gIWAjYCRgJWAnYChgZ2BpYGtgbWBwYHFgcmBzYHRgdmB4YHlgemB8YH1gvGC+YMBgwmDFYMZgx2DIYMlgy2DNYM5gz2DRYNJhEWETYRVhF2EaYRthHGEdYR5hIGEiYSNhJGEmYSdhZmFoYWphbGFvYXBhcWFyYXNhdWF3YXhheWF7YXxhu2G9Yb9hwWHEYcVhxmHHYchhymHMYc1hzmHQYdFiHGI/Yl9if2KBYoNihWKHYoliimKLYo5ij2KRYpJilGKWYpdimGKbYpxioWKuYrNitWK3Yrxiv2LCYsRi6WMNYzRjWGNbY11jX2NhY2NjZWNmY2ljdmOHY4lji2ONY49jkWOTY5Vjl2OoY6tjrmOxY7Rjt2O6Y71jwGPCZAFkA2QFZAdkCmQLZAxkDWQOZBBkEmQTZBRkFmQXZFZkWGRaZFxkX2RgZGFkYmRjZGVkZ2RoZGlka2RsZKtkrWSwZLJktWS2ZLdkuGS5ZLtkvWS+ZL9kwWTCZM9k0GTRZNNlEmUUZRZlGGUbZRxlHWUeZR9lIWUjZSRlJWUnZShlZ2VpZWtlbWVwZXFlcmVzZXRldmV4ZXllemV8ZX1lvGW+ZcBlwmXFZcZlx2XIZclly2XNZc5lz2XRZdJmEWYTZhVmF2YaZhtmHGYdZh5mIGYiZiNmJGYmZidmZmZoZmpmbGZvZnBmcWZyZnNmdWZ3ZnhmeWZ7ZnxmoWbFZuxnEGcTZxVnF2cZZxtnHWceZyFnLmc9Zz9nQWdDZ0VnR2dJZ0tnWmddZ2BnY2dmZ2lnbGdvZ3FnsGeyZ7Vnt2e6Z7tnvGe9Z75nwGfCZ8NnxGfGZ8doBmgIaApoDGgPaBBoEWgSaBNoFWgXaBhoGWgbaBxoW2hdaF9oYWhkaGVoZmhnaGhoamhsaG1obmhwaHFosGiyaLRotmi5aLpou2i8aL1ov2jBaMJow2jFaMZpBWkHaQlpC2kOaQ9pEGkRaRJpFGkWaRdpGGkaaRtpWmlcaV5pYGljaWRpZWlmaWdpaWlraWxpbWlvaXBpr2mxabNptWm4ablpumm7abxpvmnAacFpwmnEacVqEGozalNqc2p1andqeWp7an1qfmp/aoJqg2qFaoZqiGqKaotqjGqPapBqlWqiaqdqqWqrarBqs2q2arhq3WsBayhrTGtPa1FrU2tVa1drWWtaa11ramt7a31rf2uBa4NrhWuHa4lri2uca59romula6hrq2uua7FrtGu2a/Vr92v5a/tr/mv/bABsAWwCbARsBmwHbAhsCmwLbEpsTGxObFBsU2xUbFVsVmxXbFlsW2xcbF1sX2xgbJ9soWykbKZsqWyqbKtsrGytbK9ssWyybLNstWy2bMNsxGzFbMdtBm0IbQptDG0PbRBtEW0SbRNtFW0XbRhtGW0bbRxtW21dbV9tYW1kbWVtZm1nbWhtam1sbW1tbm1wbXFtsG2ybbRttm25bbptu228bb1tv23BbcJtw23FbcZuBW4HbgluC24Obg9uEG4RbhJuFG4WbhduGG4abhtuWm5cbl5uYG5jbmRuZW5mbmduaW5rbmxubW5vbnBulW65buBvBG8HbwlvC28Nbw9vEW8SbxVvIm8xbzNvNW83bzlvO289bz9vTm9Rb1RvV29ab11vYG9jb2VvpG+mb6hvqm+tb65vr2+wb7Fvs2+1b7Zvt2+5b7pv+W/7b/1v/3ACcANwBHAFcAZwCHAKcAtwDHAOcA9wTnBQcFJwVHBXcFhwWXBacFtwXXBfcGBwYXBjcGRwo3ClcKdwqXCscK1wrnCvcLBwsnC0cLVwtnC4cLlw+HD6cPxw/nEBcQJxA3EEcQVxB3EJcQpxC3ENcQ5xTXFPcVJxVHFXcVhxWXFacVtxXXFfcWBxYXFjcWRxi3HKccxxznHQcdNx1HHVcdZx13HZcdtx3HHdcd9x4HHrcfRx9XH3cgByC3IaciVyM3JIclxyc3KFcpJyk3KUcpZyo3KkcqVyp3K0crVytnK4csFy0HLdcuxy/nMScylzO3NEc0VzR3NUc1VzVnNYc1lzYnNsc3MAAAAAAAACAgAAAAAAABDfAAAAAAAAAAAAAAAAAABzew== </attribute> <attribute name="destinationmodelpath" type="string">AirshipMessageCenter/Resources/UAInbox.xcdatamodeld/UAInbox 4.xcdatamodel</attribute> <attribute name="destinationmodeldata" type="binary">YnBsaXN0MDDUAAEAAgADAAQABQAGAAcAClgkdmVyc2lvblkkYXJjaGl2ZXJUJHRvcFgkb2JqZWN0 cxIAAYagXxAPTlNLZXllZEFyY2hpdmVy0QAIAAlUcm9vdIABrxEBkAALAAwAGQA1ADYANwA/AEAAWwBcAF0AYwBkAHAAhgCHAIgAiQCKAIsAjACNAI4AjwCoAKsAsgC4AMcA1gDZAOgA9wD6AFoBCgEZAR0BIQEwATYBNwE/AU4BTwFYAXYBdwF4AXkBegF7AXwBfQF+AX8BgAGBAYIBgwGYAZkBoQGiAaMBrwHDAcQBxQHGAccByAHJAcoBywHaAekB+AH8AgsCGgIbAioCOQJIAlQCZgJnAmgCaQJqAmsCbAJtAnwCiwKaAqkCqgK5AsgCyQLYAuAC9QL2Av4DCgMeAy0DPANLA08DXgNtA3wDiwOaA6YDuAPHA9YD5QP0A/UEBAQTBBQEIwQ4BDkEQQRNBGEEcAR/BI4EkgShBLAEvwTOBN0E6QT7BQoFGQUoBTcFOAVHBVYFZQV6BXsFgwWPBaMFsgXBBdAF1AXjBfIGAQYQBh8GKwY9BkwGWwZqBnkGiAaXBpgGpwa8Br0GxQbRBuUG9AcDBxIHFgclBzQHQwdSB2EHbQd/B44HjweeB60HvAe9B8wH2wfqB/8IAAgICBQIKAg3CEYIVQhZCGgIdwiGCJUIpAiwCMII0QjgCO8I/gkNCRwJHQksCUEJQglKCVYJagl5CYgJlwmbCaoJuQnICdcJ5gnyCgQKEwoiCjEKQApBClAKXwpuCoMKhAqMCpgKrAq7CsoK2QrdCuwK+wsKCxkLKAs0C0YLVQtkC3MLgguRC6ALrwvEC8ULzQvZC+0L/AwLDBoMHgwtDDwMSwxaDGkMdQyHDJYMpQy0DMMM0gzhDPANBQ0GDQ4NGg0uDT0NTA1bDV8Nbg19DYwNmw2qDbYNyA3XDeYN9Q4EDhMOIg4xDkYORw5PDlsObw5+Do0OnA6gDq8Ovg7NDtwO6w73DwkPGA8ZDygPNw9GD1UPZA9zD4gPiQ+RD50PsQ/AD88P3g/iD/EQABAPEB4QLRA5EEsQWhBpEHgQhxCWEKUQtBDJEMoQ0hDeEPIRAREQER8RIxEyEUERUBFfEW4RehGMEZsRqhG5EcgR1xHmEecR9hH3EfoSAxIHEgsSDxIXEhoSHhIfVSRudWxs1gANAA4ADwAQABEAEgATABQAFQAWABcAGF8QD194ZF9yb290UGFja2FnZVYkY2xhc3NdX3hkX21vZGVsTmFtZVxfeGRfY29tbWVudHNfEBVfY29uZmlndXJhdGlvbnNCeU5hbWVfEBdfbW9kZWxWZXJzaW9uSWRlbnRpZmllcoACgQGPgACBAYyBAY2BAY7eABoAGwAcAB0AHgAfACAADgAhACIAIwAkACUAJgAnACgAKQAJACcAFQAtAC4ALwAwADEAJwAnABVfEBxYREJ1Y2tldEZvckNsYXNzZXN3YXNFbmNvZGVkXxAaWERCdWNrZXRGb3JQYWNrYWdlc3N0b3JhZ2VfEBxYREJ1Y2tldEZvckludGVyZmFjZXNzdG9yYWdlXxAPX3hkX293bmluZ01vZGVsXxAdWERCdWNrZXRGb3JQYWNrYWdlc3dhc0VuY29kZWRWX293bmVyXxAbWERCdWNrZXRGb3JEYXRhVHlwZXNzdG9yYWdlW192aXNpYmlsaXR5XxAZWERCdWNrZXRGb3JDbGFzc2Vzc3RvcmFnZVVfbmFtZV8QH1hEQnVja2V0Rm9ySW50ZXJmYWNlc3dhc0VuY29kZWRfEB5YREJ1Y2tldEZvckRhdGFUeXBlc3dhc0VuY29kZWRfEBBfdW5pcXVlRWxlbWVudElEgASBAYqBAYiAAYAEgACBAYmBAYsQAIAFgAOABIAEgABQU1lFU9MAOAA5AA4AOgA8AD5XTlMua2V5c1pOUy5vYmplY3RzoQA7gAahAD2AB4AlXlVBSW5ib3hNZXNzYWdl3xAQAEEAQgBDAEQAHwBFAEYAIQBHAEgADgAjAEkASgAmAEsATABNACcAJwATAFEAUgAvACcATABVADsATABYAFkAWl8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yT3duZWRBdHRyaWJ1dGVzc3RvcmFnZV8QIFhEQnVja2V0Rm9yU3RlcmVvdHlwZXN3YXNFbmNvZGVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAdWERCdWNrZXRGb3JTdGVyZW90eXBlc3N0b3JhZ2VfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc2R1cGxpY2F0ZXNfECRYREJ1Y2tldEZvckdlbmVyYWxpemF0aW9uc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zb3JkZXJlZF8QIVhEQnVja2V0Rm9yR2VuZXJhbGl6YXRpb25zc3RvcmFnZVtfaXNBYnN0cmFjdIAJgC2ABIAEgAKACoEBhYAEgAmBAYeABoAJgQGGgAgIElci9zNXb3JkZXJlZNMAOAA5AA4AXgBgAD6hAF+AC6EAYYAMgCVeWERfUFN0ZXJlb3R5cGXZAB8AIwBlAA4AJgBmACEASwBnAD0AXwBMAGsAFQAnAC8AWgBvXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNvcmRlcmVkXxAkWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXN3YXNFbmNvZGVkXxAhWERCdWNrZXRGb3JPd25lZEF0dHJpYnV0ZXNzdG9yYWdlgAeAC4AJgCyAAIAECIAN0wA4ADkADgBxAHsAPqkAcgBzAHQAdQB2AHcAeAB5AHqADoAPgBCAEYASgBOAFIAVgBapAHwAfQB+AH8AgACBAIIAgwCEgBeAG4AcgB6AH4AhgCOAJoAqgCVfEBNYRFBNQ29tcG91bmRJbmRleGVzXxAQWERfUFNLX2VsZW1lbnRJRF8QGVhEUE1VbmlxdWVuZXNzQ29uc3RyYWludHNfEBpYRF9QU0tfdmVyc2lvbkhhc2hNb2RpZmllcl8QGVhEX1BTS19mZXRjaFJlcXVlc3RzQXJyYXlfEBFYRF9QU0tfaXNBYnN0cmFjdF8QD1hEX1BTS191c2VySW5mb18QE1hEX1BTS19jbGFzc01hcHBpbmdfEBZYRF9QU0tfZW50aXR5Q2xhc3NOYW1l3xAPAJAAkQCSAB8AkwCUAJUAIQCWAA4AIwCXAJgAJgCZABUAmwAVAGEAWgBaAFoALwBaAKIAcgBaAFoAFQBaVV90eXBlWF9kZWZhdWx0XF9hc3NvY2lhdGlvbltfaXNSZWFkT25seVlfaXNTdGF0aWNZX2lzVW5pcXVlWl9pc0Rlcml2ZWRaX2lzT3JkZXJlZFxfaXNDb21wb3NpdGVXX2lzTGVhZoAAgBiAAIAMCAgICIAagA4ICIAACNIAOQAOAKkAqqCAGdIArACtAK4Ar1okY2xhc3NuYW1lWCRjbGFzc2VzXk5TTXV0YWJsZUFycmF5owCuALAAsVdOU0FycmF5WE5TT2JqZWN00gCsAK0AswC0XxAQWERVTUxQcm9wZXJ0eUltcKQAtQC2ALcAsV8QEFhEVU1MUHJvcGVydHlJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVABUAFQBhAFoAWgBaAC8AWgCiAHMAWgBaABUAWoAAgACAAIAMCAgICIAagA8ICIAACN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAMkAFQBhAFoAWgBaAC8AWgCiAHQAWgBaABUAWoAAgB2AAIAMCAgICIAagBAICIAACNIAOQAOANcAqqCAGd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVABUAFQBhAFoAWgBaAC8AWgCiAHUAWgBaABUAWoAAgACAAIAMCAgICIAagBEICIAACN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAOoAFQBhAFoAWgBaAC8AWgCiAHYAWgBaABUAWoAAgCCAAIAMCAgICIAagBIICIAACNIAOQAOAPgAqqCAGd8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAPwAFQBhAFoAWgBaAC8AWgCiAHcAWgBaABUAWoAAgCKAAIAMCAgICIAagBMICIAACAjfEA8AkACRAJIAHwCTAJQAlQAhAJYADgAjAJcAmAAmAJkAFQEMABUAYQBaAFoAWgAvAFoAogB4AFoAWgAVAFqAAIAkgACADAgICAiAGoAUCAiAAAjTADgAOQAOARoBGwA+oKCAJdIArACtAR4BH18QE05TTXV0YWJsZURpY3Rpb25hcnmjAR4BIACxXE5TRGljdGlvbmFyed8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVASMAFQBhAFoAWgBaAC8AWgCiAHkAWgBaABUAWoAAgCeAAIAMCAgICIAagBUICIAACNYAIwAOACYASwAfACEBMQEyABUAWgAVAC+AKIApgAAIgABfEBRYREdlbmVyaWNSZWNvcmRDbGFzc9IArACtATgBOV1YRFVNTENsYXNzSW1wpgE6ATsBPAE9AT4AsV1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcN8QDwCQAJEAkgAfAJMAlACVACEAlgAOACMAlwCYACYAmQAVAUEAFQBhAFoAWgBaAC8AWgCiAHoAWgBaABUAWoAAgCuAAIAMCAgICIAagBYICIAACF8QElVBSW5ib3hNZXNzYWdlRGF0YdIArACtAVABUV8QElhEVU1MU3RlcmVvdHlwZUltcKcBUgFTAVQBVQFWAVcAsV8QElhEVU1MU3RlcmVvdHlwZUltcF1YRFVNTENsYXNzSW1wXxASWERVTUxDbGFzc2lmaWVySW1wXxARWERVTUxOYW1lc3BhY2VJbXBfEBRYRFVNTE5hbWVkRWxlbWVudEltcF8QD1hEVU1MRWxlbWVudEltcNMAOAA5AA4BWQFnAD6tAVoBWwFcAV0BXgFfAWABYQFiAWMBZAFlAWaALoAvgDCAMYAygDOANIA1gDaAN4A4gDmAOq0BaAFpAWoBawFsAW0BbgFvAXABcQFyAXMBdIA7gGeAgICYgLCAyYDhgPmBARCBASeBAT6BAVaBAW2AJVVleHRyYV5tZXNzYWdlQm9keVVSTF8QEW1lc3NhZ2VFeHBpcmF0aW9uXxAQbWVzc2FnZVJlcG9ydGluZ11kZWxldGVkQ2xpZW50XxAQcmF3TWVzc2FnZU9iamVjdFltZXNzYWdlSURbY29udGVudFR5cGVbbWVzc2FnZVNlbnRVdGl0bGVcdW5yZWFkQ2xpZW50VnVucmVhZFptZXNzYWdlVVJM3xASAJAAkQCSAYQAHwCUAJUBhQAhAJMBhgCWAA4AIwCXAJgAJgCZABUAFQAVACcAPQBaAFoBjgAvAFoATABaAZIBWgBaAFoBlgBaXxAgWERCdWNrZXRGb3JTdGVyZW90eXBlc3dhc0VuY29kZWRfEB1YREJ1Y2tldEZvclN0ZXJlb3R5cGVzc3RvcmFnZV8QHVhEQnVja2V0Rm9yU3RlcmVvdHlwZXNvcmRlcmVkgACAAIAAgASABwgIgD0IgAkIgGaALggIgDwIEwAAAAEW7FMV0wA4ADkADgGaAZ0APqIBmwGcgD6AP6IBngGfgECAVIAlXxASWERfUFByb3BTdGVyZW90eXBlXxASWERf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1R3XHdgd2R3bHdwd6R3qHesd7R4sHi4eMB4yHjQeNR42HjceOB46HjwePR4+HkAeQR6AHoIehB6GHogeiR6KHosejB6OHpAekR6SHpQelR7UHtYe2B7aHtwe3R7eHt8e4B7iHuQe5R7mHuge6R8oHyofLB8uHzAfMR8yHzMfNB82HzgfOR86HzwfPR98H34fgB+CH4QfhR+GH4cfiB+KH4wfjR+OH5AfkR+2H9ogASAlICcgKSArIC0gLyAxIDIgNCBBIFAgUiBUIFYgWCBaIFwgXiBtIG8gcSBzIHUgdyB5IHsgfSC8IL4gwCDCIMQgxSDGIMcgyCDKIMwgzSDOINAg0SEQIRIhFCEWIRghGSEaIRshHCEeISAhISEiISQhJSFkIWYhaCFqIWwhbSFuIW8hcCFyIXQhdSF2IXgheSG4IbohvCG+IcAhwSHCIcMhxCHGIcghySHKIcwhzSHQIg8iESITIhUiFyIYIhkiGiIbIh0iHyIgIiEiIyIkImMiZSJnImkiayJsIm0ibiJvInEicyJ0InUidyJ4Ip8i3iLgIuIi5CLmIuci6CLpIuoi7CLuIu8i8CLyIvMjPiNhI4EjoSOjI6UjpyOpI6sjrCOtI68jsCOyI7MjtSO3I7gjuSO7I7wjwSPOI9Mj1SPXI9wj3iPgI+IkByQrJFIkdiR4JHokfCR+JIAkgiSDJIUkkiSjJKUkpySpJKskrSSvJLEksyTEJMYkyCTKJMwkziTQJNIk1CTWJRUlFyUZJRslHSUeJR8lICUhJSMlJSUmJSclKSUqJWklayVtJW8lcSVyJXMldCV1JXcleSV6JXslfSV+Jb0lvyXBJcMlxSXGJcclyCXJJcslzSXOJc8l0SXSJd8l4CXhJeMmIiYkJiYmKCYqJismLCYtJi4mMCYyJjMmNCY2JjcmdiZ4JnomfCZ+Jn8mgCaBJoImhCaGJocmiCaKJosmyibMJs4m0CbSJtMm1CbVJtYm2CbaJtsm3CbeJt8nHicgJyInJCcmJycnKCcpJyonLCcuJy8nMCcyJzMncid0J3YneCd6J3snfCd9J34ngCeCJ4MnhCeGJ4cnrCfQJ/coGygdKB8oISgjKCUoJygoKCooNyhGKEgoSihMKE4oUChSKFQoYyhlKGcoaShrKG0obyhxKHMosii0KLYouCi6KLsovCi9KL4owCjCKMMoxCjGKMcpBikIKQopDCkOKQ8pECkRKRIpFCkWKRcpGCkaKRspWilcKV4pYCliKWMpZCllKWYpaClqKWspbCluKW8primwKbIptCm2KbcpuCm5KbopvCm+Kb8pwCnCKcMpxioFKgcqCSoLKg0qDioPKhAqESoTKhUqFioXKhkqGipZKlsqXSpfKmEqYipjKmQqZSpnKmkqaiprKm0qbiqtKq8qsSqzKrUqtiq3KrgquSq7Kr0qviq/KsEqwisNKzArUCtwK3IrdCt2K3greit7K3wrfit/K4ErgiuEK4YrhyuIK4oriyuQK50roiukK6YrqyutK68rsSvWK/osISxFLEcsSSxLLE0sTyxRLFIsVCxhLHIsdCx2LHgseix8LH4sgCyCLJMslSyXLJksmyydLJ8soSyjLKUs5CzmLOgs6izsLO0s7izvLPAs8iz0LPUs9iz4LPktOC06LTwtPi1ALUEtQi1DLUQtRi1ILUktSi1MLU0tjC2OLZAtki2ULZUtli2XLZgtmi2cLZ0tni2gLaEtri2vLbAtsi3xLfMt9S33Lfkt+i37Lfwt/S3/LgEuAi4DLgUuBi5FLkcuSS5LLk0uTi5PLlAuUS5TLlUuVi5XLlkuWi6ZLpsunS6fLqEuoi6jLqQupS6nLqkuqi6rLq0uri7tLu8u8S7zLvUu9i73Lvgu+S77Lv0u/i7/LwEvAi9BL0MvRS9HL0kvSi9LL0wvTS9PL1EvUi9TL1UvVi97L58vxi/qL+wv7i/wL/Iv9C/2L/cv+TAGMBUwFzAZMBswHTAfMCEwIzAyMDQwNjA4MDowPDA+MEAwQjCBMIMwhTCHMIkwijCLMIwwjTCPMJEwkjCTMJUwljDVMNcw2TDbMN0w3jDfMOAw4TDjMOUw5jDnMOkw6jEpMSsxLTEvMTExMjEzMTQxNTE3MTkxOjE7MT0xPjF9MX8xgTGDMYUxhjGHMYgxiTGLMY0xjjGPMZExkjHRMdMx1THXMdkx2jHbMdwx3THfMeEx4jHjMeUx5jIlMicyKTIrMi0yLjIvMjAyMTIzMjUyNjI3MjkyOjJhMqAyojKkMqYyqDKpMqoyqzKsMq4ysDKxMrIytDK1MwAzIzNDM2MzZTNnM2kzazNtM24zbzNxM3IzdDN1M3czeTN6M3szfTN+M4MzkDOVM5czmTOeM6AzojOkM8kz7TQUNDg0OjQ8ND40QDRCNEQ0RTRHNFQ0ZTRnNGk0azRtNG80cTRzNHU0hjSINIo0jDSONJA0kjSUNJY0mDTXNNk02zTdNN804DThNOI04zTlNOc06DTpNOs07DUrNS01LzUxNTM1NDU1NTY1NzU5NTs1PDU9NT81QDV/NYE1gzWFNYc1iDWJNYo1izWNNY81kDWRNZM1lDWhNaI1ozWlNeQ15jXoNeo17DXtNe417zXwNfI19DX1NfY1+DX5Njg2OjY8Nj42QDZBNkI2QzZENkY2SDZJNko2TDZNNow2jjaQNpI2lDaVNpY2lzaYNpo2nDadNp42oDahNuA24jbkNuY26DbpNuo26zbsNu428DbxNvI29Db1NzQ3Njc4Nzo3PDc9Nz43PzdAN0I3RDdFN0Y3SDdJN243kje5N9033zfhN+M35TfnN+k36jfsN/k4CDgKOAw4DjgQOBI4FDgWOCU4JzgpOCs4LTgvODE4Mzg1OHQ4djh4OHo4fDh9OH44fziAOII4hDiFOIY4iDiJOIw4yzjNOM840TjTONQ41TjWONc42TjbONw43TjfOOA5HzkhOSM5JTknOSg5KTkqOSs5LTkvOTA5MTkzOTQ5czl1OXc5eTl7OXw5fTl+OX85gTmDOYQ5hTmHOYg5iznKOcw5zjnQOdI50znUOdU51jnYOdo52zncOd453zoeOiA6IjokOiY6JzooOik6KjosOi46LzowOjI6MzpyOnQ6djp4Ono6ezp8On06fjqAOoI6gzqEOoY6hzrSOvU7FTs1Ozc7OTs7Oz07PztAO0E7QztEO0Y7RztJO0s7TDtNO087UDtVO2I7ZztpO2s7cDtyO3Q7djubO7875jwKPAw8DjwQPBI8FDwWPBc8GTwmPDc8OTw7PD08PzxBPEM8RTxHPFg8WjxcPF48YDxiPGQ8ZjxoPGo8qTyrPK08rzyxPLI8szy0PLU8tzy5PLo8uzy9PL48/Tz/PQE9Az0FPQY9Bz0IPQk9Cz0NPQ49Dz0RPRI9UT1TPVU9Vz1ZPVo9Wz1cPV09Xz1hPWI9Yz1lPWY9cz10PXU9dz22Pbg9uj28Pb49vz3APcE9wj3EPcY9xz3IPco9yz4KPgw+Dj4QPhI+Ez4UPhU+Fj4YPho+Gz4cPh4+Hz5ePmA+Yj5kPmY+Zz5oPmk+aj5sPm4+bz5wPnI+cz6yPrQ+tj64Pro+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FFUVVRZVF1UYVRpVHFUdVR5VIFUhVS5VL1UwVTJVcVVzVXVVd1V6VXtVfFV9VX5VgFWCVYNVhFWGVYdVxlXIVcpVzFXPVdBV0VXSVdNV1VXXVdhV2VXbVdxWG1YdVh9WIVYkViVWJlYnVihWKlYsVi1WLlYwVjFWcFZyVnRWdlZ5VnpWe1Z8Vn1Wf1aBVoJWg1aFVoZWxVbHVslWy1bOVs9W0FbRVtJW1FbWVtdW2FbaVttXAFckV0tXb1dyV3RXdld4V3pXfFd9V4BXjVecV55XoFeiV6RXpleoV6pXuVe8V79XwlfFV8hXy1fOV9BYD1gRWBNYFVgYWBlYGlgbWBxYHlggWCFYIlgkWCVYZFhmWGhYalhtWG5Yb1hwWHFYc1h1WHZYd1h5WHpYuVi7WL1Yv1jCWMNYxFjFWMZYyFjKWMtYzFjOWM9ZDlkQWRJZFFkXWRhZGVkaWRtZHVkfWSBZIVkjWSRZY1llWWdZaVlsWW1ZbllvWXBZcll0WXVZdll4WXlZuFm6WbxZvlnBWcJZw1nEWcVZx1nJWcpZy1nNWc5aDVoPWhFaE1oWWhdaGFoZWhpaHFoeWh9aIFoiWiNablqRWrFa0VrTWtVa11rZWtta3FrdWuBa4VrjWuRa5lroWula6lrtWu5a81sAWwVbB1sJWw5bEVsUWxZbO1tfW4ZbqlutW69bsVuzW7Vbt1u4W7tbyFvZW9tb3VvfW+Fb41vlW+db6Vv6W/1cAFwDXAZcCVwMXA9cElwUXFNcVVxXXFlcXFxdXF5cX1xgXGJcZFxlXGZcaFxpXKhcqlysXK5csVyyXLNctFy1XLdcuVy6XLtcvVy+XP1c/10CXQRdB10IXQldCl0LXQ1dD10QXRFdE10UXSFdIl0jXSVdZF1mXWhdal1tXW5db11wXXFdc111XXZdd115XXpduV27Xb1dv13CXcNdxF3FXcZdyF3KXctdzF3OXc9eDl4QXhJeFF4XXhheGV4aXhteHV4fXiBeIV4jXiReY15lXmdeaV5sXm1ebl5vXnBecl50XnVedl54XnleuF66Xrxevl7BXsJew17EXsVex17JXspey17NXs5e818XXz5fYl9lX2dfaV9rX21fb19wX3NfgF+PX5Ffk1+VX5dfmV+bX51frF+vX7JftV+4X7tfvl/BX8NgAmAEYAZgCGALYAxgDWAOYA9gEWATYBRgFWAXYBhgV2BZYFtgXWBgYGFgYmBjYGRgZmBoYGlgamBsYG1grGCuYLBgsmC1YLZgt2C4YLlgu2C9YL5gv2DBYMJhAWEDYQVhB2EKYQthDGENYQ5hEGESYRNhFGEWYRdhVmFYYVphXGFfYWBhYWFiYWNhZWFnYWhhaWFrYWxhq2GtYa9hsWG0YbVhtmG3YbhhumG8Yb1hvmHAYcFiAGICYgRiBmIJYgpiC2IMYg1iD2IRYhJiE2IVYhZiYWKEYqRixGLGYshiymLMYs5iz2LQYtNi1GLWYtdi2WLbYtxi3WLgYuFi6mL3Yvxi/mMAYwVjCGMLYw1jMmNWY31joWOkY6ZjqGOqY6xjrmOvY7Jjv2PQY9Jj1GPWY9hj2mPcY95j4GPxY/Rj92P6Y/1kAGQDZAZkCWQLZEpkTGROZFBkU2RUZFVkVmRXZFlkW2RcZF1kX2RgZJ9koWSjZKVkqGSpZKpkq2SsZK5ksGSxZLJktGS1ZPRk9mT5ZPtk/mT/ZQBlAWUCZQRlBmUHZQhlCmULZRhlGWUaZRxlW2VdZV9lYWVkZWVlZmVnZWhlamVsZW1lbmVwZXFlsGWyZbRltmW5Zbplu2W8Zb1lv2XBZcJlw2XFZcZmBWYHZglmC2YOZg9mEGYRZhJmFGYWZhdmGGYaZhtmWmZcZl5mYGZjZmRmZWZmZmdmaWZrZmxmbWZvZnBmr2axZrNmtWa4Zrlmuma7ZrxmvmbAZsFmwmbEZsVm6mcOZzVnWWdcZ15nYGdiZ2RnZmdnZ2pnd2eGZ4hnimeMZ45nkGeSZ5Rno2emZ6lnrGevZ7JntWe4Z7pn+Wf7Z/5oAGgDaARoBWgGaAdoCWgLaAxoDWgPaBBoFGhTaFVoV2hZaFxoXWheaF9oYGhiaGRoZWhmaGhoaWioaKporGiuaLFosmizaLRotWi3aLloumi7aL1ovmj9aP9pAWkDaQZpB2kIaQlpCmkMaQ5pD2kQaRJpE2lSaVRpVmlYaVtpXGldaV5pX2lhaWNpZGllaWdpaGmnaalpq2mtabBpsWmyabNptGm2abhpuWm6abxpvWn8af5qAGoCagVqBmoHaghqCWoLag1qDmoPahFqEmpdaoBqoGrAasJqxGrGashqymrLasxqz2rQatJq02rVatdq2GrZatxq3WrmavNq+Gr6avxrAWsEawdrCWsua1JreWuda6Bromuka6ZrqGuqa6trrmu7a8xrzmvQa9Jr1GvWa9hr2mvca+1r8Gvza/Zr+Wv8a/9sAmwFbAdsRmxIbEpsTGxPbFBsUWxSbFNsVWxXbFhsWWxbbFxsm2ydbJ9soWykbKVspmynbKhsqmysbK1srmywbLFs8GzybPVs92z6bPts/Gz9bP5tAG0CbQNtBG0GbQdtFG0VbRZtGG1XbVltW21dbWBtYW1ibWNtZG1mbWhtaW1qbWxtbW2sba5tsG2ybbVttm23bbhtuW27bb1tvm2/bcFtwm4BbgNuBW4HbgpuC24Mbg1uDm4QbhJuE24UbhZuF25WblhuWm5cbl9uYG5hbmJuY25lbmduaG5pbmtubG6rbq1ur26xbrRutW62brduuG66brxuvW6+bsBuwW7mbwpvMW9Vb1hvWm9cb15vYG9ib2NvZm9zb4JvhG+Gb4hvim+Mb45vkG+fb6JvpW+ob6tvrm+xb7Rvtm/1b/dv+m/8b/9wAHABcAJwA3AFcAdwCHAJcAtwDHBLcE1wT3BRcFRwVXBWcFdwWHBacFxwXXBecGBwYXCgcKJwpHCmcKlwqnCrcKxwrXCvcLFwsnCzcLVwtnD1cPdw+XD7cP5w/3EAcQFxAnEEcQZxB3EIcQpxC3FKcUxxTnFQcVNxVHFVcVZxV3FZcVtxXHFdcV9xYHGfcaFxo3GlcahxqXGqcatxrHGucbBxsXGycbRxtXH0cfZx+HH6cf1x/nH/cgByAXIDcgVyBnIHcglyCnJVcnhymHK4crpyvHK+csBywnLDcsRyx3LIcspyy3LNcs9y0HLRctRy1XLacudy7HLucvBy9XL4cvty/XMic0ZzbXORc5RzlnOYc5pznHOec59zonOvc8BzwnPEc8ZzyHPKc8xzznPQc+Fz5HPnc+pz7XPwc/Nz9nP5c/t0OnQ8dD50QHRDdER0RXRGdEd0SXRLdEx0TXRPdFB0j3SRdJN0lXSYdJl0mnSbdJx0nnSgdKF0onSkdKV05HTmdOl063TudO908HTxdPJ09HT2dPd0+HT6dPt1CHUJdQp1DHVLdU11T3VRdVR1VXVWdVd1WHVadVx1XXVedWB1YXWgdaJ1pHWmdal1qnWrdax1rXWvdbF1snWzdbV1tnX1dfd1+XX7df51/3YAdgF2AnYEdgZ2B3YIdgp2C3ZKdkx2TnZQdlN2VHZVdlZ2V3ZZdlt2XHZddl92YHafdqF2o3aldqh2qXaqdqt2rHaudrB2sXaydrR2tXbadv53JXdJd0x3TndQd1J3VHdWd1d3Wndnd3Z3eHd6d3x3fneAd4J3hHeTd5Z3mXecd593oneld6h3qnfpd+t37Xfvd/J383f0d/V39nf4d/p3+3f8d/53/3g+eEB4QnhEeEd4SHhJeEp4S3hNeE94UHhReFN4VHiTeJV4l3iZeJx4nXieeJ94oHiieKR4pXimeKh4qXjoeOp47HjuePF48njzePR49Xj3ePl4+nj7eP14/nk9eT95QXlDeUZ5R3lIeUl5SnlMeU55T3lQeVJ5U3mSeZR5l3mZeZx5nXmeeZ95oHmieaR5pXmmeah5qXnQeg96EXoTehV6GHoZehp6G3oceh56IHoheiJ6JHolejB6OXo6ejx6RXpQel96anp4eo16oXq4esp613rYetl623roeul66nrsevl6+nr7ev17BnsVeyJ7MXtDe1d7bnuAe4l7inuMe5l7mnube517nnune7F7uAAAAAAAAAICAAAAAAAAEiMAAAAAAAAAAAAAAAAAAHvA </attribute> <relationship name="entitymappings" type="0/0" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z109"> <attribute name="name" type="string">title</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z110"> <attribute name="name" type="string">messageReporting</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z111"> <attribute name="name" type="string">messageExpiration</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVENTITYMAPPING" id="z112"> <attribute name="sourcename" type="string">UAInboxMessage</attribute> <attribute name="mappingtypename" type="string">Undefined</attribute> <attribute name="mappingnumber" type="int16">1</attribute> <attribute name="destinationname" type="string">UAInboxMessage</attribute> <attribute name="autogenerateexpression" type="bool">1</attribute> <relationship name="mappingmodel" type="1/1" destination="XDDEVMAPPINGMODEL" idrefs="z108"></relationship> <relationship name="attributemappings" type="0/0" destination="XDDEVATTRIBUTEMAPPING" idrefs="z102 z115 z116 z105 z106 z107 z109 z110 z111 z113 z114 z103 z104"></relationship> <relationship name="relationshipmappings" type="0/0" destination="XDDEVRELATIONSHIPMAPPING"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z113"> <attribute name="name" type="string">messageURL</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z114"> <attribute name="name" type="string">deletedClient</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z115"> <attribute name="name" type="string">messageBodyURL</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> <object type="XDDEVATTRIBUTEMAPPING" id="z116"> <attribute name="name" type="string">rawMessageObject</attribute> <relationship name="entitymapping" type="1/1" destination="XDDEVENTITYMAPPING" idrefs="z112"></relationship> </object> </database> ================================================ FILE: Airship/AirshipMessageCenter/Source/AirshipMessageCenterResources.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Resources for AirshipMessageCenter public final class AirshipMessageCenterResources { /// Module bundle public static let bundle = resolveBundle() private static func resolveBundle() -> Bundle { #if SWIFT_PACKAGE AirshipLogger.trace("Using Bundle.module for AirshipMessageCenter") let bundle = Bundle.module #if DEBUG if bundle.resourceURL == nil { assertionFailure(""" AirshipMessageCenter module was built with SWIFT_PACKAGE but no resources were found. Check your build configuration. """) } #endif return bundle #endif return Bundle.airshipFindModule( moduleName: "AirshipMessageCenter", sourceBundle: Bundle(for: Self.self) ) } public static func localizedString(key: String) -> String? { return AirshipLocalizationUtils.localizedString( key, withTable: "UrbanAirship", moduleBundle: bundle ) } } extension String { var messageCenterLocalizedString: String { return AirshipMessageCenterResources.localizedString(key: self) ?? self } } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenter.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine #if canImport(AirshipCore) public import AirshipCore #endif #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif /// Delegate protocol for receiving callbacks related to message center. public protocol MessageCenterDisplayDelegate { /// Called when a message is requested to be displayed. /// /// - Parameters: /// - messageID: The message ID. @MainActor func displayMessageCenter(messageID: String) /// Called when the message center is requested to be displayed. @MainActor func displayMessageCenter() /// Called when the message center is requested to be dismissed. @MainActor func dismissMessageCenter() } /// Airship Message Center Protocol. @MainActor public protocol MessageCenter: AnyObject, Sendable { /// Called when the Message Center is requested to be displayed. /// Return `true` if the display was handled, `false` to fall back to default SDK behavior. var onDisplay: (@MainActor @Sendable (_ messageID: String?) -> Bool)? { get set } /// Called when the Message Center is requested to be dismissed. var onDismissDisplay: (@MainActor @Sendable () -> Void)? { get set } /// Message center display delegate. var displayDelegate: (any MessageCenterDisplayDelegate)? { get set } /// Message center inbox. var inbox: any MessageCenterInbox { get } /// The message center controller. var controller: MessageCenterController { get set } /// Default message center theme. var theme: MessageCenterTheme? { get set } /// Default message center predicate. Only applies to the OOTB Message Center. If you are embedding the MessageCenterView directly /// you should pass the predicate in through the view extension `.messageCenterPredicate(_:)`. var predicate: (any MessageCenterPredicate)? { get set } /// Loads a Message center theme from a plist file. If you are embedding the MessageCenterView directly /// you should pass the theme in through the view extension `.messageCenterTheme(_:)`. /// - Parameters: /// - plist: The name of the plist in the bundle. func setThemeFromPlist(_ plist: String) throws /// Display the message center. func display() /// Display the given message with animation. /// - Parameters: /// - messageID: The messageID of the message. func display(messageID: String) /// Dismiss the message center. func dismiss() } /// Airship Message Center module. final class DefaultMessageCenter: MessageCenter { @MainActor public var onDisplay: (@MainActor @Sendable (_ messageID: String?) -> Bool)? @MainActor public var onDismissDisplay: (@MainActor @Sendable () -> Void)? @MainActor public var displayDelegate: (any MessageCenterDisplayDelegate)? { get { mutable.displayDelegate } set { mutable.displayDelegate = newValue } } private let mutable: MutableValues private let privacyManager: any AirshipPrivacyManager let internalInbox: any InternalMessageCenterInbox let meteredUsage: any AirshipMeteredUsage let analytics: any InternalAirshipAnalytics public var inbox: any MessageCenterInbox { return self.internalInbox } @MainActor public var controller: MessageCenterController { get { self.mutable.controller } set { self.mutable.controller = newValue } } @MainActor public var theme: MessageCenterTheme? { get { self.mutable.theme } set { self.mutable.theme = newValue } } @MainActor public func setThemeFromPlist(_ plist: String) throws { self.theme = try MessageCenterTheme.fromPlist(plist) } @MainActor public var predicate: (any MessageCenterPredicate)? { get { self.mutable.predicate } set { self.mutable.predicate = newValue } } private var enabled: Bool { return self.privacyManager.isEnabled(.messageCenter) } @MainActor init( dataStore: PreferenceDataStore, config: RuntimeConfig, privacyManager: any AirshipPrivacyManager, notificationCenter: NotificationCenter = NotificationCenter.default, inbox: DefaultMessageCenterInbox, controller: MessageCenterController, meteredUsage: any AirshipMeteredUsage, analytics: any InternalAirshipAnalytics ) { self.internalInbox = inbox self.privacyManager = privacyManager self.mutable = MutableValues(controller: controller, theme: MessageCenterThemeLoader.defaultPlist()) self.meteredUsage = meteredUsage self.analytics = analytics if let plist = config.airshipConfig.messageCenterStyleConfig { do { try setThemeFromPlist(plist) } catch { AirshipLogger.error("Failed to load Message Center \(plist) theme \(error) ") } } notificationCenter.addObserver( forName: AirshipNotifications.PrivacyManagerUpdated.name, object: nil, queue: nil ) { [weak self, inbox] _ in Task { @MainActor in inbox.enabled = self?.enabled ?? false } } inbox.enabled = self.enabled } @MainActor convenience init( dataStore: PreferenceDataStore, config: RuntimeConfig, channel: any InternalAirshipChannel, privacyManager: any AirshipPrivacyManager, workManager: any AirshipWorkManagerProtocol, meteredUsage: any AirshipMeteredUsage, analytics: any InternalAirshipAnalytics ) { let controller = MessageCenterController() let inbox = DefaultMessageCenterInbox( with: config, dataStore: dataStore, channel: channel, workManager: workManager ) self.init( dataStore: dataStore, config: config, privacyManager: privacyManager, inbox: inbox, controller: controller, meteredUsage: meteredUsage, analytics: analytics ) } @MainActor public func display() { guard self.enabled else { AirshipLogger.warn("Message center disabled. Unable to display.") return } let handled: Bool if let onDisplay { handled = onDisplay(nil) } else if let displayDelegate { displayDelegate.displayMessageCenter() handled = true } else { handled = false } guard !handled else { AirshipLogger.trace( "Message center display request handled by the app." ) return } AirshipLogger.trace("Launching OOTB message center") showDefaultMessageCenter() self.controller.navigate(messageID: nil) } @MainActor public func display(messageID: String) { guard self.enabled else { AirshipLogger.warn("Message center disabled. Unable to display.") return } let handled: Bool if let onDisplay { handled = onDisplay(messageID) } else if let displayDelegate { displayDelegate.displayMessageCenter(messageID: messageID) handled = true } else { handled = false } guard !handled else { AirshipLogger.trace( "Message center display request for message \(messageID) handled by the app." ) return } AirshipLogger.trace("Launching OOTB message center") showDefaultMessageCenter() self.controller.navigate(messageID: messageID) } @MainActor public func dismiss() { if let onDismissDisplay { onDismissDisplay() } else if let displayDelegate { displayDelegate.dismissMessageCenter() } else { Task { @MainActor in self.dismissDefaultMessageCenter() } } } @MainActor final class MutableValues: Sendable { var displayDelegate: (any MessageCenterDisplayDelegate)? var controller: MessageCenterController var predicate: (any MessageCenterPredicate)? var theme: MessageCenterTheme? var currentDisplay: (any AirshipMainActorCancellable)? init( displayDelegate: (any MessageCenterDisplayDelegate)? = nil, controller: MessageCenterController, predicate: (any MessageCenterPredicate)? = nil, theme: MessageCenterTheme? = nil, currentDisplay: (any AirshipMainActorCancellable)? = nil ) { self.displayDelegate = displayDelegate self.controller = controller self.predicate = predicate self.theme = theme self.currentDisplay = currentDisplay } } } extension DefaultMessageCenter { private static let kUARichPushMessageIDKey = "_uamid" @MainActor func deepLink(_ deepLink: URL) -> Bool { // Ensure the scheme matches Airship deeplLink scheme guard deepLink.scheme == Airship.deepLinkScheme else { return false } // Ensure the host matches guard deepLink.host == "message_center" else { return false } let components = deepLink.pathComponents let path = deepLink.path // Case 1: No path -> open message center if path.isEmpty || path == "/" { display() return true } // Case 2: /message/<id> if components.count == 3, components[1] == "message" { display(messageID: components[2]) return true } // Case 3: /<id> if components.count == 2 { display(messageID: components[1]) return true } // Anything else is unsupported return false } @MainActor func receivedRemoteNotification( _ notification: AirshipJSON ) async -> UABackgroundFetchResult { guard let userInfo = notification.unWrap() as? [AnyHashable: Any], let messageID = MessageCenterMessage.parseMessageID( userInfo: userInfo ) else { return .noData } let result = await self.inbox.refreshMessages() if !result { return .failed } let message = await self.inbox.message(forID: messageID) guard message != nil else { return .noData } return .newData } @MainActor fileprivate func showDefaultMessageCenter() { guard self.mutable.currentDisplay == nil else { return } let displayable = AirshipDisplayTarget().prepareDisplay(for: .modal) let controller = MessageCenterViewControllerFactory.make( theme: theme, predicate: predicate, controller: self.controller ) { self.mutable.currentDisplay?.cancel() self.mutable.currentDisplay = nil } do { try displayable.display { _ in return controller } self.mutable.currentDisplay = AirshipMainActorCancellableBlock { displayable.dismiss() } } catch { AirshipLogger.error("Unable to display message center \(error)") } } @MainActor fileprivate func dismissDefaultMessageCenter() { self.mutable.currentDisplay?.cancel() self.mutable.currentDisplay = nil } } public extension Airship { /// The shared `MessageCenter` instance. `Airship.takeOff` must be called before accessing this instance. static var messageCenter: any MessageCenter { Airship.requireComponent( ofType: MessageCenterComponent.self ).messageCenter } } extension Airship { static var internalMessageCenter: DefaultMessageCenter { Airship.requireComponent( ofType: MessageCenterComponent.self ).messageCenter } } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterAPIClient.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Message Center API client protocol protocol MessageCenterAPIClientProtocol: Sendable { /// Retrieves the full message list from the server. /// - Parameters: /// - user: The user credentials /// - channel: The channel ID /// - lastModified: The last modified time /// - Returns: The messages, or throws an error if there was an error or the message list has not changed since the last update. func retrieveMessageList( user: MessageCenterUser, channelID: String, lastModified: String? ) async throws -> AirshipHTTPResponse<[MessageCenterMessage]> /// Performs a batch delete request on the server. /// - Parameters: /// - messages: An array of messages. /// - user: The user credentials /// - channel: The channel ID /// - Returns: Returns an AirshipHTTPResponse func performBatchDelete( forMessages messages: [MessageCenterMessage], user: MessageCenterUser, channelID: String ) async throws -> AirshipHTTPResponse<Void> /// Performs a batch mark-as-read request on the server. /// - Parameters: /// - messages: An Array of messages be marked as read. /// - user: The user credentials /// - channelID: The channel ID. /// - Returns: Returns an AirshipHTTPResponse func performBatchMarkAsRead( forMessages messages: [MessageCenterMessage], user: MessageCenterUser, channelID: String ) async throws -> AirshipHTTPResponse<Void> /// Create a user. /// - Parameters: /// - channelID: The channel ID /// - Returns: The user credentials, or throws an error if there was an error. func createUser( withChannelID channelID: String ) async throws -> AirshipHTTPResponse<MessageCenterUser> /// Update a user. /// - Parameters: /// - user: The user credentials /// - channelID: The channel ID /// - Returns: An airship http response. func updateUser( _ user: MessageCenterUser, channelID: String ) async throws -> AirshipHTTPResponse<Void> } struct MessageCenterAPIClient: MessageCenterAPIClientProtocol, Sendable { private static let channelIDHeader = "X-UA-Channel-ID" private static let lastModifiedIDHeader = "If-Modified-Since" private let config: RuntimeConfig private let session: any AirshipRequestSession init(config: RuntimeConfig, session: any AirshipRequestSession) { self.config = config self.session = session } func retrieveMessageList( user: MessageCenterUser, channelID: String, lastModified: String? ) async throws -> AirshipHTTPResponse<[MessageCenterMessage]> { guard let deviceAPIURL = config.deviceAPIURL else { throw AirshipErrors.error("The deviceAPIURL is nil") } let urlString = "\(deviceAPIURL)\("/api/user/")\(user.username)\("/messages/")" var headers: [String: String] = [ MessageCenterAPIClient.channelIDHeader: channelID, "Accept": "application/vnd.urbanairship+json; version=3;" ] if let lastModified = lastModified { headers[MessageCenterAPIClient.lastModifiedIDHeader] = lastModified } let request = AirshipRequest( url: URL(string: urlString), headers: headers, method: "GET", auth: .basic(username: user.username, password: user.password) ) AirshipLogger.trace("Request to retrieve message list: \(urlString)") return try await self.session.performHTTPRequest(request) { data, response in guard response.isSuccess else { return nil } do { guard let data else { throw AirshipErrors.parseError("Missing response body") } let parsed = try JSONDecoder().decode(MessageListResponse.self, from: data) return try parsed.convertMessages() } catch { let responseBody = data.flatMap { String(data: $0, encoding: .utf8) } ?? "nil" AirshipLogger.error("Failed to parse message list response: \(error) responseBody: \(responseBody)") throw error } } } func performBatchDelete( forMessages messages: [MessageCenterMessage], user: MessageCenterUser, channelID: String ) async throws -> AirshipHTTPResponse<Void> { guard let deviceAPIURL = config.deviceAPIURL else { throw AirshipErrors.error("The deviceAPIURL is nil") } let messageReportings = messages.compactMap { message in message.messageReporting } guard !messageReportings.isEmpty else { throw AirshipErrors.error("No reporting") } let urlString = "\(deviceAPIURL)\("/api/user/")\(user.username)\("/messages/delete/")" let body = UpdateMessagesRequestBody(messages: messageReportings) let request = try AirshipRequest( url: URL(string: urlString), headers: [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json", MessageCenterAPIClient.channelIDHeader: channelID, ], method: "POST", auth: .basic(username: user.username, password: user.password), encodableBody: body ) AirshipLogger.trace( "Request to perform batch delete: \(urlString) body: \(body)" ) return try await self.session.performHTTPRequest(request) } func performBatchMarkAsRead( forMessages messages: [MessageCenterMessage], user: MessageCenterUser, channelID: String ) async throws -> AirshipHTTPResponse<Void> { guard let deviceAPIURL = config.deviceAPIURL else { throw AirshipErrors.error("The deviceAPIURL is nil") } let messageReportings = messages.compactMap { message in message.messageReporting } guard !messageReportings.isEmpty else { throw AirshipErrors.error("No reporting") } let urlString = "\(deviceAPIURL)\("/api/user/")\(user.username)\("/messages/unread/")" let headers: [String: String] = [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json", MessageCenterAPIClient.channelIDHeader: channelID, ] let body = UpdateMessagesRequestBody(messages: messageReportings) let request = try AirshipRequest( url: URL(string: urlString), headers: headers, method: "POST", auth: .basic(username: user.username, password: user.password), encodableBody: body ) AirshipLogger.trace( "Request to perform batch mark messages as read: \(urlString) body: \(body)" ) return try await self.session.performHTTPRequest(request) } func createUser( withChannelID channelID: String ) async throws -> AirshipHTTPResponse<MessageCenterUser> { guard let deviceAPIURL = config.deviceAPIURL else { throw AirshipErrors.error("The deviceAPIURL is nil") } let urlString = "\(deviceAPIURL)\("/api/user/")" let headers: [String: String] = [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json", MessageCenterAPIClient.channelIDHeader: channelID, ] let body = CreateUserRequestBody(iOSChannels: [channelID]) let request = try AirshipRequest( url: URL(string: urlString), headers: headers, method: "POST", auth: .channelAuthToken(identifier: channelID), encodableBody: body ) AirshipLogger.trace( "Request to perform batch create user: \(urlString) body: \(body)" ) return try await self.session.performHTTPRequest(request) { data, response in guard response.isSuccess else { return nil } guard let data else { throw AirshipErrors.parseError("Missing response body") } return try JSONDecoder().decode(MessageCenterUser.self, from: data) } } func updateUser( _ user: MessageCenterUser, channelID: String ) async throws -> AirshipHTTPResponse<Void> { guard let deviceAPIURL = config.deviceAPIURL else { throw AirshipErrors.error("The deviceAPIURL is nil") } let urlString = "\(deviceAPIURL)\("/api/user/")\(user.username)" let headers: [String: String] = [ "Accept": "application/vnd.urbanairship+json; version=3;", "Content-Type": "application/json", ] let body = UpdateUserRequestBody( iOSChannels: UpdateUserRequestBody.UserOperation( add: [channelID] ) ) let request = try AirshipRequest( url: URL(string: urlString), headers: headers, method: "POST", auth: .basic(username: user.username, password: user.password), encodableBody: body ) AirshipLogger.trace( "Request to perform batch update user: \(urlString) body: \(body)" ) return try await self.session.performHTTPRequest(request) } } private struct UpdateMessagesRequestBody: Encodable { let messages: [AirshipJSON] } private struct UpdateUserRequestBody: Encodable { var iOSChannels: UserOperation private enum CodingKeys: String, CodingKey { case iOSChannels = "ios_channels" } fileprivate struct UserOperation: Encodable { var add: [String] } } private struct CreateUserRequestBody: Encodable { var iOSChannels: [String] private enum CodingKeys: String, CodingKey { case iOSChannels = "ios_channels" } } private struct MessageListResponse: Decodable { let messages: [Message] struct Message: Decodable { let messageID: String let messageBodyURL: URL let messageReporting: AirshipJSON let messageURL: URL let contentType: MessageCenterMessage.ContentType? /// String instead of Date because they might be nonstandard ISO dates let messageSent: String let messageExpiration: String? let title: String let extra: AirshipJSON? let icons: AirshipJSON? let unread: Bool let rawJSON: AirshipJSON private enum CodingKeys: String, CodingKey { case messageID = "message_id" case title = "title" case contentType = "content_type" case messageBodyURL = "message_body_url" case messageURL = "message_url" case unread = "unread" case messageSent = "message_sent" case messageExpiration = "message_expiry" case extra = "extra" case icons = "icons" case messageReporting = "message_reporting" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.messageID = try container.decode(String.self, forKey: .messageID) self.messageBodyURL = try container.decode(URL.self, forKey: .messageBodyURL) self.messageReporting = try container.decode(AirshipJSON.self, forKey: .messageReporting) self.messageURL = try container.decode(URL.self, forKey: .messageURL) self.contentType = try? container.decode(MessageCenterMessage.ContentType.self, forKey: .contentType) self.messageSent = try container.decode(String.self, forKey: .messageSent) self.messageExpiration = try container.decodeIfPresent(String.self, forKey: .messageExpiration) self.title = try container.decode(String.self, forKey: .title) self.extra = try container.decodeIfPresent(AirshipJSON.self, forKey: .extra) self.icons = try container.decodeIfPresent(AirshipJSON.self, forKey: .icons) self.unread = try container.decode(Bool.self, forKey: .unread) self.rawJSON = try AirshipJSON(from: decoder) } } } extension MessageListResponse { fileprivate func convertMessages() throws -> [MessageCenterMessage] { return try self.messages.map { responseMessage in return MessageCenterMessage( title: responseMessage.title, id: responseMessage.messageID, contentType: responseMessage.contentType ?? .unknown(nil), extra: responseMessage.extra?.object?.compactMapValues { $0.string } ?? [:], bodyURL: responseMessage.messageBodyURL, expirationDate: try responseMessage.messageExpiration?.toDate(), messageReporting: responseMessage.messageReporting, unread: responseMessage.unread, sentDate: try responseMessage.messageSent.toDate(), messageURL: responseMessage.messageURL, rawMessageObject: responseMessage.rawJSON ) } } } extension HTTPURLResponse { fileprivate var isSuccess: Bool { return self.statusCode >= 200 && self.statusCode <= 299 } } extension String { fileprivate func toDate() throws -> Date { guard let date = AirshipDateFormatter.date(fromISOString: self) else { throw AirshipErrors.error("Invalid date \(self)") } return date } } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterAction.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Message center action that launches the message center. /// /// /// Valid situations: `ActionSituation.manualInvocation`, `ActionSituation.launchedFromPush` /// `ActionSituation.webViewInvocation`, `ActionSituation.foregroundInteractiveButton`, /// `ActionSituation.manualInvocation`, and `ActionSituation.automation` /// public final class MessageCenterAction: AirshipAction { /// Default names - "_uamid", "overlay_inbox_action", "display_inbox_action", "^mc", "^mco" public static let defaultNames = ["_uamid", "overlay_inbox_action", "display_inbox_action", "^mc", "^mco"] /// Action value for the message ID place holder. public static let messageIDPlaceHolder = "auto" public func accepts(arguments: ActionArguments) async -> Bool { switch arguments.situation { case .manualInvocation, .launchedFromPush, .webViewInvocation, .automation, .foregroundInteractiveButton: return true case .backgroundInteractiveButton: fallthrough case .foregroundPush: fallthrough case .backgroundPush: fallthrough @unknown default: return false } } private func parseMessageID(arguments: ActionArguments) -> String? { if let value = arguments.value.unWrap() as? String { guard value == MessageCenterAction.messageIDPlaceHolder else { return value } if let messageID = arguments.metadata[ActionArguments.inboxMessageIDMetadataKey] as? String { return messageID } else if let payload = (arguments.metadata[ActionArguments.pushPayloadJSONMetadataKey] as? AirshipJSON)?.unWrap() as? [AnyHashable: Any] { return MessageCenterMessage.parseMessageID(userInfo: payload) } else { return nil } } else if let value = arguments.value.unWrap() as? [String] { return value.first } else { return nil } } @MainActor public func perform(arguments: ActionArguments) async throws -> AirshipJSON? { if let messageID = parseMessageID(arguments: arguments), !messageID.isEmpty { Airship.messageCenter.display(messageID: messageID) } else { Airship.messageCenter.display() } return nil } } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterComponent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif #if canImport(UIKit) import UIKit #endif import UserNotifications /// Actual airship component for MessageCenter. Used to hide AirshipComponent methods. final class MessageCenterComponent : AirshipComponent, AirshipPushableComponent, Sendable { final let messageCenter: DefaultMessageCenter init(messageCenter: DefaultMessageCenter) { self.messageCenter = messageCenter } @MainActor public func deepLink(_ deepLink: URL) -> Bool { return self.messageCenter.deepLink(deepLink) } func receivedRemoteNotification(_ notification: AirshipJSON) async -> UABackgroundFetchResult { return await self.messageCenter.receivedRemoteNotification(notification) } #if !os(tvOS) func receivedNotificationResponse(_ response: UNNotificationResponse) async { // no-op } #endif } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterList.swift ================================================ /* Copyright Airship and Contributors */ public import Combine public import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Errors that can occur when loading or refreshing Message Center Inbox. public enum MessageCenterInboxError: Error, Equatable { /// Message Center is unavailable because the `AirshipFeature.messageCenter` feature /// is disabled in the privacy manager. case disabled /// The message could not be fetched from the server. case failedToFetchMessage /// The refresh operation has been cancelled case cancelled } /// Airship Message Center inbox protocol. public protocol MessageCenterInbox: AnyObject, Sendable { /// Refreshes the list of messages in the inbox. /// - Returns: `true` if the messages was refreshed, otherwise `false`. @discardableResult func refreshMessages() async -> Bool /// Refreshes the list of messages in the inbox. /// - Throws : An error of type `MessageCenterInboxError` func refreshMessagesThrowing() async throws /// Marks messages read. /// - Parameters: /// - messages: The list of messages to be marked read. func markRead(messages: [MessageCenterMessage]) async /// Marks messages read by message IDs. /// - Parameters: /// - messageIDs: The list of message IDs for the messages to be marked read. func markRead(messageIDs: [String]) async /// Marks messages deleted. /// - Parameters: /// - messages: The list of messages to be marked deleted. func delete(messages: [MessageCenterMessage]) async /// Marks messages deleted by message IDs. /// - Parameters: /// - messageIDs: The list of message IDs for the messages to be marked deleted. func delete(messageIDs: [String]) async /// Returns the message associated with a particular URL. /// - Parameters: /// - bodyURL: The URL of the message. /// - Returns: The associated `MessageCenterMessage` object or nil if a message was unable to be found. func message(forBodyURL bodyURL: URL) async -> MessageCenterMessage? /// Returns the message associated with a particular ID. /// - Parameters: /// - messageID: The message ID. /// - Returns: The associated `MessageCenterMessage` object or nil if a message was unable to be found. func message(forID messageID: String) async -> MessageCenterMessage? /// Publisher that emits messages. @MainActor var messagePublisher: AnyPublisher<[MessageCenterMessage], Never> { get } /// Async Stream on messages' updates var messageUpdates: AsyncStream<[MessageCenterMessage]> { get } /// Publisher that emits unread counts. @MainActor var unreadCountPublisher: AnyPublisher<Int, Never> { get } /// Async Stream of unread count updates var unreadCountUpdates: AsyncStream<Int> { get } /// The list of messages in the inbox. var messages: [MessageCenterMessage] { get async } /// The user associated to the Message Center var user: MessageCenterUser? { get async } /// The number of messages that are currently unread. var unreadCount: Int { get async } /// Refreshes the list of messages in the inbox. /// - Returns: `true` if the messages was refreshed, otherwise `false`. @discardableResult func refreshMessages(timeout: TimeInterval) async throws -> Bool } protocol InternalMessageCenterInbox: MessageCenterInbox { func saveDisplayHistory(for messageID: String, history: MessageDisplayHistory) async func saveLayoutState(for messageID: String, state: MessageCenterMessage.AssociatedData.ViewState?) async @MainActor func getNativeStateStorage(for messageID: String) -> any LayoutDataStorage } /// Airship Message Center inbox. final class DefaultMessageCenterInbox: InternalMessageCenterInbox, Sendable { private enum UpdateType: Sendable { case local case refreshSucess case refreshFailed } private let updateWorkID = "Airship.MessageCenterInbox#update" private let store: MessageCenterStore private let channel: any InternalAirshipChannel private let client: any MessageCenterAPIClientProtocol private let config: RuntimeConfig private let notificationCenter: NotificationCenter private let date: any AirshipDateProtocol private let workManager: any AirshipWorkManagerProtocol private let startUpTask: Task<Void, Never>? private let _enabled: AirshipAtomicValue<Bool> = AirshipAtomicValue(false) private let refreshOnExpireTask: AirshipAtomicValue<Task<Void, any Error>?> = AirshipAtomicValue(nil) private let taskSleeper: any AirshipTaskSleeper private let nativeStateStorageFactory: @Sendable @MainActor (String) -> any LayoutDataStorage private let nativeMessageStorageValue: AirshipMainActorValue<(any LayoutDataStorage)?> = .init(nil) var enabled: Bool { get { _enabled.value } set { if (_enabled.setValue(newValue)) { self.dispatchUpdateWorkRequest() } } } @MainActor public var messagePublisher: AnyPublisher<[MessageCenterMessage], Never> { return self.messageUpdates .airshipPublisher .compactMap { $0 } .eraseToAnyPublisher() } var messageUpdates: AsyncStream<[MessageCenterMessage]> { return self.updateChannel.makeNonIsolatedDedupingStream { [weak self] in await self?.messages } transform: { [weak self] _ in await self?.messages } } @MainActor public var unreadCountPublisher: AnyPublisher<Int, Never> { return self.unreadCountUpdates .airshipPublisher .compactMap { $0 } .eraseToAnyPublisher() } var unreadCountUpdates: AsyncStream<Int> { return self.updateChannel.makeNonIsolatedDedupingStream { [weak self] in await self?.unreadCount } transform: { [weak self] _ in await self?.unreadCount } } public var messages: [MessageCenterMessage] { get async { guard self.enabled else { AirshipLogger.error("Message center is disabled") return [] } return await self.store.messages } } public var user: MessageCenterUser? { get async { guard self.enabled else { AirshipLogger.error("Message center is disabled") return nil } await self.startUpTask?.value return await self.store.user } } public var unreadCount: Int { get async { guard self.enabled else { AirshipLogger.error("Message center is disabled") return 0 } return await self.store.unreadCount } } @MainActor init( channel: any InternalAirshipChannel, client: any MessageCenterAPIClientProtocol, config: RuntimeConfig, store: MessageCenterStore, notificationCenter: NotificationCenter = NotificationCenter.default, date: any AirshipDateProtocol = AirshipDate.shared, workManager: any AirshipWorkManagerProtocol, taskSleeper: (any AirshipTaskSleeper)? = nil, stateStorageFactory: (@Sendable @MainActor (String) -> any LayoutDataStorage)? = nil ) { self.channel = channel self.client = client self.config = config self.store = store self.notificationCenter = notificationCenter self.date = date self.workManager = workManager self.taskSleeper = taskSleeper ?? DefaultAirshipTaskSleeper.shared self.nativeStateStorageFactory = stateStorageFactory ?? { messageID in //can't use self here because it's init NativeLayoutPersistentDataStore( messageID: messageID) { state in Task { await Airship.internalMessageCenter .internalInbox .saveLayoutState( for: messageID, state: state ) } } onFetch: { await Airship.internalMessageCenter .internalInbox .message(forID: messageID)? .associatedData.viewState } } self.startUpTask = if channel.identifier == nil, !config.airshipConfig.restoreMessageCenterOnReinstall { Task { [weak store] in await store?.resetUser() } } else { nil } workManager.registerWorker( updateWorkID ) { [weak self] request in self?.refreshOnExpireTask.value?.cancel() return try await self?.updateInbox() ?? .success } notificationCenter.addObserver( forName: RuntimeConfig.configUpdatedEvent, object: nil, queue: nil ) { [weak self] _ in self?.remoteURLConfigUpdated() } notificationCenter.addObserver( forName: AppStateTracker.didBecomeActiveNotification, object: nil, queue: nil ) { [weak self] _ in self?.dispatchUpdateWorkRequest() } notificationCenter.addObserver( forName: AppStateTracker.didEnterBackgroundNotification, object: nil, queue: nil ) { [weak self] _ in self?.refreshOnExpireTask.value?.cancel() } notificationCenter.addObserver( forName: AirshipNotifications.ChannelCreated.name, object: nil, queue: nil ) { [weak self] _ in self?.dispatchUpdateWorkRequest( conflictPolicy: .replace ) } Task { @MainActor [weak self] in guard let stream = await self?.updateChannel.makeStream() else { return } for await update in stream { guard update != .refreshFailed else { continue } notificationCenter.post( name: AirshipNotifications.MessageCenterListUpdated.name, object: nil ) await self?.setupRefreshOnMessageExpires() } } self.channel.addRegistrationExtender { [weak self] payload in await self?.startUpTask?.value guard self?.enabled == true, let user = await self?.store.user else { return } if payload.identityHints == nil { payload.identityHints = ChannelRegistrationPayload.IdentityHints( userID: user.username ) } else { payload.identityHints?.userID = user.username } } } @MainActor convenience init( with config: RuntimeConfig, dataStore: PreferenceDataStore, channel: any InternalAirshipChannel, workManager: any AirshipWorkManagerProtocol ) { self.init( channel: channel, client: MessageCenterAPIClient( config: config, session: config.requestSession ), config: config, store: MessageCenterStore( config: config, dataStore: dataStore ), workManager: workManager ) } private func sendUpdate(_ update: UpdateType) async { await self.updateChannel.send(update) } private func setupRefreshOnMessageExpires() async { self.refreshOnExpireTask.value?.cancel() guard let refresh = await self.messages .compactMap({ $0.expirationDate }) .sorted() .first else { return } let delay = refresh.timeIntervalSince(self.date.now) self.refreshOnExpireTask.value = Task { [weak self] in try await self?.taskSleeper.sleep(timeInterval: delay) self?.dispatchUpdateWorkRequest() } } private let updateChannel: AirshipAsyncChannel<UpdateType> = AirshipAsyncChannel() @discardableResult public func refreshMessages() async -> Bool { do { try await refreshMessagesThrowing() return true } catch { return false } } public func refreshMessagesThrowing() async throws { if !self.enabled { AirshipLogger.error("Message center is disabled") throw MessageCenterInboxError.disabled } let stream = await updateChannel.makeStream() dispatchUpdateWorkRequest( conflictPolicy: .replace, requireNetwork: false ) for await update in stream { guard !Task.isCancelled else { break } guard update == .refreshSucess || update == .refreshFailed else { continue } if update != .refreshSucess { throw MessageCenterInboxError.failedToFetchMessage } return } throw MessageCenterInboxError.cancelled } func refreshMessages(timeout: TimeInterval) async throws -> Bool { return try await withThrowingTaskGroup(of: Bool.self) { [weak self] group in group.addTask { [weak self] in return await self?.refreshMessages() ?? false } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) throw AirshipErrors.error("Timed out") } guard let success = try await group.next() else { group.cancelAll() throw CancellationError() } group.cancelAll() return success } } public func markRead(messages: [MessageCenterMessage]) async { await self.markRead( messageIDs: messages.map { message in message.id } ) } public func markRead(messageIDs: [String]) async { do { try await self.store.markRead(messageIDs: messageIDs, level: .local) self.dispatchUpdateWorkRequest() await self.sendUpdate(.local) } catch { AirshipLogger.error("Failed to mark messages read: \(error)") } } public func delete(messages: [MessageCenterMessage]) async { await self.delete( messageIDs: messages.map { message in message.id } ) } public func delete(messageIDs: [String]) async { do { try await self.store.markDeleted(messageIDs: messageIDs) self.dispatchUpdateWorkRequest() await self.sendUpdate(.local) } catch { AirshipLogger.error("Failed to delete messages: \(error)") } } public func message(forBodyURL bodyURL: URL) async -> MessageCenterMessage? { do { return try await self.store.message(forBodyURL: bodyURL) } catch { AirshipLogger.error("Failed to fetch message: \(error)") return nil } } public func message(forID messageID: String) async -> MessageCenterMessage? { do { return try await self.store.message(forID: messageID) } catch { AirshipLogger.error("Failed to fetch message: \(error)") return nil } } func saveDisplayHistory(for messageID: String, history: MessageDisplayHistory) async { await updateAssociatedData(for: messageID) { associatedData in associatedData.displayHistory = try JSONEncoder().encode(history) } } func saveLayoutState(for messageID: String, state: MessageCenterMessage.AssociatedData.ViewState?) async { await updateAssociatedData(for: messageID) { $0.viewState = state } } private func updateAssociatedData(for messageID: String, block: (inout MessageCenterMessage.AssociatedData) throws -> Void) async { do { guard var message = try await self.store.message(forID: messageID) else { AirshipLogger.error("Failed to find message to update") return } try block(&message.associatedData) try await self.store.updateMessages( messages: [message], lastModifiedTime: nil, updateLastModifiedTime: false, overwriteAssociatedData: true ) } catch { AirshipLogger.error("Failed to save history data message: \(error)") } } @MainActor func getNativeStateStorage(for messageID: String) -> any LayoutDataStorage { if let stored = self.nativeMessageStorageValue.value, stored.messageID == messageID { return stored } let storage = self.nativeStateStorageFactory(messageID) self.nativeMessageStorageValue.set(storage) return storage } private func getOrCreateUser( forChannelID channelID: String ) async -> MessageCenterUser? { guard let user = await self.store.user else { do { AirshipLogger.debug("Creating Message Center user") let response = try await self.client.createUser( withChannelID: channelID ) AirshipLogger.debug( "Message Center user create request finished with response: \(response)" ) guard let user = response.result else { return nil } await self.store.setUserRequireUpdate(false) await self.store.saveUser(user, channelID: channelID) return user } catch { AirshipLogger.info( "Failed to create Message Center user: \(error)" ) return nil } } let requireUpdate = await self.store.userRequiredUpdate let channelMismatch = await self.store.registeredChannelID != channelID guard requireUpdate || channelMismatch else { return user } do { AirshipLogger.debug("Updating Message Center user") let response = try await self.client.updateUser( user, channelID: channelID ) AirshipLogger.debug( "Message Center update request finished with response: \(response)" ) guard response.isSuccess else { return nil } await self.store.setUserRegisteredChannelID(channelID) await self.store.setUserRequireUpdate(false) return user } catch { AirshipLogger.info("Failed to update Message Center user: \(error)") return nil } } private func updateInbox() async throws -> AirshipWorkResult { await self.startUpTask?.value guard self.enabled else { await self.sendUpdate(.refreshFailed) return .success } guard let channelID = channel.identifier else { await self.sendUpdate(.refreshFailed) return .success } guard let user = await getOrCreateUser( forChannelID: channelID ) else { await self.sendUpdate(.refreshFailed) return .failure } let syncedRead = await syncReadMessageState( user: user, channelID: channelID ) let synedDeleted = await syncDeletedMessageState( user: user, channelID: channelID ) let syncedList = await syncMessageList( user: user, channelID: channelID ) if syncedList { await self.sendUpdate(.refreshSucess) } else { await self.sendUpdate(.refreshFailed) } guard syncedRead && synedDeleted && syncedList else { return .failure } return .success } // MARK: Enqueue tasks private func dispatchUpdateWorkRequest( conflictPolicy: AirshipWorkRequestConflictPolicy = .keepIfNotStarted, requireNetwork: Bool = true ) { guard self.enabled else { return } self.workManager.dispatchWorkRequest( AirshipWorkRequest( workID: self.updateWorkID, requiresNetwork: requireNetwork, conflictPolicy: conflictPolicy ) ) } private func syncMessageList( user: MessageCenterUser, channelID: String ) async -> Bool { do { let lastModified = await self.store.lastMessageListModifiedTime let response = try await self.client.retrieveMessageList( user: user, channelID: channelID, lastModified: lastModified ) guard response.isSuccess || response.statusCode == 304 else { AirshipLogger.error("Retrieve list message failed") return false } if response.isSuccess, let messages = response.result { try await self.store.updateMessages( messages: messages, lastModifiedTime: response.headers["Last-Modified"] ) } return true } catch { AirshipLogger.error("Retrieve message list failed with error \(error.localizedDescription)") } return false } private func syncReadMessageState( user: MessageCenterUser, channelID: String ) async -> Bool { do { let messages = try await self.store.fetchLocallyReadOnlyMessages() guard !messages.isEmpty else { return true } AirshipLogger.trace( "Synchronizing locally read messages on server. \(messages)" ) let response = try await self.client.performBatchMarkAsRead( forMessages: messages, user: user, channelID: channelID ) if response.isSuccess { AirshipLogger.trace( "Successfully synchronized locally read messages on server." ) try await self.store.markRead( messageIDs: messages.compactMap { $0.id }, level: .local ) return true } } catch { AirshipLogger.trace( "Failed to synchronize locally read messages on server." ) } return false } private func syncDeletedMessageState( user: MessageCenterUser, channelID: String ) async -> Bool { do { let messages = try await self.store.fetchLocallyDeletedMessages() guard !messages.isEmpty else { return true } AirshipLogger.trace( "Synchronizing locally deleted messages on server." ) let response = try await self.client.performBatchDelete( forMessages: messages, user: user, channelID: channelID ) if response.isSuccess { AirshipLogger.trace( "Successfully synchronized locally deleted messages on server." ) try await self.store.delete( messageIDs: messages.compactMap { $0.id } ) return true } } catch { AirshipLogger.trace( "Failed to synchronize locally deleted messages on server." ) } return false } private func remoteURLConfigUpdated() { Task { await self.store.setUserRequireUpdate(true) dispatchUpdateWorkRequest( conflictPolicy: .replace ) } } } public extension AirshipNotifications { /// NSNotification info when the inbox is updated is updated. final class MessageCenterListUpdated: NSObject { /// NSNotification name. public static let name = NSNotification.Name( "com.urbanairship.notification.message_list_updated" ) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterNativeBridgeExtension.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) import Foundation public import WebKit #if canImport(AirshipCore) public import AirshipCore #endif /// Airship native bridge extension for the Message Center. public final class MessageCenterNativeBridgeExtension: NSObject, NativeBridgeExtensionDelegate, Sendable { let message: MessageCenterMessage let user: MessageCenterUser public init( message: MessageCenterMessage, user: MessageCenterUser ) { self.message = message self.user = user } public func actionsMetadata( for command: JavaScriptCommand, webView: WKWebView ) -> [String: String] { return [ ActionArguments.inboxMessageIDMetadataKey: message.id ] } public func extendJavaScriptEnvironment( _ js: any JavaScriptEnvironmentProtocol, webView: WKWebView ) async { js.add("getMessageId", string: self.message.id) js.add("getMessageTitle", string: self.message.title) js.add( "getMessageSentDateMS", number: (self.message.sentDate.timeIntervalSince1970 * 1000.0).rounded() ) js.add( "getMessageSentDate", string: AirshipDateFormatter.string(fromDate: message.sentDate, format: .iso) ) js.add("getMessageExtras", dictionary: message.extra) js.add("getUserId", string: self.user.username) } } #endif ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterPredicate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// Predicate for filtering messages in the Message Center. public protocol MessageCenterPredicate: Sendable { /// Evaluate the message center message. Used to filter the message center list /// - Parameters: /// - message: The message center message /// - Returns: True if the message passed the evaluation, otherwise false. func evaluate(message: MessageCenterMessage) -> Bool } // MARK: Message center predicate extension View { /// Overrides the message center predicate /// - Parameters: /// - predicate: The message center predicate public func messageCenterPredicate(_ predicate: (any MessageCenterPredicate)?) -> some View { environment(\.airshipMessageCenterPredicate, predicate) } } struct MessageCenterPredicateKey: EnvironmentKey { static let defaultValue: (any MessageCenterPredicate)? = nil } extension EnvironmentValues { /// Airship message center predicate environment value public var airshipMessageCenterPredicate: (any MessageCenterPredicate)? { get { self[MessageCenterPredicateKey.self] } set { self[MessageCenterPredicateKey.self] = newValue } } } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterSDKModule.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) public import AirshipCore #endif public import Foundation /// AirshipMessageCenter module loader. /// @note For internal use only. :nodoc: @objc(UAMessageCenterSDKModule) public class MessageCenterSDKModule: NSObject, AirshipSDKModule { public let actionsManifest: (any ActionsManifest)? = MessageCenterActionsManifest() public let components: [any AirshipComponent] init(messageCenter: DefaultMessageCenter) { self.components = [MessageCenterComponent(messageCenter: messageCenter)] } public static func load(_ args: AirshiopModuleLoaderArgs) -> (any AirshipSDKModule)? { let messageCenter = DefaultMessageCenter( dataStore: args.dataStore, config: args.config, channel: args.channel, privacyManager: args.privacyManager, workManager: args.workManager, meteredUsage: args.meteredUsage, analytics: args.analytics ) return MessageCenterSDKModule(messageCenter: messageCenter) } } fileprivate struct MessageCenterActionsManifest : ActionsManifest { var manifest: [[String] : () -> ActionEntry] = [ MessageCenterAction.defaultNames: { return ActionEntry( action: MessageCenterAction() ) } ] } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageCenterStore.swift ================================================ /* Copyright Airship and Contributors */ import CoreData import Foundation #if canImport(AirshipCore) import AirshipCore #endif enum MessageCenterStoreError: Error { case coreDataUnavailble case coreDataError } enum MessageCenterStoreLevel: Int { case local case global } actor MessageCenterStore { /// User defualts key to clear the keychain of Airship values for one app run. Used for testing. :nodoc: private static let resetKeychainKey = "com.urbanairship.reset_keychain" private static let coreDataStoreName = "Inbox-%@.sqlite" private static let lastMessageListModifiedTime = "UALastMessageListModifiedTime" private static let userRegisteredChannelID = "UAUserRegisteredChannelID" private static let userRequireUpdate = "UAUserRequireUpdate" private let coreData: UACoreData? private let config: RuntimeConfig private let dataStore: PreferenceDataStore private let keychainAccess: any AirshipKeychainAccessProtocol private let date: any AirshipDateProtocol private nonisolated let inMemory: Bool var registeredChannelID: String? { return self.dataStore.string( forKey: MessageCenterStore.userRegisteredChannelID ) } private var _user: MessageCenterUser? = nil var user: MessageCenterUser? { get async { // Clearing the keychain if UserDefaults.standard.bool(forKey: MessageCenterStore.resetKeychainKey) == true { AirshipLogger.debug("Deleting the keychain credentials") await resetUser() UserDefaults.standard.removeObject( forKey: MessageCenterStore.resetKeychainKey ) } if let user = _user { return user } let credentials = await self.keychainAccess.readCredentails( identifier: self.config.appCredentials.appKey, appKey: self.config.appCredentials.appKey ) if let credentials = credentials { _user = MessageCenterUser( username: credentials.username, password: credentials.password ) } return _user } } var userRequiredUpdate: Bool { return self.dataStore.bool(forKey: MessageCenterStore.userRequireUpdate) } var lastMessageListModifiedTime: String? { return self.dataStore.string( forKey: MessageCenterStore.lastMessageListModifiedTime ) } var messages: [MessageCenterMessage] { get async { let predicate = AirshipCoreDataPredicate( format: "(messageExpiration == nil || messageExpiration >= %@) && (deletedClient == NO || deletedClient == nil)", args: [self.date.now] ) let messages = try? await fetchMessages(withPredicate: predicate) return messages ?? [] } } init( config: RuntimeConfig, dataStore: PreferenceDataStore, date: any AirshipDateProtocol = AirshipDate.shared ) { self.config = config self.dataStore = dataStore self.keychainAccess = AirshipKeychainAccess.shared self.date = date let modelURL = AirshipMessageCenterResources.bundle .url( forResource: "UAInbox", withExtension: "momd" ) if let modelURL = modelURL { let storeName = String( format: MessageCenterStore.coreDataStoreName, config.appCredentials.appKey ) self.coreData = UACoreData( name: "UAInbox", modelURL: modelURL, inMemory: false, stores: [storeName] ) } else { self.coreData = nil } self.inMemory = false } init( config: RuntimeConfig, dataStore: PreferenceDataStore, coreData: UACoreData, date: any AirshipDateProtocol = AirshipDate.shared ) { self.inMemory = coreData.inMemory self.config = config self.dataStore = dataStore self.coreData = coreData self.keychainAccess = AirshipKeychainAccess.shared self.date = date } var unreadCount: Int { get async { guard let coreData = self.coreData else { return 0 } let result: Int? = try? await coreData.performWithResult { context in let request: NSFetchRequest<InboxMessageData> = InboxMessageData.fetchRequest() request.predicate = NSPredicate(format: "unread == YES") request.includesPropertyValues = false let fetchedMessages = try context.fetch(request) return fetchedMessages.count } return result ?? 0 } } func message(forID messageID: String) async throws -> MessageCenterMessage? { let predicate = AirshipCoreDataPredicate( format: "messageID == %@ && (messageExpiration == nil || messageExpiration >= %@) && (deletedClient == NO || deletedClient == nil)", args: [ messageID, self.date.now ] ) let messages = try await fetchMessages(withPredicate: predicate) return messages.first } func message(forBodyURL bodyURL: URL) async throws -> MessageCenterMessage? { let predicate = AirshipCoreDataPredicate( format: "messageBodyURL == %@ && (messageExpiration == nil || messageExpiration >= %@) && (deletedClient == NO || deletedClient == nil)", args: [ bodyURL, self.date.now ] ) let messages = try await fetchMessages(withPredicate: predicate) return messages.first } func markRead( messageIDs: [String], level: MessageCenterStoreLevel ) async throws { guard let coreData = self.coreData else { throw MessageCenterStoreError.coreDataUnavailble } AirshipLogger.trace("Mark messages with IDs: \(messageIDs) read") try await coreData.perform { context in let request = InboxMessageData.batchUpdateRequest() request.predicate = NSPredicate( format: "messageID IN %@", messageIDs ) if level == .local { request.propertiesToUpdate = ["unreadClient": false] } else if level == .global { request.propertiesToUpdate = ["unread": false] } request.resultType = .updatedObjectsCountResultType try context.execute(request) } } func delete(messageIDs: [String]) async throws { guard let coreData = self.coreData else { throw MessageCenterStoreError.coreDataUnavailble } AirshipLogger.trace("Deleting messages with IDs: \(messageIDs)") try await coreData.perform { context in try self.delete( predicate: NSPredicate( format: "messageID IN %@", messageIDs ), useBatch: !self.inMemory, context: context ) } } func markDeleted(messageIDs: [String]) async throws { guard let coreData = self.coreData else { throw MessageCenterStoreError.coreDataUnavailble } AirshipLogger.trace("Mark messages with IDs: \(messageIDs) deleted") try await coreData.perform { context in let request = InboxMessageData.batchUpdateRequest() request.predicate = NSPredicate( format: "messageID IN %@", messageIDs ) request.propertiesToUpdate = ["deletedClient": true] request.resultType = .updatedObjectsCountResultType try context.execute(request) } } func fetchLocallyDeletedMessages() async throws -> [MessageCenterMessage] { let predicate = AirshipCoreDataPredicate( format: "deletedClient == YES" ) return try await fetchMessages(withPredicate: predicate) } func fetchLocallyReadOnlyMessages() async throws -> [MessageCenterMessage] { let predicate = AirshipCoreDataPredicate( format: "unreadClient == NO && unread == YES" ) return try await fetchMessages(withPredicate: predicate) } func saveUser(_ user: MessageCenterUser, channelID: String) async { let result = await self.keychainAccess.writeCredentials( AirshipKeychainCredentials( username: user.username, password: user.password ), identifier: self.config.appCredentials.appKey, appKey: self.config.appCredentials.appKey ) if !result { AirshipLogger.error("Failed to write user credentials") } setUserRegisteredChannelID(channelID) _user = user } func resetUser() async { _user = nil await self.keychainAccess.deleteCredentials( identifier: self.config.appCredentials.appKey, appKey: self.config.appCredentials.appKey ) } func setUserRequireUpdate(_ value: Bool) { self.dataStore.setBool( value, forKey: MessageCenterStore.userRequireUpdate ) } func setUserRegisteredChannelID(_ value: String) { self.dataStore.setValue( value, forKey: MessageCenterStore.userRegisteredChannelID ) } func setLastMessageListModifiedTime(_ value: String?) { self.dataStore.setValue( value, forKey: MessageCenterStore.lastMessageListModifiedTime ) } func clearLastModified(username: String) { self.dataStore.removeObject( forKey: MessageCenterStore.lastMessageListModifiedTime ) } private func fetchMessages( withPredicate predicate: AirshipCoreDataPredicate? = nil ) async throws -> [MessageCenterMessage] { guard let coreData = self.coreData else { throw MessageCenterStoreError.coreDataUnavailble } AirshipLogger.trace( "Fetching message center with predicate: \(String(describing: predicate))" ) return try await coreData.performWithResult { context in let request: NSFetchRequest<InboxMessageData> = InboxMessageData.fetchRequest() request.sortDescriptors = [ NSSortDescriptor( key: "messageSent", ascending: false ) ] if let predicate = predicate { request.predicate = predicate.toNSPredicate() } let fetchedMessages = try context.fetch(request) return fetchedMessages.compactMap { data in data.message() } } } nonisolated private func delete( predicate: NSPredicate, useBatch: Bool, context: NSManagedObjectContext ) throws { if useBatch { let request = InboxMessageData.fetchRequest() request.predicate = predicate let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) try context.execute(deleteRequest) } else { let request: NSFetchRequest<InboxMessageData> = InboxMessageData.fetchRequest() request.predicate = predicate request.includesPropertyValues = false let fetchedMessages = try context.fetch(request) fetchedMessages.forEach { message in context.delete(message) } } } nonisolated private func getOrCreateMessageEntity( messageID: String, context: NSManagedObjectContext ) throws -> InboxMessageData { let request: NSFetchRequest<InboxMessageData> = InboxMessageData.fetchRequest() request.predicate = NSPredicate(format: "messageID == %@", messageID) request.fetchLimit = 1 let response = try context.fetch(request) if let existing = response.first { return existing } guard let data = NSEntityDescription.insertNewObject( forEntityName: InboxMessageData.messageDataEntity, into: context ) as? InboxMessageData else { throw MessageCenterStoreError.coreDataError } return data } func updateMessages( messages: [MessageCenterMessage], lastModifiedTime: String?, updateLastModifiedTime: Bool = true, overwriteAssociatedData: Bool = false ) async throws { guard let coreData = self.coreData else { throw MessageCenterStoreError.coreDataUnavailble } try await coreData.perform { context in // Track the response messageIDs so we can remove any messages that are // no longer in the response. try messages.forEach { message in let data = try self.getOrCreateMessageEntity( messageID: message.id, context: context ) data.messageID = message.id data.title = message.title data.contentType = message.contentType.stringValue data.extra = AirshipJSON.object(message.extra.mapValues { .string($0) }).toDataLoggingError() data.messageBodyURL = message.bodyURL data.messageURL = message.messageURL data.unread = message.unread data.messageSent = message.sentDate data.rawMessageObject = message.rawMessageObject.toDataLoggingError() data.messageReporting = message.messageReporting?.toDataLoggingError() data.messageExpiration = message.expirationDate if overwriteAssociatedData { data.associatedData = message.associatedData.encoded() } } // Delete any messages no longer in the listing let messageIDs = messages.map { message in message.id } try self.delete( predicate: NSPredicate( format: "NOT(messageID IN %@)", messageIDs ), useBatch: !self.inMemory, context: context ) } if updateLastModifiedTime { self.setLastMessageListModifiedTime(lastModifiedTime) } } } extension InboxMessageData { fileprivate func message() -> MessageCenterMessage? { guard let title = self.title, let messageID = self.messageID, let messageBodyURL = self.messageBodyURL, let messageReporting = self.messageReporting, let messageURL = self.messageURL, let messageSent = self.messageSent, let rawMessageObject = self.rawMessageObject, let rawJSON = AirshipJSON.fromDataLoggingError(data:rawMessageObject) else { AirshipLogger.error("Invalid message data") return nil } let contentTypeString = contentType ?? rawJSON.object?["content_type"]?.string let contentType: MessageCenterMessage.ContentType = contentTypeString.map { MessageCenterMessage.ContentType.parse($0) } ?? .unknown(nil) return MessageCenterMessage( title: title, id: messageID, contentType: contentType, extra: AirshipJSON.fromDataLoggingError(data:self.extra)?.unWrap() as? [String: String] ?? [:], bodyURL: messageBodyURL, expirationDate: self.messageExpiration, messageReporting: AirshipJSON.fromDataLoggingError(data:messageReporting), unread: (self.unread && self.unreadClient), sentDate: messageSent, messageURL: messageURL, rawMessageObject: rawJSON, associatedData: self.associatedData ) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/MessageViewAnalytics.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif protocol MessageViewAnalytics: ThomasLayoutMessageAnalyticsProtocol { } final class DefaultMessageViewAnalytics: MessageViewAnalytics { private static let impressionReportInterval: TimeInterval = 30 * 60 // 30 mins private static let defaultProductID = "default_native_mc" private let messageID: ThomasLayoutEventMessageID private let productID: String private let reportingContext: AirshipJSON? private let eventRecorder: any ThomasLayoutEventRecorderProtocol private let eventSource: ThomasLayoutEventSource private let historyStorage: any MessageDisplayHistoryStoreProtocol private let displayContext: AirshipMainActorValue<ThomasLayoutEventContext.Display?> private let sessionID: String private let date: any AirshipDateProtocol private let queue: AirshipAsyncSerialQueue init( message: MessageCenterMessage, eventRecorder: any ThomasLayoutEventRecorderProtocol, historyStorage: any MessageDisplayHistoryStoreProtocol, eventSource: ThomasLayoutEventSource = .airship, date: any AirshipDateProtocol = AirshipDate.shared, sessionID: String = UUID().uuidString, queue: AirshipAsyncSerialQueue = AirshipAsyncSerialQueue() ) { self.messageID = .airship(identifier: message.id, campaigns: nil) self.productID = message.productID ?? Self.defaultProductID self.reportingContext = message.messageReporting self.eventRecorder = eventRecorder self.eventSource = eventSource self.date = date self.historyStorage = historyStorage self.sessionID = sessionID self.displayContext = .init(nil) self.queue = queue queue.enqueue { [messageID = messageID.identifier, sessionID, weak self] in guard let history = await self?.historyStorage.get(scheduleID: messageID) else { return } await MainActor.run { let lastTriggerSessionId = history.lastDisplay?.triggerSessionID self?.displayContext.set( ThomasLayoutEventContext.Display( triggerSessionID: lastTriggerSessionId ?? sessionID, isFirstDisplay: history.lastDisplay == nil, isFirstDisplayTriggerSessionID: lastTriggerSessionId == history.lastImpression?.triggerSessionID ) ) } } } @MainActor func recordEvent(_ event: any ThomasLayoutEvent, layoutContext: ThomasLayoutContext?) { let now = date.now queue.enqueue { @MainActor [weak self] in guard let self = self else { return } if event is ThomasLayoutDisplayEvent { var history = await self.historyStorage.get(scheduleID: messageID.identifier) if let lastDisplay = history.lastDisplay { if self.sessionID != lastDisplay.triggerSessionID { self.displayContext.update { value in value?.isFirstDisplay = false value?.isFirstDisplayTriggerSessionID = false } } else { self.displayContext.update { value in value?.isFirstDisplay = false } } } if (recordImpression(date: now, history: history)) { history.lastImpression = MessageDisplayHistory .LastImpression( date: now, triggerSessionID: self.sessionID ) } history.lastDisplay = MessageDisplayHistory.LastDisplay( triggerSessionID: self.sessionID ) self.historyStorage.set(history, scheduleID: messageID.identifier) } let data = ThomasLayoutEventData( event: event, context: ThomasLayoutEventContext.makeContext( reportingContext: reportingContext, experimentsResult: nil, layoutContext: layoutContext, displayContext: displayContext.value ), source: eventSource, messageID: messageID, renderedLocale: nil ) eventRecorder.recordEvent(inAppEventData: data) } } private func shouldRecordImpression(history: MessageDisplayHistory) -> Bool { if let lastImpression = history.lastImpression, date.now.timeIntervalSince(lastImpression.date) < Self.impressionReportInterval { return false } else { return true } } private func recordImpression(date: Date, history: MessageDisplayHistory) -> Bool { guard shouldRecordImpression(history: history) else { return false } let event = AirshipMeteredUsageEvent( eventID: UUID().uuidString, entityID: self.messageID.identifier, usageType: .inAppExperienceImpression, product: productID, reportingContext: reportingContext, timestamp: date, contactID: nil ) self.eventRecorder.recordImpressionEvent(event) return true } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Model/InboxMessageData.swift ================================================ /* Copyright Airship and Contributors */ import CoreData import Foundation /// CoreData class representing the backing data for a UAInboxMessage. /// This class should not ordinarily be used directly. @objc(UAInboxMessageData) class InboxMessageData: NSManagedObject { static let messageDataEntity = "UAInboxMessage" @nonobjc class func fetchRequest<T>() -> NSFetchRequest<T> { return NSFetchRequest<T>(entityName: InboxMessageData.messageDataEntity) } @nonobjc class func batchUpdateRequest() -> NSBatchUpdateRequest { return NSBatchUpdateRequest( entityName: InboxMessageData.messageDataEntity ) } /// The Airship message ID. /// This ID may be used to match an incoming push notification to a specific message. @NSManaged var messageID: String? /// The URL for the message body itself. /// This URL may only be accessed with Basic Auth credentials set to the user ID and password. @NSManaged var messageBodyURL: URL? /// The URL for the message. /// This URL may only be accessed with Basic Auth credentials set to the user ID and password. @NSManaged var messageURL: URL? /// The data object that contains the message ID, the group ID and the variant ID. @NSManaged var messageReporting: Data? /// YES if the message is unread, otherwise NO. @NSManaged var unread: Bool /// YES if the message is unread on the client, otherwise NO. @NSManaged var unreadClient: Bool /// YES if the message is deleted, otherwise NO. @NSManaged var deletedClient: Bool /// The date and time the message was sent (UTC) @NSManaged var messageSent: Date? /// The date and time the message will expire. /// A nil value indicates it will never expire. @NSManaged var messageExpiration: Date? /// The message title @NSManaged var title: String? /// The message's extra dictionary. This dictionary can be populated /// with arbitrary key-value data at the time the message is composed. @NSManaged var extra: Data? /// The raw message dictionary. This is the dictionary that originally created the message. /// It can contain more values then the message. @NSManaged var rawMessageObject: Data? /// The message content type @NSManaged var contentType: String? /// The message associated data(display history) @NSManaged var associatedData: Data? } ================================================ FILE: Airship/AirshipMessageCenter/Source/Model/MessageCenterMessage.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Message center message. public struct MessageCenterMessage: Sendable, Equatable, Identifiable, Hashable { private static let productIDKey = "product_id" /// The message title. public var title: String /// The Airship message ID. /// This ID may be used to match an incoming push notification to a specific message. public var id: String /// The message's extra dictionary. /// This dictionary can be populated with arbitrary key-value data at the time the message is composed. public var extra: [String: String] /// The URL for the message body itself. /// This URL may only be accessed with Basic Auth credentials set to the user ID and password. public var bodyURL: URL /// The date and time the message will expire. /// A nil value indicates it will never expire. public var expirationDate: Date? /// The date and time the message was sent (UTC). public var sentDate: Date /// The unread status of the message. /// `true` if the message is unread, otherwise `false`. public var unread: Bool /// The message center content type public let contentType: ContentType /// The reporting data of the message. let messageReporting: AirshipJSON? /// The URL for the message. /// This URL may only be accessed with Basic Auth credentials set to the user ID and password. let messageURL: URL /// The raw message dictionary. /// This is the dictionary that originally created the message. /// It can contain more values than the message. let rawMessageObject: AirshipJSON /// The message associated data var associatedData: AssociatedData public enum ContentType: Sendable, Hashable, Codable { case html case plain case native(version: Int) case unknown(String?) var stringValue: String? { switch self { case .html: return "text/html" case .plain: return "text/plain" case .native(let version): return "application/vnd.urbanairship.thomas+json;version=\(version);" case .unknown(let value): return value } } static let nativeContentTypePrefix: String = "application/vnd.urbanairship.thomas+json" public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() try container.encode(self.stringValue) } static func parse(_ value: String) -> ContentType { if value == "text/html" { return .html } else if value == "text/plain" { return .plain } else if value.hasPrefix(Self.nativeContentTypePrefix), let version = value .replacingOccurrences(of: " ", with: "") .components(separatedBy: ";") .last(where: { $0.hasPrefix("version") })? .components(separatedBy: "=") .last .flatMap(Int.init) { return .native(version: version) } else { return .unknown(value) } } public init(from decoder: any Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) self = Self.parse(value) } } init( title: String, id: String, contentType: ContentType, extra: [String: String], bodyURL: URL, expirationDate: Date?, messageReporting: AirshipJSON?, unread: Bool, sentDate: Date, messageURL: URL, rawMessageObject: AirshipJSON, associatedData: Data? = nil ) { self.title = title self.id = id self.contentType = contentType self.extra = extra self.bodyURL = bodyURL self.expirationDate = expirationDate self.messageReporting = messageReporting self.unread = unread self.sentDate = sentDate self.messageURL = messageURL self.rawMessageObject = rawMessageObject self.associatedData = associatedData .flatMap { try? JSONDecoder().decode(MessageCenterMessage.AssociatedData.self, from: $0) } ?? AssociatedData() } public static func == (lhs: MessageCenterMessage, rhs: MessageCenterMessage) -> Bool { return lhs.rawMessageObject == rhs.rawMessageObject && lhs.unread == rhs.unread } public func hash(into hasher: inout Hasher) { hasher.combine(rawMessageObject) hasher.combine(unread) } struct AssociatedData: Codable, Sendable, Equatable, Hashable { var displayHistory: Data? var viewState: ViewState? func encoded() -> Data? { return try? JSONEncoder().encode(self) } struct ViewState: Codable, Sendable, Equatable, Hashable { var restoreID: String var state: Data? } } } extension MessageCenterMessage { /// The list icon of the message. `nil` if there is none. public var listIcon: String? { guard let rawMessage = self.rawMessageObject.unWrap() as? [String: Any], let icons = rawMessage["icons"] as? [String: String], let listIcon = icons["list_icon"] else { return nil } return listIcon } /// The subtitle of the message. `nil` if there is none. public var subtitle: String? { return self.extra["com.urbanairship.listing.field1"] } /// Parses the message ID. /// - Parameters: /// - userInfo: The notification user info. /// - Returns: The message ID. public static func parseMessageID(userInfo: [AnyHashable: Any]) -> String? { guard let uamid = userInfo["_uamid"] else { return nil } if let uamid = uamid as? [String] { return uamid.first } else if let uamid = uamid as? String { return uamid } else { return nil } } /// Tells if the message is expired. /// `true` if the message is expired, otherwise `false`. public var isExpired: Bool { if let messageExpiration = self.expirationDate { let result = messageExpiration.compare(AirshipDate().now) return (result == .orderedAscending || result == .orderedSame) } return false } var productID: String? { return self.rawMessageObject.object?[Self.productIDKey]?.string } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Model/MessageCenterUser.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) import AirshipCore #endif /// Model object for holding user data. public struct MessageCenterUser: Codable, Sendable, Equatable { /// The username. public var password: String /// The password. public var username: String /// - Note: for internal use only. :nodoc: init(username: String, password: String) { self.username = username self.password = password } private enum CodingKeys: String, CodingKey { case username = "user_id" case password = "password" } } extension MessageCenterUser { public var basicAuthString: String { return AirshipUtils.authHeader( username: self.username, password: self.password ) ?? "" } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Model/UAInboxDataMapping.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import CoreData #if canImport(AirshipCore) import AirshipCore #endif @objc(UAInboxDataMappingV2toV4) class UAInboxDataMappingV2toV4: NSEntityMigrationPolicy { override func createDestinationInstances( forSource source: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager ) throws { /// extras -> JSON Data /// rawMessageObject -> JSON Data /// messageReporting -> JSON Data guard source.entity.name == InboxMessageData.messageDataEntity else { return } let messageID = source.value(forKey: "messageID") as? String let messageBodyURL = source.value(forKey: "messageBodyURL") as? URL let messageURL = source.value(forKey: "messageURL") as? URL let messageReporting = source.value(forKey: "messageReporting") as? [String: Any] let unread = source.value(forKey: "unread") as? Bool let unreadClient = source.value(forKey: "unreadClient") as? Bool let deletedClient = source.value(forKey: "deletedClient") as? Bool let messageSent = source.value(forKey: "messageSent") as? Date let messageExpiration = source.value(forKey: "messageExpiration") as? Date let title = source.value(forKey: "title") as? String let extra = source.value(forKey: "extra") as? [String: String] let rawMessageObject = source.value(forKey: "rawMessageObject") as? [String: Any] let newEntity = NSEntityDescription.insertNewObject( forEntityName: InboxMessageData.messageDataEntity, into: manager.destinationContext ) newEntity.setValue(messageID, forKey: "messageID") newEntity.setValue(messageBodyURL, forKey: "messageBodyURL") newEntity.setValue(messageURL, forKey: "messageURL") newEntity.setValue(AirshipJSONUtils.toData(messageReporting), forKey: "messageReporting") newEntity.setValue(unread, forKey: "unread") newEntity.setValue(unreadClient, forKey: "unreadClient") newEntity.setValue(deletedClient, forKey: "deletedClient") newEntity.setValue(messageSent, forKey: "messageSent") newEntity.setValue(messageExpiration, forKey: "messageExpiration") newEntity.setValue(title, forKey: "title") newEntity.setValue(AirshipJSONUtils.toData(extra), forKey: "extra") newEntity.setValue(AirshipJSONUtils.toData(rawMessageObject), forKey: "rawMessageObject") manager.associate(sourceInstance: source, withDestinationInstance: newEntity, for: mapping) } } @objc(UAInboxDataMappingV1toV4) class UAInboxDataMappingV1toV4: NSEntityMigrationPolicy { override func createDestinationInstances( forSource source: NSManagedObject, in mapping: NSEntityMapping, manager: NSMigrationManager ) throws { /// extras -> Json Data /// rawMessageObject -> Json Data guard source.entity.name == InboxMessageData.messageDataEntity else { return } let messageID = source.value(forKey: "messageID") as? String let messageBodyURL = source.value(forKey: "messageBodyURL") as? URL let messageURL = source.value(forKey: "messageURL") as? URL let unread = source.value(forKey: "unread") as? Bool let unreadClient = source.value(forKey: "unreadClient") as? Bool let deletedClient = source.value(forKey: "deletedClient") as? Bool let messageSent = source.value(forKey: "messageSent") as? Date let messageExpiration = source.value(forKey: "messageExpiration") as? Date let title = source.value(forKey: "title") as? String let extra = source.value(forKey: "extra") as? [String: String] let rawMessageObject = source.value(forKey: "rawMessageObject") as? [String: Any] let newEntity = NSEntityDescription.insertNewObject( forEntityName: InboxMessageData.messageDataEntity, into: manager.destinationContext ) newEntity.setValue(messageID, forKey: "messageID") newEntity.setValue(messageBodyURL, forKey: "messageBodyURL") newEntity.setValue(messageURL, forKey: "messageURL") newEntity.setValue(unread, forKey: "unread") newEntity.setValue(unreadClient, forKey: "unreadClient") newEntity.setValue(deletedClient, forKey: "deletedClient") newEntity.setValue(messageSent, forKey: "messageSent") newEntity.setValue(messageExpiration, forKey: "messageExpiration") newEntity.setValue(title, forKey: "title") newEntity.setValue(AirshipJSONUtils.toData(extra), forKey: "extra") newEntity.setValue(AirshipJSONUtils.toData(rawMessageObject), forKey: "rawMessageObject") manager.associate(sourceInstance: source, withDestinationInstance: newEntity, for: mapping) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/StateStore/NativeLayoutPersistentDataStore.swift ================================================ // Copyright Urban Airship and Contributors import Foundation #if canImport(AirshipCore) import AirshipCore #endif @MainActor final class NativeLayoutPersistentDataStore: LayoutDataStorage { let messageID: String private var restoreID: String? = nil private var storage: [String: Data] = [:] private let save: @Sendable (MessageCenterMessage.AssociatedData.ViewState?) -> Void private let fetch: @Sendable () async -> MessageCenterMessage.AssociatedData.ViewState? init( messageID: String, onSave: @Sendable @escaping (MessageCenterMessage.AssociatedData.ViewState?) -> Void, onFetch: @Sendable @escaping () async -> MessageCenterMessage.AssociatedData.ViewState? ) { self.messageID = messageID self.save = onSave self.fetch = onFetch } func prepare(restoreID: String) async { self.restoreID = restoreID guard let saved = await fetch(), saved.restoreID == restoreID else { self.clear() return } if let data = saved.state, let decoded = try? JSONDecoder().decode([String: Data].self, from: data) { self.storage = decoded } } func store(_ state: Data?, key: String) { guard let restoreID else { return } self.storage[key] = state let state = makeViewState(restoreID: restoreID) storeState(state) } func retrieve(_ key: String) -> Data? { //assume storage is preloaded return self.storage[key] } func clear() { self.storage.removeAll() if let restoreID { storeState(.init(restoreID: restoreID)) } else { storeState(nil) } } private func storeState(_ state: MessageCenterMessage.AssociatedData.ViewState?) { save(state) } private func makeViewState(restoreID: String) -> MessageCenterMessage.AssociatedData.ViewState? { let data = try? JSONEncoder().encode(self.storage) return .init( restoreID: restoreID, state: data ) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Theme/MessageCenterNavigationAppearance.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) import AirshipCore #endif import SwiftUI /// Resolves the effective navigation styling by prioritizing detected /// system appearance over the Airship theme. internal struct MessageCenterNavigationAppearance { let theme: MessageCenterTheme let colorScheme: ColorScheme // Detected system properties (optional) var barTintColor: Color? var barBackgroundColor: Color? var titleColor: Color? var titleFont: Font? /// Initializer with optional detected properties. init( theme: MessageCenterTheme, colorScheme: ColorScheme, barTintColor: Color? = nil, barBackgroundColor: Color? = nil, titleColor: Color? = nil, titleFont: Font? = nil ) { self.theme = theme self.colorScheme = colorScheme self.barTintColor = barTintColor self.barBackgroundColor = barBackgroundColor self.titleColor = titleColor self.titleFont = titleFont } private func resolve(light: Color?, dark: Color?, detected: Color?) -> Color? { colorScheme.airshipResolveColor(light: light, dark: dark) ?? detected } var backButtonColor: Color? { resolve( light: theme.backButtonColor, dark: theme.backButtonColorDark, detected: barTintColor) } var deleteButtonColor: Color? { resolve( light: theme.deleteButtonTitleColor, dark: theme.deleteButtonTitleColorDark, detected: barTintColor ) } func editButtonColor(isEditing: Bool) -> Color? { if isEditing { return resolve( light: theme.cancelButtonTitleColor, dark: theme.cancelButtonTitleColorDark, detected: barTintColor ) } else { return resolve( light: theme.editButtonTitleColor, dark: theme.editButtonTitleColorDark, detected: barTintColor ) } } var effectiveBarBackgroundColor: Color? { resolve( light: theme.messageListContainerBackgroundColor, dark: theme.messageListContainerBackgroundColorDark, detected: barBackgroundColor ) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Theme/MessageCenterTheme.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif public enum SeparatorStyle: Sendable { case none case singleLine } /// Model object representing a custom theme to be applied to the default message center. /// /// To customize the message center theme: /// /// MessageCenterView( /// controller: messageCenterController /// ) /// .messageCenterTheme(theme) /// public struct MessageCenterTheme: Sendable { /// The tint color of the "pull to refresh" control public var refreshTintColor: Color? = nil /// The dark mode tint color of the "pull to refresh" control public var refreshTintColorDark: Color? = nil /// Whether icons are enabled. Defaults to `false`. public var iconsEnabled: Bool = false /// An optional placeholder image to use when icons haven't fully loaded. public var placeholderIcon: Image? = nil /// The font to use for message cell titles. public var cellTitleFont: Font? = .headline /// The font to use for message cell dates. public var cellDateFont: Font? = .subheadline /// The regular color for message cells public var cellColor: Color? = nil /// The dark mode color for message cells public var cellColorDark: Color? = nil /// The regular color for message cell titles. public var cellTitleColor: Color? = .primary /// The dark mode color for message cell titles. public var cellTitleColorDark: Color? = nil /// The regular color for message cell dates. public var cellDateColor: Color? = .secondary /// The dark mode color for message cell dates. public var cellDateColorDark: Color? = nil /// The message cell separator style. public var cellSeparatorStyle: SeparatorStyle? #if os(macOS) /// The message cell separator color. public var cellSeparatorColor: Color? = Color(.separatorColor) #else /// The message cell separator color. public var cellSeparatorColor: Color? = Color(.separator) #endif /// The dark mode message cell separator color. public var cellSeparatorColorDark: Color? = nil /// The message cell tint color. public var cellTintColor: Color? = nil /// The dark mode message cell tint color. public var cellTintColorDark: Color? = nil /// The background color for the unread indicator. public var unreadIndicatorColor: Color? = nil /// The dark mode background color for the unread indicator. public var unreadIndicatorColorDark: Color? = nil /// The title color for the "Select All" button. public var selectAllButtonTitleColor: Color? = nil /// The dark mode title color for the "Select All" button. public var selectAllButtonTitleColorDark: Color? = nil /// The title color for the "Delete" button. public var deleteButtonTitleColor: Color? = nil /// The dark mode title color for the "Delete" button. public var deleteButtonTitleColorDark: Color? = nil /// The title color for the "Mark Read" button. public var markAsReadButtonTitleColor: Color? = nil /// The dark mode title color for the "Mark Read" button. public var markAsReadButtonTitleColorDark: Color? = nil /// Whether the delete message button from the message view is enabled. Defaults to `false`. public var hideDeleteButton: Bool? = false /// The title color for the "Edit" button. public var editButtonTitleColor: Color? = nil /// The dark mode title color for the "Edit" button. public var editButtonTitleColorDark: Color? = nil /// The title color for the "Cancel" button. public var cancelButtonTitleColor: Color? = nil /// The dark mode title color for the "Cancel" button. public var cancelButtonTitleColorDark: Color? = nil /// The title color for the "Done" button. public var backButtonColor: Color? = nil /// The dark mode title color for the "Done" button. public var backButtonColorDark: Color? = nil /// The navigation bar title public var navigationBarTitle: String? = nil /// The background of the message list. public var messageListBackgroundColor: Color? = nil /// The dark mode background of the message list. public var messageListBackgroundColorDark: Color? = nil /// The background of the message list container. public var messageListContainerBackgroundColor: Color? = nil /// The dark mode background of the message list container. public var messageListContainerBackgroundColorDark: Color? = nil /// The background of the message view. public var messageViewBackgroundColor: Color? = nil /// The dark mode background of the message view. public var messageViewBackgroundColorDark: Color? = nil /// The background of the message view container. public var messageViewContainerBackgroundColor: Color? = nil /// The dark mode background of the message view container. public var messageViewContainerBackgroundColorDark: Color? = nil public init( refreshTintColor: Color? = nil, refreshTintColorDark: Color? = nil, iconsEnabled: Bool = false, placeholderIcon: Image? = nil, cellTitleFont: Font? = nil, cellDateFont: Font? = nil, cellColor: Color? = nil, cellColorDark: Color? = nil, cellTitleColor: Color? = nil, cellTitleColorDark: Color? = nil, cellDateColor: Color? = nil, cellDateColorDark: Color? = nil, cellSeparatorStyle: SeparatorStyle? = nil, cellSeparatorColor: Color? = nil, cellSeparatorColorDark: Color? = nil, cellTintColor: Color? = nil, cellTintColorDark: Color? = nil, unreadIndicatorColor: Color? = nil, unreadIndicatorColorDark: Color? = nil, selectAllButtonTitleColor: Color? = nil, selectAllButtonTitleColorDark: Color? = nil, deleteButtonTitleColor: Color? = nil, deleteButtonTitleColorDark: Color? = nil, markAsReadButtonTitleColor: Color? = nil, markAsReadButtonTitleColorDark: Color? = nil, hideDeleteButton: Bool? = nil, editButtonTitleColor: Color? = nil, editButtonTitleColorDark: Color? = nil, cancelButtonTitleColor: Color? = nil, cancelButtonTitleColorDark: Color? = nil, backButtonColor: Color? = nil, backButtonColorDark: Color? = nil, navigationBarTitle: String? = nil, messageListBackgroundColor: Color? = nil, messageListBackgroundColorDark: Color? = nil, messageListContainerBackgroundColor: Color? = nil, messageListContainerBackgroundColorDark: Color? = nil, messageViewBackgroundColor: Color? = nil, messageViewBackgroundColorDark: Color? = nil, messageViewContainerBackgroundColor: Color? = nil, messageViewContainerBackgroundColorDark: Color? = nil ) { self.refreshTintColor = refreshTintColor self.refreshTintColorDark = refreshTintColorDark self.iconsEnabled = iconsEnabled self.placeholderIcon = placeholderIcon self.cellTitleFont = cellTitleFont self.cellDateFont = cellDateFont self.cellColor = cellColor self.cellColorDark = cellColorDark self.cellTitleColor = cellTitleColor self.cellTitleColorDark = cellTitleColorDark self.cellDateColor = cellDateColor self.cellDateColorDark = cellDateColorDark self.cellSeparatorStyle = cellSeparatorStyle self.cellSeparatorColor = cellSeparatorColor self.cellSeparatorColorDark = cellSeparatorColorDark self.cellTintColor = cellTintColor self.cellTintColorDark = cellTintColorDark self.unreadIndicatorColor = unreadIndicatorColor self.unreadIndicatorColorDark = unreadIndicatorColorDark self.selectAllButtonTitleColor = selectAllButtonTitleColor self.selectAllButtonTitleColorDark = selectAllButtonTitleColorDark self.deleteButtonTitleColor = deleteButtonTitleColor self.deleteButtonTitleColorDark = deleteButtonTitleColorDark self.markAsReadButtonTitleColor = markAsReadButtonTitleColor self.markAsReadButtonTitleColorDark = markAsReadButtonTitleColorDark self.hideDeleteButton = hideDeleteButton self.editButtonTitleColor = editButtonTitleColor self.editButtonTitleColorDark = editButtonTitleColorDark self.cancelButtonTitleColor = cancelButtonTitleColor self.cancelButtonTitleColorDark = cancelButtonTitleColorDark self.backButtonColor = backButtonColor self.backButtonColorDark = backButtonColorDark self.navigationBarTitle = navigationBarTitle self.messageListBackgroundColor = messageListBackgroundColor self.messageListBackgroundColorDark = messageListBackgroundColorDark self.messageListContainerBackgroundColor = messageListContainerBackgroundColor self.messageListContainerBackgroundColorDark = messageListContainerBackgroundColorDark self.messageViewBackgroundColor = messageViewBackgroundColor self.messageViewBackgroundColorDark = messageViewBackgroundColorDark self.messageViewContainerBackgroundColor = messageViewContainerBackgroundColor self.messageViewContainerBackgroundColorDark = messageViewContainerBackgroundColorDark } } extension View { /// Overrides the message center theme /// - Parameters: /// - theme: The message center theme public func messageCenterTheme(_ theme: MessageCenterTheme) -> some View { environment(\.airshipMessageCenterTheme, theme) } } struct MessageCenterThemeKey: EnvironmentKey { static let defaultValue = MessageCenterTheme() } extension EnvironmentValues { /// Airship message center theme environment value public var airshipMessageCenterTheme: MessageCenterTheme { get { self[MessageCenterThemeKey.self] } set { self[MessageCenterThemeKey.self] = newValue } } } extension MessageCenterTheme { /// Loads a message center theme from a plist file /// - Parameters: /// - plist: The name of the plist in the bundle public static func fromPlist(_ plist: String) throws -> MessageCenterTheme { return try MessageCenterThemeLoader.fromPlist(plist) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Theme/MessageCenterThemeLoader.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif struct MessageCenterThemeLoader { static let messageCenterFileName = "MessageCenterTheme" static let cellSeparatorStyleNoneKey = "none" static func defaultPlist() -> MessageCenterTheme? { if let _ = try? plistPath( file: messageCenterFileName, bundle: Bundle.main ) { do { return try fromPlist(messageCenterFileName) } catch { AirshipLogger.error( "Unable to load message center theme \(error)" ) } } return nil } static func fromPlist( _ file: String, bundle: Bundle = Bundle.main ) throws -> MessageCenterTheme { let path = try plistPath(file: file, bundle: bundle) guard let data = FileManager.default.contents(atPath: path) else { throw AirshipErrors.error("Failed to load contents of theme.") } let decoder = PropertyListDecoder() let config = try decoder.decode(Config.self, from: data) return try config.toMessageCenterTheme(bundle: bundle) } static func plistPath(file: String, bundle: Bundle) throws -> String { guard let path = bundle.path(forResource: file, ofType: "plist"), FileManager.default.fileExists(atPath: path) else { throw AirshipErrors.error("File not found \(file).") } return path } internal struct Config: Decodable { let tintColor: String? let tintColorDark: String? let refreshTintColor: String? let refreshTintColorDark: String? let iconsEnabled: Bool? let placeholderIcon: String? let cellTitleFont: FontConfig? let cellDateFont: FontConfig? let cellColor: String? let cellColorDark: String? let cellTitleColor: String? let cellTitleColorDark: String? let cellDateColor: String? let cellDateColorDark: String? let cellSeparatorStyle: String? let cellSeparatorColor: String? let cellSeparatorColorDark: String? let cellTintColor: String? let cellTintColorDark: String? let unreadIndicatorColor: String? let unreadIndicatorColorDark: String? let selectAllButtonTitleColor: String? let selectAllButtonTitleColorDark: String? let deleteButtonTitleColor: String? let deleteButtonTitleColorDark: String? let markAsReadButtonTitleColor: String? let markAsReadButtonTitleColorDark: String? let hideDeleteButton: Bool? let editButtonTitleColor: String? let editButtonTitleColorDark: String? let cancelButtonTitleColor: String? let cancelButtonTitleColorDark: String? let backButtonColor: String? let backButtonColorDark: String? let messageListBackgroundColor: String? let messageListBackgroundColorDark: String? let messageListContainerBackgroundColor: String? let messageListContainerBackgroundColorDark: String? let messageViewBackgroundColor: String? let messageViewBackgroundColorDark: String? let messageViewContainerBackgroundColor: String? let messageViewContainerBackgroundColorDark: String? let navigationBarTitle: String? } struct FontConfig: Decodable { let fontName: String let fontSize: FontSize enum FontSize: Decodable { case string(String) case cgFloat(CGFloat) init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() if let value = try? container.decode(String.self) { self = .string(value) } else if let value = try? container.decode(CGFloat.self) { self = .cgFloat(value) } else { throw AirshipErrors.error( "Font size must be able to be parsed into a String or CGFloat" ) } } var size: CGFloat { switch self { case .string(let value): return CGFloat(Double(value) ?? 0.0) case .cgFloat(let value): return value } } } } } extension Color { var isClear: Bool { var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var opacity: CGFloat = 0 AirshipNativeColor(self).getRed(&red, green: &green, blue: &blue, alpha: &opacity) return opacity == 0.0 } } extension MessageCenterThemeLoader.FontConfig { internal func toFont() throws -> Font { let size: CGFloat let zeroSizeError = AirshipErrors.error("Font size must represent a valid number greater than 0") switch fontSize { case .string(let value): guard let fontSize = Double(value), fontSize > 0.0 else { throw zeroSizeError } size = CGFloat(fontSize) case .cgFloat(let value): guard value > 0.0 else { throw zeroSizeError } size = value } return Font.custom(fontName.trimmingCharacters(in: .whitespaces), size: size) } } extension MessageCenterThemeLoader.Config { internal func toMessageCenterTheme(bundle: Bundle = Bundle.main) throws -> MessageCenterTheme { var theme = MessageCenterTheme() theme.refreshTintColor = self.refreshTintColor?.airshipToColor(bundle) theme.refreshTintColorDark = self.refreshTintColorDark?.airshipToColor(bundle) theme.iconsEnabled = self.iconsEnabled ?? false if let placeholderIcon = self.placeholderIcon { theme.placeholderIcon = Image(placeholderIcon) } theme.cellTitleFont = try? self.cellTitleFont?.toFont() theme.cellDateFont = try? self.cellDateFont?.toFont() theme.cellColor = self.cellColor?.airshipToColor(bundle) theme.cellColorDark = self.cellColorDark?.airshipToColor(bundle) theme.cellTitleColor = self.cellTitleColor?.airshipToColor(bundle) theme.cellTitleColorDark = self.cellTitleColorDark?.airshipToColor(bundle) theme.cellDateColor = self.cellDateColor?.airshipToColor(bundle) theme.cellDateColorDark = self.cellDateColorDark?.airshipToColor(bundle) theme.cellSeparatorStyle = self.cellSeparatorStyle?.toSeparatorStyle() theme.cellSeparatorColor = self.cellSeparatorColor?.airshipToColor(bundle) theme.cellSeparatorColorDark = self.cellSeparatorColorDark?.airshipToColor(bundle) theme.cellTintColor = self.cellTintColor?.airshipToColor(bundle) theme.cellTintColorDark = self.cellTintColorDark?.airshipToColor(bundle) theme.unreadIndicatorColor = self.unreadIndicatorColor?.airshipToColor(bundle) theme.unreadIndicatorColorDark = self.unreadIndicatorColorDark?.airshipToColor(bundle) theme.selectAllButtonTitleColor = self.selectAllButtonTitleColor? .airshipToColor(bundle) theme.selectAllButtonTitleColorDark = self.selectAllButtonTitleColorDark? .airshipToColor(bundle) theme.deleteButtonTitleColor = self.deleteButtonTitleColor?.airshipToColor(bundle) theme.deleteButtonTitleColorDark = self.deleteButtonTitleColorDark? .airshipToColor(bundle) theme.markAsReadButtonTitleColor = self.markAsReadButtonTitleColor? .airshipToColor(bundle) theme.markAsReadButtonTitleColorDark = self .markAsReadButtonTitleColorDark?.airshipToColor(bundle) theme.hideDeleteButton = self.hideDeleteButton ?? false theme.editButtonTitleColor = self.editButtonTitleColor?.airshipToColor(bundle) theme.editButtonTitleColorDark = self.editButtonTitleColorDark? .airshipToColor(bundle) theme.cancelButtonTitleColor = self.cancelButtonTitleColor?.airshipToColor(bundle) theme.cancelButtonTitleColorDark = self.cancelButtonTitleColorDark? .airshipToColor(bundle) theme.backButtonColor = self.backButtonColor?.airshipToColor(bundle) theme.backButtonColorDark = self.backButtonColorDark?.airshipToColor(bundle) theme.navigationBarTitle = self.navigationBarTitle theme.messageListBackgroundColor = self.messageListBackgroundColor?.airshipToColor(bundle) theme.messageListBackgroundColorDark = self.messageListBackgroundColorDark?.airshipToColor(bundle) theme.messageListContainerBackgroundColor = self.messageListContainerBackgroundColor?.airshipToColor(bundle) theme.messageListContainerBackgroundColorDark = self.messageListContainerBackgroundColorDark?.airshipToColor(bundle) theme.messageViewBackgroundColor = self.messageViewBackgroundColor?.airshipToColor(bundle) theme.messageViewBackgroundColorDark = self.messageViewBackgroundColorDark?.airshipToColor(bundle) theme.messageViewContainerBackgroundColor = self.messageViewContainerBackgroundColor?.airshipToColor(bundle) theme.messageViewContainerBackgroundColorDark = self.messageViewContainerBackgroundColorDark?.airshipToColor(bundle) return theme } } fileprivate extension String { func toSeparatorStyle() -> SeparatorStyle { let separatorStyle = self.trimmingCharacters(in: .whitespaces) if separatorStyle == MessageCenterThemeLoader.cellSeparatorStyleNoneKey { return .none } return .singleLine } } ================================================ FILE: Airship/AirshipMessageCenter/Source/ViewModel/MessageCenterListItemViewModel.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation #if canImport(AirshipCore) import AirshipCore #endif @MainActor class MessageCenterListItemViewModel: ObservableObject { private var cancellables = Set<AnyCancellable>() @Published private(set) public var message: MessageCenterMessage public init(message: MessageCenterMessage) { self.message = message Airship.messageCenter.inbox .messagePublisher .compactMap({ messages in messages.filter { $0.id == message.id }.first }) .removeDuplicates() .receive(on: RunLoop.main) .sink { message in self.message = message } .store(in: &self.cancellables) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageCenter/MessageCenterContent.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI #if canImport(UIKit) public import UIKit #endif #if canImport(AppKit) public import AppKit #endif #if canImport(AirshipCore) public import AirshipCore #endif /// The Message Center content view. /// This view can be used to construct a custom Message Center. For a more turnkey solution, see `MessageCenterView`. /// /// To use this view, a `MessageCenterController` must be supplied. The controller will be shared between the list and message views, /// keeping the state in sync. /// /// ### Using it with your own navigation stack: /// ```swift /// @StateObject /// private var messageCenterController = MessageCenterController() /// /// var body: some View { /// NavigationStack(path: $messageCenterController.path) { /// MessageCenterContent(controller: messageCenterController) /// .navigationDestination(for: MessageCenterController.Route.self) { route in /// switch(route) { /// case .message(let messageID): /// MessageCenterMessageViewWithNavigation(messageID: messageID) /// @unknown default: /// fatalError() /// } /// } /// } /// } /// ``` /// /// ### Using it in a deprecated NavigationView or UIKIt: ///```swift /// @StateObject /// private var messageCenterController = MessageCenterController() /// /// var body: some View { /// NavigationView { /// ZStack { /// MessageCenterContent(controller: self.messageCenterController) /// NavigationLink( /// destination: Group { /// if case .message(let messageID) = self.messageCenterController.path.last { /// MessageCenterMessageViewWithNavigation(messageID: messageID) { /// // Clear selection on close /// self.messageCenterController.path.removeAll() /// } /// } else { /// EmptyView() /// } /// }, /// isActive: Binding( /// get: { self.messageCenterController.path.last != nil }, /// set: { isActive in /// if !isActive { self.messageCenterController.path.removeAll() } /// } /// ) /// ) { /// EmptyView() /// } /// .hidden() /// } /// } /// } ///``` @MainActor public struct MessageCenterContent: View { /// The message center state @ObservedObject private var controller: MessageCenterController @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme @StateObject private var listViewModel: MessageCenterMessageListViewModel /// Weak reference to the hosting view controller for UIKit appearance detection weak private var hostingController: AirshipNativeViewController? /// Initializer. /// - Parameters: /// - controller: The message center controller. /// - listViewModel: The message center list view model. public init( controller: MessageCenterController, listViewModel: MessageCenterMessageListViewModel ) { self.controller = controller _listViewModel = .init(wrappedValue: listViewModel) } /// Initializer. /// - Parameters: /// - controller: The message center controller. /// - hostingController: A weak reference to the hosting controller to apply apperance changes. /// - predicate: A predicate to filter messages. public init( controller: MessageCenterController, hostingController: AirshipNativeViewController? = nil, predicate: (any MessageCenterPredicate)? = nil ) { self.controller = controller self.hostingController = hostingController _listViewModel = .init(wrappedValue: .init(predicate: predicate)) } /// The body of the view. @ViewBuilder public var body: some View { let content = MessageCenterListViewWithNavigation(viewModel: self.listViewModel) .airshipOnChangeOf(self.listViewModel.selectedMessageID) { selection in // sync list ID to the controller path if let messageID = selection { controller.navigate(messageID: messageID) } } .airshipOnChangeOf(controller.path, initial: true) { _ in // Sync controller path to the ID if self.listViewModel.selectedMessageID != controller.currentMessageID { self.listViewModel.selectedMessageID = controller.currentMessageID } } #if !os(macOS) if let hostingController = hostingController { content.modifier( MessageCenterUIKitContextModifier( hostingControllerRef: MessageCenterUIKitAppearance.WeakReference(hostingController) ) ) } else { content } #else content #endif } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageCenter/MessageCenterController.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI public import Combine #if canImport(AirshipCore) import AirshipCore #endif /// The message center controller's possible states. public enum MessageCenterState: Equatable, Sendable { /// The message center is visible, with an optional message ID. case visible(messageID: String?) /// The message center is not visible. case notVisible } /// Controller for the Message Center. @MainActor public class MessageCenterController: ObservableObject { /// The routes available in the message center. public enum Route: Sendable, Hashable { /// The message route, with the message ID. case message(String) } @Published var visibleMessageID: String? = nil @Published var isMessageCenterVisible: Bool = false /// The navigation path. @Published public var path: [Route] = [] private var subscriptions: Set<AnyCancellable> = Set() private let updateSubject = PassthroughSubject<MessageCenterState, Never>() /// Publisher that emits the message center state. public var statePublisher: AnyPublisher<MessageCenterState, Never> { self.updateSubject .removeDuplicates() .eraseToAnyPublisher() } /// Navigates to a message. /// - Parameters: /// - messageID: The message ID to navigate to. A `nil` value will pop to the root view. public func navigate(messageID: String?) { guard self.currentMessageID != messageID else { return } guard let messageID else { self.path = [] return } self.path = [.message(messageID)] } var currentMessageID: String? { guard case .message(let messageID) = self.path.last else { return nil } return messageID } /// Default initializer. public init() { Publishers .CombineLatest($visibleMessageID, $isMessageCenterVisible) .sink {[updateSubject] (visibleMessageID, isMessageCenterVisible) in if let messageID = visibleMessageID { updateSubject.send(.visible(messageID: messageID)) } else if isMessageCenterVisible { updateSubject.send(.visible(messageID: nil)) } else { updateSubject.send(.notVisible) } } .store(in: &subscriptions) } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageCenter/MessageCenterNavigationStack.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif @MainActor struct MessageCenterNavigationStack: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme /// The message center state @ObservedObject private var controller: MessageCenterController @StateObject private var listViewModel: MessageCenterMessageListViewModel #if !os(macOS) @State private var editMode: EditMode = .inactive #endif init(controller: MessageCenterController, predicate: (any MessageCenterPredicate)?) { self.controller = controller _listViewModel = .init(wrappedValue: .init(predicate: predicate)) } var body: some View { NavigationStack(path: $controller.path) { MessageCenterContent(controller: self.controller, listViewModel: self.listViewModel) #if !os(macOS) .environment(\.editMode, $editMode) .navigationDestination(for: MessageCenterController.Route.self) { route in switch(route) { case .message(let messageID): MessageCenterMessageViewWithNavigation(messageID: messageID, title: nil) { self.controller.path.removeAll() } } } #endif } } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageCenter/MessageCenterSplitNavigationView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif @MainActor struct MessageCenterNavigationSplitView: View { @ObservedObject private var controller: MessageCenterController @StateObject private var listViewModel: MessageCenterMessageListViewModel #if !os(macOS) @State private var editMode: EditMode = .inactive #endif init(controller: MessageCenterController, predicate: (any MessageCenterPredicate)?) { self.controller = controller _listViewModel = .init(wrappedValue: .init(predicate: predicate)) } @ViewBuilder private var sidebar: some View { NavigationStack { let content = MessageCenterContent(controller: self.controller, listViewModel: self.listViewModel) content #if !os(macOS) .environment(\.editMode, $editMode) .airshipOnChangeOf(editMode) { editMode in if !editMode.isEditing, let last = self.listViewModel.selectedMessageID { DispatchQueue.main.async { self.listViewModel.selectedMessageID = last } } } #endif } } @ViewBuilder private var detailView: some View { Group { if let messageID = self.controller.currentMessageID { MessageCenterMessageViewWithNavigation(messageID: messageID) { self.controller.path.removeAll { $0 == .message(messageID) } } .id(messageID) } else { Text("Select a message") .font(.title) .foregroundColor(.secondary) } } } @ViewBuilder public var body: some View { NavigationSplitView { self.sidebar } detail: { NavigationStack { self.detailView } } } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageCenter/MessageCenterUIKitAppearance.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(UIKit) import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// Detects and bridges UIKit navigation appearance to SwiftUI Message Center internal struct MessageCenterUIKitAppearance { // MARK: - Appearance Data Model /// Captured UIKit appearance data struct DetectedAppearance: Equatable { var navigationBarTintColor: Color? var navigationBarBackgroundColor: Color? var navigationTitleColor: Color? var navigationTitleFont: Font? var navigationLargeTitleColor: Color? var navigationLargeTitleFont: Font? var navigationBarIsTranslucent: Bool = true var prefersLargeTitles: Bool = false var navigationTitle: String? /// Simplified equality that compares only the non-Font properties /// Font instances don't support Equatable, so we exclude them from comparison static func == (lhs: DetectedAppearance, rhs: DetectedAppearance) -> Bool { // Early return for boolean properties if lhs.navigationBarIsTranslucent != rhs.navigationBarIsTranslucent || lhs.prefersLargeTitles != rhs.prefersLargeTitles || lhs.navigationTitle != rhs.navigationTitle { return false } // Compare color properties separately to reduce type-checking load return lhs.compareColors(rhs) } private func compareColors(_ other: DetectedAppearance) -> Bool { navigationBarTintColor == other.navigationBarTintColor && navigationBarBackgroundColor == other.navigationBarBackgroundColor && navigationTitleColor == other.navigationTitleColor && navigationLargeTitleColor == other.navigationLargeTitleColor } // Convert UIKit appearance to this model @MainActor static func from(navigationBar: UINavigationBar?, navigationItem: UINavigationItem?) -> DetectedAppearance { var appearance = DetectedAppearance() // Extract tint color (affects back buttons and bar button items) if let tintColor = navigationBar?.tintColor { appearance.navigationBarTintColor = Color(tintColor) } // Prioritize navigation item's appearance over navigation bar's appearance let standardAppearance = navigationItem?.standardAppearance ?? navigationBar?.standardAppearance // Extract from standard appearance if let standardAppearance = standardAppearance { // Background color if let backgroundColor = standardAppearance.backgroundColor { appearance.navigationBarBackgroundColor = Color(backgroundColor) } // Title attributes if let titleColor = standardAppearance.titleTextAttributes[.foregroundColor] as? UIColor { appearance.navigationTitleColor = Color(titleColor) } if let titleFont = standardAppearance.titleTextAttributes[.font] as? UIFont { appearance.navigationTitleFont = Font(titleFont) } // Large title attributes if let largeTitleColor = standardAppearance.largeTitleTextAttributes[.foregroundColor] as? UIColor { appearance.navigationLargeTitleColor = Color(largeTitleColor) } if let largeTitleFont = standardAppearance.largeTitleTextAttributes[.font] as? UIFont { appearance.navigationLargeTitleFont = Font(largeTitleFont) } } // Extract other properties appearance.navigationBarIsTranslucent = navigationBar?.isTranslucent ?? true #if !os(tvOS) appearance.prefersLargeTitles = navigationBar?.prefersLargeTitles ?? false #endif // Extract title from navigation item appearance.navigationTitle = navigationItem?.title return appearance } } // MARK: - Environment Keys struct DetectedAppearanceKey: EnvironmentKey { static let defaultValue: DetectedAppearance? = nil } // MARK: - Weak Reference Wrapper /// Weak reference wrapper to prevent retain cycles final class WeakReference<T: AnyObject> { weak var value: T? init(_ value: T?) { self.value = value } } } // MARK: - Environment Extensions extension EnvironmentValues { /// The detected UIKit appearance from parent navigation var messageCenterDetectedAppearance: MessageCenterUIKitAppearance.DetectedAppearance? { get { self[MessageCenterUIKitAppearance.DetectedAppearanceKey.self] } set { self[MessageCenterUIKitAppearance.DetectedAppearanceKey.self] = newValue } } } // MARK: - Appearance Detector View internal struct MessageCenterAppearanceDetector: UIViewRepresentable { @Binding var detectedAppearance: MessageCenterUIKitAppearance.DetectedAppearance? let hostingControllerRef: MessageCenterUIKitAppearance.WeakReference<UIViewController> func makeUIView(context: Context) -> UIView { let view = IntrospectionView() view.onAppearanceDetected = { appearance in DispatchQueue.main.async { self.detectedAppearance = appearance } } view.hostingControllerRef = hostingControllerRef return view } func updateUIView(_ uiView: UIView, context: Context) { if let introspectionView = uiView as? IntrospectionView { introspectionView.detectAppearance() } } class IntrospectionView: UIView { var onAppearanceDetected: ((MessageCenterUIKitAppearance.DetectedAppearance) -> Void)? var hostingControllerRef: MessageCenterUIKitAppearance.WeakReference<UIViewController>? override func didMoveToWindow() { super.didMoveToWindow() if window != nil { detectAppearance() } } @MainActor func detectAppearance() { guard let hostingController = hostingControllerRef?.value, let navController = hostingController.navigationController else { return } let appearance = MessageCenterUIKitAppearance.DetectedAppearance.from( navigationBar: navController.navigationBar, navigationItem: hostingController.navigationItem ) onAppearanceDetected?(appearance) } } } // MARK: - View Modifier for Applying Detected Appearance internal struct MessageCenterApplyDetectedAppearance: ViewModifier { @Environment(\.messageCenterDetectedAppearance) var detectedAppearance func body(content: Content) -> some View { if let appearance = detectedAppearance { content .airshipApplyIf(appearance.navigationBarTintColor != nil) { view in // Apply navigation bar tint color (affects back button and bar items) view.accentColor(appearance.navigationBarTintColor) .tint(appearance.navigationBarTintColor) } .airshipApplyIf(appearance.navigationBarBackgroundColor != nil) { view in // Apply navigation bar background color view.toolbarBackground(appearance.navigationBarBackgroundColor!, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } #if !os(tvOS) .navigationBarTitleDisplayMode(appearance.prefersLargeTitles ? .large : .inline) #endif } else { content } } } // MARK: - View Extension for Appearance extension View { /// Detects UIKit navigation appearance and applies it to Message Center func applyUIKitNavigationAppearance() -> some View { self.modifier(MessageCenterApplyDetectedAppearance()) } } extension MessageCenterUIKitAppearance.DetectedAppearance { /// Factory method to convert detected UIKit data into the platform-agnostic NavigationAppearance @MainActor func resolveAppearance(theme: MessageCenterTheme, colorScheme: ColorScheme) -> MessageCenterNavigationAppearance { return MessageCenterNavigationAppearance( theme: theme, colorScheme: colorScheme, barTintColor: self.navigationBarTintColor, barBackgroundColor: self.navigationBarBackgroundColor, titleColor: self.navigationTitleColor, titleFont: self.navigationTitleFont ) } } #endif ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageCenter/MessageCenterView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI #if canImport(UIKit) import UIKit #endif #if canImport(AirshipCore) import AirshipCore #endif /// The main view for the Airship Message Center. This view provides a navigation stack. /// If you wish to provide your own navigation, see `MessageCenterContent`. public struct MessageCenterView: View { /// The navigation style. public enum NavigationStyle: Sendable { /// A navigation style that uses a split view on larger devices and a stack view on smaller devices. case split /// A navigation style that uses a stack view. case stack /// The default navigation style. Defers to `split` on larger devices and `stack` on smaller devices. case auto } private let navigationStyle: NavigationStyle @ObservedObject private var controller: MessageCenterController @Environment(\.airshipMessageCenterPredicate) private var predicate /// Initializer. /// - Parameters: /// - navigationStyle: The navigation style. Defaults to `auto`. /// - controller: The message center controller. If `nil` the default controller will be used. public init(navigationStyle: NavigationStyle = .auto, controller: MessageCenterController? = nil) { self.navigationStyle = navigationStyle self.controller = controller ?? (Airship.isFlying ? Airship.messageCenter.controller : MessageCenterController()) } private var shouldUseSplit: Bool { switch navigationStyle { case .split: return true case .stack: return false case .auto: #if canImport(UIKit) return UIDevice.current.userInterfaceIdiom == .pad #else return true // fallback for macOS, etc. #endif } } /// The body of the view. public var body: some View { Group { if shouldUseSplit { MessageCenterNavigationSplitView(controller: controller, predicate: self.predicate) } else { MessageCenterNavigationStack(controller: controller, predicate: self.predicate) } } } } extension EnvironmentValues { var messageCenterDismissAction: (@MainActor @Sendable () -> Void)? { get { self[MessageCenterDismissActionKey.self] } set { self[MessageCenterDismissActionKey.self] = newValue } } } private struct MessageCenterDismissActionKey: EnvironmentKey { static let defaultValue: (@MainActor @Sendable () -> Void)? = nil } internal extension View { func addMessageCenterDismissAction(action: (@MainActor @Sendable () -> Void)?) -> some View { environment(\.messageCenterDismissAction, action) } } #if canImport(UIKit) struct MessageCenterUIKitContextModifier: ViewModifier { let hostingControllerRef: MessageCenterUIKitAppearance.WeakReference<UIViewController> @State private var detectedAppearance: MessageCenterUIKitAppearance.DetectedAppearance? func body(content: Content) -> some View { content .environment(\.messageCenterDetectedAppearance, detectedAppearance) .applyUIKitNavigationAppearance() .background( MessageCenterAppearanceDetector( detectedAppearance: $detectedAppearance, hostingControllerRef: hostingControllerRef ) .frame(width: 0, height: 0) .hidden() ) } } #endif ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageCenterViewController.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) public import AirshipCore #endif #if canImport(UIKit) public import UIKit #endif #if canImport(AppKit) public import AppKit #endif /// View controller for Message Center view public class MessageCenterViewControllerFactory: NSObject { /// Makes a message view controller with the given theme. /// - Parameters: /// - theme: The message center theme. /// - predicate: The message center predicate. /// - controller: The Message Center controller /// - dismissAction: Optional action to dismiss the view controller. /// - Returns: A view controller. @MainActor public class func make( theme: MessageCenterTheme? = nil, predicate: (any MessageCenterPredicate)? = nil, controller: MessageCenterController, dismissAction: (@MainActor @Sendable () -> Void)? = nil ) -> AirshipNativeViewController { let theme = theme ?? MessageCenterTheme() return MessageCenterViewController( rootView: MessageCenterView( controller: controller ) .messageCenterTheme(theme) .messageCenterPredicate(predicate) .addMessageCenterDismissAction( action: dismissAction ) ) } /// Makes a message view controller with the given theme. /// - Parameters: /// - themePlist: A path to a theme plist /// - controller: The Message Center controller /// - dismissAction: Optional action to dismiss the view controller. /// - Returns: A view controller. @MainActor public class func make( themePlist: String?, controller: MessageCenterController, dismissAction: (@Sendable () -> Void)? = nil ) throws -> AirshipNativeViewController { if let themePlist = themePlist { return make( theme: try MessageCenterThemeLoader.fromPlist(themePlist), controller: controller, dismissAction: dismissAction ) } else { return make( controller: controller, dismissAction: dismissAction ) } } /// Makes a message view controller with the given theme. /// - Parameters: /// - themePlist: A path to a theme plist /// - predicate: The message center predicate /// - controller: The Message Center controller /// - dismissAction: Optional action to dismiss the view controller. /// - Returns: A view controller. @MainActor public class func make( themePlist: String?, predicate: (any MessageCenterPredicate)?, controller: MessageCenterController, dismissAction: (@Sendable () -> Void)? = nil ) throws -> AirshipNativeViewController { if let themePlist = themePlist { return make( theme: try MessageCenterThemeLoader.fromPlist(themePlist), predicate: predicate, controller: controller, dismissAction: dismissAction ) } else { return make( predicate: predicate, controller: controller, dismissAction: dismissAction ) } } } private class MessageCenterViewController<Content>: AirshipNativeHostingController<Content> where Content: View { override init(rootView: Content) { super.init(rootView: rootView) #if os(macOS) self.view.wantsLayer = true self.view.layer?.backgroundColor = NSColor.clear.cgColor #else self.view.backgroundColor = .clear #endif } required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageList/MessageCenterListItemView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif struct MessageCenterListItemView: View { @Environment(\.airshipMessageCenterListItemStyle) private var itemStyle @ObservedObject var viewModel: MessageCenterListItemViewModel @ViewBuilder var body: some View { let configuration = ListItemViewStyleConfiguration( message: self.viewModel.message ) itemStyle.makeBody(configuration: configuration) } } extension View { /// Sets the list item style for the Message Center. /// - Parameters: /// - style: The style to apply. public func messageCenterItemViewStyle<S>( _ style: S ) -> some View where S: MessageCenterListItemViewStyle { self.environment( \.airshipMessageCenterListItemStyle, AnyListItemViewStyle(style: style) ) } } /// The configuration for a Message Center list item view. public struct ListItemViewStyleConfiguration { /// The message associated with the list item. public let message: MessageCenterMessage } /// A protocol that defines the style for a Message Center list item view. public protocol MessageCenterListItemViewStyle: Sendable { associatedtype Body: View typealias Configuration = ListItemViewStyleConfiguration /// Creates the view body for the list item. /// - Parameters: /// - configuration: The configuration for the list item. /// - Returns: The view body. func makeBody(configuration: Self.Configuration) -> Self.Body } extension MessageCenterListItemViewStyle where Self == DefaultListItemViewStyle { /// The default list item style. public static var defaultStyle: Self { return .init() } } /// The default style for a Message Center list item view. public struct DefaultListItemViewStyle: MessageCenterListItemViewStyle { @ViewBuilder /// Creates the view body for the list item. /// - Parameters: /// - configuration: The configuration for the list item. /// - Returns: The view body. public func makeBody(configuration: Configuration) -> some View { MessageCenterListContentView(message: configuration.message) } } struct AnyListItemViewStyle: MessageCenterListItemViewStyle { @ViewBuilder private let _makeBody: @Sendable (Configuration) -> AnyView init<S: MessageCenterListItemViewStyle>(style: S) { _makeBody = { configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct ListItemViewStyleKey: EnvironmentKey { static let defaultValue = AnyListItemViewStyle(style: .defaultStyle) } extension EnvironmentValues { var airshipMessageCenterListItemStyle: AnyListItemViewStyle { get { self[ListItemViewStyleKey.self] } set { self[ListItemViewStyleKey.self] = newValue } } } private struct MessageCenterListContentView: View { #if os(tvOS) private static let iconWidth: Double = 100.0 private static let noIconSpacerWidth: Double = 30.0 #else private static let iconWidth: Double = 60.0 private static let noIconSpacerWidth: Double = 20.0 #endif private static let unreadIndicatorSize: Double = 8.0 private static let placeHolderImageName: String = "photo" private static let unreadIndicatorImageName: String = "circle.fill" @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme let message: MessageCenterMessage @ViewBuilder func makeIcon() -> some View { if let listIcon = self.message.listIcon { AirshipAsyncImage(url: listIcon) { image, _ in image.resizable() .scaledToFit() .frame(width: MessageCenterListContentView.iconWidth) } placeholder: { return makeImagePlaceHolder() } } else { makeImagePlaceHolder() } } private func makeImagePlaceHolder() -> some View { let placeHolderImage = theme.placeholderIcon ?? Image( systemName: MessageCenterListContentView.placeHolderImageName ) return placeHolderImage .resizable() .scaledToFit() .foregroundColor(.primary) .frame(width: MessageCenterListContentView.iconWidth) } @ViewBuilder func makeUnreadIndicator() -> some View { if message.unread { let foregroundColor = colorScheme.airshipResolveColor( light: theme.unreadIndicatorColor, dark: theme.unreadIndicatorColorDark ) ?? colorScheme.airshipResolveColor( light: theme.cellTintColor, dark: theme.cellTintColorDark ) Image(systemName: MessageCenterListContentView.unreadIndicatorImageName) .foregroundColor( foregroundColor ) .frame( width: MessageCenterListContentView.unreadIndicatorSize, height: MessageCenterListContentView.unreadIndicatorSize ) } } @ViewBuilder func makeMessageInfo() -> some View { VStack(alignment: .leading) { Text(self.message.title) .font(theme.cellTitleFont) .foregroundColor(colorScheme.airshipResolveColor(light: theme.cellTitleColor, dark: theme.cellTitleColorDark)) .accessibilityHidden(true) if let subtitle = self.message.subtitle { Text(subtitle) .font(.subheadline) .accessibilityHidden(true) } Text(self.message.sentDate, style: .date) .font(theme.cellDateFont) .foregroundColor(colorScheme.airshipResolveColor(light: theme.cellDateColor, dark: theme.cellDateColorDark)) .accessibilityHidden(true) } } @ViewBuilder var body: some View { HStack(alignment: .top) { if (theme.iconsEnabled) { makeIcon() #if !os(tvOS) .padding(.trailing) #endif .overlay(makeUnreadIndicator(), alignment: .topLeading) } else { Spacer().frame( width: MessageCenterListContentView.noIconSpacerWidth ) .overlay(makeUnreadIndicator(), alignment: .topLeading) } makeMessageInfo() Spacer() } #if os(tvOS) .padding() #else .padding(8) #endif } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageList/MessageCenterListView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// A view that displays a list of messages. public struct MessageCenterListView: View { #if !os(macOS) @Environment(\.editMode) private var editMode #endif @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme @StateObject private var viewModel = MessageCenterMessageListViewModel() @State private var listOpacity = 0.0 @State private var isRefreshing = false /// Initializer. /// - Parameters: /// - viewModel: The message center list view model. public init(viewModel: MessageCenterMessageListViewModel) { _viewModel = .init(wrappedValue: viewModel) } /// Initializer. /// - Parameters: /// - predicate: A predicate to filter messages. public init(predicate: (any MessageCenterPredicate)? = nil) { _viewModel = .init(wrappedValue: .init(predicate: predicate)) } @ViewBuilder private func makeCellContent( item: MessageCenterListItemViewModel, messageID: String ) -> some View { MessageCenterListItemView(viewModel: item) } @ViewBuilder private func makeCell( item: MessageCenterListItemViewModel, messageID: String ) -> some View { let accessibilityLabel = String( format: item.message.unread ? "ua_message_unread_description".messageCenterLocalizedString : "ua_message_description".messageCenterLocalizedString, item.message.title, AirshipDateFormatter.string(fromDate: item.message.sentDate, format: .relativeShortDate) ) let cell = makeCellContent(item: item, messageID: messageID) .accessibilityLabel( accessibilityLabel ).accessibilityHint( "ua_message_cell_description".messageCenterLocalizedString ) cell.listRowBackground( colorScheme.airshipResolveColor(light: theme.cellColor, dark: theme.cellColorDark) ) #if !os(tvOS) .listRowSeparator( (theme.cellSeparatorStyle == SeparatorStyle.none) ? .hidden : .automatic ) .listRowSeparatorTint(colorScheme.airshipResolveColor(light: theme.cellSeparatorColor, dark: theme.cellSeparatorColorDark)) #endif } @ViewBuilder private func makeCell(messageID: String) -> some View { if let item = self.viewModel.messageItem(forID: messageID) { #if !os(tvOS) makeCell(item: item, messageID: messageID) #else /** * List items are not selectable by tvOS without a focusable element */ Button( action: { self.viewModel.selectedMessageID = messageID }) { makeCell(item: item, messageID: messageID) } .buttonStyle(.plain) #endif } else { EmptyView() } } private var isEditMode: Bool { #if os(macOS) false #else self.editMode?.wrappedValue.isEditing ?? false #endif } @ViewBuilder private func makeList() -> some View { let binding: Binding<Set<String>> = .init( get: { if isEditMode { return self.viewModel.editModeSelection } else { var set = Set<String>() if let selectedMessageID = viewModel.selectedMessageID { set.insert(selectedMessageID) } return set } }, set: { if isEditMode { self.viewModel.editModeSelection = $0 } else { self.viewModel.selectedMessageID = $0.first } } ) List(selection: binding) { ForEach(self.viewModel.messages) { message in makeCell(messageID: message.id) } .onDelete { offsets in self.viewModel.delete( messages: Set(offsets.map { self.viewModel.messages[$0].id }) ) } } .refreshable { await self.viewModel.refresh() } .disabled(self.viewModel.messages.isEmpty) } @ViewBuilder private func emptyMessageListMessage() -> some View { let refreshColor = colorScheme.airshipResolveColor( light: theme.refreshTintColor, dark: theme.refreshTintColorDark ) VStack { Button { Task { @MainActor in isRefreshing = true await self.viewModel.refresh() isRefreshing = false } } label: { ZStack { if isRefreshing { ProgressView() } else { Image(systemName: "arrow.clockwise") .foregroundColor(refreshColor ?? .primary) } } .frame(height: 44) .background(Color.airshipTappableClear) } .disabled(isRefreshing) Text("ua_empty_message_list".messageCenterLocalizedString) .foregroundColor(refreshColor ?? .primary) } .opacity(1.0 - self.listOpacity) } @ViewBuilder /// The body of the view. public var body: some View { let listBackgroundColor = colorScheme.airshipResolveColor( light: theme.messageListBackgroundColor, dark: theme.messageListBackgroundColorDark ) ZStack { if !self.viewModel.messagesLoaded { ProgressView().opacity(1.0 - self.listOpacity) } else if viewModel.messages.isEmpty { emptyMessageListMessage() } else { makeList() .opacity(self.listOpacity) .listBackground(listBackgroundColor) .animation(.easeInOut(duration: 0.5), value: self.listOpacity) .padding(.bottom, 60) // small spacing at bottom to avoid tab bars } } .airshipOnChangeOf(self.viewModel.messages) { messages in if messages.isEmpty { self.listOpacity = 0.0 } else { self.listOpacity = 1.0 } } } } fileprivate extension View { @ViewBuilder func listBackground(_ color: Color?) -> some View { if let color { #if !os(tvOS) self.scrollContentBackground(.hidden).background(color) #endif } else { self } } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageList/MessageCenterListViewModel.swift ================================================ /* Copyright Airship and Contributors */ public import Combine import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// A view model for the message center list. @MainActor public class MessageCenterMessageListViewModel: ObservableObject { /// The list of messages. @Published public private(set) var messages: [MessageCenterMessage] = [] /// The set of selected message IDs in edit mode. @Published public var editModeSelection: Set<String> = [] /// The selected message ID. @Published public var selectedMessageID: String? = nil /// A flag indicating if the messages have been loaded. @Published public private(set) var messagesLoaded: Bool = false private var messageItems: [String: MessageCenterListItemViewModel] = [:] private var updates = Set<AnyCancellable>() private let messageCenter: (any MessageCenter)? /// Initializer. /// - Parameters: /// - predicate: A predicate to filter messages. public init(predicate: (any MessageCenterPredicate)? = nil) { if Airship.isFlying { messageCenter = Airship.messageCenter } else { messageCenter = nil } self.messageCenter?.inbox.messagePublisher .receive(on: RunLoop.main) .sink { [weak self] incoming in guard let self else { return } self.objectWillChange.send() self.messagesLoaded = true var incomings: [MessageCenterMessage] = [] incoming.filter { predicate?.evaluate(message: $0) ?? true } .forEach { message in incomings.append(message) if self.messageItems[message.id] == nil { self.messageItems[message.id] = MessageCenterListItemViewModel( message: message ) } } let incomingIDs = incomings.map { $0.id } Set(self.messageItems.keys) .subtracting(incomingIDs) .forEach { self.messageItems.removeValue(forKey: $0) } self.messages = incomings } .store(in: &self.updates) Task { await self.refresh() } } func messageItem(forID: String) -> MessageCenterListItemViewModel? { return self.messageItems[forID] } /// Refreshes the list of messages. public func refresh() async { await self.messageCenter?.inbox.refreshMessages() } /// Marks a set of messages as read. /// - Parameters: /// - messages: A set of message IDs to mark as read. public func markRead(messages: Set<String>) { Task { await self.messageCenter?.inbox .markRead( messageIDs: Array(messages) ) } } /// Deletes a set of messages. /// - Parameters: /// - messages: A set of message IDs to delete. public func delete(messages: Set<String>) { Task { await self.messageCenter?.inbox .delete( messageIDs: Array(messages) ) } } /// Selects all messages in edit mode. public func editModeSelectAll() { editModeSelection = Set(messages.map { $0.id }) } /// Clears the selection in edit mode. public func editModeClearAll() { editModeSelection.removeAll() } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageList/MessageCenterListViewWithNavigation.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// A view that displays a list of messages as well as modifies the toolbars and navigation title. @MainActor public struct MessageCenterListViewWithNavigation: View { @Environment(\.messageCenterDismissAction) private var dismissAction: (@MainActor @Sendable () -> Void)? #if !os(macOS) @Environment(\.editMode) private var editMode @Environment(\.messageCenterDetectedAppearance) private var detectedAppearance #endif @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme @StateObject private var viewModel: MessageCenterMessageListViewModel /// Initializer. /// - Parameters: /// - viewModel: The message center list view model. public init(viewModel: MessageCenterMessageListViewModel) { _viewModel = .init(wrappedValue: viewModel) } /// Initializer. /// - Parameters: /// - predicate: A predicate to filter messages. public init(predicate: (any MessageCenterPredicate)? = nil) { _viewModel = .init(wrappedValue: .init(predicate: predicate)) } private var navigationBarAppearance: MessageCenterNavigationAppearance { #if !os(macOS) if let detectedAppearance { return detectedAppearance.resolveAppearance(theme: theme, colorScheme: colorScheme) } #endif return MessageCenterNavigationAppearance(theme: theme, colorScheme: colorScheme) } #if !os(macOS) private var isEditMode: Bool { self.editMode?.wrappedValue.isEditing ?? false } #endif #if !os(tvOS) && !os(macOS) private func editButton() -> some View { return EditButton() .foregroundColor(navigationBarAppearance.editButtonColor(isEditing: isEditMode)) .accessibilityHint("ua_edit_messages_description".messageCenterLocalizedString) } #endif private func markRead(messages: Set<String>) { #if !os(macOS) withAnimation { self.editMode?.wrappedValue = .inactive } #endif self.viewModel.markRead(messages: messages) } private func delete(messages: Set<String>) { #if !os(macOS) withAnimation { self.editMode?.wrappedValue = .inactive } #endif self.viewModel.delete(messages: messages) } private func markDeleteButton() -> some View { Button( "ua_mark_messages_read".messageCenterLocalizedString, systemImage: "trash", role: .destructive ) { self.viewModel.delete(messages: self.viewModel.editModeSelection) } .tint( colorScheme.airshipResolveColor( light: theme.deleteButtonTitleColor, dark: theme.deleteButtonTitleColorDark ) ) .accessibilityHint("ua_delete_messages".messageCenterLocalizedString) .disabled(self.viewModel.editModeSelection.isEmpty) } @ViewBuilder private func markReadButton() -> some View { Button( "ua_mark_messages_read".messageCenterLocalizedString, systemImage: "envelope.open" ) { markRead(messages: self.viewModel.editModeSelection) } .tint( colorScheme.airshipResolveColor( light: theme.markAsReadButtonTitleColor, dark: theme.markAsReadButtonTitleColorDark ) ) .disabled(self.viewModel.editModeSelection.isEmpty) .accessibilityHint("ua_mark_messages_read".messageCenterLocalizedString) } @ViewBuilder private func selectButton() -> some View { if self.viewModel.editModeSelection.count == self.viewModel.messages.count { selectNone() } else { selectAll() } } private func selectAll() -> some View { Button( "ua_select_all_messages".messageCenterLocalizedString ) { self.viewModel.editModeSelectAll() } .tint( colorScheme.airshipResolveColor( light: theme.selectAllButtonTitleColor, dark: theme.selectAllButtonTitleColorDark ) ) .accessibilityHint("ua_select_all_messages".messageCenterLocalizedString) } private func selectNone() -> some View { Button( "ua_select_none_messages".messageCenterLocalizedString ) { self.viewModel.editModeClearAll() } .tint( colorScheme.airshipResolveColor( light: theme.selectAllButtonTitleColor, dark: theme.selectAllButtonTitleColorDark ) ) .accessibilityHint("ua_select_none_messages".messageCenterLocalizedString) } /// The body of the view. public var body: some View { let containerColor: Color? = colorScheme.airshipResolveColor( light: theme.messageListContainerBackgroundColor, dark: theme.messageListContainerBackgroundColorDark ) let content = MessageCenterListView(viewModel: self.viewModel) .frame(maxHeight: .infinity) #if !os(macOS) .applyUIKitNavigationAppearance() #endif .navigationTitle( theme.navigationBarTitle ?? "ua_message_center_title".messageCenterLocalizedString ) .toolbar { #if os(iOS) if #available(iOS 26.0, *) { ToolbarItemGroup(placement: .topBarTrailing) { editButton() } } else { ToolbarItemGroup(placement: .navigationBarTrailing) { editButton() } } ToolbarItemGroup(placement: .bottomBar) { selectButton() Spacer() markReadButton() markDeleteButton() } #elseif !os(tvOS) && !os(macOS) ToolbarItemGroup(placement: .navigationBarTrailing) { #if !os(macOS) editButton() #endif } ToolbarItemGroup(placement: .bottomBar) { selectButton() Spacer() markReadButton() Spacer() markDeleteButton() } #endif if navigationBarAppearance.titleColor != nil || navigationBarAppearance.titleFont != nil { ToolbarItemGroup(placement: .principal) { // Custom title with detected color Text(theme.navigationBarTitle ?? "ua_message_center_title".messageCenterLocalizedString) .foregroundColor(navigationBarAppearance.titleColor) .airshipApplyIf(navigationBarAppearance.titleFont != nil) { text in text.font(navigationBarAppearance.titleFont) } } } } #if !os(tvOS) && !os(macOS) .toolbar(isEditMode ? .visible : .hidden, for: .bottomBar) #endif #if !os(macOS) .airshipApplyIf(containerColor != nil) { view in let visibility: Visibility = if #available(iOS 26.0, *) { .automatic } else { .visible } view.toolbarBackground(containerColor!, for: .navigationBar) .toolbarBackground(visibility, for: .navigationBar) } .airshipApplyIf(dismissAction != nil) { view in view.toolbar { ToolbarItem(placement: .navigationBarLeading) { MessageCenterBackButton(dismissAction: dismissAction) } } } #endif #if !os(macOS) if #available(iOS 26.0, *) { content.toolbar( isEditMode ? .hidden : .automatic, for: .tabBar ) .ignoresSafeArea(edges: .bottom) } else { content } #else content #endif } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageView/MessageCenterMessageError.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Errors that can occur when loading Message Center messages. public enum MessageCenterMessageError: Error { /// No message exists in the inbox for the provided message ID. case messageGone /// A network failure occurred while fetching the message or inbox data. case failedToFetchMessage } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageView/MessageCenterMessageView.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI #if canImport(WebKit) import WebKit #endif #if canImport(AirshipCore) import AirshipCore #endif /// The Message Center message view. @MainActor public struct MessageCenterMessageView: View { @Environment(\.airshipMessageViewStyle) private var style /// The message's ID @StateObject private var viewModel: MessageCenterMessageViewModel /// The dismiss action callback private let dismissAction: (@MainActor @Sendable () -> Void)? /// Initializer. /// - Parameters: /// - viewModel: The message center message view model. /// - dismissAction: A dismiss action. public init( viewModel: MessageCenterMessageViewModel, dismissAction: (@MainActor @Sendable () -> Void)? = nil ) { _viewModel = .init(wrappedValue: viewModel) self.dismissAction = dismissAction } /// Initializer. /// - Parameters: /// - messageID: The message ID. /// - dismissAction: A dismiss action. public init( messageID: String, dismissAction: (@MainActor @Sendable () -> Void)? = nil ) { _viewModel = .init(wrappedValue: .init(messageID: messageID)) self.dismissAction = dismissAction } @ViewBuilder /// The body of the view. public var body: some View { let configuration = MessageViewStyleConfiguration( viewModel: viewModel, dismissAction: dismissAction ) style.makeBody(configuration: configuration) } enum DisplayPhase { case loading case error(any Error) case loaded } } extension View { /// Sets the style for the Message Center message view. /// - Parameters: /// - style: The style to apply. public func messageCenterMessageViewStyle<S>( _ style: S ) -> some View where S: MessageViewStyle { self.environment(\.airshipMessageViewStyle, AnyMessageViewStyle(style: style)) } } /// The configuration for a Message Center message view. public struct MessageViewStyleConfiguration: Sendable { /// The message view model. public let viewModel: MessageCenterMessageViewModel /// The dismiss action. public let dismissAction: (@MainActor @Sendable () -> Void)? } /// A protocol that defines the style for a Message Center message view. public protocol MessageViewStyle: Sendable { associatedtype Body: View typealias Configuration = MessageViewStyleConfiguration @MainActor /// Creates the view body for the message view. /// - Parameters: /// - configuration: The configuration for the message view. /// - Returns: The view body. func makeBody(configuration: Self.Configuration) -> Self.Body } extension MessageViewStyle where Self == DefaultMessageViewStyle { /// The default message view style. public static var defaultStyle: Self { return .init() } } /// The default style for a Message Center message view. public struct DefaultMessageViewStyle: MessageViewStyle { @ViewBuilder @MainActor /// Creates the view body for the message view. /// - Parameters: /// - configuration: The configuration for the message view. /// - Returns: The view body. public func makeBody(configuration: Configuration) -> some View { MessageCenterMessageContentView( viewModel: configuration.viewModel, dismissAction: configuration.dismissAction ) } } struct AnyMessageViewStyle: MessageViewStyle { @ViewBuilder private let _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: MessageViewStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct MessageViewStyleKey: EnvironmentKey { static let defaultValue = AnyMessageViewStyle(style: .defaultStyle) } extension EnvironmentValues { var airshipMessageViewStyle: AnyMessageViewStyle { get { self[MessageViewStyleKey.self] } set { self[MessageViewStyleKey.self] = newValue } } } private struct MessageCenterMessageContentView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme @State private var messageLoadingPhase: MessageCenterMessageView.DisplayPhase = .loading @State private var opacity = 0.0 @State private var contentType: MessageCenterMessage.ContentType = .unknown(nil) @ObservedObject var viewModel: MessageCenterMessageViewModel let dismissAction: (@MainActor @Sendable () -> Void)? init( viewModel: MessageCenterMessageViewModel, dismissAction: (@MainActor @Sendable () -> Void)? ) { self.viewModel = viewModel self.dismissAction = dismissAction self.contentType = viewModel.message?.contentType ?? .unknown(nil) } @MainActor private static func makeRequest( viewModel: MessageCenterMessageViewModel ) async throws -> URLRequest { guard let message = await viewModel.fetchMessage(), let user = await Airship.messageCenter.inbox.user else { throw AirshipErrors.error("") } var request = URLRequest(url: message.bodyURL) request.setValue( user.basicAuthString, forHTTPHeaderField: "Authorization" ) request.timeoutInterval = 120 return request } #if canImport(WebKit) @MainActor private func makeExtensionDelegate( messageID: String ) async throws -> MessageCenterNativeBridgeExtension { guard let message = await viewModel.fetchMessage(), let user = await Airship.messageCenter.inbox.user else { throw AirshipErrors.error("") } return MessageCenterNativeBridgeExtension( message: message, user: user ) } #endif var body: some View { let backgroundColor = self.colorScheme.airshipResolveColor( light: self.theme.messageViewBackgroundColor, dark: self.theme.messageViewBackgroundColorDark ) ZStack { if let backgroundColor { backgroundColor.ignoresSafeArea() } messageContent() .opacity(self.opacity) .onReceive(Just(messageLoadingPhase)) { _ in if case .loaded = self.messageLoadingPhase { self.opacity = 1.0 if Airship.isFlying { Task { await viewModel.markRead() } } } } .animation(.easeInOut(duration: 0.5), value: self.opacity) if case .loading = self.messageLoadingPhase { ProgressView().task { do { let message = try await viewModel.fetchMessageThrowing() self.contentType = message.contentType } catch { self.messageLoadingPhase = .error(error) } } } else if case .error(let error) = self.messageLoadingPhase { if let error = error as? MessageCenterMessageError, error == .messageGone { VStack { Text( "ua_mc_no_longer_available".messageCenterLocalizedString ) .font(.headline) .foregroundColor(.primary) } } else { VStack { Text("ua_mc_failed_to_load".messageCenterLocalizedString) .font(.headline) .foregroundColor(.primary) Button("ua_retry_button".messageCenterLocalizedString) { self.messageLoadingPhase = .loading } } } } } } @ViewBuilder private func messageContent() -> some View { switch self.contentType { case .html, .plain, .unknown: webBasedMessageView() case .native: thomasMessageView() } } @ViewBuilder private func webBasedMessageView() -> some View { #if canImport(WebKit) MessageCenterWebView( phase: self.$messageLoadingPhase, nativeBridgeExtension: { try await makeExtensionDelegate(messageID: viewModel.messageID) }, request: { try await Self.makeRequest(viewModel: self.viewModel) }, dismiss: { await MainActor.run { dismiss() } } ) #else Text("ua_mc_failed_to_load".messageCenterLocalizedString) .font(.headline) .foregroundColor(.primary) #endif } @ViewBuilder private func thomasMessageView() -> some View { if let analytics = viewModel.makeAnalytics(onDismiss: { [action = dismissAction] in action?() } ) { MessageCenterThomasView( phase: self.$messageLoadingPhase, layoutRequest: { try await Self.makeRequest(viewModel: viewModel) }, analytics: analytics, dismissHandle: self.viewModel.thomasDismissHandle, // stateStorage: viewModel.getOrCreateNativeStateStorage() //disables state restoring for all message center views ) } else { EmptyView() } } private func dismiss() { self.dismissAction?() } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageView/MessageCenterMessageViewModel.swift ================================================ /* Copyright Airship and Contributors */ public import Combine #if canImport(AirshipCore) import AirshipCore #endif /// A view model for a message. @MainActor public final class MessageCenterMessageViewModel: ObservableObject { /// The message ID. public let messageID: String /// The message. @Published public var message: MessageCenterMessage? = nil /// Initializer. /// - Parameters: /// - messageID: The message ID. public init(messageID: String) { self.messageID = messageID } private var fetchMessageTask: Task<MessageCenterMessage, any Error>? = nil private var nativeAnalytics: ThomasDisplayListener? = nil let thomasDismissHandle: ThomasDismissHandle = .init() func makeAnalytics( messageCenter: DefaultMessageCenter = Airship.internalMessageCenter, onDismiss: @MainActor @escaping () -> Void ) -> ThomasDisplayListener? { guard let message else { return nil } if let cached = nativeAnalytics { return cached } let result = ThomasDisplayListener( analytics: DefaultMessageViewAnalytics( message: message, eventRecorder: ThomasLayoutEventRecorder( airshipAnalytics: messageCenter.analytics, meteredUsage: messageCenter.meteredUsage ), historyStorage: MessageDisplayHistoryStore( storageGetter: { messageID in await messageCenter.internalInbox.message(forID: messageID)?.associatedData.displayHistory }, storageSetter: { messageID, data in await messageCenter.internalInbox.saveDisplayHistory(for: messageID, history: data) }) ), onDismiss: { _ in onDismiss() } ) nativeAnalytics = result return result } func getOrCreateNativeStateStorage( messageCenter: DefaultMessageCenter = Airship.internalMessageCenter ) -> any LayoutDataStorage { return messageCenter.internalInbox.getNativeStateStorage(for: messageID) } /// Fetches the message. /// - Returns: The message. @MainActor public func fetchMessage() async -> MessageCenterMessage? { return try? await fetchMessageThrowing() } /// Fetches the message. /// - Throws: An error of type `MessageCenterMessageError` /// - Returns: The message. @MainActor public func fetchMessageThrowing() async throws -> MessageCenterMessage { _ = try await fetchMessageTask?.value if let message = message { return message } let task = Task { var message = await Airship.messageCenter.inbox.message( forID: messageID ) do { if message == nil { try await Airship.messageCenter.inbox.refreshMessagesThrowing() message = await Airship.messageCenter.inbox.message( forID: messageID ) } } catch { throw MessageCenterMessageError.failedToFetchMessage } if let message { return message } else { throw MessageCenterMessageError.messageGone } } self.fetchMessageTask = task let result = try await task.value self.message = result return result } /// Marks the message as read. /// - Returns: `true` if the message was marked as read, `false` otherwise. @discardableResult public func markRead() async -> Bool { guard let message = await fetchMessage() else { return false } await Airship.messageCenter.inbox.markRead(messageIDs: [message.id]) return true } /// Deletes the message. /// - Returns: `true` if the message was deleted, `false` otherwise. @discardableResult public func delete() async -> Bool { guard let message = await fetchMessage() else { return false } await Airship.messageCenter.inbox.delete(messageIDs: [message.id]) return true } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageView/MessageCenterMessageViewWithNavigation.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// A view that displays a message as well as modifies the toolbars and navigation title. @MainActor public struct MessageCenterMessageViewWithNavigation: View { @Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode> @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme #if !os(macOS) @Environment(\.messageCenterDetectedAppearance) private var detectedAppearance #endif @State private var opacity = 0.0 @StateObject private var messageViewModel: MessageCenterMessageViewModel private let showBackButton: Bool? private let title: String? private let dismissAction: (@MainActor () -> Void)? @State private var isDismissed = false // Add this state /// Initializer. /// - Parameters: /// - messageID: The message ID. /// - title: The title to use until the message is loaded. /// - showBackButton: Flag to show or hide the back button. If not set, back button will be displayed if it has a presentationMode. /// - dismissAction: A dismiss action. public init( messageID: String, title: String? = nil, showBackButton: Bool? = nil, dismissAction: (@MainActor () -> Void)? = nil ) { _messageViewModel = .init(wrappedValue: .init(messageID: messageID)) self.title = title self.showBackButton = showBackButton self.dismissAction = dismissAction } /// Initializer. /// - Parameters: /// - viewModel: The message center message view model. /// - title: The title to use until the message is loaded. /// - showBackButton: Flag to show or hide the back button. If not set, back button will be displayed if it has a presentationMode. /// - dismissAction: A dismiss action. public init( viewModel: MessageCenterMessageViewModel, title: String? = nil, showBackButton: Bool? = nil, dismissAction: (@MainActor () -> Void)? = nil ) { _messageViewModel = .init(wrappedValue: viewModel) self.title = title self.showBackButton = showBackButton self.dismissAction = dismissAction } private var navigationBarAppearance: MessageCenterNavigationAppearance { #if !os(macOS) if let detectedAppearance { return detectedAppearance.resolveAppearance(theme: theme, colorScheme: colorScheme) } #endif return MessageCenterNavigationAppearance(theme: theme, colorScheme: colorScheme) } private var shouldShowBackButton: Bool { if let showBackButton { return showBackButton } return showBackButton ?? self.presentationMode.wrappedValue.isPresented } /// The body of the view. public var body: some View { let containerColor = navigationBarAppearance.barBackgroundColor MessageCenterMessageView( viewModel: self.messageViewModel, dismissAction: dismiss ) #if !os(macOS) .applyUIKitNavigationAppearance() #endif .navigationBarBackButtonHidden(true) // Hide the default back button #if !os(tvOS) && !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif .navigationTitle(self.messageViewModel.message?.title ?? self.title ?? "") .toolbar { if shouldShowBackButton { #if !os(macOS) ToolbarItemGroup(placement: .navigationBarLeading) { MessageCenterBackButton(dismissAction: dismiss) } #else ToolbarItemGroup(placement: .automatic) { MessageCenterBackButton(dismissAction: dismiss) } #endif } #if os(iOS) if #available(iOS 26.0, *) { ToolbarItemGroup(placement: .topBarTrailing) { deleteButton } } else { ToolbarItemGroup(placement: .navigationBarTrailing) { deleteButton } } #elseif !os(macOS) ToolbarItemGroup(placement: .navigationBarTrailing) { deleteButton } #else ToolbarItemGroup(placement: .automatic) { deleteButton } #endif if navigationBarAppearance.titleColor != nil || navigationBarAppearance.titleFont != nil { ToolbarItemGroup(placement: .principal) { // Custom title with detected color Text(self.messageViewModel.message?.title ?? self.title ?? "") .foregroundColor(navigationBarAppearance.titleColor) .airshipApplyIf(navigationBarAppearance.titleFont != nil) { text in text.font(navigationBarAppearance.titleFont) } } } } .airshipApplyIf(containerColor != nil) { view in let visibility: Visibility = if #available(iOS 26.0, *) { .automatic } else { .visible } #if !os(macOS) view.toolbarBackground(containerColor!, for: .navigationBar) .toolbarBackground(visibility, for: .navigationBar) #endif } } @ViewBuilder private var deleteButton: some View { if theme.hideDeleteButton != true { Button( "ua_delete_message".messageCenterLocalizedString, systemImage: "trash", role: .destructive ) { Task { await messageViewModel.delete() } dismiss() } .tint(navigationBarAppearance.deleteButtonColor) .disabled(messageViewModel.message == nil) } } private func dismiss() { guard !isDismissed else { return } isDismissed = true if let dismissAction = self.dismissAction { dismissAction() } messageViewModel.thomasDismissHandle.dismiss() presentationMode.wrappedValue.dismiss() } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageView/MessageCenterThomasView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif struct MessageCenterThomasView: View { @Binding var phase: MessageCenterMessageView.DisplayPhase @StateObject private var viewModel: ViewModel init( phase: Binding<MessageCenterMessageView.DisplayPhase>, layoutRequest: @escaping () async throws -> URLRequest, analytics: ThomasDisplayListener, dismissHandle: ThomasDismissHandle, stateStorage: (any LayoutDataStorage)? = nil ) { self._phase = phase self._viewModel = StateObject( wrappedValue: ViewModel( request: layoutRequest, analytics: analytics, dismissHandle: dismissHandle, stateStorage: stateStorage ) ) } var body: some View { if let layout = viewModel.layout { AirshipSimpleLayoutView( layout: layout, viewModel: viewModel.layoutViewModel ) } else { Color.clear.task { switch phase { case .loaded: return default: self.phase = await viewModel.loadLayout() } } } } } @MainActor private final class ViewModel: ObservableObject { private let layoutRequest: () async throws -> URLRequest private let stateStorage: (any LayoutDataStorage)? @Published private(set) var layout: AirshipLayout? = nil let analyticsRecorder: any ThomasDelegate let dismissHandle: ThomasDismissHandle let layoutViewModel: AirshipSimpleLayoutViewModel init( request: @escaping () async throws -> URLRequest, analytics: ThomasDisplayListener, dismissHandle: ThomasDismissHandle, stateStorage: (any LayoutDataStorage)? = nil ) { self.layoutRequest = request self.analyticsRecorder = analytics self.dismissHandle = dismissHandle self.stateStorage = stateStorage self.layoutViewModel = AirshipSimpleLayoutViewModel( delegate: analytics, dismissHandle: dismissHandle, stateStorage: stateStorage ) } func loadLayout() async -> MessageCenterMessageView.DisplayPhase { if let layout { await preloadData(for: layout) return .loaded } do { let request = try await self.layoutRequest() let (data, _) = try await URLSession.airshipSecureSession.data(for: request) let downloaded = try JSONDecoder().decode(AirshipLayout.self, from: data) await preloadData(for: downloaded) self.layout = downloaded } catch { return .error(error) } return .loaded } func dismiss() { self.dismissHandle.dismiss() } private func preloadData(for layout: AirshipLayout) async { await self.stateStorage?.prepare(restoreID: "static") //TODO: replace with the actual } } extension View { func also(_ action: (Self) -> ()) -> some View { action(self) return self } } ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/MessageView/MessageCenterWebView.swift ================================================ import Combine import Foundation import SwiftUI #if canImport(WebKit) import WebKit #endif #if canImport(AirshipCore) import AirshipCore #endif #if canImport(WebKit) struct MessageCenterWebView: AirshipNativeViewRepresentable { #if os(macOS) typealias NSViewType = WKWebView func makeNSView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateNSView(_ nsView: WKWebView, context: Context) { updateView(nsView, context: context) } #else typealias UIViewType = WKWebView func makeUIView(context: Context) -> WKWebView { return makeWebView(context: context) } func updateUIView(_ uiView: WKWebView, context: Context) { updateView(uiView, context: context) } #endif @Binding var phase: MessageCenterMessageView.DisplayPhase let nativeBridgeExtension: (() async throws -> MessageCenterNativeBridgeExtension)? let request: () async throws -> URLRequest let dismiss: () async -> Void @State private var isWebViewLoading: Bool = false private var isLoading: Bool { guard case .loading = self.phase else { return false } return true } func makeWebView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() #if !os(macOS) configuration.allowsInlineMediaPlayback = true configuration.dataDetectorTypes = .all #endif let webView = WKWebView( frame: CGRect.zero, configuration: configuration ) webView.allowsLinkPreview = false webView.navigationDelegate = context.coordinator.nativeBridge if #available(iOS 16.4, *) { webView.isInspectable = Airship.isFlying && Airship.config.airshipConfig.isWebViewInspectionEnabled } return webView } func makeCoordinator() -> Coordinator { Coordinator(self) } func updateView(_ uiView: WKWebView, context: Context) { Task { await checkLoad( webView: uiView, coordinator: context.coordinator ) } } @MainActor func checkLoad(webView: WKWebView, coordinator: Coordinator) async { if isLoading, !isWebViewLoading { await self.load(webView: webView, coordinator: coordinator) } } @MainActor func load(webView: WKWebView, coordinator: Coordinator) async { self.phase = .loading do { let delegate = try await self.nativeBridgeExtension?() coordinator.nativeBridgeExtensionDelegate = delegate let request = try await self.request() _ = webView.load(request) self.isWebViewLoading = true } catch { self.phase = .error(error) } } @MainActor private func pageFinished(error: (any Error)? = nil) async { self.isWebViewLoading = false if let error = error { self.phase = .error(error) } else { self.phase = .loaded } } class Coordinator: NSObject, AirshipWKNavigationDelegate, JavaScriptCommandDelegate, NativeBridgeDelegate { private let parent: MessageCenterWebView private let challengeResolver: ChallengeResolver let nativeBridge: NativeBridge var nativeBridgeExtensionDelegate: (any NativeBridgeExtensionDelegate)? { didSet { self.nativeBridge.nativeBridgeExtensionDelegate = self.nativeBridgeExtensionDelegate } } init(_ parent: MessageCenterWebView, resolver: ChallengeResolver = .shared) { self.parent = parent self.nativeBridge = NativeBridge() self.challengeResolver = resolver super.init() self.nativeBridge.forwardNavigationDelegate = self self.nativeBridge.javaScriptCommandDelegate = self self.nativeBridge.nativeBridgeDelegate = self } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { Task { @MainActor in await parent.pageFinished() } } func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { Task { @MainActor in await parent.load(webView: webView, coordinator: self) } } func webView( _ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error ) { Task { @MainActor in await parent.pageFinished(error: error) } } func webView( _ webView: WKWebView, respondTo challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await challengeResolver.resolve(challenge) } func performCommand(_ command: JavaScriptCommand, webView: WKWebView) -> Bool { return false } nonisolated func close() { Task { @MainActor in await parent.dismiss() } } } } #endif ================================================ FILE: Airship/AirshipMessageCenter/Source/Views/Shared/MessageCenterBackButton.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif @MainActor struct MessageCenterBackButton: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipMessageCenterTheme) private var theme @Environment(\.airshipMessageCenterPredicate) private var predicate var dismissAction: (@MainActor @Sendable () -> Void)? @ViewBuilder public var body: some View { let backButtonColor = colorScheme.airshipResolveColor( light: theme.backButtonColor, dark: theme.backButtonColorDark ) Button(action: { self.dismissAction?() }) { Image(systemName: "chevron.backward") .scaleEffect(0.68) .font(Font.title.weight(.medium)) .foregroundColor(backButtonColor) } } } ================================================ FILE: Airship/AirshipMessageCenter/Tests/MessageCenterAPIClientTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @testable import AirshipMessageCenter final class MessageCenterAPIClientTest: XCTestCase { private var client: MessageCenterAPIClient! = nil private let session = TestAirshipRequestSession() private let user = MessageCenterUser( username: "username", password: "password" ) private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private let messages = [ MessageCenterMessage( title: "Foo message", id: "foo", contentType: .html, extra: [:], bodyURL: URL(string: "anyurl.com")!, expirationDate: nil, messageReporting: ["foo": "reporting"], unread: true, sentDate: Date(), messageURL: URL(string: "anyurl.com")!, rawMessageObject: [:] ) ] override func setUpWithError() throws { self.client = MessageCenterAPIClient( config: .testConfig(), session: session ) } /// Tests retrieving the message list with success. func testRetrieveMessageListSuccess() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) let messageResponse: String = """ { "messages": [ { "message_id": "some_mesg_id", "message_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/", "message_body_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/body/", "message_read_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/read/", "unread": true, "message_sent": "2010-09-05 12:13 -0000", "title": "Message title", "extra": { "some_key": "some_value" }, "message_reporting": { "cool": "story" }, "content_type": "text/html", "content_size": "128" } ] } """ self.session.data = messageResponse.data(using: .utf8) let response = try await self.client.retrieveMessageList( user: self.user, channelID: "some channel", lastModified: "some modified date" ) let messages = response.result! let message = messages[0] as MessageCenterMessage XCTAssertEqual(message.id, "some_mesg_id") XCTAssertEqual(message.title, "Message title") XCTAssertEqual(message.contentType, .html) let request = self.session.lastRequest! XCTAssertEqual( "https://device-api.urbanairship.com/api/user/username/messages/", request.url!.absoluteString ) XCTAssertEqual("GET", request.method) let expectedHeaders = [ "X-UA-Channel-ID": "some channel", "If-Modified-Since": "some modified date", "Accept": "application/vnd.urbanairship+json; version=3;" ] XCTAssertEqual(expectedHeaders, request.headers) } /// Tests retrieving the message list with missing body failure func testRetrieveMessageMissingBody() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) do { let _ = try await self.client.retrieveMessageList( user: self.user, channelID: "some channel", lastModified: nil ) XCTFail("Expected error") } catch { XCTAssertNotNil(error) } } /// Tests retrieving the message list with status code failure func testRetrieveMessageFailure() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 500, httpVersion: nil, headerFields: [:] ) self.session.data = "{\"ok\":true}".data(using: .utf8) let response = try await self.client.retrieveMessageList( user: self.user, channelID: "some channel", lastModified: nil ) XCTAssertEqual(response.statusCode, 500) XCTAssertNil(response.result) } /// Tests retrieving the message list with parsing failure func testRetrieveMessageJSONParseFailure() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) self.session.data = "{\"ok\":true}".data(using: .utf8) do { let _ = try await self.client.retrieveMessageList( user: self.user, channelID: "some channel", lastModified: nil ) XCTFail("Expected error") } catch { XCTAssertNotNil(error) } } /// Tests batch mark as read success. func testBatchMarkAsReadSuccess() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) self.session.data = "{\"ok\":true}".data(using: .utf8) let _ = try await self.client.performBatchMarkAsRead( forMessages: self.messages, user: self.user, channelID: "some channel" ) let request = self.session.lastRequest! let requestPayload = try JSONSerialization.jsonObject( with: request.body! ) let expected: [String: AnyHashable] = [ "messages": [["foo": "reporting"]] ] XCTAssertEqual( expected as NSDictionary, requestPayload as! NSDictionary ) } /// Tests batch mark as read failure. func testBatchMarkAsReadFailure() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 500, httpVersion: nil, headerFields: [:] ) self.session.data = "{\"ok\":true}".data(using: .utf8) let response = try await self.client.performBatchMarkAsRead( forMessages: self.messages, user: self.user, channelID: "some channel" ) XCTAssertEqual(500, response.statusCode) } /// Tests batch delete success. func testBatchDeleteSuccess() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) self.session.data = "{\"ok\":true}".data(using: .utf8) let _ = try await self.client.performBatchDelete( forMessages: self.messages, user: self.user, channelID: "some channel" ) let request = self.session.lastRequest! let requestPayload = try JSONSerialization.jsonObject( with: request.body! ) let expected: [String: AnyHashable] = [ "messages": [["foo": "reporting"]] ] XCTAssertEqual( expected as NSDictionary, requestPayload as! NSDictionary ) } /// Tests batch delete failure. func testBatchDeleteAsReadFailure() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 500, httpVersion: nil, headerFields: [:] ) self.session.data = "{\"ok\":true}".data(using: .utf8) let response = try await self.client.performBatchDelete( forMessages: self.messages, user: self.user, channelID: "some channel" ) XCTAssertEqual(500, response.statusCode) } /// Tests creating user with success. func testCreateUserSuccess() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 201, httpVersion: nil, headerFields: [:] ) self.session.data = try AirshipJSONUtils.data( [ "user_id": "some user id", "password": "some password", ] ) let response = try await self.client.createUser( withChannelID: "some channel" ) let request = self.session.lastRequest! XCTAssertEqual(response.statusCode, 201) XCTAssertNotNil(response.result) XCTAssertEqual(response.result?.username, "some user id") XCTAssertEqual(response.result?.password, "some password") XCTAssertEqual( "https://device-api.urbanairship.com/api/user/", request.url?.absoluteString ) XCTAssertEqual( AirshipRequestAuth.channelAuthToken(identifier: "some channel"), request.auth ) XCTAssertEqual("POST", request.method) let requestPayload = try JSONSerialization.jsonObject( with: request.body! ) let expected: [String: AnyHashable] = [ "ios_channels": ["some channel"] ] XCTAssertEqual( expected as NSDictionary, requestPayload as! NSDictionary ) } /// Tests creating user with status code failure func testCreateUserFailure() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 400, httpVersion: nil, headerFields: [:] ) self.session.data = "{\"ok\":true}".data(using: .utf8) let response = try await self.client.createUser( withChannelID: "channelID" ) XCTAssertEqual(response.statusCode, 400) XCTAssertNil(response.result) } /// Tests create user with parsing failure func testCreateUserFailureJSONParseError() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) self.session.data = try AirshipJSONUtils.data([:]) do { let _ = try await self.client.createUser(withChannelID: "channelID") XCTFail("Expected error") } catch { XCTAssertNotNil(error) } } /// Tests updating user with success. func testUpdateUserSuccess() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) let response = try await self.client.updateUser( self.user, channelID: "some channel" ) let request = self.session.lastRequest! XCTAssertEqual(response.statusCode, 200) XCTAssertNil(response.result) XCTAssertEqual( "https://device-api.urbanairship.com/api/user/username", request.url!.absoluteString ) XCTAssertEqual("POST", request.method) let requestPayload = try JSONSerialization.jsonObject( with: request.body! ) let expected: [String: AnyHashable] = [ "ios_channels": [ "add": ["some channel"] ] ] XCTAssertEqual( expected as NSDictionary, requestPayload as! NSDictionary ) } func testRetrieveMessageListInvalidContentTypeBecomesUnknown() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) let messageResponse: String = """ { "messages": [ { "message_id": "some_mesg_id", "message_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/", "message_body_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/body/", "message_read_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/read/", "unread": true, "message_sent": "2010-09-05 12:13 -0000", "title": "Message title", "extra": { "some_key": "some_value" }, "message_reporting": { "cool": "story" }, "content_type": "application/x-unknown", "content_size": "128" } ] } """ self.session.data = messageResponse.data(using: .utf8) let response = try await self.client.retrieveMessageList( user: self.user, channelID: "some channel", lastModified: nil ) let messages = response.result! XCTAssertEqual(messages.count, 1) XCTAssertEqual(messages[0].contentType, .unknown("application/x-unknown")) } func testRetrieveMessageListMissingContentTypeBecomesUnknown() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 200, httpVersion: nil, headerFields: [:] ) let messageResponse: String = """ { "messages": [ { "message_id": "some_mesg_id", "message_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/", "message_body_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/body/", "message_read_url": "https://go.urbanairship.com/api/user/userId/messages/message/some_mesg_id/read/", "unread": true, "message_sent": "2010-09-05 12:13 -0000", "title": "Message title", "extra": { "some_key": "some_value" }, "message_reporting": { "cool": "story" }, "content_size": "128" } ] } """ self.session.data = messageResponse.data(using: .utf8) let response = try await self.client.retrieveMessageList( user: self.user, channelID: "some channel", lastModified: nil ) let messages = response.result! XCTAssertEqual(messages.count, 1) XCTAssertEqual(messages[0].contentType, .unknown(nil)) } /// Tests creating user with status code failure func testUpdateUserFailure() async throws { self.session.response = HTTPURLResponse( url: URL(string: "www.anyurl.com")!, statusCode: 400, httpVersion: nil, headerFields: [:] ) do { let response = try await self.client.updateUser( self.user, channelID: "some channel" ) XCTAssertEqual(response.statusCode, 400) XCTAssertNil(response.result) } } } ================================================ FILE: Airship/AirshipMessageCenter/Tests/MessageCenterListTests.swift ================================================ /* Copyright Airship and Contributors */ import Combine import XCTest @testable import AirshipCore @testable import AirshipMessageCenter @MainActor final class MessageCenterListTest: XCTestCase { private var disposables = Set<AnyCancellable>() private let dataStore = PreferenceDataStore(appKey: UUID().uuidString) private let config: RuntimeConfig = .testConfig() private lazy var store: MessageCenterStore = { let modelURL = AirshipMessageCenterResources.bundle .url( forResource: "UAInbox", withExtension: "momd" ) if let modelURL = modelURL { let storeName = String( format: "Inbox-%@.sqlite", self.config.appCredentials.appKey ) let coreData = UACoreData( name: "UAInbox", modelURL: modelURL, inMemory: true, stores: [storeName] ) return MessageCenterStore( config: self.config, dataStore: self.dataStore, coreData: coreData, date: self.date ) } return MessageCenterStore( config: self.config, dataStore: self.dataStore, date: self.date ) }() private let channel = TestChannel() private let workManager: TestWorkManager = TestWorkManager() private let client: TestMessageCenterAPIClient = TestMessageCenterAPIClient() private let sleeper = TestTaskSleeper() private let notificationCenter = NotificationCenter() private let date = UATestDate(offset: 0, dateOverride: Date()) private lazy var inbox = DefaultMessageCenterInbox( channel: channel, client: client, config: config, store: store, notificationCenter: notificationCenter, date: date, workManager: workManager, taskSleeper: sleeper ) func testMessageCenterInboxUser() async throws { let expectedUser = MessageCenterUser( username: "AnyName", password: "AnyPassword" ) // Save user await store.saveUser(expectedUser, channelID: "987654433") self.inbox.enabled = true var user = await self.inbox.user XCTAssertNotNil(user) XCTAssertEqual(user!.username, expectedUser.username) XCTAssertEqual(user!.password, expectedUser.password) self.inbox.enabled = false user = await self.inbox.user XCTAssertNil(user) // Reset User await store.resetUser() let resetedUser = await self.inbox.user XCTAssertNil(resetedUser) } func testMessageCenterIdenityHint() async throws { let user = MessageCenterUser( username: "AnyName", password: "AnyPassword" ) // Save user await store.saveUser(user, channelID: "987654433") self.inbox.enabled = true XCTAssertEqual(1, self.channel.extenders.count) let payload = await self.channel.channelPayload XCTAssertEqual(user.username, payload.identityHints?.userID) } func testMessageCenterIdenityHintRestoreMessageCenterDisabled() async throws { self.channel.extenders.removeAll() var airshipConfig = AirshipConfig() airshipConfig.restoreMessageCenterOnReinstall = false let user = MessageCenterUser( username: "AnyName", password: "AnyPassword" ) // Save user await store.saveUser(user, channelID: "987654433") let inbox = DefaultMessageCenterInbox( channel: channel, client: client, config: .testConfig(airshipConfig: airshipConfig), store: store, workManager: workManager ) inbox.enabled = true XCTAssertEqual(1, self.channel.extenders.count) let payload = await self.channel.channelPayload XCTAssertNil(payload.identityHints?.userID) } func testRestoreMessageCenterDisabled() async throws { self.channel.extenders.removeAll() var airshipConfig = AirshipConfig() airshipConfig.restoreMessageCenterOnReinstall = false let user = MessageCenterUser( username: "AnyName", password: "AnyPassword" ) // Save user await store.saveUser(user, channelID: "987654433") let inbox = DefaultMessageCenterInbox( channel: channel, client: client, config: .testConfig(airshipConfig: airshipConfig), store: store, workManager: workManager ) inbox.enabled = true let fromInbox = await self.inbox.user let fromStore = await self.store.user XCTAssertNil(fromInbox) XCTAssertNil(fromStore) } func testMessageRetrieve() async throws { self.inbox.enabled = true try await self.store.updateMessages( messages: MessageCenterMessage.generateMessages(3), lastModifiedTime: "" ) let messages = await self.inbox.messages XCTAssertNotNil(messages) XCTAssertEqual(messages.count, 3) } func testMessageRetrieveWithId() async throws { self.inbox.enabled = true let messages = MessageCenterMessage.generateMessages(1) try await self.store.updateMessages( messages: messages, lastModifiedTime: "" ) let message = try XCTUnwrap(messages.first) let fetchedMessage = await self.inbox.message(forID: message.id) XCTAssertNotNil(fetchedMessage) XCTAssertEqual(message.id, fetchedMessage?.id) XCTAssertEqual(message.sentDate, fetchedMessage?.sentDate) XCTAssertEqual(message.bodyURL, fetchedMessage?.bodyURL) XCTAssertEqual(message.expirationDate, fetchedMessage?.expirationDate) XCTAssertEqual(message.messageURL, fetchedMessage?.messageURL) } @MainActor func testUpdateMessages() async throws { self.inbox.enabled = true let messages = MessageCenterMessage.generateMessages(1) let message = try XCTUnwrap(messages.first) // The message does not exists on the store yet let fetchedMessage = await self.inbox.message(forID: message.id) XCTAssertNil(fetchedMessage) let expectation = self.expectation( description: "waiting for message publisher" ) self.inbox.messagePublisher .receive(on: RunLoop.main) .sink { _ in expectation.fulfill() } .store(in: &disposables) // Add the message to the store try await self.store.updateMessages( messages: messages, lastModifiedTime: "" ) await fulfillment(of: [expectation], timeout: 3.0) let updatedMessage = await self.inbox.message(forID: message.id) XCTAssertNotNil(updatedMessage) } func testRefreshMessages() async throws { self.channel.identifier = UUID().uuidString let expectations = self.expectation(description: "client called") expectations.expectedFulfillmentCount = 2 var messageUpdates = self.inbox.messageUpdates.makeAsyncIterator() var messageUpdate = await messageUpdates.next() XCTAssertEqual(messageUpdate, []) var unreadCountUpdates = self.inbox.unreadCountUpdates.makeAsyncIterator() var unreadCountUpdate = await unreadCountUpdates.next() XCTAssertEqual(unreadCountUpdate, 0) let messages = MessageCenterMessage.generateMessages(1) let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) self.client.onCreateUser = { channelID in XCTAssertEqual(channelID, self.channel.identifier) expectations.fulfill() return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } self.client.onRetrieve = { user, channelID, lastModified in XCTAssertEqual(channelID, self.channel.identifier) XCTAssertEqual(user, mcUser) XCTAssertNil(lastModified) expectations.fulfill() return AirshipHTTPResponse( result: messages, statusCode: 200, headers: [:] ) } self.inbox.enabled = true self.workManager.autoLaunchRequests = true let result = await self.inbox.refreshMessages() XCTAssertTrue(result) XCTAssertFalse(self.workManager.workRequests.last!.requiresNetwork) XCTAssertEqual(self.workManager.workRequests.last!.conflictPolicy, .replace) await self.fulfillment(of: [expectations]) messageUpdate = await messageUpdates.next() XCTAssertEqual(messageUpdate, messages) unreadCountUpdate = await unreadCountUpdates.next() XCTAssertEqual(unreadCountUpdate, 1) } func testRefreshMessagesThrowingSuccess() async throws { self.channel.identifier = UUID().uuidString let expectations = self.expectation(description: "client called") expectations.expectedFulfillmentCount = 2 let messages = MessageCenterMessage.generateMessages(1) let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) self.client.onCreateUser = { channelID in XCTAssertEqual(channelID, self.channel.identifier) expectations.fulfill() return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } self.client.onRetrieve = { user, channelID, lastModified in XCTAssertEqual(channelID, self.channel.identifier) XCTAssertEqual(user, mcUser) XCTAssertNil(lastModified) expectations.fulfill() return AirshipHTTPResponse( result: messages, statusCode: 200, headers: [:] ) } self.inbox.enabled = true self.workManager.autoLaunchRequests = true try await self.inbox.refreshMessagesThrowing() await self.fulfillment(of: [expectations]) let inboxMessages = await self.inbox.messages XCTAssertEqual(inboxMessages, messages) } func testRefreshMessagesThrowingThrowsDisabled() async throws { self.inbox.enabled = false self.workManager.autoLaunchRequests = true do { try await self.inbox.refreshMessagesThrowing() XCTFail("Expected MessageCenterInboxError.disabled") } catch let error as MessageCenterInboxError { XCTAssertEqual(error, .disabled) } catch { XCTFail("Unexpected error: \(error)") } } func testRefreshMessagesThrowingThrowsFailedToFetchWhenNoChannel() async throws { self.channel.identifier = nil self.inbox.enabled = true self.workManager.autoLaunchRequests = true do { try await self.inbox.refreshMessagesThrowing() XCTFail("Expected MessageCenterInboxError.failedToFetchMessage") } catch let error as MessageCenterInboxError { XCTAssertEqual(error, .failedToFetchMessage) } catch { XCTFail("Unexpected error: \(error)") } } func testRefreshMessagesThrowingThrowsFailedToFetchWhenRetrieveFails() async throws { self.channel.identifier = UUID().uuidString let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) self.client.onCreateUser = { channelID in XCTAssertEqual(channelID, self.channel.identifier) return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } self.client.onRetrieve = { user, channelID, lastModified in XCTAssertEqual(channelID, self.channel.identifier) XCTAssertEqual(user, mcUser) XCTAssertNil(lastModified) return AirshipHTTPResponse( result: [], statusCode: 400, headers: [:] ) } self.inbox.enabled = true self.workManager.autoLaunchRequests = true do { try await self.inbox.refreshMessagesThrowing() XCTFail("Expected MessageCenterInboxError.failedToFetchMessage") } catch let error as MessageCenterInboxError { XCTAssertEqual(error, .failedToFetchMessage) } catch { XCTFail("Unexpected error: \(error)") } } func testRefreshMessagesWithTimeout() async throws { self.channel.identifier = UUID().uuidString let expectations = self.expectation(description: "client called") expectations.expectedFulfillmentCount = 2 let messages = MessageCenterMessage.generateMessages(1) let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) self.client.onCreateUser = { channelID in XCTAssertEqual(channelID, self.channel.identifier) expectations.fulfill() return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } self.client.onRetrieve = { user, channelID, lastModified in XCTAssertEqual(channelID, self.channel.identifier) XCTAssertEqual(user, mcUser) XCTAssertNil(lastModified) expectations.fulfill() return AirshipHTTPResponse( result: messages, statusCode: 200, headers: [:] ) } self.inbox.enabled = true self.workManager.autoLaunchRequests = true let result = try await self.inbox.refreshMessages(timeout: 4.0) XCTAssertTrue(result) XCTAssertFalse(self.workManager.workRequests.last!.requiresNetwork) XCTAssertEqual(self.workManager.workRequests.last!.conflictPolicy, .replace) await self.fulfillment(of: [expectations]) } func testRefreshMessagesNoChannel() async throws { self.channel.identifier = nil self.inbox.enabled = true self.workManager.autoLaunchRequests = true let result = await self.inbox.refreshMessages() XCTAssertFalse(result) } func testRefreshMessagesUserCreationFailed() async throws { self.channel.identifier = UUID().uuidString self.client.onCreateUser = { channelID in XCTAssertEqual(channelID, self.channel.identifier) return AirshipHTTPResponse( result: nil, statusCode: 400, headers: [:] ) } self.inbox.enabled = true self.workManager.autoLaunchRequests = true let result = await self.inbox.refreshMessages() XCTAssertFalse(result) } func testRefreshMessagesRetrieveFailed() async throws { self.channel.identifier = UUID().uuidString let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) self.client.onCreateUser = { channelID in XCTAssertEqual(channelID, self.channel.identifier) return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } self.client.onRetrieve = { user, channelID, lastModified in XCTAssertEqual(channelID, self.channel.identifier) XCTAssertEqual(user, mcUser) XCTAssertNil(lastModified) return AirshipHTTPResponse( result: [], statusCode: 400, headers: [:] ) } self.inbox.enabled = true self.workManager.autoLaunchRequests = true let result = await self.inbox.refreshMessages() XCTAssertFalse(result) } func testRefreshOnMessageExpiresOnAfterUpdate() async throws { var sleeps = await self.sleeper.sleepUpdates.makeStream().makeAsyncIterator() self.channel.identifier = UUID().uuidString let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) let message = MessageCenterMessage.generateMessage( sentDate: self.date.now.advanced(by: -1), expiry: self.date.now.advanced(by: 1) ) self.client.onCreateUser = { _ in return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } var refreshes = AsyncStream<Bool> { continuation in let responses = AirshipAtomicValue([[message], []]) self.client.onRetrieve = { _, _, _ in defer { continuation.yield(true) } let response = responses.value.first responses.update { responses in var updated = responses if !updated.isEmpty { updated.removeFirst() } return updated } return AirshipHTTPResponse( result: response ?? [], statusCode: 200, headers: [:] ) } }.makeAsyncIterator() XCTAssert(self.workManager.workRequests.isEmpty) self.inbox.enabled = true self.workManager.autoLaunchRequests = true await self.inbox.refreshMessages() _ = await refreshes.next() var fetched = await self.inbox.message(forID: message.id) XCTAssertNotNil(fetched) let sleep = await sleeps.next() XCTAssertEqual(1, sleep) _ = await refreshes.next() fetched = await self.inbox.message(forID: message.id) XCTAssertNil(fetched) } func testRefreshOnMessageExpiresTakesEarliestDate() async throws { self.channel.identifier = UUID().uuidString let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) let messages = [ MessageCenterMessage.generateMessage( sentDate: self.date.now.advanced(by: -1), expiry: self.date.now.advanced(by: 2) ), MessageCenterMessage.generateMessage( sentDate: self.date.now.advanced(by: -1), expiry: self.date.now.advanced(by: 3) ) ] let refresh = self.expectation(description: "client called") refresh.assertForOverFulfill = false self.client.onCreateUser = { _ in return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } var isRefreshed = false self.client.onRetrieve = { _, _, _ in defer { isRefreshed = true } refresh.fulfill() return AirshipHTTPResponse( result: isRefreshed ? [] : messages, statusCode: 200, headers: [:] ) } XCTAssert(self.workManager.workRequests.isEmpty) self.inbox.enabled = true self.workManager.autoLaunchRequests = true await self.inbox.refreshMessages() await fulfillment(of: [refresh], timeout: 5) let saved = await self.inbox.message(forID: messages.first!.id) XCTAssertNotNil(saved) try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) XCTAssertEqual(3, self.workManager.workRequests.count) } func testNoRefreshWithNoExpirationDate() async throws { self.channel.identifier = UUID().uuidString let mcUser = MessageCenterUser( username: UUID().uuidString, password: UUID().uuidString ) let messages = [ MessageCenterMessage.generateMessage( sentDate: self.date.now.advanced(by: -1) ), MessageCenterMessage.generateMessage( sentDate: self.date.now.advanced(by: -2) ) ] let refresh = self.expectation(description: "client called") refresh.assertForOverFulfill = false self.client.onCreateUser = { _ in return AirshipHTTPResponse( result: mcUser, statusCode: 200, headers: [:] ) } var isRefreshed = false self.client.onRetrieve = { _, _, _ in defer { isRefreshed = true } refresh.fulfill() return AirshipHTTPResponse( result: isRefreshed ? [] : messages, statusCode: 200, headers: [:] ) } XCTAssert(self.workManager.workRequests.isEmpty) self.inbox.enabled = true self.workManager.autoLaunchRequests = true await self.inbox.refreshMessages() await fulfillment(of: [refresh], timeout: 5) let saved = await self.inbox.message(forID: messages.first!.id) XCTAssertNotNil(saved) self.date.advance(by: 1) XCTAssertEqual(2, self.workManager.workRequests.count) } } fileprivate final class TestMessageCenterAPIClient : MessageCenterAPIClientProtocol, @unchecked Sendable { var onRetrieve: ((MessageCenterUser, String, String?) async throws -> AirshipHTTPResponse<[MessageCenterMessage]>)? var onDelete: (([MessageCenterMessage], MessageCenterUser, String) async throws -> AirshipHTTPResponse<Void>)? var onRead: (([MessageCenterMessage], MessageCenterUser, String) async throws -> AirshipHTTPResponse<Void>)? var onCreateUser: ((String) async throws -> AirshipHTTPResponse<MessageCenterUser>)? var onUpdateUser: ((MessageCenterUser, String) async throws -> AirshipHTTPResponse<Void>)? func retrieveMessageList(user: MessageCenterUser, channelID: String, lastModified: String?) async throws -> AirshipHTTPResponse<[MessageCenterMessage]> { return try await self.onRetrieve!(user, channelID, lastModified) } func performBatchDelete(forMessages messages: [MessageCenterMessage], user: MessageCenterUser, channelID: String) async throws -> AirshipHTTPResponse<Void> { return try await self.onDelete!(messages, user, channelID) } func performBatchMarkAsRead(forMessages messages: [MessageCenterMessage], user: MessageCenterUser, channelID: String) async throws -> AirshipHTTPResponse<Void> { return try await self.onRead!(messages, user, channelID) } func createUser(withChannelID channelID: String) async throws -> AirshipHTTPResponse<MessageCenterUser> { return try await self.onCreateUser!(channelID) } func updateUser(_ user: MessageCenterUser, channelID: String) async throws -> AirshipHTTPResponse<Void> { return try await self.onUpdateUser!(user, channelID) } } actor TestTaskSleeper : AirshipTaskSleeper { var sleepUpdates: AirshipAsyncChannel<TimeInterval> = AirshipAsyncChannel() var sleeps : [TimeInterval] = [] func sleep(timeInterval: TimeInterval) async throws { sleeps.append(timeInterval) await sleepUpdates.send(timeInterval) await Task.yield() } } ================================================ FILE: Airship/AirshipMessageCenter/Tests/MessageCenterMessageTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest import AirshipCore @testable import AirshipMessageCenter final class MessageCenterMessageTest: XCTestCase { func testHashing() throws { let date = Date() let m1 = MessageCenterMessage(title: "title", id: "identifier", contentType: .html, extra: ["cool": "story"], bodyURL: URL(string: "www.myspace.com")!, expirationDate: date, messageReporting: ["any": "thing"], unread: true, sentDate: date, messageURL: URL(string: "www.myspace.com")!, rawMessageObject: ["raw": "message object"]) let m2 = MessageCenterMessage(title: "title", id: "identifier", contentType: .html, extra: ["cool": "story"], bodyURL: URL(string: "www.myspace.com")!, expirationDate: date, messageReporting: ["any": "thing"], unread: true, sentDate: date, messageURL: URL(string: "www.myspace.com")!, rawMessageObject: ["raw": "message object"]) XCTAssertTrue(m1 == m2) var dictionary = [MessageCenterMessage: String]() dictionary[m1] = "keyed with m1" dictionary[m2] = "keyed with m2" XCTAssertEqual(dictionary.count, 1, "dictionary should only contain one entry since m1 and m2 are equal.") } func testEqualityConsidersUnreadState() { let date = Date() let unread = MessageCenterMessage( title: "title", id: "identifier", contentType: .html, extra: [:], bodyURL: URL(string: "www.myspace.com")!, expirationDate: date, messageReporting: nil, unread: true, sentDate: date, messageURL: URL(string: "www.myspace.com")!, rawMessageObject: ["raw": "message object"] ) let read = MessageCenterMessage( title: "title", id: "identifier", contentType: .html, extra: [:], bodyURL: URL(string: "www.myspace.com")!, expirationDate: date, messageReporting: nil, unread: false, sentDate: date, messageURL: URL(string: "www.myspace.com")!, rawMessageObject: ["raw": "message object"] ) XCTAssertNotEqual(unread, read) XCTAssertNotEqual(unread.hashValue, read.hashValue) var readCopy = unread readCopy.unread = false XCTAssertEqual(read, readCopy) XCTAssertEqual(read.hashValue, readCopy.hashValue) } func testContentTypeDecoding() throws { let validCases: [String: MessageCenterMessage.ContentType] = [ "text/html": .html, "text/plain": .plain, "application/vnd.urbanairship.thomas+json;version=1": .native(version: 1), "application/vnd.urbanairship.thomas+json; version=2": .native(version: 2), "application/vnd.urbanairship.thomas+json; version=3; foo=bar": .native(version: 3), ] let unknownCases: [String] = [ "", "text/json", "garbage_value", "application/vnd.urbanairship.thomas+json", "application/vnd.urbanairship.thomas+json;", "application/vnd.urbanairship.thomas+json;version=nan", "application/vnd.urbanairship.thomas+json;garbage version=1" ] for (input, expected) in validCases { let data = try JSONEncoder().encode(input) let result = try JSONDecoder().decode(MessageCenterMessage.ContentType.self, from: data) XCTAssertEqual( result, expected, "Failed to decode valid input: '\(input)'" ) let roundTripped = try JSONDecoder().decode( MessageCenterMessage.ContentType.self, from: JSONEncoder().encode(result) ) XCTAssertEqual( roundTripped, expected, "Round-trip Codable failed for '\(input)'" ) } for input in unknownCases { let data = try JSONEncoder().encode(input) let result = try JSONDecoder().decode(MessageCenterMessage.ContentType.self, from: data) XCTAssertEqual( result, .unknown(input), "Expected .unknown for unrecognized input: '\(input)'" ) } } func testMessageProductIDNilWhenNotProvided() throws { let date = Date() let message = MessageCenterMessage( title: "title", id: "identifier", contentType: .native(version: 1), extra: [:], bodyURL: URL(string: "www.myspace.com")!, expirationDate: date, messageReporting: ["any": "thing"], unread: true, sentDate: date, messageURL: URL(string: "www.myspace.com")!, rawMessageObject: ["raw": "message object"] ) XCTAssertNil(message.productID) } func testNativeMessageCenterUsesExplicitProductIDWhenProvided() throws { let date = Date() let message = MessageCenterMessage( title: "title", id: "identifier", contentType: .native(version: 1), extra: [:], bodyURL: URL(string: "www.myspace.com")!, expirationDate: date, messageReporting: ["any": "thing"], unread: true, sentDate: date, messageURL: URL(string: "www.myspace.com")!, rawMessageObject: [ "raw": "message object", "product_id": "custom_product_id" ] ) XCTAssertEqual(message.productID, "custom_product_id") } } ================================================ FILE: Airship/AirshipMessageCenter/Tests/MessageCenterStoreTest.swift ================================================ /* Copyright Airship and Contributors */ import XCTest @testable import AirshipCore @testable import AirshipMessageCenter final class MessageCenterStoreTest: XCTestCase { private var dataStore = PreferenceDataStore(appKey: UUID().uuidString) private let config: RuntimeConfig = .testConfig() private lazy var store: MessageCenterStore = { let modelURL = AirshipMessageCenterResources.bundle .url( forResource: "UAInbox", withExtension: "momd" ) if let modelURL = modelURL { let storeName = String( format: "Inbox-%@.sqlite", self.config.appCredentials.appKey ) let coreData = UACoreData( name: "UAInbox", modelURL: modelURL, inMemory: true, stores: [storeName] ) return MessageCenterStore( config: self.config, dataStore: self.dataStore, coreData: coreData ) } return MessageCenterStore( config: self.config, dataStore: self.dataStore ) }() func testMessageCenterStoreSaveAndResetUser() async throws { let expectedUser = MessageCenterUser( username: "AnyName", password: "AnyPassword" ) // Save user await store.saveUser(expectedUser, channelID: "987654433") let user = await store.user XCTAssertNotNil(user) XCTAssertEqual(user!.username, expectedUser.username) XCTAssertEqual(user!.password, expectedUser.password) // Reset User await store.resetUser() let resetedUser = await store.user XCTAssertNil(resetedUser) } func testUserRequiredUpdate() async throws { // Set setUserRequireUpdate true await store.setUserRequireUpdate(true) var requiredUpdate = await store.userRequiredUpdate XCTAssertTrue(requiredUpdate) // Set Required update false await store.setUserRequireUpdate(false) requiredUpdate = await store.userRequiredUpdate XCTAssertFalse(requiredUpdate) } func testFetchMessages() async throws { let messages = MessageCenterMessage.generateMessages(3) try await store.updateMessages( messages: messages, lastModifiedTime: "" ) } func testSyncMessages() async throws { let generated = MessageCenterMessage.generateMessages(5) var messages = Array(generated[0...2]) try await store.updateMessages( messages: messages, lastModifiedTime: "" ) var fetchedMessage = await store.messages XCTAssertEqual(messages, fetchedMessage) messages.remove(at: 0) messages.append(contentsOf: generated[3...4]) try await store.updateMessages( messages: messages, lastModifiedTime: "" ) fetchedMessage = await store.messages XCTAssertEqual(messages, fetchedMessage) } } extension MessageCenterMessage { static func generateMessage( sentDate: Date = Date(), expiry: Date? = nil ) -> MessageCenterMessage { return MessageCenterMessage( title: UUID().uuidString, id: UUID().uuidString, contentType: .html, extra: [UUID().uuidString: UUID().uuidString], bodyURL: URL( string: "https://www.some-url.fr/\(UUID().uuidString)" )!, expirationDate: expiry, messageReporting: [UUID().uuidString: .string(UUID().uuidString)], unread: true, sentDate: sentDate, messageURL: URL( string: "https://some-url.fr/\(UUID().uuidString)" )!, rawMessageObject: [:] ) } static func generateMessages(_ count: Int) -> [MessageCenterMessage] { // Sets the sent date to make the order predictable let date = Date() return (0..<count) .map { index in generateMessage( sentDate: date.advanced(by: Double(-index)) ) } } } ================================================ FILE: Airship/AirshipMessageCenter/Tests/MessageCenterThemeLoaderTest.swift ================================================ ///* Copyright Airship and Contributors */ // //import SwiftUI // //#if canImport(AirshipCore) //import AirshipCore //#endif // //import XCTest //@testable import AirshipMessageCenter //class MessageCenterThemeLoaderTests: XCTestCase { // // var themeLoader: MessageCenterThemeLoader! // // override func setUp() { // super.setUp() // themeLoader = MessageCenterThemeLoader() // } // // override func tearDown() { // themeLoader = nil // super.tearDown() // } // // func testFromPlist() { // let testBundle = Bundle(for: type(of: self)) // // do { // let theme = try MessageCenterThemeLoader.fromPlist("ValidTestMessageCenterTheme", bundle: testBundle) // // let expectedRefreshTintColor = Color(AirshipColorUtils.color("#990099")!) // let expectedRefreshTintColorDark = Color(AirshipColorUtils.color("#000001")!) // let expectedCellColor = Color(AirshipColorUtils.color("#009900")!) // let expectedCellColorDark = Color(AirshipColorUtils.color("#000002")!) // let expectedCellTitleColor = Color(AirshipColorUtils.color("#000099")!) // let expectedCellTitleColorDark = Color(AirshipColorUtils.color("#000003")!) // let expectedCellDateColor = Color(AirshipColorUtils.color("#999900")!) // let expectedCellDateColorDark = Color(AirshipColorUtils.color("#000004")!) // let expectedCellSeparatorColorDark = Color(AirshipColorUtils.color("#000005")!) // let expectedCellTintColor = Color(AirshipColorUtils.color("#990000")!) // let expectedCellTintColorDark = Color(AirshipColorUtils.color("#000006")!) // let expectedUnreadIndicatorColor = Color(AirshipColorUtils.color("#009999")!) // let expectedUnreadIndicatorColorDark = Color(AirshipColorUtils.color("#000007")!) // let expectedSelectAllButtonTitleColor = Color(AirshipColorUtils.color("#999999")!) // let expectedSelectAllButtonTitleColorDark = Color(AirshipColorUtils.color("#000008")!) // let expectedDeleteButtonTitleColor = Color(AirshipColorUtils.color("#123456")!) // let expectedDeleteButtonTitleColorDark = Color(AirshipColorUtils.color("#000009")!) // let expectedMarkAsReadButtonTitleColor = Color(AirshipColorUtils.color("#654321")!) // let expectedMarkAsReadButtonTitleColorDark = Color(AirshipColorUtils.color("#000010")!) // let expectedEditButtonTitleColor = Color(AirshipColorUtils.color("#abcdef")!) // let expectedEditButtonTitleColorDark = Color(AirshipColorUtils.color("#000011")!) // let expectedCancelButtonTitleColor = Color(AirshipColorUtils.color("#fedcba")!) // let expectedCancelButtonTitleColorDark = Color(AirshipColorUtils.color("#000012")!) // let expectedBackButtonColor = Color(AirshipColorUtils.color("#112233")!) // let expectedBackButtonColorDark = Color(AirshipColorUtils.color("#000013")!) // let expectedMessageListBackgroundColor = Color(AirshipColorUtils.color("#334455")!) // let expectedMessageListBackgroundColorDark = Color(AirshipColorUtils.color("#000014")!) // let expectedMessageListContainerBackgroundColor = Color(AirshipColorUtils.color("#556677")!) // let expectedMessageListContainerBackgroundColorDark = Color(AirshipColorUtils.color("#000015")!) // // XCTAssertNotNil(theme) // // XCTAssertEqual(theme.refreshTintColor, expectedRefreshTintColor) // XCTAssertEqual(theme.refreshTintColorDark, expectedRefreshTintColorDark) // XCTAssertEqual(theme.iconsEnabled, true) // XCTAssertEqual(theme.placeholderIcon, Image("placeholderIcon")) // XCTAssertEqual(theme.cellTitleFont, Font.custom("cellTitleFont", size: 16)) // XCTAssertEqual(theme.cellDateFont, Font.custom("cellDateFont", size: 14)) // XCTAssertEqual(theme.cellColor, expectedCellColor) // XCTAssertEqual(theme.cellColorDark, expectedCellColorDark) // XCTAssertEqual(theme.cellTitleColor, expectedCellTitleColor) // XCTAssertEqual(theme.cellTitleColorDark, expectedCellTitleColorDark) // XCTAssertEqual(theme.cellDateColor, expectedCellDateColor) // XCTAssertEqual(theme.cellDateColorDark, expectedCellDateColorDark) // XCTAssertEqual(theme.cellSeparatorStyle, AirshipMessageCenter.SeparatorStyle.none) // XCTAssertEqual(theme.cellSeparatorColor, Color("testNamedColor", bundle: testBundle)) // XCTAssertEqual(theme.cellSeparatorColorDark, expectedCellSeparatorColorDark) // XCTAssertEqual(theme.cellTintColor, expectedCellTintColor) // XCTAssertEqual(theme.cellTintColorDark, expectedCellTintColorDark) // XCTAssertEqual(theme.unreadIndicatorColor, expectedUnreadIndicatorColor) // XCTAssertEqual(theme.unreadIndicatorColorDark, expectedUnreadIndicatorColorDark) // XCTAssertEqual(theme.selectAllButtonTitleColor, expectedSelectAllButtonTitleColor) // XCTAssertEqual(theme.selectAllButtonTitleColorDark, expectedSelectAllButtonTitleColorDark) // XCTAssertEqual(theme.deleteButtonTitleColor, expectedDeleteButtonTitleColor) // XCTAssertEqual(theme.deleteButtonTitleColorDark, expectedDeleteButtonTitleColorDark) // XCTAssertEqual(theme.markAsReadButtonTitleColor, expectedMarkAsReadButtonTitleColor) // XCTAssertEqual(theme.markAsReadButtonTitleColorDark, expectedMarkAsReadButtonTitleColorDark) // XCTAssertEqual(theme.hideDeleteButton, true) // XCTAssertEqual(theme.editButtonTitleColor, expectedEditButtonTitleColor) // XCTAssertEqual(theme.editButtonTitleColorDark, expectedEditButtonTitleColorDark) // XCTAssertEqual(theme.cancelButtonTitleColor, expectedCancelButtonTitleColor) // XCTAssertEqual(theme.cancelButtonTitleColorDark, expectedCancelButtonTitleColorDark) // XCTAssertEqual(theme.backButtonColor, expectedBackButtonColor) // XCTAssertEqual(theme.backButtonColorDark, expectedBackButtonColorDark) // XCTAssertEqual(theme.navigationBarTitle, "Test Navigation Bar Title") // XCTAssertEqual(theme.messageListBackgroundColor, expectedMessageListBackgroundColor) // XCTAssertEqual(theme.messageListBackgroundColorDark, expectedMessageListBackgroundColorDark) // XCTAssertEqual(theme.messageListContainerBackgroundColor, expectedMessageListContainerBackgroundColor) // XCTAssertEqual(theme.messageListContainerBackgroundColorDark, expectedMessageListContainerBackgroundColorDark) // } catch { // XCTFail("Failed to load theme from plist: \(error)") // } // } // // // /// Tests that fonts of multiple sizings can be converted properly into CGFloat // /// Users can specify font size in plist at String or Number // func testFontConfigToFont() { // /// Test CGFloat // let fontConfigCGFloat = MessageCenterThemeLoader.FontConfig(fontName: "testFont", fontSize: .cgFloat(CGFloat(10))) // // do { // let font = try fontConfigCGFloat.toFont() // XCTAssertEqual(font, Font.custom("testFont", size: 10)) // } catch { // XCTFail("Failed to convert FontConfig to Font: \(error)") // } // // /// Test string // let fontConfigString = MessageCenterThemeLoader.FontConfig(fontName: "testFont", fontSize: .string("12")) // // do { // let font = try fontConfigString.toFont() // XCTAssertEqual(font, Font.custom("testFont", size: 12)) // } catch { // XCTFail("Failed to convert FontConfig to Font: \(error)") // } // } // // func testStringOrNamedToColor() { // let testBundle = Bundle(for: type(of: self)) // /// This is testing the implementation: // /// we want to be sure both conversions from hex OR named color name work as expected // let colorHexString = "#990099" // let expectedColor = Color(AirshipColorUtils.color(colorHexString)!) // let hexColor = colorHexString.airshipToColor(testBundle) // XCTAssertEqual(hexColor, expectedColor) // // let colorName = "testNamedColor" // let color = colorName.airshipToColor(testBundle) // XCTAssertEqual(color, Color(colorName, bundle:testBundle)) // } //} ================================================ FILE: Airship/AirshipMessageCenter/Tests/MessageViewAnalyticsTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipCore @testable import AirshipMessageCenter import Foundation struct DefaultMessageViewAnalyticsTests { let mockRecorder = MockThomasLayoutEventRecorder() let historyStorage = MockDispalyHistoryStorage() let clock = UATestDate(offset: 0, dateOverride: Date(timeIntervalSince1970: 0)) let operationsQueue = AirshipAsyncSerialQueue() let mockEvent = ThomasLayoutFormDisplayEvent(data: .init(identifier: "test", formType: "test")) @Test("Record event captures correct data with default Airship source") @MainActor func recordEventDefaults() async throws { let messageID = "test-message-id" let message = createStubMessage(id: messageID, reporting: ["test": "reporting"]) let analytics = makeAnalytics(message: message) let layoutContext = ThomasLayoutContext() analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: layoutContext) await operationsQueue.waitForCurrentOperations() let capturedData = try #require(mockRecorder.lastCapturedData, "No event data was captured") #expect(capturedData.event is ThomasLayoutDisplayEvent) #expect(capturedData.source == .airship) if case .airship(let identifier, let campaigns) = capturedData.messageID { #expect(identifier == messageID) #expect(campaigns == nil) } else { Issue.record("Message ID should be of type .airship") } #expect(capturedData.context!.reportingContext == ["test": "reporting"]) } @Test("Record events with no saved history") @MainActor func recordEventNoHistory() async throws { let messageID = "test-message-id" let message = createStubMessage(id: messageID) let analytics = makeAnalytics(message: message) let layoutContext = ThomasLayoutContext() analytics.recordEvent(mockEvent, layoutContext: layoutContext) await operationsQueue.waitForCurrentOperations() let capturedData = try #require(mockRecorder.lastCapturedData, "No event data was captured") #expect(capturedData.context?.display?.isFirstDisplay == true) #expect(capturedData.context?.display?.isFirstDisplayTriggerSessionID == true) } @Test("Record event with saved history from previous session") @MainActor func recordEventWithPreviousSession() async throws { let messageID = "test-message-id" let message = createStubMessage(id: messageID) let analytics = makeAnalytics(message: message, sessionID: "current-session") let layoutContext = ThomasLayoutContext() self.historyStorage.currentValue = MessageDisplayHistory( lastImpression: .init(date: clock.now.advanced(by: -100), triggerSessionID: "impression-session"), lastDisplay: .init(triggerSessionID: "previous-session") ) analytics.recordEvent(mockEvent, layoutContext: layoutContext) await operationsQueue.waitForCurrentOperations() let capturedData = try #require(mockRecorder.lastCapturedData, "No event data was captured") #expect(capturedData.context?.display?.isFirstDisplay == false) #expect(capturedData.context?.display?.isFirstDisplayTriggerSessionID == false) } @Test("Record event with history same session") @MainActor func recordEventWithHistorySameSession() async throws { let messageID = "test-message-id" let message = createStubMessage(id: messageID) let analytics = makeAnalytics(message: message, sessionID: "current-session") let layoutContext = ThomasLayoutContext() self.historyStorage.currentValue = MessageDisplayHistory( lastImpression: .init(date: clock.now.advanced(by: -100), triggerSessionID: "last-session"), lastDisplay: .init(triggerSessionID: "last-session") ) analytics.recordEvent(mockEvent, layoutContext: layoutContext) await operationsQueue.waitForCurrentOperations() let capturedData = try #require(mockRecorder.lastCapturedData, "No event data was captured") #expect(capturedData.context?.display?.isFirstDisplay == false) #expect(capturedData.context?.display?.isFirstDisplayTriggerSessionID == true) } @Test("Record generic event") @MainActor func recordGenericEvent() async throws { let messageID = "test-message-id" let message = createStubMessage(id: messageID) let analytics = makeAnalytics(message: message, sessionID: "current-session") let layoutContext = ThomasLayoutContext() analytics.recordEvent(mockEvent, layoutContext: layoutContext) await operationsQueue.waitForCurrentOperations() #expect(mockRecorder.events.count == 1) #expect(mockRecorder.impressions.isEmpty) #expect(historyStorage.getCalls(for: .get) == 1) #expect(historyStorage.getCalls(for: .set) == 0) } @Test("Record record first impression") @MainActor func recordFirstImpression() async throws { let messageID = "test-message-id" let message = createStubMessage( id: messageID, reporting: ["test": "reporting"], productID: "product-id" ) let analytics = makeAnalytics(message: message, sessionID: "current-session") let layoutContext = ThomasLayoutContext() clock.offset = 100 analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: layoutContext) await operationsQueue.waitForCurrentOperations() let impression = try #require(mockRecorder.lastRecordedImpression) #expect(impression.entityID == messageID) #expect(impression.usageType == .inAppExperienceImpression) #expect(impression.reportingContext == .object(["test": .string("reporting")])) #expect(impression.product == "product-id") #expect(impression.timestamp == clock.now) let history = try #require(historyStorage.currentValue) #expect(history.lastImpression?.date == clock.now) #expect(history.lastImpression?.triggerSessionID == "current-session") #expect(history.lastDisplay?.triggerSessionID == "current-session") } @Test("Record native message impression uses default product ID") @MainActor func recordNativeFirstImpressionUsesDefaultProductID() async throws { let messageID = "test-native-message-id" let message = createStubMessage( id: messageID, reporting: ["test": "reporting"], contentType: .native(version: 1) ) let analytics = makeAnalytics(message: message, sessionID: "current-session") clock.offset = 100 analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: ThomasLayoutContext()) await operationsQueue.waitForCurrentOperations() let impression = try #require(mockRecorder.lastRecordedImpression) #expect(impression.entityID == messageID) #expect(impression.usageType == .inAppExperienceImpression) #expect(impression.reportingContext == .object(["test": .string("reporting")])) #expect(impression.product == "default_native_mc") #expect(impression.timestamp == clock.now) } @Test("Record impression timeout") @MainActor func recordImpressionTimeout() async throws { let messageID = "test-message-id" let message = createStubMessage(id: messageID) let analytics = makeAnalytics(message: message, sessionID: "current-session") clock.offset = 100 let impression = MessageDisplayHistory.LastImpression(date: clock.now, triggerSessionID: "other-session") historyStorage.currentValue = MessageDisplayHistory( lastImpression: impression ) analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) await operationsQueue.waitForCurrentOperations() #expect(mockRecorder.lastRecordedImpression == nil) #expect(historyStorage.currentValue?.lastImpression == impression) #expect(historyStorage.currentValue?.lastDisplay?.triggerSessionID == "current-session") clock.offset += 30 * 60 - 1 analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) await operationsQueue.waitForCurrentOperations() #expect(mockRecorder.lastRecordedImpression == nil) #expect(historyStorage.currentValue?.lastImpression == impression) #expect(historyStorage.currentValue?.lastDisplay?.triggerSessionID == "current-session") clock.offset += 1 analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) await operationsQueue.waitForCurrentOperations() #expect(mockRecorder.lastRecordedImpression != nil) #expect(historyStorage.currentValue?.lastImpression?.date == clock.now) #expect(historyStorage.currentValue?.lastDisplay?.triggerSessionID == "current-session") } @Test("Impression updates display context") @MainActor func impressionUpdatesDisplayContext() async throws { let messageID = "test-message-id" let message = createStubMessage(id: messageID) let analytics = makeAnalytics(message: message, sessionID: "current-session") await operationsQueue.waitForCurrentOperations() clock.offset = 100 #expect(mockRecorder.events.count == 0) #expect(historyStorage.getCalls(for: .set) == 0) //first event analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) await operationsQueue.waitForCurrentOperations() #expect(mockRecorder.events.count == 1) #expect(historyStorage.getCalls(for: .set) == 1) #expect(mockRecorder.lastRecordedImpression != nil) #expect(mockRecorder.lastCapturedData?.context?.display?.isFirstDisplay == true) //second event clock.offset += 100 analytics.recordEvent(ThomasLayoutDisplayEvent(), layoutContext: nil) await operationsQueue.waitForCurrentOperations() #expect(mockRecorder.events.count == 2) #expect(mockRecorder.impressions.count == 1) #expect(mockRecorder.lastCapturedData?.context?.display?.isFirstDisplay == false) #expect(historyStorage.getCalls(for: .set) == 2) } // MARK: - Helpers private func createStubMessage( id: String, reporting: AirshipJSON? = nil, productID: String? = nil, contentType: MessageCenterMessage.ContentType = .html ) -> MessageCenterMessage { let rawJSON: AirshipJSON = productID.map { id in ["product_id": .string(id)] } ?? .null return MessageCenterMessage( title: "Test Title", id: id, contentType: contentType, extra: [:], bodyURL: .init(string: "https://test.url")!, expirationDate: nil, messageReporting: reporting, unread: true, sentDate: Date(), messageURL: .init(string: "https://test.url")!, rawMessageObject: rawJSON ) } private func makeAnalytics( message: MessageCenterMessage, sessionID: String = "test-session-id" ) -> DefaultMessageViewAnalytics { return DefaultMessageViewAnalytics( message: message, eventRecorder: mockRecorder, historyStorage: historyStorage, date: clock, sessionID: sessionID, queue: operationsQueue ) } } final class MockThomasLayoutEventRecorder: ThomasLayoutEventRecorderProtocol, @unchecked Sendable { private(set) var lastRecordedImpression: AirshipMeteredUsageEvent? = nil private(set) var impressions: [AirshipMeteredUsageEvent] = [] func recordImpressionEvent(_ event: AirshipMeteredUsageEvent) { lastRecordedImpression = event impressions.append(event) } private(set) var lastCapturedData: ThomasLayoutEventData? = nil private(set) var events: [ThomasLayoutEventData] = [] func recordEvent(inAppEventData: ThomasLayoutEventData) { lastCapturedData = inAppEventData events.append(inAppEventData) } } final class MockDispalyHistoryStorage: MessageDisplayHistoryStoreProtocol, @unchecked Sendable { enum CallType { case get, set } var currentValue: MessageDisplayHistory? = MessageDisplayHistory() private var recordedCalls: [CallType: Int] = [:] func getCalls(for callType: CallType) -> Int { recordedCalls[callType, default: 0] } func set(_ history: MessageDisplayHistory, scheduleID: String) { currentValue = history recordedCalls[.set, default: 0] += 1 } func get(scheduleID: String) async -> MessageDisplayHistory { recordedCalls[.get, default: 0] += 1 return currentValue! } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Analytics/Events/Templates/UAAccountEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Account template @objc public final class UACustomEventAccountTemplate: NSObject { fileprivate var template: CustomEvent.AccountTemplate private init(template: CustomEvent.AccountTemplate) { self.template = template } @objc public static func registered() -> UACustomEventAccountTemplate { self.init(template: .registered) } @objc public static func loggedIn() -> UACustomEventAccountTemplate { self.init(template: .loggedIn) } @objc public static func loggedOut() -> UACustomEventAccountTemplate { self.init(template: .loggedOut) } } @objc public final class UACustomEventAccountProperties: NSObject { /// User ID. @objc public var userID: String? /// The event's category. @objc public var category: String? /// The event's type. @objc public var type: String? /// If the value is a lifetime value or not. @objc public var isLTV: Bool @objc public init(category: String? = nil, type: String? = nil, isLTV: Bool = false, userID: String? = nil) { self.category = category self.type = type self.isLTV = isLTV self.userID = userID } fileprivate var properties: CustomEvent.AccountProperties { return .init( category: self.category, type: self.type, isLTV: self.isLTV, userID: self.userID ) } } @objc public extension UACustomEvent { @objc convenience init(accountTemplate: UACustomEventAccountTemplate) { let customEvent = CustomEvent(accountTemplate: accountTemplate.template) self.init(event: customEvent) } @objc convenience init(accountTemplate: UACustomEventAccountTemplate, properties: UACustomEventAccountProperties) { let customEvent = CustomEvent( accountTemplate: accountTemplate.template, properties: properties.properties ) self.init(event: customEvent) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Analytics/Events/Templates/UAMediaEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Media template @objc public final class UACustomEventMediaTemplate: NSObject { fileprivate var template: CustomEvent.MediaTemplate private init(template: CustomEvent.MediaTemplate) { self.template = template } @objc public static func browsed() -> UACustomEventMediaTemplate { self.init(template: .browsed) } @objc public static func consumed() -> UACustomEventMediaTemplate { self.init(template: .consumed) } @objc public static func shared(source: String?, medium: String?) -> UACustomEventMediaTemplate { self.init(template: .shared(source: source, medium: medium)) } @objc public static func starred() -> UACustomEventMediaTemplate { self.init(template: .starred) } } @objc public class UACustomEventMediaProperties: NSObject { /// The event's ID. @objc public var id: String? /// The event's category. @objc public var category: String? /// The event's type. @objc public var type: String? /// The event's description. @objc public var eventDescription: String? /// The event's author. @objc public var author: String? /// The event's published date. @objc public var publishedDate: Date? /// If the event is a feature @objc public var isFeature: NSNumber? /// If the value is a lifetime value or not. @objc public var isLTV: Bool @objc public init(id: String? = nil, category: String? = nil, type: String? = nil, eventDescription: String? = nil, isLTV: Bool = false, author: String? = nil, publishedDate: Date? = nil, isFeature: NSNumber? = nil) { self.id = id self.category = category self.type = type self.eventDescription = eventDescription self.author = author self.publishedDate = publishedDate self.isFeature = isFeature self.isLTV = isLTV } fileprivate var properties: CustomEvent.MediaProperties { return CustomEvent.MediaProperties( id: self.id, category: self.category, type: self.type, eventDescription: self.eventDescription, isLTV: self.isLTV, author: self.author, publishedDate: self.publishedDate, isFeature: self.isFeature?.boolValue ) } } @objc public extension UACustomEvent { @objc convenience init(mediaTemplate: UACustomEventMediaTemplate) { let customEvent = CustomEvent(mediaTemplate: mediaTemplate.template) self.init(event: customEvent) } @objc convenience init(mediaTemplate: UACustomEventMediaTemplate, properties: UACustomEventMediaProperties) { let customEvent = CustomEvent( mediaTemplate: mediaTemplate.template, properties: properties.properties ) self.init(event: customEvent) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Analytics/Events/Templates/UARetailEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Retail template @objc public final class UACustomEventRetailTemplate: NSObject { fileprivate var template: CustomEvent.RetailTemplate private init(template: CustomEvent.RetailTemplate) { self.template = template } @objc public static func browsed() -> UACustomEventRetailTemplate { self.init(template: .browsed) } @objc public static func addedToCart() -> UACustomEventRetailTemplate { self.init(template: .addedToCart) } @objc public static func shared(source: String?, medium: String?) -> UACustomEventRetailTemplate { self.init(template: .shared(source: source, medium: medium)) } @objc public static func starred() -> UACustomEventRetailTemplate { self.init(template: .starred) } @objc public static func purchased() -> UACustomEventRetailTemplate { self.init(template: .purchased) } @objc public static func wishlist(identifier: String?, name: String?) -> UACustomEventRetailTemplate { self.init(template: .wishlist(id: identifier, name: name)) } } @objc public class UACustomEventRetailProperties: NSObject { /// The event's ID. @objc public var id: String? /// The event's category. @objc public var category: String? /// The event's type. @objc public var type: String? /// The event's description. @objc public var eventDescription: String? /// The brand. @objc public var brand: String? /// If its a new item or not. @objc public var isNewItem: NSNumber? /// The currency. @objc public var currency: String? /// If the value is a lifetime value or not. @objc public var isLTV: Bool @objc public init(id: String? = nil, category: String? = nil, type: String? = nil, eventDescription: String? = nil, isLTV: Bool = false, brand: String? = nil, isNewItem: NSNumber? = nil, currency: String? = nil) { self.id = id self.category = category self.type = type self.eventDescription = eventDescription self.isLTV = isLTV self.brand = brand self.isNewItem = isNewItem self.currency = currency } fileprivate var properties: CustomEvent.RetailProperties { return CustomEvent.RetailProperties( id: self.id, category: self.category, type: self.type, eventDescription: self.eventDescription, isLTV: self.isLTV, brand: self.brand, isNewItem: self.isNewItem?.boolValue, currency: self.currency ) } } @objc public extension UACustomEvent { @objc convenience init(retailTemplate: UACustomEventRetailTemplate) { let customEvent = CustomEvent(retailTemplate: retailTemplate.template) self.init(event: customEvent) } @objc convenience init(retailTemplate: UACustomEventRetailTemplate, properties: UACustomEventRetailProperties) { let customEvent = CustomEvent( retailTemplate: retailTemplate.template, properties: properties.properties ) self.init(event: customEvent) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Analytics/Events/Templates/UASearchEventTemplate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Search template @objc public class UACustomEventSearchTemplate: NSObject { fileprivate var template: CustomEvent.SearchTemplate private init(template: CustomEvent.SearchTemplate) { self.template = template } @objc public static func search() -> UACustomEventSearchTemplate { return UACustomEventSearchTemplate(template: .search) } } @objc public class UACustomEventSearchProperties: NSObject { /// The event's ID. @objc public var id: String? /// The search query. @objc public var query: String? /// The total search results @objc public var totalResults: NSNumber? /// The event's category. @objc public var category: String? /// The event's type. @objc public var type: String? /// If the value is a lifetime value or not. @objc public var isLTV: Bool @objc public init(id: String? = nil, query: String? = nil, totalResults: NSNumber? = nil, category: String? = nil, type: String? = nil, isLTV: Bool = false) { self.id = id self.query = query self.totalResults = totalResults self.category = category self.type = type self.isLTV = isLTV } fileprivate var properties: CustomEvent.SearchProperties { CustomEvent.SearchProperties( id: self.id, category: self.category, type: self.type, isLTV: self.isLTV, query: self.query, totalResults: self.totalResults?.intValue ) } } @objc public extension UACustomEvent { @objc convenience init(searchTemplate: UACustomEventSearchTemplate) { let customEvent = CustomEvent(searchTemplate: searchTemplate.template) self.init(event: customEvent) } @objc convenience init(searchTemplate: UACustomEventSearchTemplate, properties: UACustomEventSearchProperties) { let customEvent = CustomEvent( searchTemplate: searchTemplate.template, properties: properties.properties ) self.init(event: customEvent) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Analytics/Events/UACustomEvent.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// CustomEvent captures information regarding a custom event for /// Analytics. @objc public class UACustomEvent: NSObject { var customEvent: CustomEvent /** * The event's value. The value must be between -2^31 and * 2^31 - 1 or it will invalidate the event. */ @objc public var eventValue: Decimal { get { return customEvent.eventValue } set { customEvent.eventValue = newValue } } /** * The event's name. The name's length must not exceed 255 characters or it will * invalidate the event. */ @objc public var eventName: String { get { return customEvent.eventName } set { customEvent.eventName = newValue } } /** * The event's transaction ID. The ID's length must not exceed 255 characters or it will * invalidate the event. */ @objc public var transactionID: String? { get { return customEvent.transactionID } set { customEvent.transactionID = newValue } } /** * The event's interaction type. The type's length must not exceed 255 characters or it will * invalidate the event. */ @objc public var interactionType: String? { get { return customEvent.interactionType } set { customEvent.interactionType = newValue } } /** * The event's interaction ID. The ID's length must not exceed 255 characters or it will * invalidate the event. */ @objc public var interactionID: String? { get { return customEvent.interactionID } set { customEvent.interactionID = newValue } } /** * The event's properties. Properties must be valid JSON. */ @objc public var properties: [String: Any] { get { return customEvent.properties.mapValues { $0.unWrap() as Any } } } /// Default constructor. /// - Parameter name: The name of the event. The event's name must not exceed /// 255 characters or it will invalidate the event. @objc public convenience init(name: String) { let customEvent = CustomEvent(name: name) self.init(event: customEvent) } /// Default constructor. /// - Parameter name: The name of the event. The event's name must not exceed /// 255 characters or it will invalidate the event. /// - Parameter value: The event value. The value must be between -2^31 and /// 2^31 - 1 or it will invalidate the event. Defaults to 1. @objc public convenience init(name: String, value: Double) { let customEvent = CustomEvent(name: name, value: value) self.init(event: customEvent) } /// Default constructor. /// - Parameter name: The name of the event. The event's name must not exceed /// 255 characters or it will invalidate the event. /// - Parameter decimalValue: The event value. The value must be between -2^31 and /// 2^31 - 1 or it will invalidate the event. Defaults to 1. @objc public convenience init(name: String, decimalValue: Decimal) { let customEvent = CustomEvent(name: name, decimalValue: decimalValue) self.init(event: customEvent) } init(event: CustomEvent) { self.customEvent = event } @objc public func isValid() -> Bool { return customEvent.isValid() } /** * Adds the event to analytics. */ @objc public func track() { customEvent.track() } /// Sets a property string value. /// - Parameters: /// - string: The string value to set. /// - forKey: The properties key @objc public func setProperty( string: String, forKey key: String ) { customEvent.setProperty(string: string, forKey: key) } /// Removes a property. /// - Parameters: /// - forKey: The properties key @objc public func removeProperty( forKey key: String ) { customEvent.removeProperty(forKey: key) } /// Sets a property double value. /// - Parameters: /// - double: The double value to set. /// - forKey: The properties key @objc public func setProperty( double: Double, forKey key: String ) { customEvent.setProperty(double: double, forKey: key) } /// Sets a property bool value. /// - Parameters: /// - bool: The bool value to set. /// - forKey: The properties key @objc public func setProperty( bool: Bool, forKey key: String ) { customEvent.setProperty(bool: bool, forKey: key) } /// Sets a property value. /// - Parameters: /// - value: The value to set. /// - forKey: The properties key @objc public func setProperty( value: Any, forKey key: String ) throws { try customEvent.setProperty(value: value, forKey: key) } /// Sets a property value. /// - Parameters: /// - value: The values to set. The value must result in a JSON object or an error will be thrown. @objc public func setProperties(_ object: Any) throws { try customEvent.setProperties(object) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Analytics/UAAnalytics.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// The Analytics object provides an interface to the Airship Analytics API. @objc public final class UAAnalytics: NSObject, Sendable { override init() { super.init() } /// The current session ID. @objc public var sessionID: String { get { return Airship.analytics.sessionID } } /// Initiates screen tracking for a specific app screen, must be called once per tracked screen. /// - Parameter screen: The screen's identifier. @objc @MainActor public func trackScreen(_ screen: String?) { Airship.analytics.trackScreen(screen) } @objc public func recordCustomEvent(_ event: UACustomEvent) { Airship.analytics.recordCustomEvent(event.customEvent) } @objc public func associateDeviceIdentifier(_ associatedIdentifiers: UAAssociatedIdentifiers) { let identifiers = AssociatedIdentifiers.init(identifiers: associatedIdentifiers.identifiers) Airship.analytics.associateDeviceIdentifiers(identifiers) } @objc public func currentAssociatedDeviceIdentifiers() -> UAAssociatedIdentifiers { let identifiers = Airship.analytics.currentAssociatedDeviceIdentifiers() return UAAssociatedIdentifiers.init(identifiers: identifiers.allIDs) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Analytics/UAAssociatedIdentifiers.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif @objc public final class UAAssociatedIdentifiers: NSObject { var identifiers: [String: String] init(identifiers: [String: String]) { self.identifiers = identifiers } @objc public func set(identifier: String?, key: String) { self.identifiers[key] = identifier } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Automation/UAInAppAutomation.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore import AirshipAutomation #endif /** * In-App Automation */ @objc public final class UAInAppAutomation: NSObject, Sendable { override init() { super.init() } /// In-App messaging @objc public let inAppMessaging: UAInAppMessaging = UAInAppMessaging() /// Paused state of in-app automation. @objc @MainActor public var isPaused: Bool { get { return Airship.inAppAutomation.isPaused } set { Airship.inAppAutomation.isPaused = newValue } } /// Display interval @objc @MainActor public var displayInterval: TimeInterval { get { return Airship.inAppAutomation.inAppMessaging.displayInterval } set { Airship.inAppAutomation.inAppMessaging.displayInterval = newValue } } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Automation/UAInAppMessaging.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore import AirshipAutomation #endif /// In-App messaging public final class UAInAppMessaging: NSObject, Sendable { /// Display interval @MainActor public var displayInterval: TimeInterval { get { Airship.inAppAutomation.inAppMessaging.displayInterval } set { Airship.inAppAutomation.inAppMessaging.displayInterval = newValue } } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Channel/UAAttributesEditor.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Attributes editor. @objc public class UAAttributesEditor: NSObject { var editor: AttributesEditor? /** * Removes an attribute. * - Parameters: * - attribute: The attribute. */ @objc(removeAttribute:) public func remove(_ attribute: String) { self.editor?.remove(attribute) } /** * Sets the attribute. * - Parameters: * - date: The value * - attribute: The attribute */ @objc(setDate:attribute:) public func set(date: Date, attribute: String) { self.editor?.set(date: date, attribute: attribute) } /** * Sets the attribute. * - Parameters: * - number: The value. * - attribute: The attribute. */ @objc(setNumber:attribute:) public func set(number: NSNumber, attribute: String) { self.editor?.set(number: number, attribute: attribute) } /** * Sets the attribute. * - Parameters: * - string: The value. * - attribute: The attribute. */ @objc(setString:attribute:) public func set(string: String, attribute: String) { self.editor?.set(string: string, attribute: attribute) } /** * Applies the attribute changes. */ @objc public func apply() { self.editor?.apply() } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Channel/UAChannel.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Provides an interface to the channel functionality. @objc public final class UAChannel: NSObject, Sendable { override init() { super.init() } @objc public var identifier: String? { return Airship.channel.identifier } /// Device tags @objc public var tags: [String] { get { return Airship.channel.tags } set { Airship.channel.tags = newValue } } @objc public func editTags() -> UATagEditor? { let tagEditor = UATagEditor() tagEditor.editor = Airship.channel.editTags() return tagEditor } @objc public func editTagGroups() -> UATagGroupsEditor { let tagGroupsEditor = UATagGroupsEditor() tagGroupsEditor.editor = Airship.channel.editTagGroups() return tagGroupsEditor } @objc public func editTagGroups(_ editorBlock: (UATagGroupsEditor) -> Void) { let editor = editTagGroups() editorBlock(editor) editor.apply() } @objc public func editSubscriptionLists() -> UASubscriptionListEditor { let subscriptionListEditor = UASubscriptionListEditor() subscriptionListEditor.editor = Airship.channel.editSubscriptionLists() return subscriptionListEditor } @objc public func editSubscriptionLists(_ editorBlock: (UASubscriptionListEditor) -> Void) { let editor = editSubscriptionLists() editorBlock(editor) editor.apply() } @objc public func fetchSubscriptionLists() async throws -> [String] { try await Airship.channel.fetchSubscriptionLists() } /// Fetches current subscription lists. /// - Parameter completionHandler: The completion handler with the subscription lists or an error. @objc(fetchSubscriptionListsWithCompletion:) public func fetchSubscriptionLists(completionHandler: @escaping @Sendable ([String]?, (any Error)?) -> Void) { Task { do { let subscriptionLists = try await Airship.channel.fetchSubscriptionLists() completionHandler(subscriptionLists, nil) } catch { completionHandler(nil, error) } } } @objc public func editAttributes() -> UAAttributesEditor { let attributesEditor = UAAttributesEditor() attributesEditor.editor = Airship.channel.editAttributes() return attributesEditor } @objc public func editAttributes(_ editorBlock: (UAAttributesEditor) -> Void) { let editor = editAttributes() editorBlock(editor) editor.apply() } @objc(enableChannelCreation) public func enableChannelCreation() { Airship.channel.enableChannelCreation() } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Channel/UATagEditor.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Tag editor. @objc public class UATagEditor: NSObject { var editor: TagEditor? /** * Adds tags. * - Parameters: * - tags: The tags. */ @objc(addTags:) public func add(_ tags: [String]) { self.editor?.add(tags) } /** * Adds a single tag. * - Parameters: * - tag: The tag. */ @objc(addTag:) public func add(_ tag: String) { self.editor?.add(tag) } /** * Removes tags from the given group. * - Parameters: * - tags: The tags. */ @objc(removeTags:) public func remove(_ tags: [String]) { self.editor?.remove(tags) } /** * Removes a single tag. * - Parameters: * - tag: The tag. */ @objc(removeTag:) public func remove(_ tag: String) { self.editor?.remove(tag) } /** * Sets tags on the given group. * - Parameters: * - tags: The tags. */ @objc(setTags:) public func set(_ tags: [String]) { self.editor?.set(tags) } /** * Clears tags. */ @objc(clearTags) public func clear() { self.editor?.clear() } /** * Applies tag changes. */ @objc public func apply() { self.editor?.apply() } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Channel/UATagGroupsEditor.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Tag groups editor. @objc public class UATagGroupsEditor: NSObject { var editor: TagGroupsEditor? /** * Adds tags to the given group. * - Parameters: * - tags: The tags. * - group: The tag group. */ @objc(addTags:group:) public func add(_ tags: [String], group: String) { self.editor?.add(tags, group: group) } /** * Removes tags from the given group. * - Parameters: * - tags: The tags. * - group: The tag group. */ @objc(removeTags:group:) public func remove(_ tags: [String], group: String) { self.editor?.remove(tags, group: group) } /** * Sets tags on the given group. * - Parameters: * - tags: The tags. * - group: The tag group. */ @objc(setTags:group:) public func set(_ tags: [String], group: String) { self.editor?.set(tags, group: group) } /** * Applies tag changes. */ @objc public func apply() { self.editor?.apply() } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Contact/UAContact.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Airship contact. A contact is distinct from a channel and represents a "user" /// within Airship. Contacts may be named and have channels associated with it. @objc public final class UAContact: NSObject, Sendable { override init() { super.init() } /// Identifies the contact. /// - Parameter namedUserID: The named user ID. @objc public func identify(_ namedUserID: String) { Airship.contact.identify(namedUserID) } /// Resets the contact. @objc public func reset() { Airship.contact.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. @objc public func notifyRemoteLogin() { Airship.contact.notifyRemoteLogin() } /// Begins a tag groups editing session. /// - Returns: A TagGroupsEditor @objc public func editTagGroups() -> UATagGroupsEditor { let tagGroupsEditor = UATagGroupsEditor() tagGroupsEditor.editor = Airship.contact.editTagGroups() return tagGroupsEditor } @objc public func editTagGroups(_ editorBlock: (UATagGroupsEditor) -> Void) { let editor = editTagGroups() editorBlock(editor) editor.apply() } /// Begins an attribute editing session. /// - Returns: An AttributesEditor @objc public func editAttributes() -> UAAttributesEditor { let attributesEditor = UAAttributesEditor() attributesEditor.editor = Airship.contact.editAttributes() return attributesEditor } @objc public func editAttributes(_ editorBlock: (UAAttributesEditor) -> Void) { let editor = editAttributes() editorBlock(editor) editor.apply() } /** * Associates a channel to the contact. * - Parameters: * - channelID: The channel ID. * - type: The channel type. */ @objc public func associateChannel(_ channelID: String, type: UAChannelType) { Airship.contact.associateChannel(channelID, type: UAHelpers.toAirshipChannelType(type: type)) } /// Begins a subscription list editing session /// - Returns: A Scoped subscription list editor @objc public func editSubscriptionLists() -> UAScopedSubscriptionListEditor { let subscriptionListEditor = UAScopedSubscriptionListEditor() subscriptionListEditor.editor = Airship.contact.editSubscriptionLists() return subscriptionListEditor } @objc public func editSubscriptionLists(_ editorBlock: (UAScopedSubscriptionListEditor) -> Void) { let editor = editSubscriptionLists() editorBlock(editor) editor.apply() } /// Fetches subscription lists. /// - Parameter completionHandler: The completion handler with the subscription lists or an error. @objc public func fetchSubscriptionLists(completionHandler: @escaping @Sendable ([String: [String]]?, (any Error)?) -> Void) { Task { do { let subscriptionLists = try await Airship.contact.fetchSubscriptionLists() // Convert [String: [ChannelScope]] to [String: [String]] for Objective-C var result: [String: [String]] = [:] for (key, scopes) in subscriptionLists { result[key] = scopes.map { $0.rawValue } } completionHandler(result, nil) } catch { completionHandler(nil, error) } } } /// Gets the current named user ID. /// - Parameter completionHandler: The completion handler with the named user ID or an error. @objc public func getNamedUserID(completionHandler: @escaping @Sendable (String?, (any Error)?) -> Void) { Task { let namedUserID = await Airship.contact.namedUserID completionHandler(namedUserID, nil) } } } @objc /// Channel type public enum UAChannelType: Int, Sendable, Equatable { /** * Email channel */ case email /** * SMS channel */ case sms /** * Open channel */ case open } ================================================ FILE: Airship/AirshipObjectiveC/Source/Embedded/UAEmbeddedViewController.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public import UIKit import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// Embedded view controller factory @objc public final class UAEmbeddedViewControllerFactory: NSObject, Sendable { @MainActor @objc public class func makeViewController(embeddedID: String) -> UIViewController { let vc = UIHostingController(rootView: AirshipEmbeddedView<EmptyView>(embeddedID: embeddedID)) // Let Auto Layout drive the height from the SwiftUI content's natural size if #available(iOS 16.0, *) { vc.sizingOptions = .intrinsicContentSize } return vc } @MainActor @objc public class func embed(embeddedID: String, in parentViewController: UIViewController) -> UIView { let childVC = makeViewController(embeddedID: embeddedID) parentViewController.addChild(childVC) let containerView = UIView(frame: .zero) containerView.addSubview(childVC.view) childVC.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ childVC.view.topAnchor.constraint(equalTo: containerView.topAnchor), childVC.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), childVC.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), childVC.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) ]) childVC.didMove(toParent: parentViewController) return containerView } } ================================================ FILE: Airship/AirshipObjectiveC/Source/MessageCenter/UAMessageCenter.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipMessageCenter import AirshipCore #endif /// Delegate protocol for receiving callbacks related to message center. @objc public protocol UAMessageCenterDisplayDelegate { /// Called when a message is requested to be displayed. /// /// - Parameters: /// - messageID: The message ID. @objc(displayMessageCenterForMessageID:) @MainActor func displayMessageCenter(messageID: String) /// Called when the message center is requested to be displayed. @objc @MainActor func displayMessageCenter() /// Called when the message center is requested to be dismissed. @objc @MainActor func dismissMessageCenter() } @objc public protocol UAMessageCenterPredicate: Sendable { /// Evaluate the message center message. Used to filter the message center list /// - Parameters: /// - message: The message center message /// - Returns: True if the message passed the evaluation, otherwise false. func evaluate(message: UAMessageCenterMessage) -> Bool } @objc public final class UAMessageCenter: NSObject, Sendable { private let storage: Storage = Storage() override init() { super.init() } /// Message center display delegate. @objc @MainActor public weak var displayDelegate: (any UAMessageCenterDisplayDelegate)? { get { guard let wrapped = Airship.messageCenter.displayDelegate as? UAMessageCenterDisplayDelegateWrapper else { return nil } return wrapped.forwardDelegate } set { if let newValue { let wrapper = UAMessageCenterDisplayDelegateWrapper(newValue) Airship.messageCenter.displayDelegate = wrapper storage.displayDelegate = wrapper } else { Airship.messageCenter.displayDelegate = nil storage.displayDelegate = nil } } } /// Message center inbox. @objc public let inbox: UAMessageCenterInbox = UAMessageCenterInbox() /// Loads a Message center theme from a plist file. If you are embedding the MessageCenterView directly /// you should pass the theme in through the view extension `.messageCenterTheme(_:)`. /// - Parameters: /// - plist: The name of the plist in the bundle. @objc @MainActor public func setThemeFromPlist(_ plist: String) throws { try Airship.messageCenter.setThemeFromPlist(plist) } /// Default message center predicate. Only applies to the OOTB Message Center. If you are embedding the MessageCenterView directly /// you should pass the predicate in through the view extension `.messageCenterPredicate(_:)`. @objc @MainActor public var predicate: (any UAMessageCenterPredicate)? { didSet { if let predicate { Airship.messageCenter.predicate = UAMessageCenterPredicateWrapper(delegate: predicate) } else { Airship.messageCenter.predicate = nil } } } /// Display the message center. @objc @MainActor public func display() { Airship.messageCenter.display() } /// Display the given message with animation. /// - Parameters: /// - messageID: The messageID of the message. @objc(displayWithMessageID:) @MainActor public func display(messageID: String) { Airship.messageCenter.display(messageID: messageID) } /// Dismiss the message center. @objc @MainActor public func dismiss() { Airship.messageCenter.dismiss() } fileprivate final class Storage: Sendable { @MainActor var displayDelegate: (any MessageCenterDisplayDelegate)? init() {} } } fileprivate final class UAMessageCenterDisplayDelegateWrapper: NSObject, MessageCenterDisplayDelegate { weak var forwardDelegate: (any UAMessageCenterDisplayDelegate)? init(_ forwardDelegate: any UAMessageCenterDisplayDelegate) { self.forwardDelegate = forwardDelegate } public func displayMessageCenter(messageID: String) { self.forwardDelegate?.displayMessageCenter(messageID: messageID) } public func displayMessageCenter() { self.forwardDelegate?.displayMessageCenter() } public func dismissMessageCenter() { self.forwardDelegate?.dismissMessageCenter() } } final class UAMessageCenterPredicateWrapper: NSObject, MessageCenterPredicate { private let delegate: any UAMessageCenterPredicate init(delegate: any UAMessageCenterPredicate) { self.delegate = delegate } public func evaluate(message: MessageCenterMessage) -> Bool { let mcMessage = UAMessageCenterMessage(message: message) return self.delegate.evaluate(message: mcMessage) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/MessageCenter/UAMessageCenterList.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipMessageCenter import AirshipCore #endif /// Message center inbox. @objc public final class UAMessageCenterInbox: NSObject, Sendable { /// The list of messages in the inbox. @objc public func getMessages() async -> [UAMessageCenterMessage] { let messages = await Airship.messageCenter.inbox.messages return messages.map { UAMessageCenterMessage(message: $0) } } /// The user associated to the Message Center @objc public func getUser() async -> UAMessageCenterUser? { guard let user = await Airship.messageCenter.inbox.user else { return nil } return UAMessageCenterUser(user: user) } /// The number of messages that are currently unread. @objc public func getUnreadCount() async -> Int { await Airship.messageCenter.inbox.unreadCount } /// Returns the message associated with a particular URL. /// - Parameters: /// - bodyURL: The URL of the message. /// - Returns: The associated `MessageCenterMessage` object or nil if a message was unable to be found. @objc public func message(forBodyURL bodyURL: URL) async -> UAMessageCenterMessage? { guard let message = await Airship.messageCenter.inbox.message(forBodyURL: bodyURL) else { return nil } return UAMessageCenterMessage(message: message) } /// Returns the message associated with a particular ID. /// - Parameters: /// - messageID: The message ID. /// - Returns: The associated `MessageCenterMessage` object or nil if a message was unable to be found. @objc public func message(forID messageID: String) async -> UAMessageCenterMessage? { guard let message = await Airship.messageCenter.inbox.message(forID: messageID) else { return nil } return UAMessageCenterMessage(message: message) } /// Refreshes the list of messages in the inbox. /// - Returns: `true` if the messages was refreshed, otherwise `false`. @objc @discardableResult public func refreshMessages() async -> Bool { await Airship.messageCenter.inbox.refreshMessages() } /// Marks messages read. /// - Parameters: /// - messages: The list of messages to be marked read. @objc public func markRead(messages: [UAMessageCenterMessage]) async { await Airship.messageCenter.inbox.markRead(messages: messages.map{$0.mcMessage}) } /// Marks messages read by message IDs. /// - Parameters: /// - messageIDs: The list of message IDs for the messages to be marked read. @objc public func markRead(messageIDs: [String]) async { await Airship.messageCenter.inbox.markRead(messageIDs: messageIDs) } /// Marks messages deleted. /// - Parameters: /// - messages: The list of messages to be marked deleted. @objc public func delete(messages: [UAMessageCenterMessage]) async { await Airship.messageCenter.inbox.delete(messages: messages.map{$0.mcMessage}) } /// Marks messages deleted by message IDs. /// - Parameters: /// - messageIDs: The list of message IDs for the messages to be marked deleted. @objc public func delete(messageIDs: [String]) async { await Airship.messageCenter.inbox.delete(messageIDs: messageIDs) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/MessageCenter/UAMessageCenterMessage.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipMessageCenter import AirshipCore #endif /// Message center message. @objc public final class UAMessageCenterMessage: NSObject, Sendable { let mcMessage: MessageCenterMessage init(message: MessageCenterMessage) { self.mcMessage = message } @objc /// The Airship message ID. /// This ID may be used to match an incoming push notification to a specific message. public var id: String { get { return mcMessage.id } } @objc /// The message title. public var title: String { get { return mcMessage.title } } @objc /// The message subtitle. public var subtitle: String? { get { return mcMessage.subtitle } } @objc /// The message list icon. public var listIcon: String? { get { return mcMessage.listIcon } } @objc /// The message expiry. public var isExpired: Bool { get { return mcMessage.isExpired } } @objc /// The message's extra dictionary. /// This dictionary can be populated with arbitrary key-value data at the time the message is composed. public var extra: [String: String] { get { return mcMessage.extra } } @objc /// The URL for the message body itself. /// This URL may only be accessed with Basic Auth credentials set to the user ID and password. public var bodyURL: URL { get { return mcMessage.bodyURL } } @objc /// The date and time the message will expire. /// A nil value indicates it will never expire. public var expirationDate: Date? { get { return mcMessage.expirationDate } } @objc /// The date and time the message was sent (UTC). public var sentDate: Date { get { return mcMessage.sentDate } } @objc /// The unread status of the message. /// `true` if the message is unread, otherwise `false`. public var unread: Bool { get { return mcMessage.unread } } /// Parses the message ID from notification user info. /// - Parameters: /// - userInfo: The notification user info. /// - Returns: The message ID. @objc(parseMessageIDFromUserInfo:) public class func parseMessageID(userInfo: [AnyHashable: Any]) -> String? { return MessageCenterMessage.parseMessageID(userInfo: userInfo) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/MessageCenter/UAMessageCenterNativeBridge.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) public import Foundation public import WebKit #if canImport(AirshipCore) import AirshipCore import AirshipMessageCenter #endif /// Delegate for native bridge events from Message Center web views. @objc public protocol UAMessageCenterNativeBridgeDelegate: NSObjectProtocol { /// Called when `UAirship.close()` is triggered from the JavaScript environment. func close() } private final class NativeBridgeDelegateWrapper: NativeBridgeDelegate { weak var delegate: (any UAMessageCenterNativeBridgeDelegate)? init(_ delegate: any UAMessageCenterNativeBridgeDelegate) { self.delegate = delegate } func close() { delegate?.close() } } // Wraps any WKNavigationDelegate as AirshipWKNavigationDelegate using ObjC // message forwarding, so the caller doesn't need to know about AirshipWKNavigationDelegate. // delegateObj is nonisolated(unsafe) so forwardingTarget/responds can be nonisolated // (WebKit always calls them on the main thread). private final class ForwardNavigationDelegateWrapper: NSObject, AirshipWKNavigationDelegate { @MainActor weak var delegate: (any WKNavigationDelegate)? nonisolated(unsafe) weak var delegateObj: AnyObject? @MainActor init(_ delegate: any WKNavigationDelegate) { self.delegate = delegate self.delegateObj = delegate as AnyObject } nonisolated override func responds(to aSelector: Selector!) -> Bool { return super.responds(to: aSelector) || (delegateObj?.responds(to: aSelector) ?? false) } nonisolated override func forwardingTarget(for aSelector: Selector!) -> Any? { return delegateObj } } /// Airship native bridge for Message Center web views. @objc public final class UAMessageCenterNativeBridge: NSObject { private let bridge: NativeBridge private var forwardNavigationDelegateWrapper: ForwardNavigationDelegateWrapper? private var nativeBridgeExtension: MessageCenterNativeBridgeExtension? private var nativeBridgeDelegateWrapper: NativeBridgeDelegateWrapper? /// The navigation delegate to set on the web view. @objc @MainActor public var navigationDelegate: any WKNavigationDelegate { return bridge } /// Optional delegate for native bridge events such as close. @objc @MainActor public weak var nativeBridgeDelegate: (any UAMessageCenterNativeBridgeDelegate)? { get { nativeBridgeDelegateWrapper?.delegate } set { nativeBridgeDelegateWrapper = newValue.map { NativeBridgeDelegateWrapper($0) } bridge.nativeBridgeDelegate = nativeBridgeDelegateWrapper } } /// Optional delegate to receive forwarded navigation callbacks. @objc @MainActor public var forwardNavigationDelegate: (any WKNavigationDelegate)? { get { forwardNavigationDelegateWrapper?.delegate } set { forwardNavigationDelegateWrapper = newValue.map { ForwardNavigationDelegateWrapper($0) } bridge.forwardNavigationDelegate = forwardNavigationDelegateWrapper } } @MainActor @objc public override init() { bridge = NativeBridge() super.init() } /// Sets the message and user on the native bridge. /// - Parameters: /// - message: The message to display. /// - user: The Message Center user. @objc @MainActor public func setMessage(_ message: UAMessageCenterMessage, user: UAMessageCenterUser) { let ext = MessageCenterNativeBridgeExtension( message: message.mcMessage, user: user.mcUser ) nativeBridgeExtension = ext bridge.nativeBridgeExtensionDelegate = ext } } #endif ================================================ FILE: Airship/AirshipObjectiveC/Source/MessageCenter/UAMessageCenterTheme.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public import UIKit #if canImport(AirshipCore) import AirshipMessageCenter public import AirshipCore #endif /// Message Center theme @objc public final class UAMessageCenterTheme: NSObject { @objc /// The tint color of the "pull to refresh" control public var refreshTintColor: AirshipNativeColor? = nil @objc /// The dark mode tint color of the "pull to refresh" control public var refreshTintColorDark: AirshipNativeColor? = nil @objc /// Whether icons are enabled. Defaults to `NO`. public var iconsEnabled: Bool = false @objc /// An optional placeholder image to use when icons haven't fully loaded. public var placeholderIcon: AirshipNativeImage? = nil @objc /// The font to use for message cell titles. public var cellTitleFont: AirshipNativeFont? = AirshipNativeFont.preferredFont(forTextStyle: .headline) @objc /// The font to use for message cell dates. public var cellDateFont: AirshipNativeFont? = AirshipNativeFont.preferredFont(forTextStyle: .subheadline) @objc /// The regular color for message cells public var cellColor: AirshipNativeColor? = nil @objc /// The dark mode color for message cells public var cellColorDark: AirshipNativeColor? = nil @objc /// The regular color for message cell titles. public var cellTitleColor: AirshipNativeColor? = .label @objc /// The dark mode color for message cell titles. public var cellTitleColorDark: AirshipNativeColor? = nil @objc /// The regular color for message cell dates. public var cellDateColor: AirshipNativeColor? = .secondaryLabel @objc /// The dark mode color for message cell dates. public var cellDateColorDark: AirshipNativeColor? = nil @objc /// The message cell tint color. public var cellTintColor: AirshipNativeColor? = nil @objc /// The dark mode message cell tint color. public var cellTintColorDark: AirshipNativeColor? = nil @objc /// The background color for the unread indicator. public var unreadIndicatorColor: AirshipNativeColor? = nil @objc /// The dark mode background color for the unread indicator. public var unreadIndicatorColorDark: AirshipNativeColor? = nil @objc /// The title color for the "Select All" button. public var selectAllButtonTitleColor: AirshipNativeColor? = nil @objc /// The dark mode title color for the "Select All" button. public var selectAllButtonTitleColorDark: AirshipNativeColor? = nil @objc /// The title color for the "Delete" button. public var deleteButtonTitleColor: AirshipNativeColor? = nil @objc /// The dark mode title color for the "Delete" button. public var deleteButtonTitleColorDark: AirshipNativeColor? = nil @objc /// The title color for the "Mark Read" button. public var markAsReadButtonTitleColor: AirshipNativeColor? = nil @objc /// The dark mode title color for the "Mark Read" button. public var markAsReadButtonTitleColorDark: AirshipNativeColor? = nil @objc /// Whether the delete message button from the message view is enabled. Defaults to `NO`. public var hideDeleteButton: Bool = false @objc /// The title color for the "Edit" button. public var editButtonTitleColor: AirshipNativeColor? = nil @objc /// The dark mode title color for the "Edit" button. public var editButtonTitleColorDark: AirshipNativeColor? = nil @objc /// The title color for the "Cancel" button. public var cancelButtonTitleColor: AirshipNativeColor? = nil @objc /// The dark mode title color for the "Cancel" button. public var cancelButtonTitleColorDark: AirshipNativeColor? = nil @objc /// The title color for the "Done" button. public var backButtonColor: AirshipNativeColor? = nil @objc /// The dark mode title color for the "Done" button. public var backButtonColorDark: AirshipNativeColor? = nil @objc /// The navigation bar title public var navigationBarTitle: String? = nil @objc /// The background of the message list. public var messageListBackgroundColor: AirshipNativeColor? = nil @objc /// The dark mode background of the message list. public var messageListBackgroundColorDark: AirshipNativeColor? = nil @objc /// The background of the message list container. public var messageListContainerBackgroundColor: AirshipNativeColor? = nil @objc /// The dark mode background of the message list container. public var messageListContainerBackgroundColorDark: AirshipNativeColor? = nil @objc /// The background of the message view. public var messageViewBackgroundColor: AirshipNativeColor? = nil @objc /// The dark mode background of the message view. public var messageViewBackgroundColorDark: AirshipNativeColor? = nil @objc /// The background of the message view container. public var messageViewContainerBackgroundColor: AirshipNativeColor? = nil @objc /// The dark mode background of the message view container. public var messageViewContainerBackgroundColorDark: AirshipNativeColor? = nil } ================================================ FILE: Airship/AirshipObjectiveC/Source/MessageCenter/UAMessageCenterUser.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipMessageCenter import AirshipCore #endif /// Message Center user. @objc public final class UAMessageCenterUser: NSObject, Sendable { internal let mcUser: MessageCenterUser init(user: MessageCenterUser) { self.mcUser = user } /// The password. @objc public var password: String { get { return mcUser.password } } /// The username. @objc public var username: String { get { return mcUser.username } } /// The basic auth string for the user. /// - Returns: An HTTP Basic Auth header string value in the form of: `Basic [Base64 Encoded "username:password"]` @objc public var basicAuthString: String { get { return mcUser.basicAuthString } } } ================================================ FILE: Airship/AirshipObjectiveC/Source/MessageCenter/UAMessageCenterViewController.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public import UIKit #if canImport(AirshipCore) import AirshipMessageCenter import AirshipCore #endif import SwiftUI /// Message Center view controller factory @objc public final class UAMessageCenterViewControllerFactory: NSObject, Sendable { @objc /// Makes a message view controller with the given theme. /// - Parameters: /// - theme: The message center theme. /// - predicate: The message center predicate. /// - Returns: A view controller. @MainActor public class func make( theme: UAMessageCenterTheme? = nil, predicate: (any UAMessageCenterPredicate)? = nil ) -> UIViewController { var airshipTheme: MessageCenterTheme? var wrapper: UAMessageCenterPredicateWrapper? if let theme = theme { airshipTheme = UAMessageCenterViewControllerFactory.toAirshipTheme(theme: theme) } if let predicate = predicate { wrapper = UAMessageCenterPredicateWrapper(delegate: predicate) } return MessageCenterViewControllerFactory.make(theme: airshipTheme, predicate: wrapper, controller: MessageCenterController()) } @objc /// Makes a message view controller with the given theme. /// - Parameters: /// - themePlist: A path to a theme plist /// - Returns: A view controller. @MainActor public class func make( themePlist: String? ) throws -> UIViewController { return try MessageCenterViewControllerFactory.make(themePlist: themePlist, controller: MessageCenterController()) } @objc /// Makes a message view controller with the given theme. /// - Parameters: /// - themePlist: A path to a theme plist /// - predicate: The message center predicate /// - Returns: A view controller. @MainActor public class func make( themePlist: String?, predicate: (any UAMessageCenterPredicate)? ) throws -> UIViewController { if let predicate = predicate { let wrapper = UAMessageCenterPredicateWrapper(delegate: predicate) return try MessageCenterViewControllerFactory.make(themePlist: themePlist, predicate: wrapper, controller: MessageCenterController()) } return try MessageCenterViewControllerFactory.make(themePlist: themePlist, controller: MessageCenterController()) } @objc /// Embeds the message center view in another view. /// - Parameters: /// - theme: The message center theme. /// - predicate: The message center predicate. /// - parentViewController: The parent view controller into which we'll embed the message center. /// - Returns: A UIView to be added into another view. @MainActor public class func embed( theme: UAMessageCenterTheme? = nil, predicate: (any UAMessageCenterPredicate)? = nil, in parentViewController: UIViewController ) -> UIView { let childVC = self.make(theme: theme, predicate: predicate) parentViewController.addChild(childVC) let containerView = UIView(frame: .zero) containerView.addSubview(childVC.view) childVC.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ childVC.view.topAnchor.constraint(equalTo: containerView.topAnchor), childVC.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), childVC.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), childVC.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) ]) childVC.didMove(toParent: parentViewController) return containerView } private class func toAirshipTheme(theme: UAMessageCenterTheme) -> MessageCenterTheme { return MessageCenterTheme( refreshTintColor: UAMessageCenterViewControllerFactory.toColor(color: theme.refreshTintColor), refreshTintColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.refreshTintColorDark), iconsEnabled: theme.iconsEnabled, placeholderIcon: UAMessageCenterViewControllerFactory.toImage(image: theme.placeholderIcon), cellTitleFont: UAMessageCenterViewControllerFactory.toFont(font: theme.cellTitleFont), cellDateFont: UAMessageCenterViewControllerFactory.toFont(font: theme.cellDateFont), cellColor: UAMessageCenterViewControllerFactory.toColor(color: theme.cellColor), cellColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.cellColorDark), cellTitleColor: UAMessageCenterViewControllerFactory.toColor(color: theme.cellTitleColor), cellTitleColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.cellTitleColorDark), cellDateColor: UAMessageCenterViewControllerFactory.toColor(color: theme.cellDateColor), cellDateColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.cellDateColorDark), cellSeparatorStyle: nil, cellSeparatorColor: nil, cellSeparatorColorDark: nil, cellTintColor: UAMessageCenterViewControllerFactory.toColor(color: theme.cellTintColor), cellTintColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.cellTintColorDark), unreadIndicatorColor: UAMessageCenterViewControllerFactory.toColor(color: theme.unreadIndicatorColor), unreadIndicatorColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.unreadIndicatorColorDark), selectAllButtonTitleColor: UAMessageCenterViewControllerFactory.toColor(color: theme.selectAllButtonTitleColor), selectAllButtonTitleColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.selectAllButtonTitleColorDark), deleteButtonTitleColor: UAMessageCenterViewControllerFactory.toColor(color: theme.deleteButtonTitleColor), deleteButtonTitleColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.deleteButtonTitleColorDark), markAsReadButtonTitleColor: UAMessageCenterViewControllerFactory.toColor(color: theme.markAsReadButtonTitleColor), markAsReadButtonTitleColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.markAsReadButtonTitleColorDark), hideDeleteButton: theme.hideDeleteButton, editButtonTitleColor: UAMessageCenterViewControllerFactory.toColor(color: theme.editButtonTitleColor), editButtonTitleColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.editButtonTitleColorDark), cancelButtonTitleColor: UAMessageCenterViewControllerFactory.toColor(color: theme.cancelButtonTitleColor), cancelButtonTitleColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.cancelButtonTitleColorDark), backButtonColor: UAMessageCenterViewControllerFactory.toColor(color: theme.backButtonColor), backButtonColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.backButtonColorDark), navigationBarTitle: theme.navigationBarTitle, messageListBackgroundColor: UAMessageCenterViewControllerFactory.toColor(color: theme.messageListBackgroundColor), messageListBackgroundColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.messageListBackgroundColorDark), messageListContainerBackgroundColor: UAMessageCenterViewControllerFactory.toColor(color: theme.messageListContainerBackgroundColor), messageListContainerBackgroundColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.messageListContainerBackgroundColorDark), messageViewBackgroundColor: UAMessageCenterViewControllerFactory.toColor(color: theme.messageViewBackgroundColor), messageViewBackgroundColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.messageViewBackgroundColorDark), messageViewContainerBackgroundColor: UAMessageCenterViewControllerFactory.toColor(color: theme.messageViewContainerBackgroundColor), messageViewContainerBackgroundColorDark: UAMessageCenterViewControllerFactory.toColor(color: theme.messageViewContainerBackgroundColorDark)) } private class func toColor(color: AirshipNativeColor?) -> Color? { if let color = color { return Color(color) } return nil } private class func toFont(font: AirshipNativeFont?) -> Font? { if let font = font { return Font(font) } return nil } private class func toImage(image: AirshipNativeImage?) -> Image? { if let image = image { return Image(airshipNativeImage: image) } return nil } } ================================================ FILE: Airship/AirshipObjectiveC/Source/PreferenceCenter/UAPreferenceCenter.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore import AirshipPreferenceCenter #endif /// Open delegate. @objc public protocol UAPreferenceCenterOpenDelegate { /** * Opens the Preference Center with the given ID. * - Parameters: * - preferenceCenterID: The preference center ID. * - Returns: `true` if the preference center was opened, otherwise `false` to fallback to OOTB UI. */ @objc func openPreferenceCenter(_ preferenceCenterID: String) -> Bool } fileprivate final class UAPreferenceCenterOpenDelegateWrapper: NSObject, PreferenceCenterOpenDelegate { weak var forwardDelegate: (any UAPreferenceCenterOpenDelegate)? init(_ forwardDelegate: any UAPreferenceCenterOpenDelegate) { self.forwardDelegate = forwardDelegate } public func openPreferenceCenter(_ preferenceCenterID: String) -> Bool { return self.forwardDelegate?.openPreferenceCenter(preferenceCenterID) ?? false } } /// Airship PreferenceCenter module. @objc public final class UAPreferenceCenter: NSObject, Sendable { private let storage: Storage = Storage() override init() { super.init() } /** * Open delegate. * * If set, the delegate will be called instead of launching the OOTB preference center screen. Must be set * on the main actor. */ @objc @MainActor public weak var openDelegate: (any UAPreferenceCenterOpenDelegate)? { get { guard let wrapped = Airship.preferenceCenter.openDelegate as? UAPreferenceCenterOpenDelegateWrapper else { return nil } return wrapped.forwardDelegate } set { if let newValue { let wrapper = UAPreferenceCenterOpenDelegateWrapper(newValue) Airship.preferenceCenter.openDelegate = wrapper storage.openDelegate = wrapper } else { Airship.preferenceCenter.openDelegate = nil storage.openDelegate = nil } } } @objc @MainActor public func setThemeFromPlist(_ plist: String) throws { try Airship.preferenceCenter.setThemeFromPlist(plist) } /** * Opens the Preference Center with the given ID. * - Parameters: * - preferenceCenterID: The preference center ID. */ @objc(openPreferenceCenter:) @MainActor public func open(_ preferenceCenterID: String) { Airship.preferenceCenter.open(preferenceCenterID) } /** * Returns the configuration of the Preference Center as JSON data with the given ID. * - Parameters: * - preferenceCenterID: The preference center ID. */ @objc public func jsonConfig(preferenceCenterID: String) async throws -> Data { return try await Airship.preferenceCenter.jsonConfig(preferenceCenterID: preferenceCenterID) } fileprivate final class Storage: Sendable { @MainActor var openDelegate: (any PreferenceCenterOpenDelegate)? init() {} } } ================================================ FILE: Airship/AirshipObjectiveC/Source/PreferenceCenter/UAPreferenceCenterViewController.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public import UIKit #if canImport(AirshipCore) import AirshipPreferenceCenter import AirshipCore #endif import SwiftUI /// Preference Center view controller factory @objc public final class UAPreferenceCenterViewControllerFactory: NSObject, Sendable { @objc /// Makes a view controller for the given Preference Center ID. /// - Parameters: /// - preferenceCenterID: The preferenceCenterID. /// - Returns: A view controller. @MainActor public class func makeViewController( preferenceCenterID: String )-> UIViewController { return PreferenceCenterViewControllerFactory.makeViewController(preferenceCenterID: preferenceCenterID) } @objc /// Makes a view controller for the given Preference Center ID and theme. /// - Parameters: /// - preferenceCenterID: The preferenceCenterID. /// - preferenceCenterThemePlist: The theme plist. /// - Returns: A view controller. @MainActor public class func makeViewController( preferenceCenterID: String, preferenceCenterThemePlist: String ) throws -> UIViewController { return try PreferenceCenterViewControllerFactory.makeViewController(preferenceCenterID: preferenceCenterID, preferenceCenterThemePlist: preferenceCenterThemePlist) } @objc /// Embeds the preference center view in another view. /// - Parameters: /// - preferenceCenterID: The preference center ID. /// - preferenceCenterThemePlist: Optional path to a theme plist. /// - parentViewController: The parent view controller into which we'll embed the preference center. /// - Returns: A UIView to be added into another view. @MainActor public class func embed( preferenceCenterID: String, preferenceCenterThemePlist: String? = nil, in parentViewController: UIViewController ) throws -> UIView { let childVC: UIViewController if let themePlist = preferenceCenterThemePlist { childVC = try makeViewController(preferenceCenterID: preferenceCenterID, preferenceCenterThemePlist: themePlist) } else { childVC = makeViewController(preferenceCenterID: preferenceCenterID) } parentViewController.addChild(childVC) let containerView = UIView(frame: .zero) containerView.addSubview(childVC.view) childVC.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ childVC.view.topAnchor.constraint(equalTo: containerView.topAnchor), childVC.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), childVC.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), childVC.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) ]) childVC.didMove(toParent: parentViewController) return containerView } } ================================================ FILE: Airship/AirshipObjectiveC/Source/PrivacyManager/UAPrivacyManager.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// The privacy manager allow enabling/disabling features in the SDK. /// The SDK will not make any network requests or collect data if all features are disabled, with /// a few exceptions when going from enabled -> disabled. To have the SDK opt-out of all features on startup, /// set the default enabled features in the Config to an empty option set, or in the /// airshipconfig.plist file with `enabledFeatures = none`. /// If any feature is enabled, the SDK will collect and send the following data: /// - Channel ID /// - Locale /// - TimeZone /// - Platform /// - Opt in state (push and notifications) /// - SDK version /// - Accengage Device ID (Accengage module for migration) @objc public final class UAPrivacyManager: NSObject, Sendable { override init() { super.init() } /// The current set of enabled features. @objc(enabledFeatures) public var enabledFeatures: UAFeature { get { return Airship.privacyManager.enabledFeatures.asUAFeature } set { Airship.privacyManager.enabledFeatures = newValue.asAirshipFeature } } /// Enables features. /// This will append any features to the `enabledFeatures` property. /// - Parameter features: The features to enable. @objc(enableFeatures:) public func enableFeatures(_ features: UAFeature) { Airship.privacyManager.enableFeatures(features.asAirshipFeature) } /// Disables features. /// This will remove any features to the `enabledFeatures` property. /// - Parameter features: The features to disable. @objc(disableFeatures:) public func disableFeatures(_ features: UAFeature) { Airship.privacyManager.disableFeatures(features.asAirshipFeature) } /** * Checks if a given feature is enabled. * * - Parameter features: The features to check. * - Returns: True if the provided features are enabled, otherwise false. */ @objc(isEnabled:) public func isEnabled(_ features: UAFeature) -> Bool { return Airship.privacyManager.isEnabled(features.asAirshipFeature) } /// Checks if any feature is enabled. /// - Returns: `true` if a feature is enabled, otherwise `false`. @objc public func isAnyFeatureEnabled() -> Bool { return Airship.privacyManager.isAnyFeatureEnabled() } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Push/UAAuthorizedNotificationSettings.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif // Authorized notification settings. @objc public final class UAAuthorizedNotificationSettings: NSObject, OptionSet, Sendable { public let rawValue: UInt public init(rawValue: UInt) { self.rawValue = rawValue } // Badge @objc public static func badge() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.badge.rawValue) } // Sound @objc public static func sound() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.sound.rawValue) } // Alert @objc public static func alert() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.alert.rawValue) } // Car Play @objc public static func carPlay() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.carPlay.rawValue) } // Lock Screen @objc public static func lockScreen() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.lockScreen.rawValue) } // Notification Center @objc public static func notificationCenter() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.notificationCenter.rawValue) } // Critical alert @objc public static func criticalAlert() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.criticalAlert.rawValue) } // Announcement @objc public static func announcement() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.announcement.rawValue) } // Scheduled delivery @objc public static func scheduledDelivery() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.scheduledDelivery.rawValue) } // Time sensitive @objc public static func timeSensitive() -> UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: AirshipAuthorizedNotificationSettings.timeSensitive.rawValue) } public override var hash: Int { return Int(rawValue) } public override func isEqual(_ object: Any?) -> Bool { guard let that = object as? UAAuthorizedNotificationSettings else { return false } return rawValue == that.rawValue } } extension AirshipAuthorizedNotificationSettings { var asUAAuthorizedNotificationSettings: UAAuthorizedNotificationSettings { return UAAuthorizedNotificationSettings(rawValue: self.rawValue) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Push/UANotificationCategories.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public import UserNotifications #if canImport(AirshipCore) import AirshipCore #endif @objc public final class UANotificationCategories: NSObject, Sendable { /** * Factory method to create the default set of user notification categories. * Background user notification actions will default to requiring authorization. * - Returns: A set of user notification categories */ @objc public class func defaultCategories() -> Set<UNNotificationCategory> { return NotificationCategories.defaultCategories() } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Push/UAPush.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation public import UserNotifications #if canImport(AirshipCore) import AirshipCore #endif /// This singleton provides an interface to the functionality provided by the Airship iOS Push API. @objc public final class UAPush: NSObject, Sendable { private let storage = Storage() override init() { super.init() } /// Enables/disables background remote notifications on this device through Airship. /// Defaults to `true`. @objc @MainActor public var backgroundPushNotificationsEnabled: Bool { set { Airship.push.backgroundPushNotificationsEnabled = newValue } get { return Airship.push.backgroundPushNotificationsEnabled } } /// Enables/disables user notifications on this device through Airship. /// Defaults to `false`. Once set to `true`, the user will be prompted for remote notifications. @objc public var userPushNotificationsEnabled: Bool { set { Airship.push.userPushNotificationsEnabled = newValue } get { return Airship.push.userPushNotificationsEnabled } } /// When enabled, if the user has ephemeral notification authorization the SDK will prompt the user for /// notifications. Defaults to `false`. @objc public var requestExplicitPermissionWhenEphemeral: Bool { set { Airship.push.requestExplicitPermissionWhenEphemeral = newValue } get { return Airship.push.requestExplicitPermissionWhenEphemeral } } /// The device token for this device, as a hex string. @objc @MainActor public var deviceToken: String? { get { return Airship.push.deviceToken } } /// User Notification options this app will request from APNS. Changes to this value /// will not take effect until the next time the app registers with /// updateRegistration. /// /// Defaults to alert, sound and badge. @objc public var notificationOptions: UNAuthorizationOptions { set { Airship.push.notificationOptions = newValue } get { return Airship.push.notificationOptions } } #if !os(tvOS) /// Custom notification categories. Airship default notification /// categories will be unaffected by this field. /// /// Changes to this value will not take effect until the next time the app registers /// with updateRegistration. @objc @MainActor public var customCategories: Set<UNNotificationCategory> { set { Airship.push.customCategories = newValue } get { return Airship.push.customCategories } } /// The combined set of notification categories from `customCategories` set by the app /// and the Airship provided categories. @objc @MainActor public var combinedCategories: Set<UNNotificationCategory> { get { return Airship.push.combinedCategories } } #endif /// Sets authorization required for the default Airship categories. Only applies /// to background user notification actions. /// /// Changes to this value will not take effect until the next time the app registers /// with updateRegistration. @objc @MainActor public var requireAuthorizationForDefaultCategories: Bool { set { Airship.push.requireAuthorizationForDefaultCategories = newValue } get { return Airship.push.requireAuthorizationForDefaultCategories } } @objc @MainActor public weak var pushNotificationDelegate: (any UAPushNotificationDelegate)? { get { guard let wrapped = Airship.push.pushNotificationDelegate as? UAPushNotificationDelegateWrapper else { return nil } return wrapped.forwardDelegate } set { if let newValue { let wrapper = UAPushNotificationDelegateWrapper(newValue) Airship.push.pushNotificationDelegate = wrapper storage.pushNotificationDelegate = wrapper } else { Airship.push.pushNotificationDelegate = nil storage.pushNotificationDelegate = nil } } } @objc @MainActor public weak var registrationDelegate: (any UARegistrationDelegate)? { get { guard let wrapped = Airship.push.registrationDelegate as? UARegistrationDelegateWrapper else { return nil } return wrapped.forwardDelegate } set { if let newValue { let wrapper = UARegistrationDelegateWrapper(newValue) Airship.push.registrationDelegate = wrapper storage.registrationDelegate = wrapper } else { Airship.push.registrationDelegate = nil storage.registrationDelegate = nil } } } #if !os(tvOS) /// Notification response that launched the application. @objc public var launchNotificationResponse: UNNotificationResponse? { get { return Airship.push.launchNotificationResponse } } #endif @objc @MainActor public var authorizedNotificationSettings: UAAuthorizedNotificationSettings { get { return Airship.push.authorizedNotificationSettings.asUAAuthorizedNotificationSettings } } @objc public var authorizationStatus: UNAuthorizationStatus { get { return Airship.push.authorizationStatus } } @objc public var userPromptedForNotifications: Bool { get { return Airship.push.userPromptedForNotifications } } @objc public var defaultPresentationOptions: UNNotificationPresentationOptions { set { Airship.push.defaultPresentationOptions = newValue } get { return Airship.push.defaultPresentationOptions } } @objc public func enableUserPushNotifications() async -> Bool { return await Airship.push.enableUserPushNotifications() } @objc @MainActor public var isPushNotificationsOptedIn: Bool { get { return Airship.push.isPushNotificationsOptedIn } } #if !os(watchOS) /// Sets the badge number. /// - Parameters: /// - badge: The badge to set @objc public func setBadgeNumber(_ badge: Int) async throws { try await Airship.push.setBadgeNumber(badge) } /// Gets the badge number. @objc @MainActor public var badgeNumber: Int { get { return Airship.push.badgeNumber } } /// Enables/disables auto badge. @objc public var autobadgeEnabled: Bool { set { Airship.push.autobadgeEnabled = newValue } get { return Airship.push.autobadgeEnabled } } /// Resets the badge. @objc @MainActor public func resetBadge() async throws { try await Airship.push.resetBadge() } #endif /// Time Zone for quiet time. If the time zone is not set, the current /// local time zone is returned. @objc public var timeZone: NSTimeZone? { set { Airship.push.timeZone = newValue } get { return Airship.push.timeZone } } /// Enables/Disables quiet time @objc public var quietTimeEnabled: Bool { set { Airship.push.quietTimeEnabled = newValue } get { return Airship.push.quietTimeEnabled } } @objc public func setQuietTimeStartHour( _ startHour: Int, startMinute: Int, endHour: Int, endMinute: Int ) { Airship.push.setQuietTimeStartHour(startHour, startMinute: startMinute, endHour: endHour, endMinute: endMinute) } /// Gets the current push notification status. /// - Parameter completionHandler: The completion handler to call with the notification status. @objc @MainActor public func getNotificationStatus( completionHandler: @escaping (UAPushNotificationStatus) -> Void ) { Task { @MainActor in let status = await Airship.push.notificationStatus completionHandler(UAPushNotificationStatus(status)) } } fileprivate final class Storage: Sendable { @MainActor var registrationDelegate: (any RegistrationDelegate)? @MainActor var pushNotificationDelegate: (any PushNotificationDelegate)? init() {} } } public extension UAAirshipNotifications { /// NSNotification info when enabled feature changed on PrivacyManager. @objc(UAAirshipNotificationReceivedNotificationResponse) final class ReceivedNotificationResponse: NSObject { /// NSNotification name. @objc public static let name = NSNotification.Name( "com.urbanairship.push.received_notification_response" ) } /// NSNotification info when enabled feature changed on PrivacyManager. @objc(UAAirshipNotificationRecievedNotification) final class RecievedNotification: NSObject { /// NSNotification name. @objc public static let name = NSNotification.Name( "com.urbanairship.push.received_notification" ) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Push/UAPushNotificationDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(UIKit) public import UIKit #endif #if canImport(AirshipCore) import AirshipCore #endif /// Protocol to be implemented by push notification clients. All methods are optional. @objc public protocol UAPushNotificationDelegate: Sendable { /// Called when a notification is received in the foreground. /// /// - Parameters: /// - userInfo: The notification info /// - completionHandler: the completion handler to execute when notification processing is complete. @objc func receivedForegroundNotification( _ userInfo: [AnyHashable: Any], completionHandler: @escaping () -> Void ) #if !os(watchOS) /// Called when a notification is received in the background. /// /// - Parameters: /// - userInfo: The notification info /// - completionHandler: the completion handler to execute when notification processing is complete. @objc func receivedBackgroundNotification( _ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) #else /// Called when a notification is received in the background. /// /// - Parameters: /// - userInfo: The notification info /// - completionHandler: the completion handler to execute when notification processing is complete. @objc func receivedBackgroundNotification( _ userInfo: [AnyHashable: Any], completionHandler: @escaping (WKBackgroundFetchResult) -> Void ) #endif #if !os(tvOS) /// Called when a notification is received in the background or foreground and results in a user interaction. /// User interactions can include launching the application from the push, or using an interactive control on the notification interface /// such as a button or text field. /// /// - Parameters: /// - notificationResponse: UNNotificationResponse object representing the user's response /// to the notification and the associated notification contents. /// /// - completionHandler: the completion handler to execute when processing the user's response has completed. @objc func receivedNotificationResponse( _ notificationResponse: UNNotificationResponse, completionHandler: @escaping () -> Void ) #endif /// Called when a notification has arrived in the foreground and is available for display. /// /// - Parameters: /// - options: The notification presentation options. /// - notification: The notification. /// - completionHandler: The completion handler. @objc(extendPresentationOptions:notification:completionHandler:) func extendPresentationOptions( _ options: UNNotificationPresentationOptions, notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) } @MainActor final class UAPushNotificationDelegateWrapper: NSObject, PushNotificationDelegate { weak var forwardDelegate: (any UAPushNotificationDelegate)? init(_ forwardDelegate: any UAPushNotificationDelegate) { self.forwardDelegate = forwardDelegate } func receivedForegroundNotification(_ userInfo: [AnyHashable : Any]) async { await withCheckedContinuation { [forwardDelegate] continuation in forwardDelegate?.receivedForegroundNotification(userInfo) { continuation.resume() } } } #if !os(watchOS) func receivedBackgroundNotification(_ userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult { await withCheckedContinuation { [forwardDelegate] continuation in forwardDelegate?.receivedBackgroundNotification(userInfo) { result in continuation.resume(returning: result) } } } #else func receivedBackgroundNotification(_ userInfo: [AnyHashable : Any]) async -> WKBackgroundFetchResult { await withCheckedContinuation { [forwardDelegate] continuation in forwardDelegate?.receivedBackgroundNotification(userInfo) { result in continuation.resume(returning: result) } } } #endif #if !os(tvOS) func receivedNotificationResponse(_ notificationResponse: UNNotificationResponse) async { await withCheckedContinuation { [forwardDelegate] continuation in forwardDelegate?.receivedNotificationResponse(notificationResponse) { continuation.resume() } } } #endif func extendPresentationOptions(_ options: UNNotificationPresentationOptions, notification: UNNotification) async -> UNNotificationPresentationOptions { await withCheckedContinuation { [forwardDelegate] continuation in forwardDelegate?.extendPresentationOptions(options, notification: notification) { result in continuation.resume(returning: result) } } } @MainActor public func receivedForegroundNotification( _ userInfo: [AnyHashable: Any], completionHandler: @escaping () -> Void ) { guard let forwardDelegate else { completionHandler() return } forwardDelegate.receivedForegroundNotification(userInfo, completionHandler: completionHandler) } #if !os(watchOS) @MainActor public func receivedBackgroundNotification( _ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { guard let forwardDelegate else { completionHandler(.noData) return } forwardDelegate.receivedBackgroundNotification(userInfo, completionHandler: completionHandler) } #else public func receivedBackgroundNotification( _ userInfo: [AnyHashable: Any], completionHandler: @escaping (WKBackgroundFetchResult) -> Void ) { guard let forwardDelegate else { completionHandler(.noData) return } forwardDelegate.receivedBackgroundNotification(userInfo, completionHandler: completionHandler) } #endif #if !os(tvOS) @MainActor public func receivedNotificationResponse( _ notificationResponse: UNNotificationResponse, completionHandler: @escaping () -> Void ) { guard let forwardDelegate else { completionHandler() return } forwardDelegate.receivedNotificationResponse(notificationResponse, completionHandler: completionHandler) } #endif @MainActor public func extendPresentationOptions( _ options: UNNotificationPresentationOptions, notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { guard let forwardDelegate else { completionHandler(options) return } forwardDelegate.extendPresentationOptions(options, notification: notification, completionHandler: completionHandler) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Push/UAPushNotificationStatus.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Push notification status @objc public final class UAPushNotificationStatus: NSObject, Sendable { /// If user notifications are enabled on AirshipPush. @objc public let isUserNotificationsEnabled: Bool /// If notifications are either ephemeral or granted and has at least one authorized type. @objc public let areNotificationsAllowed: Bool /// If the push feature is enabled on `AirshipPrivacyManager`. @objc public let isPushPrivacyFeatureEnabled: Bool /// If a push token is generated. @objc public let isPushTokenRegistered: Bool /// Display notification permission status @objc public let notificationPermissionStatus: UAPermissionStatus /// If isUserNotificationsEnabled, isPushPrivacyFeatureEnabled, and areNotificationsAllowed are all true. @objc public let isUserOptedIn: Bool /// If isUserOptedIn and isPushTokenRegistered are both true. @objc public let isOptedIn: Bool init(_ status: AirshipNotificationStatus) { self.isUserNotificationsEnabled = status.isUserNotificationsEnabled self.areNotificationsAllowed = status.areNotificationsAllowed self.isPushPrivacyFeatureEnabled = status.isPushPrivacyFeatureEnabled self.isPushTokenRegistered = status.isPushTokenRegistered self.notificationPermissionStatus = UAPermissionStatus(status.displayNotificationStatus) self.isUserOptedIn = status.isUserOptedIn self.isOptedIn = status.isOptedIn } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Push/UARegistrationDelegate.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import UserNotifications #if canImport(AirshipCore) import AirshipCore #endif /// Implement this protocol and add as a Push.registrationDelegate to receive /// registration success and failure callbacks. /// @objc public protocol UARegistrationDelegate { #if !os(tvOS) /// Called when APNS registration completes. /// /// - Parameters: /// - authorizedSettings: The settings that were authorized at the time of registration. /// - categories: Set of the categories that were most recently registered. /// - status: The authorization status. @objc func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: UAAuthorizedNotificationSettings, categories: Set<UNNotificationCategory>, status: UNAuthorizationStatus ) #endif /// Called when APNS registration completes. /// /// - Parameters: /// - authorizedSettings: The settings that were authorized at the time of registration. /// - status: The authorization status. @objc func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: UAAuthorizedNotificationSettings, status: UNAuthorizationStatus ) /// Called when notification authentication changes with the new authorized settings. /// /// - Parameter authorizedSettings: AirshipAuthorizedNotificationSettings The newly changed authorized settings. @objc func notificationAuthorizedSettingsDidChange( _ authorizedSettings: UAAuthorizedNotificationSettings ) /// Called when the UIApplicationDelegate's application:didRegisterForRemoteNotificationsWithDeviceToken: /// delegate method is called. /// /// - Parameter deviceToken: The APNS device token. @objc func apnsRegistrationSucceeded( withDeviceToken deviceToken: Data ) /// Called when the UIApplicationDelegate's application:didFailToRegisterForRemoteNotificationsWithError: /// delegate method is called. /// /// - Parameter error: An NSError object that encapsulates information why registration did not succeed. @objc func apnsRegistrationFailedWithError(_ error: any Error) } final class UARegistrationDelegateWrapper: NSObject, RegistrationDelegate { weak var forwardDelegate: (any UARegistrationDelegate)? init(_ forwardDelegate: any UARegistrationDelegate) { self.forwardDelegate = forwardDelegate } public func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, categories: Set<UNNotificationCategory>, status: UNAuthorizationStatus ) { self.forwardDelegate?.notificationRegistrationFinished( withAuthorizedSettings: authorizedSettings.asUAAuthorizedNotificationSettings, categories: categories, status: status ) } public func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, status: UNAuthorizationStatus ) { self.forwardDelegate?.notificationRegistrationFinished( withAuthorizedSettings: authorizedSettings.asUAAuthorizedNotificationSettings, status: status ) } public func apnsRegistrationSucceeded( withDeviceToken deviceToken: Data ) { self.forwardDelegate?.apnsRegistrationSucceeded(withDeviceToken: deviceToken) } public func apnsRegistrationFailedWithError(_ error: any Error) { self.forwardDelegate?.apnsRegistrationFailedWithError(error) } public func notificationAuthorizedSettingsDidChange(_ authorizedSettings: AirshipAuthorizedNotificationSettings) { self.forwardDelegate?.notificationAuthorizedSettingsDidChange( authorizedSettings.asUAAuthorizedNotificationSettings ) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Subscription Lists/UAScopedSubscriptionListEditor.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Scoped subscription list editor. @objc public class UAScopedSubscriptionListEditor: NSObject { var editor: ScopedSubscriptionListEditor? /** * Subscribes to a list. * - Parameters: * - subscriptionListID: The subscription list identifier. * - scope: Defines the channel types that the change applies to. */ @objc(subscribe:scope:) public func subscribe(_ subscriptionListID: String, scope: UAChannelScope) { self.editor?.subscribe(subscriptionListID, scope: scope.toChannelScope) } /** * Unsubscribes from a list. * - Parameters: * - subscriptionListID: The subscription list identifier. * - scope: Defines the channel types that the change applies to. */ @objc(unsubscribe:scope:) public func unsubscribe(_ subscriptionListID: String, scope: UAChannelScope) { self.editor?.unsubscribe(subscriptionListID, scope: scope.toChannelScope) } /** * Applies subscription list changes. */ @objc public func apply() { self.editor?.apply() } } @objc /// Channel scope. public enum UAChannelScope: Int, Sendable, Equatable { /** * App channels - amazon, android, iOS */ case app /** * Web channels */ case web /** * Email channels */ case email /** * SMS channels */ case sms var toChannelScope: ChannelScope { return switch(self) { case .app: .app case .web: .web case .email: .email case .sms: .sms } } } extension ChannelScope { var toUAChannelScope: UAChannelScope { return switch(self) { case .app: .app case .web: .web case .email: .email case .sms: .sms #if canImport(AirshipCore) @unknown default: .app #endif } } } ================================================ FILE: Airship/AirshipObjectiveC/Source/Subscription Lists/UASubscriptionListEditor.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Subscription list editor. @objc public class UASubscriptionListEditor: NSObject { var editor: SubscriptionListEditor? /** * Subscribes to a list. * - Parameters: * - subscriptionListID: The subscription list identifier. */ @objc(subscribe:) public func subscribe(_ subscriptionListID: String) { self.editor?.subscribe(subscriptionListID) } /** * Unsubscribes from a list. * - Parameters: * - subscriptionListID: The subscription list identifier. */ @objc(unsubscribe:) public func unsubscribe(_ subscriptionListID: String) { self.editor?.unsubscribe(subscriptionListID) } /** * Applies subscription list changes. */ @objc public func apply() { self.editor?.apply() } } ================================================ FILE: Airship/AirshipObjectiveC/Source/UAAppIntegration.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(UIKit) public import UIKit #endif #if canImport(AirshipCore) import AirshipCore #endif @preconcurrency import UserNotifications /// Application hooks required by Airship. If `automaticSetupEnabled` is enabled /// (enabled by default), Airship will automatically integrate these calls into /// the application by swizzling methods. If `automaticSetupEnabled` is disabled, /// the application must call through to every method provided by this class. @objc @MainActor public final class UAAppIntegration: NSObject { #if !os(watchOS) /** * Must be called by the UIApplicationDelegate's * application:performFetchWithCompletionHandler:. * * - Parameters: * - application: The application * - completionHandler: The completion handler. */ @objc(application:performFetchWithCompletionHandler:) public class func application( _ application: UIApplication, performFetchWithCompletionHandler completionHandler: @Sendable @escaping ( UIBackgroundFetchResult ) -> Void ) { AppIntegration.application(application, performFetchWithCompletionHandler: completionHandler) } /** * Must be called by the UIApplicationDelegate's * application:didRegisterForRemoteNotificationsWithDeviceToken:. * * - Parameters: * - application: The application * - deviceToken: The device token. */ @objc(application:didRegisterForRemoteNotificationsWithDeviceToken:) public class func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { AppIntegration.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) } /** * Must be called by the UIApplicationDelegate's * application:didFailToRegisterForRemoteNotificationsWithError:. * * - Parameters: * - application: The application * - error: The error. */ @objc public class func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error ) { AppIntegration.application(application, didFailToRegisterForRemoteNotificationsWithError: error) } /** * Must be called by the UIApplicationDelegate's * application:didReceiveRemoteNotification:fetchCompletionHandler:. * * - Parameters: * - application: The application * - userInfo: The remote notification. */ @objc(application:didReceiveRemoteNotification:fetchCompletionHandler:) public class func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any] ) async -> UIBackgroundFetchResult { return await AppIntegration.application(application, didReceiveRemoteNotification: userInfo) } #else /** * Must be called by the WKExtensionDelegate's * didRegisterForRemoteNotificationsWithDeviceToken:. * * - Parameters: * - deviceToken: The device token. */ @objc(didRegisterForRemoteNotificationsWithDeviceToken:) public class func didRegisterForRemoteNotificationsWithDeviceToken( deviceToken: Data ) { AppIntegration.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken) } /** * Must be called by the WKExtensionDelegate's * didFailToRegisterForRemoteNotificationsWithError:. * * - Parameters: * - error: The error. */ @objc public class func didFailToRegisterForRemoteNotificationsWithError( error: Error ) { AppIntegration.didFailToRegisterForRemoteNotificationsWithError(error) } /** * Must be called by the WKExtensionDelegate's * didReceiveRemoteNotification:fetchCompletionHandler:. * * - Parameters: * - userInfo: The remote notification. */ @objc(application:didReceiveRemoteNotification:fetchCompletionHandler:) public class func didReceiveRemoteNotification( userInfo: [AnyHashable: Any] ) async -> WKBackgroundFetchResult { return await AppIntegration.didReceiveRemoteNotification(userInfo: userInfo) } #endif /** * Must be called by the UNUserNotificationDelegate's * userNotificationCenter:willPresentNotification:withCompletionHandler. * * - Parameters: * - center: The notification center. * - notification: The notification. */ @objc(userNotificationCenter:willPresentNotification:withCompletionHandler:) public class func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { return await AppIntegration.userNotificationCenter(center, willPresent: notification) } #if !os(tvOS) /** * Must be called by the UNUserNotificationDelegate's * userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler. * * - Parameters: * - center: The notification center. * - response: The notification response. */ @objc(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:) public class func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse ) async { await AppIntegration.userNotificationCenter(center, didReceive: response) } #endif } ================================================ FILE: Airship/AirshipObjectiveC/Source/UAConfig.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// The Config object provides an interface for passing common configurable values to `UAirship`. /// The simplest way to use this class is to add an AirshipConfig.plist file in your app's bundle and set /// the desired options. @objc public class UAConfig: NSObject { var config: AirshipConfig /// The development app key. This should match the application on go.urbanairship.com that is /// configured with your development push certificate. @objc public var developmentAppKey: String? { get { return config.developmentAppKey } set { config.developmentAppKey = newValue } } /// The development app secret. This should match the application on go.urbanairship.com that is /// configured with your development push certificate. @objc public var developmentAppSecret: String? { get { return config.developmentAppSecret } set { config.developmentAppSecret = newValue } } /// The production app key. This should match the application on go.urbanairship.com that is /// configured with your production push certificate. This is used for App Store, Ad-Hoc and Enterprise /// app configurations. @objc public var productionAppKey: String? { get { return config.productionAppKey } set { config.productionAppKey = newValue } } /// The production app secret. This should match the application on go.urbanairship.com that is /// configured with your production push certificate. This is used for App Store, Ad-Hoc and Enterprise /// app configurations. @objc public var productionAppSecret: String? { get { return config.productionAppSecret } set { config.productionAppSecret = newValue } } /// The log level used for development apps. Defaults to `debug`. @objc public var developmentLogLevel: UAAirshipLogLevel { get { return UAHelpers.toLogLevel(level: config.developmentLogLevel) } set { config.developmentLogLevel = UAHelpers.toAirshipLogLevel(level: newValue) } } /// The log level used for production apps. Defaults to `error`. @objc public var productionLogLevel: UAAirshipLogLevel { get { return UAHelpers.toLogLevel(level: config.productionLogLevel) } set { config.productionLogLevel = UAHelpers.toAirshipLogLevel(level: newValue) } } /// Auto pause InAppAutomation on launch. Defaults to `false` @objc public var autoPauseInAppAutomationOnLaunch: Bool { get { return config.autoPauseInAppAutomationOnLaunch } set { config.autoPauseInAppAutomationOnLaunch = newValue } } /// The airship cloud site. Defaults to `us`. @objc public var site: UACloudSite { get { return UAHelpers.toSite(site: config.site) } set { config.site = UAHelpers.toAirshipSite(site: newValue) } } @objc(enabledFeatures) public var enabledFeatures: UAFeature { get { return config.enabledFeatures.asUAFeature } set { config.enabledFeatures = newValue.asAirshipFeature } } /// The default app key. Depending on the `inProduction` status, /// `developmentAppKey` or `productionAppKey` will take priority. @objc public var defaultAppKey: String? { get { return config.defaultAppKey } set { config.defaultAppKey = newValue } } /// The default app secret. Depending on the `inProduction` status, /// `developmentAppSecret` or `productionAppSecret` will take priority. @objc public var defaultAppSecret: String? { get { return config.defaultAppSecret } set { config.defaultAppSecret = newValue } } /// The production status of this application. This may be set directly, or it may be determined /// automatically if the `detectProvisioningMode` flag is set to `true`. /// If neither `inProduction` nor `detectProvisioningMode` is set, /// `detectProvisioningMode` will be enabled. @objc public var inProduction: NSNumber? { get { return if let inProduction = config.inProduction { NSNumber(value: inProduction) } else { nil } } set { config.inProduction = newValue?.boolValue } } /// If enabled, the Airship library automatically registers for remote notifications when push is enabled /// and intercepts incoming notifications in both the foreground and upon launch. /// /// Defaults to `true`. If this is disabled, you will need to register for remote notifications /// in application:didFinishLaunchingWithOptions: and forward all notification-related app delegate /// calls to UAPush and UAInbox. @objc public var isAutomaticSetupEnabled: Bool { get { return config.isAutomaticSetupEnabled } set { config.isAutomaticSetupEnabled = newValue } } /// An array of `UAURLAllowList` entry strings. /// This url allow list is used for validating which URLs can be opened or load the JavaScript native bridge. /// It affects landing pages, the open external URL and wallet actions, /// deep link actions (if a delegate is not set), and HTML in-app messages. /// /// - NOTE: See `UAURLAllowList` for pattern entry syntax. @objc(URLAllowList) public var urlAllowList: [String]? { get { return config.urlAllowList } set { config.urlAllowList = newValue } } /// An array of` UAURLAllowList` entry strings. /// This url allow list is used for validating which URLs can load the JavaScript native bridge, /// It affects Landing Pages, Message Center and HTML In-App Messages. /// /// - NOTE: See `UAURLAllowList` for pattern entry syntax. @objc(URLAllowListScopeJavaScriptInterface) public var urlAllowListScopeJavaScriptInterface: [String]? { get { return config.urlAllowListScopeJavaScriptInterface } set { config.urlAllowListScopeJavaScriptInterface = newValue } } /// An array of UAURLAllowList entry strings. /// This url allow list is used for validating which URLs can be opened. /// It affects landing pages, the open external URL and wallet actions, /// deep link actions (if a delegate is not set), and HTML in-app messages. /// /// - NOTE: See `UAURLAllowList` for pattern entry syntax. @objc(URLAllowListScopeOpenURL) public var urlAllowListScopeOpenURL: [String]? { get { return config.urlAllowListScopeOpenURL } set { config.urlAllowListScopeOpenURL = newValue } } /// The iTunes ID used for Rate App Actions. @objc public var itunesID: String? { get { return config.itunesID } set { config.itunesID = newValue } } /// Toggles Airship analytics. Defaults to `true`. If set to `false`, many Airship features will not be /// available to this application. @objc public var isAnalyticsEnabled: Bool { get { return config.isAnalyticsEnabled } set { config.isAnalyticsEnabled = newValue } } /// The Airship default message center style configuration file. @objc public var messageCenterStyleConfig: String? { get { return config.messageCenterStyleConfig } set { config.messageCenterStyleConfig = newValue } } /// If set to `true`, the Airship user will be cleared if the application is /// restored on a different device from an encrypted backup. /// /// Defaults to `false`. @objc public var clearUserOnAppRestore: Bool { get { return config.clearUserOnAppRestore } set { config.clearUserOnAppRestore = newValue } } /// If set to `true`, the application will clear the previous named user ID on a /// re-install. Defaults to `false`. @objc public var clearNamedUserOnAppRestore: Bool { get { return config.clearNamedUserOnAppRestore } set { config.clearNamedUserOnAppRestore = newValue } } /// Flag indicating whether channel capture feature is enabled or not. /// /// Defaults to `true`. @objc public var isChannelCaptureEnabled: Bool { get { return config.isChannelCaptureEnabled } set { config.isChannelCaptureEnabled = newValue } } /// Flag indicating whether delayed channel creation is enabled. If set to `true` channel /// creation will not occur until channel creation is manually enabled. /// /// Defaults to `false`. @objc public var isChannelCreationDelayEnabled: Bool { get { return config.isChannelCreationDelayEnabled } set { config.isChannelCreationDelayEnabled = newValue } } /// Flag indicating whether extended broadcasts are enabled. If set to `true` the AirshipReady NSNotification /// will contain additional data: the channel identifier and the app key. /// /// Defaults to `false`. @objc public var isExtendedBroadcastsEnabled: Bool { get { return config.isExtendedBroadcastsEnabled } set { config.isExtendedBroadcastsEnabled = newValue } } /// If set to 'YES', the Airship SDK will request authorization to use /// notifications from the user. Apps that set this flag to `false` are /// required to request authorization themselves. /// /// Defaults to `true`. @objc public var requestAuthorizationToUseNotifications: Bool { get { return config.requestAuthorizationToUseNotifications } set { config.requestAuthorizationToUseNotifications = newValue } } /// If set to `true`, the SDK will wait for an initial remote config instead of falling back on default API URLs. /// /// Defaults to `true`. @objc public var requireInitialRemoteConfigEnabled: Bool { get { return config.requireInitialRemoteConfigEnabled } set { config.requireInitialRemoteConfigEnabled = newValue } } /// The Airship URL used to pull the initial config. This should only be set /// if you are using custom domains that forward to Airship. /// @objc public var initialConfigURL: String? { get { return config.initialConfigURL } set { config.initialConfigURL = newValue } } /// If set to `true`, the SDK will use the preferred locale. Otherwise it will use the current locale. /// /// Defaults to `false`. @objc public var useUserPreferredLocale: Bool { get { return config.useUserPreferredLocale } set { config.useUserPreferredLocale = newValue } } /// Creates an instance using the values set in the `AirshipConfig.plist` file. /// - Returns: A config with values from `AirshipConfig.plist` file. @objc(defaultConfigWithError:) public class func `default`() throws -> UAConfig { return UAConfig( config: try AirshipConfig.default() ) } /** * Creates an instance using the values found in the specified `.plist` file. * - Parameter path: The path of the specified file. * - Returns: A config with values from the specified file. */ @objc public class func fromPlist(contentsOfFile path: String) throws -> UAConfig { return UAConfig( config: try AirshipConfig(fromPlist: path) ) } /// Creates an instance with empty values. /// - Returns: A config with empty values. @objc public class func config() -> UAConfig { return UAConfig(config: AirshipConfig()) } /// Creates an instance using the values set in the `AirshipConfig.plist` file. /// - Returns: A config with values from `AirshipConfig.plist` file. @objc public static func defaultConfig() throws -> UAConfig { return UAConfig(config: try AirshipConfig.default()) } private init(config: AirshipConfig) { self.config = config } /// Validates credentails /// - Parameters: /// - inProduction: To validate production or development credentials @objc public func validateCredentials(inProduction: Bool) throws { try self.config.validateCredentials(inProduction: inProduction) } } @objc /// Represents the possible log levels. public enum UAAirshipLogLevel: Int, Sendable { /** * Undefined log level. */ case undefined = -1 /** * No log messages. */ case none = 0 /** * Log error messages. * * Used for critical errors, parse exceptions and other situations that cannot be gracefully handled. */ case error = 1 /** * Log warning messages. * * Used for API deprecations, invalid setup and other potentially problematic situations. */ case warn = 2 /** * Log informative messages. * * Used for reporting general SDK status. */ case info = 3 /** * Log debugging messages. * * Used for reporting general SDK status with more detailed information. */ case debug = 4 /** * Log detailed verbose messages. * * Used for reporting highly detailed SDK status that can be useful when debugging and troubleshooting. */ case verbose = 5 } @objc /// Represents the possible sites. public enum UACloudSite: Int, Sendable { /// Represents the US cloud site. This is the default value. /// Projects available at go.airship.com must use this value. @objc(UACloudSiteUS) case us = 0 /// Represents the EU cloud site. /// Projects available at go.airship.eu must use this value. @objc(UACloudSiteEU) case eu = 1 } class UAHelpers: NSObject { public static func toLogLevel(level: AirshipLogLevel) -> UAAirshipLogLevel { switch(level){ case AirshipLogLevel.undefined: return UAAirshipLogLevel.undefined case AirshipLogLevel.none: return UAAirshipLogLevel.none case AirshipLogLevel.error: return UAAirshipLogLevel.error case AirshipLogLevel.warn: return UAAirshipLogLevel.warn case AirshipLogLevel.info: return UAAirshipLogLevel.info case AirshipLogLevel.debug: return UAAirshipLogLevel.debug case AirshipLogLevel.verbose: return UAAirshipLogLevel.verbose @unknown default: return UAAirshipLogLevel.undefined } } public static func toAirshipLogLevel(level: UAAirshipLogLevel) -> AirshipLogLevel { switch(level){ case UAAirshipLogLevel.undefined: return AirshipLogLevel.undefined case UAAirshipLogLevel.none: return AirshipLogLevel.none case UAAirshipLogLevel.error: return AirshipLogLevel.error case UAAirshipLogLevel.warn: return AirshipLogLevel.warn case UAAirshipLogLevel.info: return AirshipLogLevel.info case UAAirshipLogLevel.debug: return AirshipLogLevel.debug case UAAirshipLogLevel.verbose: return AirshipLogLevel.verbose default: return AirshipLogLevel.undefined } } public static func toSite(site: CloudSite) -> UACloudSite { switch(site){ case CloudSite.us: return UACloudSite.us case CloudSite.eu: return UACloudSite.eu @unknown default: return UACloudSite.us } } public static func toAirshipSite(site: UACloudSite) -> CloudSite { switch(site){ case UACloudSite.us: return CloudSite.us case UACloudSite.eu: return CloudSite.eu default: return CloudSite.us } } public static func toChannelType(type: ChannelType) -> UAChannelType { switch(type){ case ChannelType.email: return UAChannelType.email case ChannelType.open: return UAChannelType.open case ChannelType.sms: return UAChannelType.sms @unknown default: return UAChannelType.email } } public static func toAirshipChannelType(type: UAChannelType) -> ChannelType { switch(type){ case UAChannelType.email: return ChannelType.email case UAChannelType.open: return ChannelType.open case UAChannelType.sms: return ChannelType.sms default: return ChannelType.email } } } ================================================ FILE: Airship/AirshipObjectiveC/Source/UADeepLinkDelegate.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Protocol to be implemented by deep link handlers. @objc public protocol UADeepLinkDelegate: Sendable { /// Called when a deep link has been triggered from Airship. If implemented, the delegate is responsible for processing the provided url. /// - Parameters: /// - deepLink: The deep link. @MainActor func receivedDeepLink(_ deepLink: URL) async } @MainActor final class UADeepLinkDelegateWrapper: NSObject, DeepLinkDelegate { var forwardDelegate: (any UADeepLinkDelegate)? init(delegate: any UADeepLinkDelegate) { self.forwardDelegate = delegate } public func receivedDeepLink(_ deepLink: URL) async { await self.forwardDelegate?.receivedDeepLink(deepLink) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/UAFeature.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Bindings for `AirshipFeature` @objc public final class UAFeature: NSObject, OptionSet, Sendable { public let rawValue: UInt /// Bindings for `AirshipFeature.inAppAutomation` @objc public static func inAppAutomation() -> UAFeature { return UAFeature(rawValue: AirshipFeature.inAppAutomation.rawValue) } /// Bindings for `AirshipFeature.messageCenter` @objc public static func messageCenter() -> UAFeature { return UAFeature(rawValue: AirshipFeature.messageCenter.rawValue) } /// Bindings for `AirshipFeature.push` @objc public static func push() -> UAFeature { return UAFeature(rawValue: AirshipFeature.push.rawValue) } /// Bindings for `AirshipFeature.analytics` @objc public static func analytics() -> UAFeature { return UAFeature(rawValue: AirshipFeature.analytics.rawValue) } /// Bindings for `AirshipFeature.tagsAndAttributes` @objc public static func tagsAndAttributes() -> UAFeature { return UAFeature(rawValue: AirshipFeature.tagsAndAttributes.rawValue) } /// Bindings for `AirshipFeature.contacts` @objc public static func contacts() -> UAFeature { return UAFeature(rawValue: AirshipFeature.contacts.rawValue) } /// Bindings for `AirshipFeature.featureFlags` @objc public static func featureFlags() -> UAFeature { return UAFeature(rawValue: AirshipFeature.featureFlags.rawValue) } /// All features @objc public static func all() -> UAFeature { return UAFeature(rawValue: AirshipFeature.all.rawValue) } @objc public static func none() -> UAFeature { return UAFeature(rawValue: 0) } @objc public convenience init(from: [UAFeature]) { self.init(from) } @objc(contains:) public func _contains(_ feature: UAFeature) -> Bool { return self.contains(feature) } public init(rawValue: UInt) { self.rawValue = rawValue } public override var hash: Int { return Int(rawValue) } public override func isEqual(_ object: Any?) -> Bool { guard let that = object as? UAFeature else { return false } return rawValue == that.rawValue } } extension UAFeature { var asAirshipFeature: AirshipFeature { return AirshipFeature(rawValue: self.rawValue) } } extension AirshipFeature { var asUAFeature: UAFeature { return UAFeature(rawValue: self.rawValue) } } ================================================ FILE: Airship/AirshipObjectiveC/Source/UAPermission.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Airship permissions. Used with `UAPermissionsManager` @objc public enum UAPermission: Int, Sendable { /// Post notifications case displayNotifications = 0 /// Location case location = 1 var airshipPermission: AirshipPermission { switch self { case .displayNotifications: return .displayNotifications case .location: return .location } } init(_ permission: AirshipPermission) { switch permission { case .displayNotifications: self = .displayNotifications case .location: self = .location @unknown default: self = .displayNotifications } } } /// Permission status @objc public enum UAPermissionStatus: Int, Sendable { /// Could not determine the permission status. case notDetermined = 0 /// Permission is granted. case granted = 1 /// Permission is denied. case denied = 2 var airshipStatus: AirshipPermissionStatus { switch self { case .notDetermined: return .notDetermined case .granted: return .granted case .denied: return .denied } } init(_ status: AirshipPermissionStatus) { switch status { case .notDetermined: self = .notDetermined case .granted: self = .granted case .denied: self = .denied @unknown default: self = .notDetermined } } } /// Protocol to be implemented by permission delegates. @objc public protocol UAAirshipPermissionDelegate: Sendable { /// Called when a permission needs to be checked. /// - Parameter completionHandler: The completion handler to call with the permission status. @MainActor func checkPermissionStatus(completionHandler: @escaping (UAPermissionStatus) -> Void) /// Called when a permission should be requested. /// - Note: A permission might be already granted when this method is called. /// - Parameter completionHandler: The completion handler to call with the permission status. @MainActor func requestPermission(completionHandler: @escaping (UAPermissionStatus) -> Void) } /// Internal wrapper to convert UAAirshipPermissionDelegate to AirshipPermissionDelegate final class UAPermissionDelegateWrapper: AirshipPermissionDelegate, @unchecked Sendable { private let forwardDelegate: any UAAirshipPermissionDelegate init(delegate: any UAAirshipPermissionDelegate) { self.forwardDelegate = delegate } @MainActor func checkPermissionStatus() async -> AirshipPermissionStatus { await withCheckedContinuation { continuation in self.forwardDelegate.checkPermissionStatus { status in continuation.resume(returning: status.airshipStatus) } } } @MainActor func requestPermission() async -> AirshipPermissionStatus { await withCheckedContinuation { continuation in self.forwardDelegate.requestPermission { status in continuation.resume(returning: status.airshipStatus) } } } } ================================================ FILE: Airship/AirshipObjectiveC/Source/UAPermissionsManager.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Airship permissions manager. /// /// Airship will provide the default handling for `UAPermission.displayNotifications`. All other permissions will need /// to be configured by the app by providing a `UAAirshipPermissionDelegate` for the given permissions. @objc public final class UAPermissionsManager: NSObject, Sendable { /// Sets a permission delegate. /// /// - Note: The delegate will be strongly retained. /// /// - Parameters: /// - delegate: The delegate. /// - permission: The permission. @objc public func setDelegate( _ delegate: (any UAAirshipPermissionDelegate)?, permission: UAPermission ) { if let delegate = delegate { let wrapper = UAPermissionDelegateWrapper(delegate: delegate) Airship.permissionsManager.setDelegate(wrapper, permission: permission.airshipPermission) } else { Airship.permissionsManager.setDelegate(nil, permission: permission.airshipPermission) } } /// Checks a permission status. /// /// - Note: If no delegate is set for the given permission this will always return `.notDetermined`. /// /// - Parameters: /// - permission: The permission. /// - completionHandler: The completion handler to call with the permission status. @objc @MainActor public func checkPermissionStatus( _ permission: UAPermission, completionHandler: @escaping (UAPermissionStatus) -> Void ) { Task { @MainActor in let status = await Airship.permissionsManager.checkPermissionStatus(permission.airshipPermission) completionHandler(UAPermissionStatus(status)) } } /// Requests a permission. /// /// - Note: If no permission delegate is set for the given permission this will always return `.notDetermined` /// /// - Parameters: /// - permission: The permission. /// - completionHandler: The completion handler to call with the permission status. @objc @MainActor public func requestPermission( _ permission: UAPermission, completionHandler: @escaping (UAPermissionStatus) -> Void ) { Task { @MainActor in let status = await Airship.permissionsManager.requestPermission(permission.airshipPermission) completionHandler(UAPermissionStatus(status)) } } /// Requests a permission with option to enable Airship usage on grant. /// /// - Note: If no permission delegate is set for the given permission this will always return `.notDetermined` /// /// - Parameters: /// - permission: The permission. /// - enableAirshipUsageOnGrant: `true` to allow any Airship features that need the permission to be enabled as well. /// - completionHandler: The completion handler to call with the permission status. @objc @MainActor public func requestPermission( _ permission: UAPermission, enableAirshipUsageOnGrant: Bool, completionHandler: @escaping (UAPermissionStatus) -> Void ) { Task { @MainActor in let status = await Airship.permissionsManager.requestPermission( permission.airshipPermission, enableAirshipUsageOnGrant: enableAirshipUsageOnGrant ) completionHandler(UAPermissionStatus(status)) } } } ================================================ FILE: Airship/AirshipObjectiveC/Source/UAirship.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation #if canImport(UIKit) public import UIKit #endif #if canImport(AirshipCore) import AirshipCore #endif /// Main entry point for Airship. The application must call `takeOff` during `application:didFinishLaunchingWithOptions:` /// before accessing any instances on Airship or Airship modules. @objc public final class UAirship: NSObject, Sendable { private static let storage = Storage() private static let _push: UAPush = UAPush() private static let _channel: UAChannel = UAChannel() private static let _contact: UAContact = UAContact() private static let _privacyManager: UAPrivacyManager = UAPrivacyManager() private static let _messageCenter: UAMessageCenter = UAMessageCenter() private static let _preferenceCenter: UAPreferenceCenter = UAPreferenceCenter() private static let _analytics: UAAnalytics = UAAnalytics() private static let _inAppAutomation: UAInAppAutomation = UAInAppAutomation() private static let _permissionsManager: UAPermissionsManager = UAPermissionsManager() /// Asserts that Airship is flying (initalized) public static func assertAirshipIsFlying() { if !Airship.isFlying { assertionFailure("TakeOff must be called before accessing Airship.") } } /// Push instance @objc public static var push: UAPush { assertAirshipIsFlying() return _push } /// Channel instance @objc public static var channel: UAChannel { assertAirshipIsFlying() return _channel } /// Contact instance @objc public static var contact: UAContact { assertAirshipIsFlying() return _contact } /// Analytics instance @objc public static var analytics: UAAnalytics { assertAirshipIsFlying() return _analytics } /// Message Center instance @objc public static var messageCenter: UAMessageCenter { assertAirshipIsFlying() return _messageCenter } /// Preference Center instance @objc public static var preferenceCenter: UAPreferenceCenter { assertAirshipIsFlying() return _preferenceCenter } /// Privacy manager @objc public static var privacyManager: UAPrivacyManager { assertAirshipIsFlying() return _privacyManager } /// In App Automation @objc public static var inAppAutomation: UAInAppAutomation { assertAirshipIsFlying() return _inAppAutomation } /// Permissions manager @objc public static var permissionsManager: UAPermissionsManager { assertAirshipIsFlying() return _permissionsManager } /// A user configurable deep link delegate @MainActor @objc public static var deepLinkDelegate: (any UADeepLinkDelegate)? { get { assertAirshipIsFlying() guard let wrapped = Airship.deepLinkDelegate as? UADeepLinkDelegateWrapper else { return nil } return wrapped.forwardDelegate } set { assertAirshipIsFlying() if let newValue { let wrapper = UADeepLinkDelegateWrapper(delegate: newValue) Airship.deepLinkDelegate = wrapper storage.deepLinkDelegate = wrapper } else { Airship.deepLinkDelegate = nil storage.deepLinkDelegate = nil } } } #if !os(watchOS) /// Initializes Airship. Config will be read from `AirshipConfig.plist`. /// - Parameters: /// - launchOptions: The launch options passed into `application:didFinishLaunchingWithOptions:`. @objc @MainActor @available(*, deprecated, message: "Use Airship.takeOff() instead") public class func takeOff( launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) throws { try Airship.takeOff(launchOptions: launchOptions) } /// Initializes Airship. /// - Parameters: /// - config: The Airship config. /// - launchOptions: The launch options passed into `application:didFinishLaunchingWithOptions:`. @objc @MainActor @available(*, deprecated, message: "Use Airship.takeOff(_:) instead") public class func takeOff( _ config: UAConfig?, launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) throws { try Airship.takeOff(config?.config, launchOptions: launchOptions) } #endif /// Initializes Airship. Config will be read from `AirshipConfig.plist`. @objc @MainActor public class func takeOff() throws { try Airship.takeOff() } /// Initializes Airship. /// - Parameters: /// - config: The Airship config. @objc @MainActor public class func takeOff(_ config: UAConfig?) throws { try Airship.takeOff(config?.config) } @MainActor fileprivate final class Storage { var deepLinkDelegate: (any DeepLinkDelegate)? } /// Processes a deep link. /// For `uairship://` scheme URLs, Airship will handle the deep link internally. /// For other URLs, Airship will forward the deep link to the deep link listener if set. /// - Parameters: /// - url: The deep link. /// - completionHandler: The result. `true` if the link was able to be processed, otherwise `false`. @objc @MainActor public class func processDeepLink( _ url: URL, completionHandler: @escaping (Bool) -> Void ) { Task { @MainActor in let handled = await Airship.processDeepLink(url) completionHandler(handled) } } } /// NSNotificationCenter keys event names @objc public final class UAAirshipNotifications: NSObject { /// Notification when Airship is ready. @objc(UAAirshipNotificationsAirshipReady) public final class UAAirshipReady: NSObject { /// Notification name @objc public static let name = AirshipNotifications.AirshipReady.name /// Airship ready channel ID key. Only available if `extendedBroadcastEnabled` is true in config. @objc public static let channelIDKey = AirshipNotifications.AirshipReady.channelIDKey /// Airship ready app key. Only available if `extendedBroadcastEnabled` is true in config. @objc public static let appKey = AirshipNotifications.AirshipReady.appKey /// Airship ready payload version. Only available if `extendedBroadcastEnabled` is true in config. @objc public static let payloadVersionKey = AirshipNotifications.AirshipReady.payloadVersionKey } /// Notification when channel is created. @objc(UAirshipNotificationChannelCreated) public final class UAirshipNotificationChannelCreated: NSObject { /// Notification name @objc public static let name = AirshipNotifications.ChannelCreated.name /// NSNotification userInfo key to get the channel ID. @objc public static let channelIDKey = AirshipNotifications.ChannelCreated.channelIDKey /// NSNotification userInfo key to get a boolean if the channel is existing or not. @objc public static let isExistingChannelKey = AirshipNotifications.ChannelCreated.isExistingChannelKey } } ================================================ FILE: Airship/AirshipPreferenceCenter/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>$(CURRENT_PROJECT_VERSION)</string> </dict> </plist> ================================================ FILE: Airship/AirshipPreferenceCenter/Source/AirshipPreferenceCenterResources.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Resources for AirshipPreferenceCenter. public final class AirshipPreferenceCenterResources { public static func localizedString(key: String) -> String? { return AirshipLocalizationUtils.localizedString( key, withTable: "UrbanAirship", moduleBundle: AirshipCoreResources.bundle ) } } extension String { var preferenceCenterLocalizedString: String { return AirshipPreferenceCenterResources.localizedString(key: self) ?? self } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/PreferenceCenter.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation import SwiftUI #if canImport(AirshipCore) public import AirshipCore #endif /// Open delegate. @MainActor public protocol PreferenceCenterOpenDelegate { /// Opens the Preference Center with the given ID. /// - Parameters: /// - preferenceCenterID: The preference center ID. /// - Returns: `true` if the preference center was opened, otherwise `false` to fallback to OOTB UI. func openPreferenceCenter(_ preferenceCenterID: String) -> Bool } /// An interface for interacting with Airship's Preference Center. @MainActor public protocol PreferenceCenter: AnyObject, Sendable { /// Called when the Preference Center is requested to be displayed. /// Return `true` if the display was handled, `false` to fall back to default SDK behavior. var onDisplay: (@MainActor @Sendable (_ preferenceCenterID: String) -> Bool)? { get set } /// Open delegate for the Preference Center. var openDelegate: (any PreferenceCenterOpenDelegate)? { get set } /// The theme for the Preference Center. var theme: PreferenceCenterTheme? { get set } /// Loads a Preference Center theme from a plist file. /// - Parameter plist: The name of the plist in the bundle. func setThemeFromPlist(_ plist: String) throws /// Displays the Preference Center with the given ID. /// - Parameter preferenceCenterID: The preference center ID. func display(_ preferenceCenterID: String) /// Opens the Preference Center with the given ID. (Deprecated) @available(*, deprecated, renamed: "display(identifier:)") func open(_ preferenceCenterID: String) /// Returns the configuration of the Preference Center with the given ID. /// - Parameter preferenceCenterID: The preference center ID. func config(preferenceCenterID: String) async throws -> PreferenceCenterConfig /// Returns the configuration of the Preference Center as JSON data with the given ID. /// - Parameter preferenceCenterID: The preference center ID. func jsonConfig(preferenceCenterID: String) async throws -> Data } @MainActor final class DefaultPreferenceCenter: PreferenceCenter { let inputValidator: any AirshipInputValidation.Validator private static let payloadType = "preference_forms" private static let preferenceFormsKey = "preference_forms" private let delegates: Delegates = Delegates() public var onDisplay: (@MainActor @Sendable (_ preferenceCenterID: String) -> Bool)? public var openDelegate: (any PreferenceCenterOpenDelegate)? { get { self.delegates.openDelegate } set { self.delegates.openDelegate = newValue } } private let dataStore: PreferenceDataStore private let privacyManager: any AirshipPrivacyManager private let remoteData: any RemoteDataProtocol private let currentDisplay: AirshipMainActorValue<(any AirshipMainActorCancellable)?> = AirshipMainActorValue(nil) private let _theme: AirshipMainActorValue<PreferenceCenterTheme?> = AirshipMainActorValue(nil) public var theme: PreferenceCenterTheme? { get { self._theme.value } set { self._theme.set(newValue) } } public func setThemeFromPlist(_ plist: String) throws { self.theme = try PreferenceCenterThemeLoader.fromPlist(plist) } init( dataStore: PreferenceDataStore, privacyManager: any AirshipPrivacyManager, remoteData: any RemoteDataProtocol, inputValidator: any AirshipInputValidation.Validator ) { self.dataStore = dataStore self.privacyManager = privacyManager self.remoteData = remoteData self.inputValidator = inputValidator self._theme.set(PreferenceCenterThemeLoader.defaultPlist()) AirshipLogger.info("PreferenceCenter initialized") } public func display(_ preferenceCenterID: String) { let handled: Bool = if let onDisplay { onDisplay(preferenceCenterID) } else if let openDelegate { openDelegate.openPreferenceCenter(preferenceCenterID) } else { false } guard !handled else { AirshipLogger.trace( "Preference center \(preferenceCenterID) display request handled by the app." ) return } Task { await displayDefaultPreferenceCenter(preferenceCenterID) } } @available(*, deprecated, renamed: "display(_:)") public func open(_ preferenceCenterID: String) { self.display(preferenceCenterID) } private func displayDefaultPreferenceCenter(_ preferenceCenterID: String) async { currentDisplay.value?.cancel() AirshipLogger.debug("Opening default preference center UI") do { let display = try displayPreferenceCenter( preferenceCenterID, theme: theme ) self.currentDisplay.set(display) } catch { AirshipLogger.error("Unable to display preference center \(error)") } } public func config(preferenceCenterID: String) async throws -> PreferenceCenterConfig { let data = try await jsonConfig(preferenceCenterID: preferenceCenterID) return try PreferenceCenterDecoder.decodeConfig(data: data) } public func jsonConfig(preferenceCenterID: String) async throws -> Data { let payloads = await self.remoteData.payloads(types: ["preference_forms"]) for payload in payloads { let config = payload.data(key: "preference_forms") as? [[AnyHashable: Any]] let form = config? .compactMap { $0["form"] as? [AnyHashable: Any] } .first(where: { $0["id"] as? String == preferenceCenterID }) if let form = form { return try JSONSerialization.data( withJSONObject: form, options: [] ) } } throw AirshipErrors.error("Preference center not found \(preferenceCenterID)") } } /// Delegates holder so I can keep the executor sendable @MainActor private final class Delegates { var openDelegate: (any PreferenceCenterOpenDelegate)? } extension DefaultPreferenceCenter { @MainActor fileprivate func displayPreferenceCenter( _ preferenceCenterID: String, theme: PreferenceCenterTheme? ) throws -> any AirshipMainActorCancellable { let displayable = AirshipDisplayTarget().prepareDisplay(for: .modal) try displayable.display { _ in return PreferenceCenterViewControllerFactory.makeViewController( view: PreferenceCenterView( preferenceCenterID: preferenceCenterID ), preferenceCenterTheme: theme, dismissAction: { displayable.dismiss() } ) } return AirshipMainActorCancellableBlock { displayable.dismiss() } } } extension DefaultPreferenceCenter { @MainActor func deepLink(_ deepLink: URL) -> Bool { guard deepLink.scheme == Airship.deepLinkScheme, deepLink.host == "preferences", deepLink.pathComponents.count == 2 else { return false } let preferenceCenterID = deepLink.pathComponents[1] self.display(preferenceCenterID) return true } } public extension Airship { /// The shared `PreferenceCenter` instance. `Airship.takeOff` must be called before accessing this instance. static var preferenceCenter: any PreferenceCenter { Airship.requireComponent( ofType: PreferenceCenterComponent.self ).preferenceCenter } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/PreferenceCenterComponent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif /// Actual airship component for PreferenceCenter. Used to hide AirshipComponent methods. final class PreferenceCenterComponent: AirshipComponent { final let preferenceCenter: DefaultPreferenceCenter init(preferenceCenter: DefaultPreferenceCenter) { self.preferenceCenter = preferenceCenter } @MainActor public func deepLink(_ deepLink: URL) -> Bool { return self.preferenceCenter.deepLink(deepLink) } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/PreferenceCenterSDKModule.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(AirshipCore) public import AirshipCore #endif public import Foundation /// AirshipPreferenceCenter module loader. /// @note For internal use only. :nodoc: @objc(UAPreferenceCenterSDKModule) public class PreferenceCenterSDKModule: NSObject, AirshipSDKModule { public let actionsManifest: (any ActionsManifest)? = nil public let components: [any AirshipComponent] public static func load(_ args: AirshiopModuleLoaderArgs) -> (any AirshipSDKModule)? { let preferenceCenter = DefaultPreferenceCenter( dataStore: args.dataStore, privacyManager: args.privacyManager, remoteData: args.remoteData, inputValidator: args.inputValidator ) return PreferenceCenterSDKModule(preferenceCenter) } private init(_ preferenceCenter: DefaultPreferenceCenter) { self.components = [ PreferenceCenterComponent(preferenceCenter: preferenceCenter) ] } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig+ContactManagement.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif public extension PreferenceCenterConfig { /// Contact management item - base container object for contact management in the preference center struct ContactManagementItem: Decodable, Equatable, PreferenceCenterConfigItem, Sendable { /// The contact management item's type. public let type = PreferenceCenterConfigItemType.contactManagement /// The contact management item's identifier. public var id: String /// The contact management item's channel platform - for example: email or sms. public var platform: Platform // The common title and optional description public var display: CommonDisplay // The add prompt public var addChannel: AddChannel? /// The remove prompt public var removeChannel: RemoveChannel? /// The empty message label that's visible when no channels of this type have been added public var emptyMessage: String? /// The section's display conditions. public var conditions: [Condition]? enum CodingKeys: String, CodingKey { case id = "id" case platform = "platform" case display = "display" case emptyMessage = "empty_message" case addChannel = "add" case removeChannel = "remove" case registrationOptions = "registration_options" case conditions = "conditions" } public init( id: String, platform: Platform, display: CommonDisplay, emptyMessage: String? = nil, addChannel: AddChannel? = nil, removeChannel: RemoveChannel? = nil, conditions: [Condition]? = nil ) { self.id = id self.platform = platform self.display = display self.emptyMessage = emptyMessage self.addChannel = addChannel self.removeChannel = removeChannel self.conditions = conditions } enum PlatformType: String, Decodable { case email case sms } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.id = try container.decode(String.self, forKey: .id) let platformType = try container.decode(PlatformType.self, forKey: CodingKeys.platform) switch platformType { case .email: self.platform = .email( try container.decode(Email.self, forKey: .registrationOptions) ) case .sms: self.platform = .sms( try container.decode(SMS.self, forKey: .registrationOptions) ) } 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) } /// Platform public enum Platform: Equatable, Sendable { case sms(SMS) case email(Email) var errorMessages: ErrorMessages? { switch self { case .sms(let sms): return sms.errorMessages case .email(let email): return email.errorMessages } } } /// Pending label that appears after channel list item is added. Resend button appears after interval. public struct PendingLabel: Decodable, Equatable, Sendable { /// 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, Sendable { /// 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 /// Error messages that can result of attempting to add an email address public var errorMessages: ErrorMessages enum CodingKeys: String, CodingKey { case placeholder = "placeholder_text" case properties = "properties" case addressLabel = "address_label" case pendingLabel = "resend" case errorMessages = "error_messages" } public init( placeholder: String?, addressLabel: String, pendingLabel: PendingLabel, properties: AirshipJSON? = nil, errorMessages: ErrorMessages ) { self.placeholder = placeholder self.addressLabel = addressLabel self.pendingLabel = pendingLabel self.properties = properties self.errorMessages = errorMessages } } /// SMS registration options public struct SMS: Decodable, Equatable, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { /// 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, Sendable { 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, Sendable { 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 calling code. Examples: (1, 33, 44) public var countryCode: String /// Country display name. public var displayName: String enum CodingKeys: String, CodingKey { case senderId = "sender_id" case placeholderText = "placeholder_text" case countryCode = "country_calling_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 {} ================================================ FILE: Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterConfig.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) public import AirshipCore #endif /// Preference center config. public struct PreferenceCenterConfig: Decodable, Sendable, Equatable { /// The config's identifier. public var identifier: String /// The config's sections. public var sections: [Section] /// The config's display info. public var display: CommonDisplay? /** * The config's options. */ public var options: Options? public init( identifier: String, sections: [Section], display: CommonDisplay? = nil, options: Options? = nil ) { self.identifier = identifier self.sections = sections self.display = display self.options = options } enum CodingKeys: String, CodingKey { case identifier = "id" case sections = "sections" case display = "display" case options = "options" } /// Config options. public struct Options: Decodable, Sendable, Equatable { /** * The config identifier. */ public var mergeChannelDataToContact: Bool? enum CodingKeys: String, CodingKey { case mergeChannelDataToContact = "merge_channel_data_to_contact" } public init(mergeChannelDataToContact: Bool?) { self.mergeChannelDataToContact = mergeChannelDataToContact } } /// Common display info public struct CommonDisplay: Decodable, Sendable, Equatable { /// Title public var title: String? // Subtitle public var subtitle: String? public init(title: String? = nil, subtitle: String? = nil) { self.title = title self.subtitle = subtitle } enum CodingKeys: String, CodingKey { case title = "name" case subtitle = "description" } } public struct NotificationOptInCondition: Decodable, PreferenceConfigCondition, Sendable { public enum OptInStatus: String, Equatable, Sendable, Codable { case optedIn = "opt_in" case optedOut = "opt_out" } public let type = PreferenceCenterConfigConditionType.notificationOptIn public var optInStatus: OptInStatus enum CodingKeys: String, CodingKey { case optInStatus = "when_status" } public init(optInStatus: OptInStatus) { self.optInStatus = optInStatus } } /** * Typed conditions. */ public enum Condition: Decodable, Equatable, Sendable { case notificationOptIn(NotificationOptInCondition) enum CodingKeys: String, CodingKey { case type = "type" } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(PreferenceCenterConfigConditionType.self, forKey: .type) let singleValueContainer = try decoder.singleValueContainer() switch type { case .notificationOptIn: self = .notificationOptIn( try singleValueContainer.decode( NotificationOptInCondition.self ) ) } } } /// Common section. public struct CommonSection: Decodable, PreferenceCenterConfigSection { /// The section's type. public let type = PreferenceCenterConfigSectionType.common /// The section's identifier. public var id: String /// The section's items. public var items: [Item] /// The section's display info. public var display: CommonDisplay? /// The section's display conditions. public var conditions: [Condition]? public init( id: String, items: [Item], display: CommonDisplay? = nil, conditions: [Condition]? = nil ) { self.id = id self.items = items self.display = display self.conditions = conditions } enum CodingKeys: String, CodingKey { case id = "id" case display = "display" case items = "items" case conditions = "conditions" } } /// Labeled section break info. public struct LabeledSectionBreak: Decodable, PreferenceCenterConfigSection { /// The section's type. public let type = PreferenceCenterConfigSectionType.labeledSectionBreak /// The section's identifier. public var id: String /// The section's display info. public var display: CommonDisplay? /// The section's display conditions. public var conditions: [Condition]? public init( id: String, display: CommonDisplay? = nil, conditions: [Condition]? = nil ) { self.id = id self.display = display self.conditions = conditions } enum CodingKeys: String, CodingKey { case id = "id" case display = "display" case conditions = "conditions" } } /// Contact Management section. public struct ContactManagementSection: Decodable, PreferenceCenterConfigSection { /// The section's type. public let type = PreferenceCenterConfigSectionType.common /// The section's identifier. public var id: String /// The section's items. public var items: [Item] /// The section's display info. public var display: CommonDisplay? /// The section's display conditions. public var conditions: [Condition]? public init( id: String, items: [Item], display: CommonDisplay? = nil, conditions: [Condition]? = nil ) { self.id = id self.items = items self.display = display self.conditions = conditions } enum CodingKeys: String, CodingKey { case id = "id" case display = "display" case items = "items" case conditions = "conditions" } } /// Preference config section. public enum Section: Decodable, Equatable, Sendable { /// Common section case common(CommonSection) /// Labeled section break case labeledSectionBreak(LabeledSectionBreak) enum CodingKeys: String, CodingKey { case type = "type" } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(PreferenceCenterConfigSectionType.self, forKey: .type) let singleValueContainer = try decoder.singleValueContainer() switch type { case .common: self = .common( (try singleValueContainer.decode(CommonSection.self)) ) case .labeledSectionBreak: self = .labeledSectionBreak( (try singleValueContainer.decode(LabeledSectionBreak.self)) ) } } } /// Channel subscription item info. public struct ChannelSubscription: Decodable, Equatable, PreferenceCenterConfigItem { /// The item's type. public let type = PreferenceCenterConfigItemType.channelSubscription /// The item's identifier. public var id: String /// The item's subscription ID. public var subscriptionID: String /// The item's display info. public var display: CommonDisplay? /// The item's display conditions. public var conditions: [Condition]? enum CodingKeys: String, CodingKey { case id = "id" case display = "display" case subscriptionID = "subscription_id" case conditions = "conditions" } public init( id: String, subscriptionID: String, display: CommonDisplay? = nil, conditions: [Condition]? = nil ) { self.id = id self.subscriptionID = subscriptionID self.display = display self.conditions = conditions } } /// Group contact subscription item info. public struct ContactSubscriptionGroup: Decodable, Equatable, PreferenceCenterConfigItem { /// The item's type. public let type = PreferenceCenterConfigItemType .contactSubscriptionGroup /// The item's identifier. public var id: String /// The item's subscription ID. public var subscriptionID: String /// Components public var components: [Component] /// The item's display info. public var display: CommonDisplay? /// The item's display conditions. public var conditions: [Condition]? enum CodingKeys: String, CodingKey { case id = "id" case display = "display" case subscriptionID = "subscription_id" case conditions = "conditions" case components = "components" } public init( id: String, subscriptionID: String, components: [Component], display: CommonDisplay? = nil, conditions: [Condition]? = nil ) { self.id = id self.subscriptionID = subscriptionID self.components = components self.display = display self.conditions = conditions } /// Contact subscription group component. public struct Component: Decodable, Sendable, Equatable { /// The component's scopes. public var scopes: [ChannelScope] /// The component's display info. public var display: CommonDisplay? enum CodingKeys: String, CodingKey { case scopes = "scopes" case display = "display" } public init( scopes: [ChannelScope], display: CommonDisplay? = nil ) { self.scopes = scopes self.display = display } } } /// Contact subscription item info. public struct ContactSubscription: Decodable, PreferenceCenterConfigItem, Equatable { /// The item's type. public let type = PreferenceCenterConfigItemType.contactSubscription /// The item's identifier. public var id: String /// The item's display info. public var display: CommonDisplay? /// The item's display conditions. public let conditions: [Condition]? /// The item's subscription ID. public var subscriptionID: String /// The item's scopes. public var scopes: [ChannelScope] enum CodingKeys: String, CodingKey { case id = "id" case display = "display" case subscriptionID = "subscription_id" case conditions = "conditions" case scopes = "scopes" } public init( id: String, subscriptionID: String, scopes: [ChannelScope], display: CommonDisplay? = nil, conditions: [Condition]? = nil ) { self.id = id self.subscriptionID = subscriptionID self.scopes = scopes self.display = display self.conditions = conditions } } /// Alert item info. public struct Alert: Decodable, PreferenceCenterConfigItem, Equatable { public let type = PreferenceCenterConfigItemType.alert /// The item's identifier. public let id: String /// The item's display info. public var display: Display? /// The item's display conditions. public var conditions: [Condition]? /// The alert's button. public var button: Button? enum CodingKeys: String, CodingKey { case id = "id" case display = "display" case conditions = "conditions" case button = "button" } public init( id: String, display: Display? = nil, conditions: [Condition]? = nil, button: Button? = nil ) { self.id = id self.display = display self.conditions = conditions self.button = button } /// Alert button info. public struct Button: Decodable, Sendable, Equatable { /// The button's text. public var text: String /// The button's content description. public var contentDescription: String? /// Actions payload to run on tap public var actionJSON: AirshipJSON enum CodingKeys: String, CodingKey { case text = "text" case contentDescription = "content_description" case actionJSON = "actions" } public init( text: String, contentDescription: String? = nil, actionJSON: AirshipJSON = .null ) { self.text = text self.contentDescription = contentDescription self.actionJSON = actionJSON } } /// Alert display info public struct Display: Decodable, Sendable, Equatable { /// Title public var title: String? /// Subtitle public var subtitle: String? /// Icon URL public var iconURL: String? enum CodingKeys: String, CodingKey { case title = "name" case subtitle = "description" case iconURL = "icon" } public init( title: String? = nil, subtitle: String? = nil, iconURL: String? = nil ) { self.title = title self.subtitle = subtitle self.iconURL = iconURL } } } /// 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" } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(PreferenceCenterConfigItemType.self, forKey: .type) let singleValueContainer = try decoder.singleValueContainer() switch type { case .channelSubscription: self = .channelSubscription( (try singleValueContainer.decode(ChannelSubscription.self)) ) case .contactSubscription: self = .contactSubscription( (try singleValueContainer.decode(ContactSubscription.self)) ) case .contactSubscriptionGroup: self = .contactSubscriptionGroup( (try singleValueContainer.decode( ContactSubscriptionGroup.self )) ) case .alert: self = .alert((try singleValueContainer.decode(Alert.self))) case .contactManagement: self = .contactManagement((try singleValueContainer.decode(ContactManagementItem.self))) } } } } /// Condition types public enum PreferenceCenterConfigConditionType: String, Equatable, Sendable, Codable { /// Notification opt-in condition. case notificationOptIn = "notification_opt_in" } /// Condition public protocol PreferenceConfigCondition: Sendable, Equatable { /** * Condition type. */ var type: PreferenceCenterConfigConditionType { get } } /// Item types. public enum PreferenceCenterConfigItemType: String, Equatable, Sendable, Codable { /// Channel subscription type. case channelSubscription = "channel_subscription" /// Contact subscription type. case contactSubscription = "contact_subscription" /// Channel group subscription type. case contactSubscriptionGroup = "contact_subscription_group" /// Alert type. case alert /// Contact management case contactManagement = "contact_management" } /// Preference section item info. public protocol PreferenceCenterConfigItem: Sendable, Identifiable { /// The type. var type: PreferenceCenterConfigItemType { get } /// The identifier. var id: String { get } } /// Preference config section type. public enum PreferenceCenterConfigSectionType: String, Equatable, Sendable, Codable { /// Common section type. case common = "section" /// Labeled section break type. case labeledSectionBreak = "labeled_section_break" } /// Preference config section. public protocol PreferenceCenterConfigSection: Sendable, Equatable, Identifiable { /** * The section's type. */ var type: PreferenceCenterConfigSectionType { get } /** * The section's identifier. */ var id: String { get } } extension PreferenceCenterConfig.Item { var info: any PreferenceCenterConfigItem { switch self { case .channelSubscription(let info): return info case .contactSubscription(let info): return info case .contactSubscriptionGroup(let info): return info case .alert(let info): return info case .contactManagement(let info): return info } } } extension PreferenceCenterConfig.Section { var info: any PreferenceCenterConfigSection { switch self { case .common(let info): return info case .labeledSectionBreak(let info): return info } } } extension PreferenceCenterConfig.Condition { var info: any PreferenceConfigCondition { switch self { case .notificationOptIn(let info): return info } } } extension PreferenceCenterConfig { public func containsChannelSubscriptions() -> 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 == .channelSubscription) }) }) } public func containsContactSubscriptions() -> 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 == .contactSubscription || item.info.type == .contactSubscriptionGroup) }) }) } 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: any 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: any 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: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .notificationOptIn(let condition): try container.encode(condition.type, forKey: .type) try condition.encode(to: encoder) } } } extension PreferenceCenterConfig.CommonSection: Encodable {} extension PreferenceCenterConfig.LabeledSectionBreak: Encodable {} extension PreferenceCenterConfig.Section: Encodable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .common(let section): try container.encode(section.type, forKey: .type) try section.encode(to: encoder) case .labeledSectionBreak(let section): try container.encode(section.type, forKey: .type) try section.encode(to: encoder) } } } extension PreferenceCenterConfig.ChannelSubscription: Encodable {} extension PreferenceCenterConfig.ContactSubscriptionGroup: Encodable {} extension PreferenceCenterConfig.ContactSubscriptionGroup.Component: Encodable {} extension PreferenceCenterConfig.ContactSubscription: Encodable {} extension PreferenceCenterConfig.Alert: Encodable {} extension PreferenceCenterConfig.Alert.Button: Encodable {} extension PreferenceCenterConfig.Alert.Display: Encodable {} extension PreferenceCenterConfig.ContactManagementItem: Encodable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) 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: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .channelSubscription(let item): try container.encode(item.type, forKey: .type) try item.encode(to: encoder) case .contactSubscription(let item): try container.encode(item.type, forKey: .type) try item.encode(to: encoder) case .contactSubscriptionGroup(let item): try container.encode(item.type, forKey: .type) try item.encode(to: encoder) case .alert(let item): try container.encode(item.type, forKey: .type) try item.encode(to: encoder) case .contactManagement(let item): try container.encode(item.type, forKey: .type) try item.encode(to: encoder) } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterDecoder.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif class PreferenceCenterDecoder { private static let decoder: JSONDecoder = { var decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder }() class func decodeConfig(data: Data) throws -> PreferenceCenterConfig { return try self.decoder.decode(PreferenceCenterConfig.self, from: data) } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/data/PreferenceCenterResponse.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if canImport(AirshipCore) import AirshipCore #endif struct PrefrenceCenterResponse: Decodable { let config: PreferenceCenterConfig enum CodingKeys: String, CodingKey { case config = "form" } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterTheme.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(UIKit) import UIKit #endif #if canImport(AppKit) import AppKit #endif #if canImport(AirshipCore) public import AirshipCore #endif /// Preference Center theme public struct PreferenceCenterTheme: Equatable, Sendable { /// View controller theme public var viewController: PreferenceCenterTheme.ViewController? = nil /// Preference center public var preferenceCenter: PreferenceCenterTheme.PreferenceCenter? = nil /// Common section theme public var commonSection: CommonSection? = nil /// Labeled section break theme public var labeledSectionBreak: LabeledSectionBreak? = nil /// Alert theme public var alert: Alert? = nil /// Contact management theme public var contactManagement: ContactManagement? = nil /// Channel subscription item theme public var channelSubscription: ChannelSubscription? = nil /// Contact subscription item theme public var contactSubscription: ContactSubscription? = nil /// Contact subscription group theme public var contactSubscriptionGroup: ContactSubscriptionGroup? = nil /// Navigation bar theme public struct NavigationBar: Equatable, Sendable { /// The default title public var title: String? = nil /// Override the preference center config title. If `false`, preference center will display the config title if exists otherwise the default title /// Defaults to `true` public var overrideConfigTitle: Bool? = true /// Navigation bar background color public var backgroundColor: AirshipNativeColor? = nil /// Navigation bar background color for dark mode public var backgroundColorDark: AirshipNativeColor? = nil /// Navigation bar back button color public var backButtonColor: AirshipNativeColor? = nil /// Navigation bar back button color for dark mode public var backButtonColorDark: AirshipNativeColor? = nil public init( title: String? = nil, overrideConfigTitle: Bool? = true, backgroundColor: AirshipNativeColor? = nil, backgroundColorDark: AirshipNativeColor? = nil, backButtonColor: AirshipNativeColor? = nil, backButtonColorDark: AirshipNativeColor? = nil ) { self.title = title self.overrideConfigTitle = overrideConfigTitle self.backgroundColor = backgroundColor self.backgroundColorDark = backgroundColorDark self.backButtonColor = backButtonColor self.backButtonColorDark = backButtonColorDark } } /// View controller theme public struct ViewController: Equatable, Sendable { /// Navigation bar theme public var navigationBar: NavigationBar? = nil /// Window background color public var backgroundColor: AirshipNativeColor? = nil /// Window background color for dark mode public var backgroundColorDark: AirshipNativeColor? = nil public init( navigationBar: NavigationBar? = nil, backgroundColor: AirshipNativeColor? = nil, backgroundColorDark: AirshipNativeColor? = nil ) { self.navigationBar = navigationBar self.backgroundColor = backgroundColor self.backgroundColorDark = backgroundColorDark } } /// Preference center public struct PreferenceCenter: Equatable, Sendable { /// Subtitle appearance public var subtitleAppearance: TextAppearance? = nil /// The retry button background color public var retryButtonBackgroundColor: Color? = nil /// The retry button background color for dark mode public var retryButtonBackgroundColorDark: Color? = nil /// The retry button label appearance public var retryButtonLabelAppearance: TextAppearance? = nil /// The retry button label public var retryButtonLabel: String? = nil /// The retry message public var retryMessage: String? = nil /// The retry message appearance public var retryMessageAppearance: TextAppearance? = nil public init( subtitleAppearance: TextAppearance? = nil, retryButtonBackgroundColor: Color? = nil, retryButtonBackgroundColorDark: Color? = nil, retryButtonLabelAppearance: TextAppearance? = nil, retryButtonLabel: String? = nil, retryMessage: String? = nil, retryMessageAppearance: TextAppearance? = nil ) { self.subtitleAppearance = subtitleAppearance self.retryButtonBackgroundColor = retryButtonBackgroundColor self.retryButtonBackgroundColorDark = retryButtonBackgroundColorDark self.retryButtonLabelAppearance = retryButtonLabelAppearance self.retryButtonLabel = retryButtonLabel self.retryMessage = retryMessage self.retryMessageAppearance = retryMessageAppearance } } /// Text appearance public struct TextAppearance: Equatable, Sendable { /// The text font public var font: Font? = nil /// The text color public var color: Color? = nil /// The text color for dark mode public var colorDark: Color? = nil public init( font: Font? = nil, color: Color? = nil, colorDark: Color? = nil ) { self.font = font self.color = color self.colorDark = colorDark } } /// Chip theme for contact subscription groups public struct Chip: Equatable, Sendable { /// The check color public var checkColor: Color? = nil /// The check color for dark mode public var checkColorDark: Color? = nil /// Border color around the full chip and check area public var borderColor: Color? = nil /// Border color around the full chip and check area for dark mode public var borderColorDark: Color? = nil /// Chip label appearance public var labelAppearance: TextAppearance? = nil public init( checkColor: Color? = nil, checkColorDark: Color? = nil, borderColor: Color? = nil, borderColorDark: Color? = nil, labelAppearance: TextAppearance? = nil ) { self.checkColor = checkColor self.checkColorDark = checkColorDark self.borderColor = borderColor self.borderColorDark = borderColorDark self.labelAppearance = labelAppearance } } /// Common section theme public struct CommonSection: Equatable, Sendable { /// Title appearance public var titleAppearance: TextAppearance? = nil /// Subtitle appearance public var subtitleAppearance: TextAppearance? = nil public init( titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance } } /// Labeled section break theme public struct LabeledSectionBreak: Equatable, Sendable { /// Title appearance public var titleAppearance: TextAppearance? = nil /// Background color public var backgroundColor: Color? = nil /// Background color for dark mode public var backgroundColorDark: Color? = nil public init( titleAppearance: TextAppearance? = nil, backgroundColor: Color? = nil, backgroundColorDark: Color? = nil ) { self.titleAppearance = titleAppearance self.backgroundColor = backgroundColor self.backgroundColorDark = backgroundColorDark } } /// Alert item theme public struct Alert: Equatable, Sendable { /// Title appearance public var titleAppearance: TextAppearance? = nil /// Subtitle appearance public var subtitleAppearance: TextAppearance? = nil /// Button label appearance public var buttonLabelAppearance: TextAppearance? = nil /// Button background color public var buttonBackgroundColor: Color? = nil /// Button background color for dark mode public var buttonBackgroundColorDark: Color? = nil public init( titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, buttonLabelAppearance: TextAppearance? = nil, buttonBackgroundColor: Color? = nil, buttonBackgroundColorDark: Color? = nil ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.buttonLabelAppearance = buttonLabelAppearance self.buttonBackgroundColor = buttonBackgroundColor self.buttonBackgroundColorDark = buttonBackgroundColorDark } } /// Contact management item theme public struct ContactManagement: Equatable, Sendable { /// Background color public var backgroundColor: Color? = nil /// Background color for dark mode public var backgroundColorDark: 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 /// Text field placeholder appearance public var textFieldTextAppearance: TextAppearance? = nil /// Text field placeholder appearance public var textFieldPlaceholderAppearance: TextAppearance? = nil /// Button label appearance public var buttonLabelAppearance: TextAppearance? = nil /// Button background color public var buttonBackgroundColor: Color? = nil /// Button background color for dark mode public var buttonBackgroundColorDark: Color? = nil /// Destructive button background color - used submit button background color when removing channels public var buttonDestructiveBackgroundColor: Color? = nil /// Destructive button background color for dark mode public var buttonDestructiveBackgroundColorDark: Color? = nil public init( backgroundColor: Color? = nil, backgroundColorDark: Color? = nil, titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, listTitleAppearance: TextAppearance? = nil, listSubtitleAppearance: TextAppearance? = nil, errorAppearance: TextAppearance? = nil, textFieldTextAppearance: TextAppearance? = nil, textFieldPlaceholderAppearance: TextAppearance? = nil, buttonLabelAppearance: TextAppearance? = nil, buttonBackgroundColor: Color? = nil, buttonBackgroundColorDark: Color? = nil, buttonDestructiveBackgroundColor: Color? = nil, buttonDestructiveBackgroundColorDark: Color? = nil ) { self.backgroundColor = backgroundColor self.backgroundColorDark = backgroundColorDark self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.listTitleAppearance = listTitleAppearance self.listSubtitleAppearance = listSubtitleAppearance self.errorAppearance = errorAppearance self.textFieldTextAppearance = textFieldTextAppearance self.textFieldPlaceholderAppearance = textFieldPlaceholderAppearance self.buttonLabelAppearance = buttonLabelAppearance self.buttonBackgroundColor = buttonBackgroundColor self.buttonBackgroundColorDark = buttonBackgroundColorDark self.buttonDestructiveBackgroundColor = buttonDestructiveBackgroundColor self.buttonDestructiveBackgroundColorDark = buttonDestructiveBackgroundColorDark } } /// Channel subscription item theme public struct ChannelSubscription: Equatable, Sendable { /// Title appearance public var titleAppearance: TextAppearance? = nil /// 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 /// Toggle tint color for dark mode public var toggleTintColorDark: Color? = nil public init( titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, emptyTextAppearance: TextAppearance? = nil, toggleTintColor: Color? = nil, toggleTintColorDark: Color? = nil ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.emptyTextAppearance = emptyTextAppearance self.toggleTintColor = toggleTintColor self.toggleTintColorDark = toggleTintColorDark } } /// Contact subscription item theme public struct ContactSubscription: Equatable, Sendable { /// Title appearance public var titleAppearance: TextAppearance? = nil /// Subtitle appearance public var subtitleAppearance: TextAppearance? = nil /// Toggle tint color public var toggleTintColor: Color? = nil /// Toggle tint color for dark mode public var toggleTintColorDark: Color? = nil public init( titleAppearance: TextAppearance? = nil, subtitleAppearance: TextAppearance? = nil, toggleTintColor: Color? = nil, toggleTintColorDark: Color? = nil ) { self.titleAppearance = titleAppearance self.subtitleAppearance = subtitleAppearance self.toggleTintColor = toggleTintColor self.toggleTintColorDark = toggleTintColorDark } } /// Contact subscription group item theme public struct ContactSubscriptionGroup: Equatable, Sendable { /// Title appearance public var titleAppearance: TextAppearance? = nil /// Subtitle appearance public var subtitleAppearance: TextAppearance? = nil /// Chip theme public var 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 ) { self.viewController = viewController self.preferenceCenter = preferenceCenter self.commonSection = commonSection self.labeledSectionBreak = labeledSectionBreak self.alert = alert self.contactManagement = contactManagement self.channelSubscription = channelSubscription self.contactSubscription = contactSubscription self.contactSubscriptionGroup = contactSubscriptionGroup } } struct PreferenceCenterThemeKey: EnvironmentKey { static let defaultValue = PreferenceCenterTheme() } extension EnvironmentValues { /// Airship preference theme environment value public var airshipPreferenceCenterTheme: PreferenceCenterTheme { get { self[PreferenceCenterThemeKey.self] } set { self[PreferenceCenterThemeKey.self] = newValue } } } extension View { /// Overrides the preference center theme /// - Parameters: /// - theme: The preference center theme public func preferenceCenterTheme( _ theme: PreferenceCenterTheme )-> some View { environment(\.airshipPreferenceCenterTheme, theme) } } extension PreferenceCenterTheme { /// Loads a preference center theme from a plist file /// - Parameters: /// - plist: The name of the plist in the bundle public static func fromPlist( _ plist: String ) throws -> PreferenceCenterTheme { return try PreferenceCenterThemeLoader.fromPlist(plist) } } extension Color { /** ** 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, primary and secondary colors to AirshipNativeColor #if os(macOS) guard let targetUIColor = AirshipNativeColor(self).usingColorSpace(.sRGB), let primaryUIColor = AirshipNativeColor(.primary).usingColorSpace(.sRGB), let secondaryUIColor = AirshipNativeColor(.secondary).usingColorSpace(.sRGB) else { return self } #else let targetUIColor = AirshipNativeColor(self) let primaryUIColor = AirshipNativeColor(.primary) let secondaryUIColor = AirshipNativeColor(.secondary) #endif /// 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 os(macOS) targetUIColor.getRed(&targetRed, green: &targetGreen, blue: &targetBlue, alpha: &targetAlpha) #else if !targetUIColor.getRed(&targetRed, green: &targetGreen, blue: &targetBlue, alpha: &targetAlpha) { return self } #endif 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(AirshipNativeColor(red: newRed, green: newGreen, blue: newBlue, alpha: newAlpha)) } } struct PreferenceCenterDefaults { #if os(tvOS) static let promptMaxWidth: Double = 800.0 static let promptMinWidth: Double = 270.0 static let chipSpacing: Double = 36.0 static let smallPadding: Double = 5 #elseif os(visionOS) static let promptMaxWidth: Double = 420.0 static let promptMinWidth: Double = 270.0 static let chipSpacing: Double = 30.0 static let smallPadding: Double = 10.0 #else static let promptMaxWidth: Double = 420.0 static let promptMinWidth: Double = 270.0 static let chipSpacing: Double = 8.0 static let smallPadding: Double = 5 #endif static let labeledSectionBreakTitleBackgroundColor: Color = .gray static let buttonDestructiveBackgroundColor = Color.red static let buttonBackgroundColor = AirshipSystemColors.label static let promptBackgroundColor: Color = { #if os(macOS) return Color(NSColor(name: nil) { appearance in return (appearance.isDark ? AirshipColor.resolveNativeColor("#272727") : .controlBackgroundColor) ?? .controlBackgroundColor }) #elseif os(tvOS) return Color(UIColor { trait in return (trait.userInterfaceStyle == .dark ? AirshipColor.resolveNativeColor("#272727") : .black) ?? .white }) #else return Color(UIColor { trait in return (trait.userInterfaceStyle == .dark ? AirshipColor.resolveNativeColor("#272727") : .secondarySystemBackground) ?? .secondarySystemBackground }) #endif }() static let labeledSectionBreakTitleAppearance = PreferenceCenterTheme.TextAppearance( font: .headline, color: Color.black, colorDark: Color.white ) static let sectionTitleAppearance = PreferenceCenterTheme.TextAppearance( font: .title2, color: AirshipSystemColors.label ) static let sectionSubtitleAppearance = PreferenceCenterTheme.TextAppearance( font: .subheadline, color: AirshipSystemColors.label ) static let titleAppearance = PreferenceCenterTheme.TextAppearance( font: .title3, color: AirshipSystemColors.label ) static let subtitleAppearance = PreferenceCenterTheme.TextAppearance( font: .subheadline, color: AirshipSystemColors.label ) static let channelListItemTitleAppearance = PreferenceCenterTheme.TextAppearance( font: .headline, color: AirshipSystemColors.label ) static let channelListItemSubtitleAppearance = PreferenceCenterTheme.TextAppearance( font: .subheadline, color: AirshipSystemColors.secondaryLabel ) static let emptyTextAppearance = PreferenceCenterTheme.TextAppearance( font: .body, color: AirshipSystemColors.label ) static let errorAppearance = PreferenceCenterTheme.TextAppearance( font: .footnote.weight(.medium), color: .red ) static let textFieldTextAppearance = PreferenceCenterTheme.TextAppearance( font: .body, ) static let textFieldPlaceholderAppearance = PreferenceCenterTheme.TextAppearance( font: .body, color: AirshipSystemColors.placeholder ) static let chipLabelAppearance = PreferenceCenterTheme.TextAppearance( font: .headline.weight(.bold), ) static let resendButtonTitleAppearance = PreferenceCenterTheme.TextAppearance( font: .caption.weight(.bold), color: AirshipSystemColors.link ) } internal struct AirshipSystemColors { static let label: Color = .primary static let secondaryLabel: Color = .secondary #if os(macOS) static let placeholder: Color = Color(.placeholderTextColor) static let tertiaryLabel: Color = Color(.tertiaryLabelColor) static let link = Color(.linkColor) static let background = Color(.windowBackgroundColor) static let secondaryBackground = Color(.controlBackgroundColor) static let tertiaryBackground = Color(.underPageBackgroundColor) #elseif os(tvOS) static let placeholder: Color = Color(.placeholderText) static let tertiaryLabel: Color = Color(.tertiaryLabel) static let link = Color(.link) static let background: Color = AirshipColor.systemBackground static let secondaryBackground: Color = AirshipColor.systemBackground static let tertiaryBackground: Color = AirshipColor.systemBackground #else static let placeholder: Color = Color(.placeholderText) static let tertiaryLabel: Color = Color(.tertiaryLabel) static let link: Color = Color(.link) static let background: Color = Color(.systemBackground) static let secondaryBackground: Color = Color(.secondarySystemBackground) static let tertiaryBackground: Color = Color(.tertiarySystemBackground) #endif } #if os(macOS) extension NSAppearance { var isDark: Bool { return bestMatch(from: [.darkAqua, .aqua]) == .darkAqua } } #endif ================================================ FILE: Airship/AirshipPreferenceCenter/Source/theme/PreferenceCenterThemeLoader.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif struct PreferenceCenterThemeLoader { // Existing code remains unchanged static func defaultPlist() -> PreferenceCenterTheme? { if let _ = try? plistPath( file: "AirshipPreferenceCenterTheme", bundle: Bundle.main ) { do { return try fromPlist("AirshipPreferenceCenterTheme") } catch { AirshipLogger.error( "Unable to load preference center theme \(error)" ) } } else if let _ = try? plistPath( file: "AirshipPreferenceCenterStyle", bundle: Bundle.main ) { do { return try fromPlist("AirshipPreferenceCenterStyle") } catch { AirshipLogger.error( "Unable to load preference center theme \(error)" ) } } return nil } static func fromPlist( _ file: String, bundle: Bundle = Bundle.main ) throws -> PreferenceCenterTheme { let path = try plistPath(file: file, bundle: bundle) guard let data = FileManager.default.contents(atPath: path) else { throw AirshipErrors.error("Failed to load contents of theme.") } let decoder = PropertyListDecoder() let config = try decoder.decode(Config.self, from: data) guard config.isEmpty else { return try config.toPreferenceCenterTheme() } let legacy = try decoder.decode(LegacyConfig.self, from: data) return try legacy.toPreferenceCenterTheme() } static func plistPath(file: String, bundle: Bundle) throws -> String { guard let path = bundle.path(forResource: file, ofType: "plist"), FileManager.default.fileExists(atPath: path) else { throw AirshipErrors.error("File not found \(file).") } return path } fileprivate struct LegacyConfig: Decodable { // Existing properties remain unchanged let title: String? let titleFont: FontConfig? let titleColor: String? let navigationBarColor: String? let backgroundColor: String? let tintColor: String? let subtitleFont: FontConfig? let subtitleColor: String? let sectionTextColor: String? let sectionTextFont: FontConfig? let sectionTitleTextColor: String? let sectionTitleTextFont: FontConfig? let sectionSubtitleTextColor: String? let sectionSubtitleTextFont: FontConfig? let sectionBreakTextColor: String? let sectionBreakTextFont: FontConfig? let sectionBreakBackgroundColor: String? let preferenceTextColor: String? let preferenceTextFont: FontConfig? let preferenceTitleTextColor: String? let preferenceTitleTextFont: FontConfig? let preferenceSubtitleTextColor: String? let preferenceSubtitleTextFont: FontConfig? let switchTintColor: String? let preferenceChipTextColor: String? let preferenceChipTextFont: FontConfig? let preferenceChipCheckmarkCheckedBackgroundColor: String? let preferenceChipBorderColor: String? let alertTitleColor: String? let alertTitleFont: FontConfig? let alertSubtitleColor: String? let alertSubtitleFont: FontConfig? let alertButtonBackgroundColor: String? let alertButtonLabelColor: String? let alertButtonLabelFont: FontConfig? // New properties for dark mode let titleColorDark: String? let navigationBarColorDark: String? let backgroundColorDark: String? let tintColorDark: String? let subtitleColorDark: String? let sectionTextColorDark: String? let sectionTitleTextColorDark: String? let sectionSubtitleTextColorDark: String? let sectionBreakTextColorDark: String? let sectionBreakBackgroundColorDark: String? let preferenceTextColorDark: String? let preferenceTitleTextColorDark: String? let preferenceSubtitleTextColorDark: String? let switchTintColorDark: String? let preferenceChipTextColorDark: String? let preferenceChipCheckmarkCheckedBackgroundColorDark: String? let preferenceChipBorderColorDark: String? let alertTitleColorDark: String? let alertSubtitleColorDark: String? let alertButtonBackgroundColorDark: String? let alertButtonLabelColorDark: String? } fileprivate struct Config: Decodable { let viewController: ViewController? let preferenceCenter: PreferenceCenter? let commonSection: CommonSection? let labeledSectionBreak: LabeledSectionBreak? let alert: Alert? let channelSubscription: ChannelSubscription? let contactSubscription: ContactSubscription? let contactSubscriptionGroup: ContactSubscriptionGroup? struct NavigationBar: Decodable { let title: String? let titleFont: FontConfig? let titleColor: String? let titleColorDark: String? let tintColor: String? let tintColorDark: String? let backgroundColor: String? let backgroundColorDark: String? let backButtonColor: String? let backButtonColorDark: String? } struct ViewController: Decodable { let navigationBar: NavigationBar? let backgroundColor: String? let backgroundColorDark: String? } struct PreferenceCenter: Decodable { let subtitleAppearance: TextAppearance? let retryButtonBackgroundColor: String? let retryButtonBackgroundColorDark: String? let retryButtonLabelAppearance: TextAppearance? let retryButtonLabel: String? let retryMessage: String? let retryMessageAppearance: TextAppearance? } struct TextAppearance: Decodable { let font: FontConfig? let color: String? let colorDark: String? } struct Chip: Decodable { let checkColor: String? let checkColorDark: String? let borderColor: String? let borderColorDark: String? let labelAppearance: TextAppearance? } struct CommonSection: Decodable { let titleAppearance: TextAppearance? let subtitleAppearance: TextAppearance? } struct LabeledSectionBreak: Decodable { let titleAppearance: TextAppearance? let backgroundColor: String? let backgroundColorDark: String? } struct Alert: Decodable { let titleAppearance: TextAppearance? let subtitleAppearance: TextAppearance? let buttonLabelAppearance: TextAppearance? let buttonBackgroundColor: String? let buttonBackgroundColorDark: String? } struct ChannelSubscription: Decodable { let titleAppearance: TextAppearance? let subtitleAppearance: TextAppearance? let toggleTintColor: String? let toggleTintColorDark: String? let buttonBackgroundColor: String? let buttonBackgroundColorDark: String? } struct ContactSubscription: Decodable { let titleAppearance: TextAppearance? let subtitleAppearance: TextAppearance? let toggleTintColor: String? let toggleTintColorDark: String? } struct ContactSubscriptionGroup: Decodable { let titleAppearance: TextAppearance? let subtitleAppearance: TextAppearance? let chip: Chip? } } fileprivate struct FontConfig: Decodable { let fontName: String let fontSize: String } } extension PreferenceCenterThemeLoader.FontConfig { fileprivate func toFont() throws -> Font { guard let fontSize = Double( fontSize.trimmingCharacters(in: .whitespaces) ), fontSize > 0.0 else { throw AirshipErrors.error( "Font size must represent a double greater than 0" ) } return Font.custom( fontName.trimmingCharacters(in: .whitespaces), size: fontSize ) } } extension PreferenceCenterThemeLoader.Config.TextAppearance { func toTextAppearance() throws -> PreferenceCenterTheme.TextAppearance { return PreferenceCenterTheme.TextAppearance( font: try self.font?.toFont(), color: self.color?.airshipToColor(), colorDark: self.colorDark?.airshipToColor() ) } } extension PreferenceCenterThemeLoader.Config.Chip { func toChip() throws -> PreferenceCenterTheme.Chip { return PreferenceCenterTheme.Chip( checkColor: self.checkColor?.airshipToColor(), checkColorDark: self.checkColorDark?.airshipToColor(), borderColor: self.borderColor?.airshipToColor(), borderColorDark: self.borderColorDark?.airshipToColor(), labelAppearance: try self.labelAppearance?.toTextAppearance() ) } } extension PreferenceCenterThemeLoader.Config.NavigationBar { func toNavigationBar() throws -> PreferenceCenterTheme.NavigationBar { return PreferenceCenterTheme.NavigationBar( title: self.title, backgroundColor: self.backgroundColor?.airshipHexToNativeColor(), backgroundColorDark: self.backgroundColorDark?.airshipHexToNativeColor(), backButtonColor: self.backButtonColor?.airshipHexToNativeColor(), backButtonColorDark: self.backButtonColorDark?.airshipHexToNativeColor() ) } } extension PreferenceCenterThemeLoader.Config.CommonSection { func toCommonSection() throws -> PreferenceCenterTheme.CommonSection { return PreferenceCenterTheme.CommonSection( titleAppearance: try self.titleAppearance?.toTextAppearance(), subtitleAppearance: try self.subtitleAppearance?.toTextAppearance() ) } } extension PreferenceCenterThemeLoader.Config.LabeledSectionBreak { func toLabeledSectionBreak() throws -> PreferenceCenterTheme.LabeledSectionBreak { return PreferenceCenterTheme.LabeledSectionBreak( titleAppearance: try self.titleAppearance?.toTextAppearance(), backgroundColor: self.backgroundColor?.airshipToColor(), backgroundColorDark: self.backgroundColorDark?.airshipToColor() ) } } extension PreferenceCenterThemeLoader.Config.ChannelSubscription { func toChannelSubscription() throws -> PreferenceCenterTheme.ChannelSubscription { return PreferenceCenterTheme.ChannelSubscription( titleAppearance: try self.titleAppearance?.toTextAppearance(), subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), toggleTintColor: self.toggleTintColor?.airshipToColor(), toggleTintColorDark: self.toggleTintColorDark?.airshipToColor() ) } } extension PreferenceCenterThemeLoader.Config.ContactSubscription { func toContactSubscription() throws -> PreferenceCenterTheme.ContactSubscription { return PreferenceCenterTheme.ContactSubscription( titleAppearance: try self.titleAppearance?.toTextAppearance(), subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), toggleTintColor: self.toggleTintColor?.airshipToColor(), toggleTintColorDark: self.toggleTintColorDark?.airshipToColor() ) } } extension PreferenceCenterThemeLoader.Config.ContactSubscriptionGroup { func toContactSubscriptionGroup() throws -> PreferenceCenterTheme.ContactSubscriptionGroup { return PreferenceCenterTheme.ContactSubscriptionGroup( titleAppearance: try self.titleAppearance?.toTextAppearance(), subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), chip: try self.chip?.toChip() ) } } extension PreferenceCenterThemeLoader.Config.Alert { func toAlert() throws -> PreferenceCenterTheme.Alert { return PreferenceCenterTheme.Alert( titleAppearance: try self.titleAppearance?.toTextAppearance(), subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), buttonLabelAppearance: try self.buttonLabelAppearance?.toTextAppearance(), buttonBackgroundColor: self.buttonBackgroundColor?.airshipToColor(), buttonBackgroundColorDark: self.buttonBackgroundColorDark?.airshipToColor() ) } } extension PreferenceCenterThemeLoader.Config.PreferenceCenter { func toPreferenceCenter() throws -> PreferenceCenterTheme.PreferenceCenter { return PreferenceCenterTheme.PreferenceCenter( subtitleAppearance: try self.subtitleAppearance?.toTextAppearance(), retryButtonBackgroundColor: self.retryButtonBackgroundColor? .airshipToColor(), retryButtonBackgroundColorDark: self.retryButtonBackgroundColorDark? .airshipToColor(), retryButtonLabelAppearance: try self.retryButtonLabelAppearance? .toTextAppearance(), retryButtonLabel: self.retryButtonLabel, retryMessage: self.retryMessage, retryMessageAppearance: try self.retryMessageAppearance? .toTextAppearance() ) } } extension PreferenceCenterThemeLoader.Config.ViewController { func toViewController() throws -> PreferenceCenterTheme.ViewController { return PreferenceCenterTheme.ViewController( navigationBar: try self.navigationBar?.toNavigationBar(), backgroundColor: self.backgroundColor?.airshipHexToNativeColor(), backgroundColorDark: self.backgroundColorDark?.airshipHexToNativeColor() ) } } extension PreferenceCenterThemeLoader.Config { fileprivate var isEmpty: Bool { guard self.viewController == nil else { return false } guard self.preferenceCenter == nil else { return false } guard self.commonSection == nil else { return false } guard self.labeledSectionBreak == nil else { return false } guard self.alert == nil else { return false } guard self.channelSubscription == nil else { return false } guard self.contactSubscription == nil else { return false } guard self.contactSubscriptionGroup == nil else { return false } return true } fileprivate func toPreferenceCenterTheme() throws -> PreferenceCenterTheme { return PreferenceCenterTheme( viewController: try self.viewController?.toViewController(), preferenceCenter: try self.preferenceCenter?.toPreferenceCenter(), commonSection: try self.commonSection?.toCommonSection(), labeledSectionBreak: try self.labeledSectionBreak? .toLabeledSectionBreak(), alert: try self.alert?.toAlert(), channelSubscription: try self.channelSubscription? .toChannelSubscription(), contactSubscription: try self.contactSubscription? .toContactSubscription(), contactSubscriptionGroup: try self.contactSubscriptionGroup? .toContactSubscriptionGroup() ) } } extension PreferenceCenterThemeLoader.LegacyConfig { fileprivate func toPreferenceCenterTheme() throws -> PreferenceCenterTheme { let preferenceTitle = PreferenceCenterTheme.TextAppearance( font: try (self.preferenceTitleTextFont ?? self.preferenceTextFont)? .toFont(), color: (self.preferenceTitleTextColor ?? self.preferenceTextColor)? .airshipToColor(), colorDark: (self.preferenceTitleTextColorDark ?? self.preferenceTextColorDark)? .airshipToColor() ) let preferenceSubtitle = PreferenceCenterTheme.TextAppearance( font: try (self.preferenceSubtitleTextFont ?? self.preferenceTextFont)? .toFont(), color: (self.preferenceSubtitleTextColor ?? self.preferenceTextColor)? .airshipToColor(), colorDark: (self.preferenceSubtitleTextColorDark ?? self.preferenceTextColorDark)? .airshipToColor() ) return PreferenceCenterTheme( viewController: PreferenceCenterTheme.ViewController( navigationBar: PreferenceCenterTheme.NavigationBar( title: self.title, backgroundColor: self.navigationBarColor?.airshipHexToNativeColor(), backgroundColorDark: self.navigationBarColorDark?.airshipHexToNativeColor() ), backgroundColor: self.backgroundColor?.airshipHexToNativeColor(), backgroundColorDark: self.backgroundColorDark?.airshipHexToNativeColor() ), preferenceCenter: PreferenceCenterTheme.PreferenceCenter( subtitleAppearance: PreferenceCenterTheme.TextAppearance( font: try self.subtitleFont?.toFont(), color: self.subtitleColor?.airshipToColor(), colorDark: self.subtitleColorDark?.airshipToColor() ) ), commonSection: PreferenceCenterTheme.CommonSection( titleAppearance: PreferenceCenterTheme.TextAppearance( font: try (self.sectionTitleTextFont ?? self.sectionTextFont)? .toFont(), color: (self.sectionTitleTextColor ?? self.sectionTextColor)? .airshipToColor(), colorDark: (self.sectionTitleTextColorDark ?? self.sectionTextColorDark)? .airshipToColor() ), subtitleAppearance: PreferenceCenterTheme.TextAppearance( font: try (self.sectionSubtitleTextFont ?? self.sectionTextFont)? .toFont(), color: (self.sectionSubtitleTextColor ?? self.sectionTextColor)? .airshipToColor(), colorDark: (self.sectionSubtitleTextColorDark ?? self.sectionTextColorDark)? .airshipToColor() ) ), labeledSectionBreak: PreferenceCenterTheme.LabeledSectionBreak( titleAppearance: PreferenceCenterTheme.TextAppearance( font: try (self.sectionBreakTextFont ?? self.sectionTextFont)? .toFont(), color: (self.sectionBreakTextColor ?? self.sectionTextColor)? .airshipToColor(), colorDark: (self.sectionBreakTextColorDark ?? self.sectionTextColorDark)? .airshipToColor() ), backgroundColor: self.sectionBreakBackgroundColor?.airshipToColor(), backgroundColorDark: self.sectionBreakBackgroundColorDark?.airshipToColor() ), alert: PreferenceCenterTheme.Alert( titleAppearance: PreferenceCenterTheme.TextAppearance( font: try self.alertTitleFont?.toFont(), color: self.alertTitleColor?.airshipToColor(), colorDark: self.alertTitleColorDark?.airshipToColor() ), subtitleAppearance: PreferenceCenterTheme.TextAppearance( font: try self.alertSubtitleFont?.toFont(), color: self.alertSubtitleColor?.airshipToColor(), colorDark: self.alertSubtitleColorDark?.airshipToColor() ), buttonLabelAppearance: PreferenceCenterTheme.TextAppearance( font: try self.alertButtonLabelFont?.toFont(), color: self.alertButtonLabelColor?.airshipToColor(), colorDark: self.alertButtonLabelColorDark?.airshipToColor() ), buttonBackgroundColor: self.alertButtonBackgroundColor? .airshipToColor(), buttonBackgroundColorDark: self.alertButtonBackgroundColorDark? .airshipToColor() ), channelSubscription: PreferenceCenterTheme.ChannelSubscription( titleAppearance: preferenceTitle, subtitleAppearance: preferenceSubtitle, toggleTintColor: self.switchTintColor?.airshipToColor(), toggleTintColorDark: self.switchTintColorDark?.airshipToColor() ), contactSubscription: PreferenceCenterTheme.ContactSubscription( titleAppearance: preferenceTitle, subtitleAppearance: preferenceSubtitle, toggleTintColor: self.switchTintColor?.airshipToColor(), toggleTintColorDark: self.switchTintColorDark?.airshipToColor() ), contactSubscriptionGroup: PreferenceCenterTheme.ContactSubscriptionGroup( titleAppearance: preferenceTitle, subtitleAppearance: preferenceSubtitle, chip: PreferenceCenterTheme.Chip( checkColor: self .preferenceChipCheckmarkCheckedBackgroundColor? .airshipToColor(), checkColorDark: self .preferenceChipCheckmarkCheckedBackgroundColorDark? .airshipToColor(), borderColor: self.preferenceChipBorderColor?.airshipToColor(), borderColorDark: self.preferenceChipBorderColorDark?.airshipToColor(), labelAppearance: PreferenceCenterTheme.TextAppearance( font: try self.preferenceChipTextFont?.toFont(), color: self.preferenceChipTextColor?.airshipToColor(), colorDark: self.preferenceChipTextColorDark?.airshipToColor() ) ) ) ) } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/ChannelSubscriptionView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// The channel subscription item view public struct ChannelSubscriptionView: View { /// The item's config public let item: PreferenceCenterConfig.ChannelSubscription // The preference state @ObservedObject public var state: PreferenceCenterState @Environment(\.airshipChannelSubscriptionViewStyle) private var style @Environment(\.airshipPreferenceCenterTheme) private var preferenceCenterTheme @Environment(\.colorScheme) private var colorScheme @State private var displayConditionsMet: Bool = true public init(item: PreferenceCenterConfig.ChannelSubscription, state: PreferenceCenterState) { self.item = item self.state = state } @ViewBuilder public var body: some View { let isSubscribed = state.makeBinding(channelListID: item.subscriptionID) let configuration = ChannelSubscriptionViewStyleConfiguration( item: self.item, state: self.state, displayConditionsMet: self.displayConditionsMet, preferenceCenterTheme: self.preferenceCenterTheme, isSubscribed: isSubscribed, colorScheme: self.colorScheme ) style.makeBody(configuration: configuration) .preferenceConditions( self.item.conditions, binding: self.$displayConditionsMet ) } } extension View { /// Sets the channel subscription style /// - Parameters: /// - style: The style public func channelSubscriptionStyle<S>(_ style: S) -> some View where S: ChannelSubscriptionViewStyle { self.environment( \.airshipChannelSubscriptionViewStyle, AnyChannelSubscriptionViewStyle(style: style) ) } } /// Channel subscription item view style configuration public struct ChannelSubscriptionViewStyleConfiguration { /// The item's config public let item: PreferenceCenterConfig.ChannelSubscription /// 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 /// The item's subscription binding public let isSubscribed: Binding<Bool> /// Color scheme public let colorScheme: ColorScheme } public protocol ChannelSubscriptionViewStyle: Sendable { associatedtype Body: View typealias Configuration = ChannelSubscriptionViewStyleConfiguration @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension ChannelSubscriptionViewStyle where Self == DefaultChannelSubscriptionViewStyle { /// Default style public static var defaultStyle: Self { return .init() } } /// The default channel subscription view style public struct DefaultChannelSubscriptionViewStyle: ChannelSubscriptionViewStyle { @ViewBuilder @MainActor public func makeBody(configuration: Configuration) -> some View { let item = configuration.item let itemTheme = configuration.preferenceCenterTheme.channelSubscription let colorScheme = configuration.colorScheme let resolvedToggleTintColor: Color? = colorScheme.airshipResolveColor(light: itemTheme?.toggleTintColor, dark: itemTheme?.toggleTintColorDark) if configuration.displayConditionsMet { Toggle(isOn: configuration.isSubscribed) { VStack(alignment: .leading) { if let title = item.display?.title { Text(title) .textAppearance( itemTheme?.titleAppearance, base: PreferenceCenterDefaults.titleAppearance, colorScheme: colorScheme ) .accessibilityAddTraits(.isHeader) } if let subtitle = item.display?.subtitle { Text(subtitle) .textAppearance( itemTheme?.subtitleAppearance, base: PreferenceCenterDefaults.subtitleAppearance, colorScheme: colorScheme ) } } } .toggleStyle(tint: resolvedToggleTintColor) .padding(.trailing, 2) } } } struct AnyChannelSubscriptionViewStyle: ChannelSubscriptionViewStyle { @ViewBuilder private let _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: ChannelSubscriptionViewStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct ChannelSubscriptionViewStyleKey: EnvironmentKey { static let defaultValue = AnyChannelSubscriptionViewStyle( style: .defaultStyle ) } extension EnvironmentValues { var airshipChannelSubscriptionViewStyle: AnyChannelSubscriptionViewStyle { get { self[ChannelSubscriptionViewStyleKey.self] } set { self[ChannelSubscriptionViewStyleKey.self] = newValue } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/CommonSectionView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// Common section item view public struct CommonSectionView: View { /// The section's config public let section: PreferenceCenterConfig.CommonSection /// The preference state @ObservedObject public var state: PreferenceCenterState @Environment(\.airshipCommonSectionViewStyle) private var style @Environment(\.airshipPreferenceCenterTheme) private var preferenceCenterTheme @State private var displayConditionsMet: Bool = true @Environment(\.colorScheme) private var colorScheme private var isLast: Bool init( section: PreferenceCenterConfig.CommonSection, state: PreferenceCenterState, isLast: Bool ) { self.section = section self.state = state self.isLast = isLast } @ViewBuilder public var body: some View { let configuration = CommonSectionViewStyleConfiguration( section: self.section, state: self.state, displayConditionsMet: self.displayConditionsMet, preferenceCenterTheme: self.preferenceCenterTheme, colorScheme: self.colorScheme, isLast: self.isLast ) style.makeBody(configuration: configuration) .preferenceConditions( self.section.conditions, binding: self.$displayConditionsMet ) } } extension View { /// Sets the common section style /// - Parameters: /// - style: The style public func commonSectionViewStyle<S>(_ style: S) -> some View where S: CommonSectionViewStyle { self.environment( \.airshipCommonSectionViewStyle, AnyCommonSectionViewStyle(style: style) ) } } /// Common section style configuration public struct CommonSectionViewStyleConfiguration { /// The section config public let section: PreferenceCenterConfig.CommonSection /// 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 /// The color scheme public let colorScheme: ColorScheme /// Is last section or not public let isLast: Bool } /// Common section view style public protocol CommonSectionViewStyle: Sendable { associatedtype Body: View typealias Configuration = CommonSectionViewStyleConfiguration @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension CommonSectionViewStyle where Self == DefaultCommonSectionViewStyle { /// The default style public static var defaultStyle: Self { return .init() } } /// The default common section view style public struct DefaultCommonSectionViewStyle: CommonSectionViewStyle { @ViewBuilder @MainActor public func makeBody(configuration: Configuration) -> some View { let section = configuration.section let sectionTheme = configuration.preferenceCenterTheme.commonSection let colorScheme = configuration.colorScheme if configuration.displayConditionsMet { VStack(alignment: .leading) { Spacer() if section.display?.title?.isEmpty == false || section.display?.subtitle?.isEmpty == false { VStack(alignment: .leading) { if let title = section.display?.title { Text(title) .textAppearance( sectionTheme?.titleAppearance, base: PreferenceCenterDefaults.sectionTitleAppearance, colorScheme: colorScheme ) .accessibilityAddTraits(.isHeader) } if let subtitle = section.display?.subtitle { Text(subtitle) .textAppearance( sectionTheme?.subtitleAppearance, base: PreferenceCenterDefaults.sectionSubtitleAppearance, colorScheme: colorScheme ) } } .padding(.bottom, PreferenceCenterDefaults.smallPadding) } ForEach(0..<section.items.count, id: \.self) { index in makeItem( section.items[index], state: configuration.state ).airshipApplyIf(index != section.items.count - 1) { $0.padding(.bottom, PreferenceCenterDefaults.smallPadding) } } } .airshipApplyIf(configuration.isLast) { $0.padding(.bottom, PreferenceCenterDefaults.smallPadding) } #if os(tvOS) .focusSection() #endif if !configuration.isLast { Divider().padding(.vertical) } } } @ViewBuilder @MainActor func makeItem( _ item: PreferenceCenterConfig.Item, state: PreferenceCenterState ) -> some View { switch item { case .alert(let item): PreferenceCenterAlertView(item: item, state: state).transition(.opacity) case .channelSubscription(let item): ChannelSubscriptionView(item: item, state: state) case .contactSubscription(let item): ContactSubscriptionView(item: item, state: state) case .contactSubscriptionGroup(let item): ContactSubscriptionGroupView(item: item, state: state) case .contactManagement(let item): PreferenceCenterContactManagementView( item: item, state: state ) } } } struct AnyCommonSectionViewStyle: CommonSectionViewStyle { @ViewBuilder private var _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: CommonSectionViewStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct CommonSectionViewStyleKey: EnvironmentKey { static let defaultValue = AnyCommonSectionViewStyle( style: DefaultCommonSectionViewStyle() ) } extension EnvironmentValues { var airshipCommonSectionViewStyle: AnyCommonSectionViewStyle { get { self[CommonSectionViewStyleKey.self] } set { self[CommonSectionViewStyleKey.self] = newValue } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/ConditionsMonitor.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation #if canImport(AirshipCore) import AirshipCore #endif @MainActor class ConditionsMonitor: ObservableObject { @Published public private(set) var isMet: Bool = true private let conditions: [PreferenceCenterConfig.Condition] private var cancellable: AnyCancellable? init(conditions: [PreferenceCenterConfig.Condition]) { self.conditions = conditions Task { @MainActor [weak self] in self?.updateConditions() } let conditionUpdates = conditions.map { self.conditionUpdates($0) } self.cancellable = Publishers.MergeMany(conditionUpdates) .receive(on: RunLoop.main) .sink { [weak self] _ in Task { @MainActor [weak self] in self?.updateConditions() } } } @MainActor private func updateConditions() { self.isMet = self.checkConditions() } private func conditionUpdates(_ condition: PreferenceCenterConfig.Condition) -> AnyPublisher<Bool, Never> { guard Airship.isFlying else { return Just(true).eraseToAnyPublisher() } switch condition { case .notificationOptIn(_): return Airship.push.notificationStatusPublisher .receive(on: RunLoop.main) .map { status in status.isUserOptedIn } .eraseToAnyPublisher() } } @MainActor private func checkConditions() -> Bool { let conditionResults = self.conditions.map { self.checkCondition($0) } return !conditionResults.contains(false) } @MainActor private func checkCondition(_ condition: PreferenceCenterConfig.Condition) -> Bool { guard Airship.isFlying else { return true } switch condition { case .notificationOptIn(let condition): switch condition.optInStatus { case .optedIn: return Airship.push.isPushNotificationsOptedIn case .optedOut: return !Airship.push.isPushNotificationsOptedIn } } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/ConditionsViewModifier.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct ConditionsViewModifier: ViewModifier { @StateObject var conditionsMonitor: ConditionsMonitor @Binding var binding: Bool @ViewBuilder func body(content: Content) -> some View { content .onReceive(conditionsMonitor.$isMet) { incoming in binding = incoming } } } extension View { @ViewBuilder func preferenceConditions( _ conditions: [PreferenceCenterConfig.Condition]? ) -> some View { self } @MainActor @ViewBuilder func preferenceConditions( _ conditions: [PreferenceCenterConfig.Condition]?, binding: Binding<Bool> ) -> some View { if let conditions = conditions { self.modifier( ConditionsViewModifier( conditionsMonitor: ConditionsMonitor( conditions: conditions ), binding: binding ) ) } else { self } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif public enum AddChannelState { case failedInvalid case failedDefault case succeeded case ready case loading } struct AddChannelPromptView: View, Sendable { // MARK: - Constants private enum Layout { static let standardSpacing: CGFloat = 20 static let buttonTopPadding: CGFloat = 10 static let maxWidth: CGFloat = 500 // Consistent max width for sheets } // MARK: - Environment @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss // MARK: - State @StateObject private var viewModel: AddChannelPromptViewModel @State private var showSuccessAlert = false // MARK: - Initialization init(viewModel: AddChannelPromptViewModel) { _viewModel = StateObject(wrappedValue: viewModel) } // MARK: - Computed Properties private var errorMessage: String? { switch viewModel.state { case .failedInvalid: return viewModel.platform?.errorMessages?.invalidMessage case .failedDefault: return viewModel.platform?.errorMessages?.defaultMessage default: return nil } } private var isLoading: Bool { viewModel.state == .loading } private var isInputValid: Bool { !viewModel.inputText.isEmpty } private var hasError: Bool { viewModel.state == .failedInvalid || viewModel.state == .failedDefault } private var successAlertTitle: String { viewModel.item.onSubmit?.title ?? "Success" } // MARK: - Body var body: some View { #if os(tvOS) // On tvOS, use a simpler structure without NavigationStack promptContentView .interactiveDismissDisabled(isLoading) .airshipOnChangeOf(viewModel.state) { newState in handleStateChange(newState) } .alert( successAlertTitle, isPresented: $showSuccessAlert, presenting: viewModel.item.onSubmit ) { successPrompt in successAlertButton(for: successPrompt) } message: { successPrompt in successAlertMessage(for: successPrompt) } #else NavigationStack { promptContentView .frame(maxWidth: Layout.maxWidth) } .interactiveDismissDisabled(isLoading) .airshipOnChangeOf(viewModel.state) { newState in handleStateChange(newState) } .alert( successAlertTitle, isPresented: $showSuccessAlert, presenting: viewModel.item.onSubmit ) { successPrompt in successAlertButton(for: successPrompt) } message: { successPrompt in successAlertMessage(for: successPrompt) } #endif } // MARK: - View Components @ViewBuilder private var promptContentView: some View { #if os(tvOS) // tvOS: Custom header with title and cancel button VStack(spacing: 0) { // Custom header bar HStack { Text(viewModel.item.display.title) .font(.title2) .fontWeight(.semibold) Spacer() Button("ua_cancel_edit_messages".preferenceCenterLocalizedString) { handleCancellation() } .buttonStyle(.bordered) } .padding() ScrollView { VStack(alignment: .leading, spacing: Layout.standardSpacing) { descriptionSection inputSection errorSection submitButtonSection footerSection Spacer(minLength: Layout.standardSpacing) } .padding() } } .airshipOnChangeOf(viewModel.inputText) { _ in resetErrorStateIfNeeded() } #else // iOS and other platforms: Use navigation bar ScrollView { VStack(alignment: .leading, spacing: Layout.standardSpacing) { descriptionSection inputSection errorSection submitButtonSection footerSection Spacer(minLength: Layout.standardSpacing) } .frame(maxWidth: .infinity) .padding() } .navigationTitle(viewModel.item.display.title) #if !os(macOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { cancelButton } } .airshipOnChangeOf(viewModel.inputText) { _ in resetErrorStateIfNeeded() } #endif } @ViewBuilder private var descriptionSection: some View { if let bodyText = viewModel.item.display.body { Text(bodyText) .textAppearance( viewModel.theme?.subtitleAppearance, base: PreferenceCenterDefaults.sectionSubtitleAppearance, colorScheme: colorScheme ) } } @ViewBuilder private var inputSection: some View { ChannelTextField( platform: viewModel.platform, selectedSender: $viewModel.selectedSender, inputText: $viewModel.inputText, theme: viewModel.theme ) } @ViewBuilder private var errorSection: some View { if let errorMessage = errorMessage { ErrorLabel( message: errorMessage, theme: viewModel.theme ) } } @ViewBuilder private var submitButtonSection: some View { submitButton .padding(.top, Layout.buttonTopPadding) } @ViewBuilder private var footerSection: some View { if let footer = viewModel.item.display.footer { FooterView( text: footer, textAppearance: viewModel.theme?.subtitleAppearance ?? PreferenceCenterDefaults.subtitleAppearance ) .padding(.top, Layout.standardSpacing) } } @ViewBuilder private var submitButton: some View { Button(action: handleSubmission) { HStack { Spacer() if isLoading { ProgressView() .progressViewStyle(CircularProgressViewStyle()) } else { Text(viewModel.item.submitButton.text) } Spacer() } } .buttonStyle(.borderedProminent) #if !os(tvOS) .controlSize(.large) #endif .disabled(isLoading || !isInputValid) .optAccessibilityLabel( string: viewModel.item.submitButton.contentDescription ) } @ViewBuilder private var cancelButton: some View { Button( "ua_cancel_edit_messages".preferenceCenterLocalizedString, systemImage: "xmark" ) { handleCancellation() } .disabled(isLoading) } // MARK: - Alert Components @ViewBuilder private func successAlertButton( for successPrompt: PreferenceCenterConfig.ContactManagementItem.ActionableMessage ) -> some View { Button { handleSuccessCompletion() } label: { Text(successPrompt.button.text) } } @ViewBuilder private func successAlertMessage( for successPrompt: PreferenceCenterConfig.ContactManagementItem.ActionableMessage ) -> some View { if let body = successPrompt.body { Text(body) } } // MARK: - Actions private func handleStateChange(_ newState: AddChannelState) { guard newState == .succeeded else { return } if viewModel.item.onSubmit != nil { showSuccessAlert = true } else { handleSuccessCompletion() } } private func handleSubmission() { viewModel.attemptSubmission() } private func handleCancellation() { viewModel.onCancel() dismiss() } private func handleSuccessCompletion() { viewModel.onSubmit() dismiss() } private func resetErrorStateIfNeeded() { guard hasError else { return } withAnimation { viewModel.state = .ready } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/AddChannelPromptViewModel.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI import Combine /// Imported for Logger and Contact calls #if canImport(AirshipCore) import AirshipCore #endif @MainActor internal class AddChannelPromptViewModel: ObservableObject { let inputValidator: (any AirshipInputValidation.Validator)? @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 private var validatedAddress: String? 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, validator: (any AirshipInputValidation.Validator)? = nil ) { self.item = item self.theme = theme self.platform = registrationOptions self.onCancel = onCancel self.onRegisterSMS = onRegisterSMS self.onRegisterEmail = onRegisterEmail self.selectedSender = .none self.inputValidator = if Airship.isFlying { validator ?? Airship.requireComponent( ofType: PreferenceCenterComponent.self ).preferenceCenter.inputValidator } else { validator } } /// Attempts submission and updates state based on results of attempt @MainActor internal func attemptSubmission() { Task { if platform?.channelType == .sms { await attemptSMSSubmission() } else { await attemptEmailSubmission() } } } @MainActor private func attemptSMSSubmission() async { do { /// Only start to load when we are sure it's not a duplicate failed request onStartLoading() let smsRequest: AirshipInputValidation.Request = .sms( AirshipInputValidation.Request.SMS( rawInput: self.inputText, validationOptions: .sender(senderID: selectedSender.senderId, prefix: selectedSender.countryCode), validationHints: .init(minDigits: 4) ) ) /// Attempt validation call let passedValidation = try await inputValidator?.validateRequest(smsRequest) ?? .invalid if case let .valid(address) = passedValidation { validatedAddress = address 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() } @MainActor private func attemptEmailSubmission() async { onStartLoading() let emailRequest: AirshipInputValidation.Request = .email( AirshipInputValidation.Request.Email( rawInput: self.inputText ) ) do { let passedValidation = try await inputValidator?.validateRequest(emailRequest) ?? .invalid if case let .valid(address) = passedValidation { validatedAddress = address onValidationSucceeded() } else { onValidationFailed() } } catch { AirshipLogger.error(error.localizedDescription) onValidationError() } } internal func onSubmit() { if let platform = platform, let validatedAddress = validatedAddress { switch platform { case .sms(_): onRegisterSMS(validatedAddress, selectedSender.senderId) case .email(_): onRegisterEmail(validatedAddress) } } validatedAddress = nil } @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 } } /// Validates input format for UI feedback (enabling/disabling submit button) @MainActor internal func validateInputFormat() { if let platform = self.platform { // Basic validation to enable/disable submit button // Full validation happens in attemptSubmission 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) isInputFormatValid = emailPredicate.evaluate(with: inputText) case .sms(_): let formattedPhone = inputText.replacingOccurrences(of: "[^0-9]", with: "", options: .regularExpression) isInputFormatValid = formattedPhone.count >= 7 } } else { isInputFormatValid = false } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif struct ChannelListView: View { // MARK: - Constants private enum Layout { static let presentationDetentHeight: CGFloat = 0.5 // medium detent as fraction } // MARK: - Environment @Environment(\.colorScheme) private var colorScheme @Environment(\.airshipPreferenceCenterTheme) private var theme: PreferenceCenterTheme // MARK: - Properties let item: PreferenceCenterConfig.ContactManagementItem @ObservedObject var state: PreferenceCenterState // MARK: - State @State private var selectedChannel: ContactChannel? @State private var showAddChannelSheet = false @State private var showRemoveChannelAlert = false @State private var showResendSuccessAlert = false // MARK: - Computed Properties private var channels: [ContactChannel] { state.channelsList.filter(with: item.platform.channelType) } private var isChannelListEmpty: Bool { channels.isEmpty } private var removeAlertTitle: String { item.removeChannel?.view.display.title ?? "Remove Channel" } private var resendAlertTitle: String { pendingLabelModel?.resendSuccessPrompt?.title ?? "Verification Sent" } private var pendingLabelModel: PreferenceCenterConfig.ContactManagementItem.PendingLabel? { switch item.platform { case .sms(let options): return options.pendingLabel case .email(let options): return options.pendingLabel } } // MARK: - Body var body: some View { VStack(alignment: .leading) { Section { VStack(alignment: .leading) { if isChannelListEmpty { EmptySectionLabel(label: item.emptyMessage) } else { channelListView } if let buttonModel = item.addChannel?.button { addChannelButton(model: buttonModel) } } } header: { headerView } } .sheet(isPresented: $showAddChannelSheet) { addChannelPromptView.presentationDetents([.medium]) } .alert( removeAlertTitle, isPresented: $showRemoveChannelAlert, presenting: selectedChannel ) { channel in removeAlertButtons(for: channel) } message: { _ in removeAlertMessage } .alert( resendAlertTitle, isPresented: $showResendSuccessAlert, presenting: selectedChannel ) { _ in resendAlertButton } message: { _ in resendAlertMessage } } // MARK: - View Components @ViewBuilder private var headerView: some View { HStack { VStack(alignment: .leading) { Text(item.display.title) .textAppearance( theme.contactManagement?.titleAppearance, base: PreferenceCenterDefaults.sectionTitleAppearance, colorScheme: colorScheme ) .accessibilityAddTraits(.isHeader) if let subtitle = item.display.subtitle { Text(subtitle) .textAppearance( theme.contactManagement?.subtitleAppearance, base: PreferenceCenterDefaults.sectionSubtitleAppearance, colorScheme: colorScheme ) } } Spacer() } .accessibilityAddTraits(.isHeader) } @ViewBuilder private var channelListView: some View { ForEach(channels, id: \.self) { channel in ChannelListViewCell( viewModel: ChannelListCellViewModel( channel: channel, pendingLabelModel: pendingLabelModel, onResend: { resend(channel) }, onRemove: { remove(channel) } ) ) #if os(tvOS) .focusSection() #endif } } @ViewBuilder private func addChannelButton(model: PreferenceCenterConfig.ContactManagementItem.LabeledButton) -> some View { HStack { Button { if item.addChannel?.view != nil { showAddChannelSheet = true } } label: { Text(model.text) .textAppearance( theme.contactManagement?.buttonLabelAppearance, colorScheme: colorScheme ) } #if !os(tvOS) .controlSize(.regular) #endif Spacer() } #if os(tvOS) .focusSection() #endif } @ViewBuilder private var addChannelPromptView: some View { if let view = item.addChannel?.view { AddChannelPromptView( viewModel: AddChannelPromptViewModel( item: view, theme: theme.contactManagement, registrationOptions: item.platform, onCancel: { showAddChannelSheet = false }, onRegisterSMS: registerSMS, onRegisterEmail: registerEmail ) ) } } // MARK: - Alert Components @ViewBuilder private func removeAlertButtons(for channel: ContactChannel) -> some View { Button(role: .destructive) { AirshipLogger.info("Removing channel: \(channel.channelType)") Airship.contact.disassociateChannel(channel) selectedChannel = nil } label: { Text(item.removeChannel?.view.submitButton?.text ?? "Remove") } Button(role: .cancel) { selectedChannel = nil } label: { Text(item.removeChannel?.view.cancelButton?.text ?? "Cancel") } } @ViewBuilder private var removeAlertMessage: some View { if let body = item.removeChannel?.view.display.body { Text(body) } } @ViewBuilder private var resendAlertButton: some View { Button { selectedChannel = nil } label: { Text(pendingLabelModel?.resendSuccessPrompt?.button.text ?? "OK") } } @ViewBuilder private var resendAlertMessage: some View { if let body = pendingLabelModel?.resendSuccessPrompt?.body { Text(body) } } // MARK: - Actions private func resend(_ channel: ContactChannel) { selectedChannel = channel Airship.contact.resend(channel) AirshipLogger.info("Resending verification for channel: \(channel.channelType)") showResendSuccessAlert = true } private func remove(_ channel: ContactChannel) { selectedChannel = channel AirshipLogger.info("Showing remove confirmation for channel: \(channel.channelType)") showRemoveChannelAlert = true } private func registerSMS(msisdn: String, sender: String) { let options = SMSRegistrationOptions.optIn(senderID: sender) Airship.contact.registerSMS(msisdn, options: options) AirshipLogger.info("Registered SMS channel: \(msisdn)") } private func registerEmail(email: String) { let options = EmailRegistrationOptions.options(properties: nil, doubleOptIn: true) Airship.contact.registerEmail(email, options: options) AirshipLogger.info("Registered email channel: \(email)") } } // MARK: - Extensions extension PreferenceCenterConfig.ContactManagementItem.Platform { var channelType: ChannelType { switch self { case .sms: return .sms case .email: return .email } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/ChannelListViewCell.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif import Combine @MainActor 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 private var resendLabelHideDelaySeconds: Double { Double(pendingLabelModel?.intervalInSeconds ?? 15) } private var hidePendingLabelTask:Task<Void, Never>? private var hideResendButtonTask:Task<Void, Never>? init(channel: ContactChannel, pendingLabelModel: PreferenceCenterConfig.ContactManagementItem.PendingLabel?, onResend: @escaping () -> (), onRemove: @escaping () -> Void ) { self.channel = channel self.pendingLabelModel = pendingLabelModel self.onResend = onResend self.onRemove = onRemove initializePendingLabel() } private func initializePendingLabel() { let isOptedIn = channel.isOptedIn if isOptedIn { withAnimation { isPendingLabelShowing = false isResendShowing = false } } else { withAnimation { isPendingLabelShowing = true } temporarilyHideResend() } } /// 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, !channel.isOptedIn else { return} withAnimation { self.isResendShowing = true } } } } struct ChannelListViewCell: View { @StateObject private var viewModel: ChannelListCellViewModel @Environment(\.airshipPreferenceCenterTheme) private var theme: PreferenceCenterTheme @Environment(\.colorScheme) private var colorScheme init(viewModel: ChannelListCellViewModel) { _viewModel = StateObject(wrappedValue: viewModel) } @ViewBuilder private var pendingLabelView: some View { HStack(spacing: PreferenceCenterDefaults.smallPadding) { if let pendingText = viewModel.pendingLabelModel?.message { Text(pendingText).textAppearance( theme.contactManagement?.listSubtitleAppearance, base: PreferenceCenterDefaults.channelListItemSubtitleAppearance, colorScheme: colorScheme ) } 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: PreferenceCenterDefaults.resendButtonTitleAppearance, colorScheme: colorScheme ) } } } 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 { SwiftUI.Label { Text(labelText).textAppearance( theme.contactManagement?.listSubtitleAppearance, base: PreferenceCenterDefaults.channelListItemTitleAppearance, colorScheme: colorScheme ) .lineLimit(1) .truncationMode(.middle) } icon: { Image(systemName: iconSystemName) .textAppearance( theme.contactManagement?.listSubtitleAppearance, base: PreferenceCenterDefaults.channelListItemTitleAppearance, colorScheme: colorScheme ) } } @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) { let cellText = channel.maskedAddress 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) { HStack(alignment: .top) { makeCell(channel: viewModel.channel) Spacer() trashButton } } .padding(PreferenceCenterDefaults.smallPadding) } 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 } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/BackgroundShape.swift ================================================ /* 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) } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ChannelTextField.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif // MARK: Channel text field public struct ChannelTextField: View { // MARK: - Constants private enum Layout { #if os(tvOS) static let fieldHeight: CGFloat = 66 // tvOS needs taller fields for focus static let fieldPadding: CGFloat = 12 #else static let fieldHeight: CGFloat = 52 static let fieldPadding: CGFloat = 10 #endif static let fieldCornerRadius: CGFloat = 4 static let stackSpacing: CGFloat = 2 static let standardSpacing: CGFloat = 12 static let placeHolderPadding = EdgeInsets(top: 4, leading: 15, bottom: 4, trailing: 4) } // MARK: - Environment @Environment(\.colorScheme) var colorScheme // MARK: - Properties private var senders: [PreferenceCenterConfig.ContactManagementItem.SMSSenderInfo]? private var platform: PreferenceCenterConfig.ContactManagementItem.Platform? private var smsOptions: PreferenceCenterConfig.ContactManagementItem.SMS? private var emailOptions: PreferenceCenterConfig.ContactManagementItem.Email? // MARK: - State @Binding var selectedSender: PreferenceCenterConfig.ContactManagementItem.SMSSenderInfo @State var selectedSenderID: String = "" @Binding var inputText: String @State private var placeholder: String = "" /// The preference center theme var theme: PreferenceCenterTheme.ContactManagement? public init( platform: PreferenceCenterConfig.ContactManagementItem.Platform?, selectedSender: Binding<PreferenceCenterConfig.ContactManagementItem.SMSSenderInfo>, inputText: Binding<String>, 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 { VStack(spacing: Layout.standardSpacing) { countryPicker VStack { HStack(spacing: Layout.stackSpacing) { textFieldLabel textField } .padding(Layout.fieldPadding) .background(backgroundView) } .frame(height: Layout.fieldHeight) } .frame(maxWidth: .infinity) } @ViewBuilder private var countryPicker: some View { /// Use text field color for picker accent. May want to expose this separately at some point. let pickerAccent = colorScheme.airshipResolveColor(light: theme?.textFieldTextAppearance?.color, dark: theme?.textFieldTextAppearance?.colorDark) if let senders = self.senders, (senders.count >= 1) { HStack { if let smsOptions = smsOptions { Text(smsOptions.countryLabel).textAppearance( theme?.textFieldPlaceholderAppearance, base: PreferenceCenterDefaults.textFieldTextAppearance, colorScheme: colorScheme ) } Spacer() Picker("senders", selection: $selectedSenderID) { ForEach(senders, id: \.self) { Text($0.displayName).textAppearance( theme?.textFieldPlaceholderAppearance, base: PreferenceCenterDefaults.textFieldTextAppearance, colorScheme: colorScheme ).tag($0.senderId) } } .pickerStyle(.menu) .accentColor(pickerAccent ?? AirshipSystemColors.label) .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(.horizontal) .frame(height: Layout.fieldHeight) .background(backgroundView) } } @ViewBuilder private var textField: some View { let textColor = colorScheme.airshipResolveColor( light: theme?.textFieldTextAppearance?.color, dark: theme?.textFieldTextAppearance?.colorDark ) TextField(makePlaceholder(), text: $inputText) .foregroundColor(textColor) .padding(Layout.placeHolderPadding) #if !os(macOS) .keyboardType(keyboardType) #endif } @ViewBuilder private var textFieldLabel: some View { if let smsOptions = smsOptions { Text(smsOptions.msisdnLabel) .textAppearance( theme?.textFieldPlaceholderAppearance, base: PreferenceCenterDefaults.textFieldTextAppearance, colorScheme: colorScheme ) } else if let emailOptions = emailOptions { Text(emailOptions.addressLabel) .textAppearance( theme?.textFieldPlaceholderAppearance, base: PreferenceCenterDefaults.textFieldTextAppearance, colorScheme: colorScheme ) } } @ViewBuilder private var backgroundView: some View { let backgroundColor = colorScheme.airshipResolveColor( light: theme?.backgroundColor, dark: theme?.backgroundColorDark ) ?? AirshipSystemColors.background RoundedRectangle(cornerRadius: Layout.fieldCornerRadius) .foregroundColor(backgroundColor.secondaryVariant(for: colorScheme).opacity(0.2)) } // MARK: Keyboard type #if !os(macOS) private var keyboardType: UIKeyboardType { if let platform = self.platform { switch platform { case .sms(_): return .decimalPad case .email(_): return .emailAddress } } else { return .default } } #endif // 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 } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/EmptySectionLabel.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI struct EmptySectionLabel: View { @Environment(\.colorScheme) private var colorScheme // The empty message var label: String? /// The preference center theme var theme: PreferenceCenterTheme.ChannelSubscription? public var body: some View { if let label = label { SwiftUI.Label { Text(label) .textAppearance( theme?.emptyTextAppearance, base: PreferenceCenterDefaults.subtitleAppearance, colorScheme: colorScheme ) } icon: { Image(systemName: "info.circle") .foregroundColor(.primary.opacity(0.5)) } .padding(PreferenceCenterDefaults.smallPadding) } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/ErrorLabel.swift ================================================ /* Copyright Airship and Contributors */ public import SwiftUI /// Error text view that appears under the add channel fields when an error occurs public struct ErrorLabel: View { @Environment(\.colorScheme) private var colorScheme 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: PreferenceCenterDefaults.errorAppearance, colorScheme: colorScheme ) .lineLimit(2) } } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/Component Views/PreferenceCloseButton.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif 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: () -> Void private let opacity: CGFloat = 0.64 private let height: CGFloat = 24 private let width: CGFloat = 24 private let tappableHeight: CGFloat = 44 private let tappableWidth: CGFloat = 44 private func imageExistsInBundle(name: String) -> Bool { return AirshipNativeImage(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() .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 addPromptBackground(theme: PreferenceCenterTheme.ContactManagement?, colorScheme: ColorScheme) -> some View { let color = colorScheme.airshipResolveColor( light: theme?.backgroundColor, dark: theme?.backgroundColorDark ) self.background( BackgroundShape( color: color ?? PreferenceCenterDefaults.promptBackgroundColor ) ) } @ViewBuilder func addPreferenceCloseButton( dismissButtonColor: Color, dismissIconResource: String, contentDescription: String?, 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) { view in view.accessibilityLabel(contentDescription!) } .zIndex(1) } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/ContactManagementView.swift ================================================ /* Copyright Airship and Contributors */ public 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: Sendable { /// 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<S>(_ style: S) -> some View where S: ContactManagementSectionStyle { self.environment( \.airshipContactManagementSectionStyle, AnyContactManagementSectionStyle(style: style) ) } } /// Contact management section style public protocol ContactManagementSectionStyle: Sendable { 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() } } // MARK: - DEFAULT Contact Management View /// Default contact management section style. Also styles alert views. public struct DefaultContactManagementSectionStyle: ContactManagementSectionStyle { @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 let _makeBody: @Sendable (Configuration) -> AnyView init<S: ContactManagementSectionStyle>(style: S) { _makeBody = { configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct ContactManagementSectionStyleKey: EnvironmentKey { static let defaultValue = AnyContactManagementSectionStyle(style: .defaultStyle) } extension EnvironmentValues { var airshipContactManagementSectionStyle: AnyContactManagementSectionStyle { get { self[ContactManagementSectionStyleKey.self] } set { self[ContactManagementSectionStyleKey.self] = newValue } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/Contact management/FooterView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif struct FooterView: View { @Environment(\.colorScheme) private var colorScheme let text: String let textAppearance: PreferenceCenterTheme.TextAppearance var body: some View { /// Footer Text(LocalizedStringKey(text)) .textAppearance( textAppearance, colorScheme: colorScheme ) .fixedSize(horizontal: false, vertical: true) .lineLimit(2) .airshipApplyIf(containsMarkdownLink(text: text)) { view in view.accessibilityAddTraits(.isLink) } } private func containsMarkdownLink(text: String) -> Bool { let text = try? AttributedString(markdown: text) return text?.runs.contains(where: { $0.link != nil }) ?? false } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/ContactSubscriptionGroupView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// Contact subscription group item view public struct ContactSubscriptionGroupView: View { /// The item's config public let item: PreferenceCenterConfig.ContactSubscriptionGroup /// The preference state @ObservedObject public var state: PreferenceCenterState @Environment(\.airshipContactSubscriptionGroupStyle) private var style @Environment(\.airshipPreferenceCenterTheme) private var preferenceCenterTheme @Environment(\.colorScheme) private var colorScheme @State private var displayConditionsMet: Bool = true public init(item: PreferenceCenterConfig.ContactSubscriptionGroup, state: PreferenceCenterState) { self.item = item self.state = state } @ViewBuilder public var body: some View { let componentStates = self.item.components .map { ContactSubscriptionGroupStyleConfiguration.ComponentState( component: $0, isSubscribed: self.state.makeBinding( contactListID: item.subscriptionID, scopes: $0.scopes ) ) } let configuration = ContactSubscriptionGroupStyleConfiguration( item: self.item, state: self.state, displayConditionsMet: self.displayConditionsMet, preferenceCenterTheme: self.preferenceCenterTheme, componentStates: componentStates, colorScheme: self.colorScheme ) style.makeBody(configuration: configuration) .preferenceConditions( self.item.conditions, binding: self.$displayConditionsMet ) } } extension View { /// Sets the contact subscription group style /// - Parameters: /// - style: The style public func contactSubscriptionGroupStyle<S>(_ style: S) -> some View where S: ContactSubscriptionGroupStyle { self.environment( \.airshipContactSubscriptionGroupStyle, AnyContactSubscriptionGroupStyle(style: style) ) } } /// The contact subscription group item style config public struct ContactSubscriptionGroupStyleConfiguration { public let item: PreferenceCenterConfig.ContactSubscriptionGroup /// 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 /// Component enabled states public let componentStates: [ComponentState] /// Color scheme public let colorScheme: ColorScheme /// Component state public struct ComponentState { /// The component public let component: PreferenceCenterConfig.ContactSubscriptionGroup.Component /// The component's subscription binding public let isSubscribed: Binding<Bool> } } public protocol ContactSubscriptionGroupStyle: Sendable { associatedtype Body: View typealias Configuration = ContactSubscriptionGroupStyleConfiguration @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension ContactSubscriptionGroupStyle where Self == DefaultContactSubscriptionGroupStyle { /// Default style public static var defaultStyle: Self { return .init() } } /// The default subscription group item style public struct DefaultContactSubscriptionGroupStyle: ContactSubscriptionGroupStyle { @ViewBuilder @MainActor public func makeBody(configuration: Configuration) -> some View { let colorScheme = configuration.colorScheme let item = configuration.item let itemTheme = configuration.preferenceCenterTheme .contactSubscriptionGroup if configuration.displayConditionsMet { VStack(alignment: .leading) { if let title = item.display?.title { Text(title) .textAppearance( itemTheme?.titleAppearance, base: PreferenceCenterDefaults.titleAppearance, colorScheme: colorScheme ) .accessibilityAddTraits(.isHeader) } if let subtitle = item.display?.subtitle { Text(subtitle) .textAppearance( itemTheme?.subtitleAppearance, base: PreferenceCenterDefaults.subtitleAppearance, colorScheme: colorScheme ) } ComponentsView( componentStates: configuration.componentStates, chipTheme: itemTheme?.chip ) } } } private struct ComponentsView: View { @Environment(\.colorScheme) private var colorScheme let componentStates: [Configuration.ComponentState] let chipTheme: PreferenceCenterTheme.Chip? @State private var componentHeight: CGFloat? @ViewBuilder var body: some View { let dx = AirshipAtomicValue(CGFloat.zero) let dy = AirshipAtomicValue(CGFloat.zero) let chipSpacing = PreferenceCenterDefaults.chipSpacing GeometryReader { geometry in ZStack(alignment: .topLeading) { ForEach(0..<self.componentStates.count, id: \.self) { index in let state = self.componentStates[index] let size = geometry.size makeComponent(state.component, isOn: state.isSubscribed) .alignmentGuide(HorizontalAlignment.leading) { viewDimensions in if index == 0 { dx.value = 0 dy.value = 0 } var offSet = dx.value if abs(offSet - viewDimensions.width) > size.width { offSet = 0 dx.value = 0 dy.value -= viewDimensions.height + chipSpacing } dx.value -= (viewDimensions.width + chipSpacing) return offSet } .alignmentGuide(VerticalAlignment.top) { viewDimensions in return dy.value } } } .background( GeometryReader(content: { contentMetrics -> Color in let size = contentMetrics.size DispatchQueue.main.async { self.componentHeight = size.height } return Color.clear }) ) } .frame(minHeight: componentHeight) } @ViewBuilder func makeComponent( _ component: PreferenceCenterConfig.ContactSubscriptionGroup .Component, isOn: Binding<Bool> ) -> some View { let onColor: Color = colorScheme.airshipResolveColor(light: chipTheme?.checkColor, dark: chipTheme?.checkColorDark) ?? .green let offColor: Color = colorScheme.airshipResolveColor(light: chipTheme?.borderColor, dark: chipTheme?.borderColorDark) ?? .secondary let borderColor: Color = colorScheme.airshipResolveColor(light: chipTheme?.borderColor, dark: chipTheme?.borderColorDark) ?? .secondary Toggle(isOn: isOn) { Text(component.display?.title ?? "") }.toggleStyle( ChipToggleStyle(onColor: onColor, offColor: offColor, borderColor: borderColor) ) #if !os(tvOS) .controlSize(.regular) #endif .textAppearance( chipTheme?.labelAppearance, base: PreferenceCenterDefaults.chipLabelAppearance, colorScheme: colorScheme ) } } } struct AnyContactSubscriptionGroupStyle: ContactSubscriptionGroupStyle { @ViewBuilder private let _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: ContactSubscriptionGroupStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct ContactSubscriptionGroupStyleKey: EnvironmentKey { static let defaultValue = AnyContactSubscriptionGroupStyle( style: .defaultStyle ) } extension EnvironmentValues { var airshipContactSubscriptionGroupStyle: AnyContactSubscriptionGroupStyle { get { self[ContactSubscriptionGroupStyleKey.self] } set { self[ContactSubscriptionGroupStyleKey.self] = newValue } } } struct ChipToggleStyle: ToggleStyle { // Custom colors can be passed in during initialization. var onColor: Color var offColor: Color var borderColor: Color func makeBody(configuration: Configuration) -> some View { #if os(tvOS) Button(action: { configuration.isOn.toggle() }) { HStack(spacing: 10) { Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle") .foregroundColor(configuration.isOn ? onColor : offColor) configuration.label .foregroundColor(.primary) } } .buttonStyle(.bordered) .tint(configuration.isOn ? onColor.opacity(0.2) : nil) .animation(.easeInOut(duration: 0.2), value: configuration.isOn) #elseif os(visionOS) Button(action: { configuration.isOn.toggle() }) { HStack { Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle") .font(.title2) .foregroundColor(configuration.isOn ? onColor : offColor) configuration.label .foregroundColor(.primary) } .frame(minHeight: 44) } .buttonStyle(.bordered) .tint(configuration.isOn ? onColor.opacity(0.2) : nil) .animation(.easeInOut(duration: 0.2), value: configuration.isOn) #else Button(action: { configuration.isOn.toggle() }) { HStack { Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle") .font(.title2) .foregroundColor(configuration.isOn ? onColor : offColor) configuration.label .foregroundColor(.primary) } } .buttonStyle(.bordered) .tint(configuration.isOn ? onColor.opacity(0.4) : offColor.opacity(0.4)) .animation(.easeInOut(duration: 0.2), value: configuration.isOn) #endif } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/ContactSubscriptionView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// The contact subscription item view public struct ContactSubscriptionView: View { /// The item's config public let item: PreferenceCenterConfig.ContactSubscription /// The preference state @ObservedObject public var state: PreferenceCenterState @Environment(\.airshipContactSubscriptionViewStyle) private var style @Environment(\.airshipPreferenceCenterTheme) private var preferenceCenterTheme @Environment(\.colorScheme) private var colorScheme @State private var displayConditionsMet: Bool = true public init(item: PreferenceCenterConfig.ContactSubscription, state: PreferenceCenterState) { self.item = item self.state = state } @ViewBuilder public var body: some View { let isSubscribed = state.makeBinding( contactListID: item.subscriptionID, scopes: item.scopes ) let configuration = ContactSubscriptionViewStyleConfiguration( item: self.item, state: self.state, displayConditionsMet: self.displayConditionsMet, preferenceCenterTheme: self.preferenceCenterTheme, isSubscribed: isSubscribed, colorScheme: self.colorScheme ) style.makeBody(configuration: configuration) .preferenceConditions( self.item.conditions, binding: self.$displayConditionsMet ) } } extension View { /// Sets the contact subscription style /// - Parameters: /// - style: The style public func contactSubscriptionStyle<S>(_ style: S) -> some View where S: ContactSubscriptionViewStyle { self.environment( \.airshipContactSubscriptionViewStyle, AnyContactSubscriptionViewStyle(style: style) ) } } /// Contact subscription item view style configuration public struct ContactSubscriptionViewStyleConfiguration { /// The item's config public let item: PreferenceCenterConfig.ContactSubscription /// 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 /// The item's subscription binding public let isSubscribed: Binding<Bool> /// Color scheme public let colorScheme: ColorScheme } /// Contact subscription view style public protocol ContactSubscriptionViewStyle: Sendable { associatedtype Body: View typealias Configuration = ContactSubscriptionViewStyleConfiguration @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension ContactSubscriptionViewStyle where Self == DefaultContactSubscriptionViewStyle { /// Default style public static var defaultStyle: Self { return .init() } } /// Default contact subscription view style public struct DefaultContactSubscriptionViewStyle: ContactSubscriptionViewStyle { @ViewBuilder @MainActor public func makeBody(configuration: Configuration) -> some View { let colorScheme = configuration.colorScheme let item = configuration.item let itemTheme = configuration.preferenceCenterTheme.contactSubscription if configuration.displayConditionsMet { Toggle(isOn: configuration.isSubscribed) { VStack(alignment: .leading) { if let title = item.display?.title { Text(title) .textAppearance( itemTheme?.titleAppearance, base: PreferenceCenterDefaults.titleAppearance, colorScheme: colorScheme ).accessibilityAddTraits(.isHeader) } if let subtitle = item.display?.subtitle { Text(subtitle) .textAppearance( itemTheme?.subtitleAppearance, base: PreferenceCenterDefaults.subtitleAppearance, colorScheme: colorScheme ) } } } .toggleStyle( tint: colorScheme.airshipResolveColor( light: itemTheme?.toggleTintColor, dark: itemTheme?.toggleTintColorDark ) ) .padding(.trailing, 2) } } } struct AnyContactSubscriptionViewStyle: ContactSubscriptionViewStyle { @ViewBuilder private let _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: ContactSubscriptionViewStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct ContactSubscriptionViewStyleKey: EnvironmentKey { static let defaultValue = AnyContactSubscriptionViewStyle( style: .defaultStyle ) } extension EnvironmentValues { var airshipContactSubscriptionViewStyle: AnyContactSubscriptionViewStyle { get { self[ContactSubscriptionViewStyleKey.self] } set { self[ContactSubscriptionViewStyleKey.self] = newValue } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/LabeledSectionBreakView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// Labeled section break view public struct LabeledSectionBreakView: View { /// The section's config public let section: PreferenceCenterConfig.LabeledSectionBreak /// The preference state @ObservedObject public var state: PreferenceCenterState @Environment(\.airshipLabeledSectionBreakStyle) private var style @Environment(\.airshipPreferenceCenterTheme) private var preferenceCenterTheme @Environment(\.colorScheme) private var colorScheme @State private var displayConditionsMet: Bool = true public init(section: PreferenceCenterConfig.LabeledSectionBreak, state: PreferenceCenterState) { self.section = section self.state = state } @ViewBuilder public var body: some View { let configuration = LabeledSectionBreakStyleConfiguration( section: self.section, state: self.state, displayConditionsMet: self.displayConditionsMet, preferenceCenterTheme: self.preferenceCenterTheme, colorScheme: self.colorScheme ) style.makeBody(configuration: configuration) .preferenceConditions( self.section.conditions, binding: self.$displayConditionsMet ) } } extension View { /// Sets the labeled section break style /// - Parameters: /// - style: The style public func labeledSectionBreakStyle<S>(_ style: S) -> some View where S: LabeledSectionBreakStyle { self.environment( \.airshipLabeledSectionBreakStyle, AnyLabeledSectionBreakStyle(style: style) ) } } /// The labeled section break style configuration @MainActor public struct LabeledSectionBreakStyleConfiguration { /// The section config public let section: PreferenceCenterConfig.LabeledSectionBreak /// 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 /// The color scheme public let colorScheme: ColorScheme } /// Labeled section break style public protocol LabeledSectionBreakStyle: Sendable { associatedtype Body: View typealias Configuration = LabeledSectionBreakStyleConfiguration @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension LabeledSectionBreakStyle where Self == DefaultLabeledSectionBreakStyle { /// Default style public static var defaultStyle: Self { return .init() } } /// Default labeled section break style public struct DefaultLabeledSectionBreakStyle: LabeledSectionBreakStyle { @ViewBuilder @preconcurrency @MainActor public func makeBody(configuration: Configuration) -> some View { let colorScheme = configuration.colorScheme let section = configuration.section let sectionTheme = configuration.preferenceCenterTheme.labeledSectionBreak let backgroundColor = colorScheme.airshipResolveColor( light: sectionTheme?.backgroundColor, dark: sectionTheme?.backgroundColorDark ) ?? PreferenceCenterDefaults.labeledSectionBreakTitleBackgroundColor if configuration.displayConditionsMet { Text(section.display?.title ?? "") .textAppearance( sectionTheme?.titleAppearance, base: PreferenceCenterDefaults.labeledSectionBreakTitleAppearance, colorScheme: colorScheme ) .padding(.vertical, PreferenceCenterDefaults.smallPadding/2) .padding(.horizontal, PreferenceCenterDefaults.smallPadding) .background(backgroundColor) .accessibilityAddTraits(.isHeader) } } } struct AnyLabeledSectionBreakStyle: LabeledSectionBreakStyle { @ViewBuilder private let _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: LabeledSectionBreakStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct LabeledSectionBreakStyleKey: EnvironmentKey { static let defaultValue = AnyLabeledSectionBreakStyle(style: .defaultStyle) } extension EnvironmentValues { var airshipLabeledSectionBreakStyle: AnyLabeledSectionBreakStyle { get { self[LabeledSectionBreakStyleKey.self] } set { self[LabeledSectionBreakStyleKey.self] = newValue } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterAlertView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// The Preference Center alert item view public struct PreferenceCenterAlertView: View { /// The item's config public let item: PreferenceCenterConfig.Alert /// The preference state @ObservedObject public var state: PreferenceCenterState @Environment(\.airshipPreferenceCenterAlertStyle) private var style @Environment(\.airshipPreferenceCenterTheme) private var preferenceCenterTheme @Environment(\.colorScheme) private var colorScheme @State private var displayConditionsMet: Bool = true public init(item: PreferenceCenterConfig.Alert, state: PreferenceCenterState) { self.item = item self.state = state } @ViewBuilder public var body: some View { let configuration = PreferenceCenterAlertStyleConfiguration( item: self.item, state: self.state, displayConditionsMet: self.displayConditionsMet, preferenceCenterTheme: self.preferenceCenterTheme, colorScheme: self.colorScheme ) style.makeBody(configuration: configuration) .preferenceConditions( self.item.conditions, binding: self.$displayConditionsMet ) } } extension View { /// Sets the alert style /// - Parameters: /// - style: The style public func PreferenceCenterAlertStyle<S>(_ style: S) -> some View where S: PreferenceCenterAlertStyle { self.environment( \.airshipPreferenceCenterAlertStyle, AnyPreferenceCenterAlertStyle(style: style) ) } } /// Preference Center alert style configuration public struct PreferenceCenterAlertStyleConfiguration { /// The item config public let item: PreferenceCenterConfig.Alert /// 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 /// The color scheme public let colorScheme: ColorScheme } /// Preference Center alert style public protocol PreferenceCenterAlertStyle: Sendable { associatedtype Body: View typealias Configuration = PreferenceCenterAlertStyleConfiguration @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension PreferenceCenterAlertStyle where Self == DefaultPreferenceCenterAlertStyle { /// Default style public static var defaultStyle: Self { return .init() } } /// The default Preference Center alert style public struct DefaultPreferenceCenterAlertStyle: PreferenceCenterAlertStyle { @ViewBuilder @MainActor public func makeBody(configuration: Configuration) -> some View { let colorScheme = configuration.colorScheme let item = configuration.item let itemTheme = configuration.preferenceCenterTheme.alert if configuration.displayConditionsMet { VStack(alignment: .center) { HStack(alignment: .top) { if let url = item.display?.iconURL, !url.isEmpty { AirshipAsyncImage( url: url, image: { image, _ in image .resizable() .scaledToFit() }, placeholder: { return ProgressView() } ) .frame(width: 60, height: 60) .padding() } VStack(alignment: .leading) { if let title = item.display?.title { Text(title) .textAppearance( itemTheme?.titleAppearance, base: PreferenceCenterDefaults.titleAppearance, colorScheme: colorScheme ) } if let subtitle = item.display?.subtitle { Text(subtitle) .textAppearance( itemTheme?.subtitleAppearance, base: PreferenceCenterDefaults.subtitleAppearance, colorScheme: colorScheme ) } if let button = item.button { Button( action: { let actions = button.actionJSON Task { await ActionRunner.run( actionsPayload: actions, situation: .manualInvocation, metadata: [:] ) } }, label: { Text(button.text) .textAppearance( itemTheme?.buttonLabelAppearance, colorScheme: colorScheme ) } ) .buttonStyle(.borderedProminent) #if !os(tvOS) .controlSize(.large) #endif .buttonBorderShape(.capsule) .optAccessibilityLabel( string: button.contentDescription ) } } } } .frame(maxWidth: .infinity) } } } struct AnyPreferenceCenterAlertStyle: PreferenceCenterAlertStyle { @ViewBuilder private var _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: PreferenceCenterAlertStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct PreferenceCenterAlertStyleKey: EnvironmentKey { static let defaultValue = AnyPreferenceCenterAlertStyle(style: .defaultStyle) } extension EnvironmentValues { var airshipPreferenceCenterAlertStyle: AnyPreferenceCenterAlertStyle { get { self[PreferenceCenterAlertStyleKey.self] } set { self[PreferenceCenterAlertStyleKey.self] = newValue } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterContent.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif /// Preference center content phase public enum PreferenceCenterContentPhase: Sendable { /// The view is loading case loading /// The view failed to load the config case error(any Error) /// The view is loaded with the state case loaded(PreferenceCenterState) } /// The core view for the Airship Preference Center. /// This view is responsible for loading and displaying the preference center content. For a navigation controller, see `PreferenceCenterView`. @MainActor public struct PreferenceCenterContent: View { @StateObject private var loader: PreferenceCenterContentLoader = PreferenceCenterContentLoader() @State private var initialLoadCalled = false @State private var namedUser: String? @Environment(\.airshipPreferenceCenterStyle) private var style @Environment(\.airshipPreferenceCenterTheme) private var preferenceCenterTheme @Environment(\.colorScheme) private var colorScheme private let preferenceCenterID: String private let onLoad: (@Sendable (String) async -> PreferenceCenterContentPhase)? private let onPhaseChange: ((PreferenceCenterContentPhase) -> Void)? /// 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 public init( preferenceCenterID: String, onLoad: (@Sendable (String) async -> PreferenceCenterContentPhase)? = nil, onPhaseChange: ((PreferenceCenterContentPhase) -> Void)? = nil ) { self.preferenceCenterID = preferenceCenterID self.onLoad = onLoad self.onPhaseChange = onPhaseChange } @ViewBuilder public var body: some View { let phase = self.loader.phase let refresh: @MainActor @Sendable () -> Void = { @MainActor in self.loader.load( preferenceCenterID: preferenceCenterID, onLoad: onLoad ) } let configuration = PreferenceCenterContentStyleConfiguration( phase: phase, preferenceCenterTheme: self.preferenceCenterTheme, colorScheme: self.colorScheme, refresh: refresh ) style.makeBody(configuration: configuration) .onReceive(makeNamedUserIDPublisher()) { identifier in if (self.namedUser != identifier) { self.namedUser = identifier refresh() } } .onReceive(self.loader.$phase) { self.onPhaseChange?($0) if !self.initialLoadCalled { refresh() self.initialLoadCalled = true } } } private func makeNamedUserIDPublisher() -> AnyPublisher<String?, Never> { guard Airship.isFlying else { return Just(nil).eraseToAnyPublisher() } return Airship.contact.namedUserIDPublisher .receive(on: RunLoop.main) .dropFirst() .eraseToAnyPublisher() } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterContentLoader.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif @MainActor final class PreferenceCenterContentLoader: ObservableObject { @Published public private(set) var phase: PreferenceCenterContentPhase = .loading private var task: Task<Void, Never>? public func load( preferenceCenterID: String, onLoad: (@Sendable (String) async -> PreferenceCenterContentPhase)? = nil ) { self.task?.cancel() self.task = Task { @MainActor in await loadAsync( preferenceCenterID: preferenceCenterID, onLoad: onLoad ) } } @MainActor private func loadAsync( preferenceCenterID: String, onLoad: (@Sendable @MainActor (String) async -> PreferenceCenterContentPhase)? = nil ) async { self.phase = .loading if let onLoad = onLoad { self.phase = await onLoad(preferenceCenterID) return } do { let state = try await self.fetchState( preferenceCenterID: preferenceCenterID ) self.phase = .loaded(state) } catch { self.phase = .error(error) } } @MainActor private func fetchState(preferenceCenterID: String) async throws -> PreferenceCenterState { AirshipLogger.info("Fetching config: \(preferenceCenterID)") guard Airship.isFlying else { throw AirshipErrors.error("TakeOff not called") } let config = try await Airship.preferenceCenter.config( preferenceCenterID: preferenceCenterID ) var channelSubscriptions: [String] = [] var contactSubscriptions: [String: Set<ChannelScope>] = [:] if config.containsChannelSubscriptions() { channelSubscriptions = try await Airship.channel .fetchSubscriptionLists() } if config.containsContactSubscriptions() { contactSubscriptions = try await Airship.contact .fetchSubscriptionLists() .mapValues { Set($0) } } var channelUpdates: AsyncStream<ContactChannelsResult>? = nil if config.containsContactManagement() { channelUpdates = Airship.contact.contactChannelUpdates } return PreferenceCenterState( config: config, contactSubscriptions: contactSubscriptions, channelSubscriptions: Set(channelSubscriptions), channelUpdates: channelUpdates ) } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterContentStyle.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif /// Preference Center view style configuration public struct PreferenceCenterContentStyleConfiguration: Sendable { /// The view's phase public let phase: PreferenceCenterContentPhase /// The preference center theme public let preferenceCenterTheme: PreferenceCenterTheme /// The colorScheme public let colorScheme: ColorScheme /// A block that can be called to refresh the view public let refresh: @MainActor @Sendable () -> Void } /// Preference Center view style public protocol PreferenceCenterContentStyle: Sendable { associatedtype Body: View typealias Configuration = PreferenceCenterContentStyleConfiguration @preconcurrency @MainActor func makeBody(configuration: Self.Configuration) -> Self.Body } extension PreferenceCenterContentStyle where Self == DefaultPreferenceCenterViewStyle { /// Default style public static var defaultStyle: Self { return .init() } } /// The default Preference Center view style @MainActor @preconcurrency public struct DefaultPreferenceCenterViewStyle: PreferenceCenterContentStyle { @ViewBuilder private func makeProgressView(configuration: Configuration) -> some View { ProgressView() .frame(alignment: .center) } @ViewBuilder public func makeErrorView(configuration: Configuration) -> some View { let colorScheme = configuration.colorScheme 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, colorScheme: colorScheme ) .padding() Button( action: { configuration.refresh() }, label: { Text(retry) .textAppearance( theme?.retryButtonLabelAppearance, base: PreferenceCenterTheme.TextAppearance( color: colorScheme.airshipResolveColor( light: Color.white, dark: Color.black ) ), colorScheme: colorScheme ) .padding(.horizontal, 8) .padding(.vertical, 4) .background( Capsule() .fill( colorScheme.airshipResolveColor( light: theme?.retryButtonBackgroundColor, dark: theme?.retryButtonBackgroundColorDark ) ?? Color.blue ) ) .cornerRadius(8) .frame(minWidth: 44) } ) #if os(macOS) .buttonStyle(.plain) #endif } } @ViewBuilder @MainActor public func makePreferenceCenterView( configuration: Configuration, state: PreferenceCenterState ) -> some View { let colorScheme = configuration.colorScheme let theme = configuration.preferenceCenterTheme ScrollView { LazyVStack(alignment: .leading) { if let subtitle = state.config.display?.subtitle { Text(subtitle) .textAppearance( theme.preferenceCenter?.subtitleAppearance, base: PreferenceCenterDefaults.subtitleAppearance, colorScheme: colorScheme ) .padding(.bottom) } ForEach(0..<state.config.sections.count, id: \.self) { index in self.section( state.config.sections[index], state: state, isLast: index == state.config.sections.count - 1 ) } } .padding() Spacer() } } @ViewBuilder @MainActor public func makeBody(configuration: Configuration) -> some View { let resolvedBackgroundColor: Color? = configuration.colorScheme.airshipResolveColor( light: configuration.preferenceCenterTheme.viewController?.backgroundColor, dark: configuration.preferenceCenterTheme.viewController?.backgroundColorDark ) ZStack { if let resolvedBackgroundColor { resolvedBackgroundColor.ignoresSafeArea() } switch configuration.phase { case .loading: makeProgressView(configuration: configuration) case .error(_): makeErrorView(configuration: configuration) case .loaded(let state): makePreferenceCenterView(configuration: configuration, state: state) } } } @ViewBuilder @MainActor func section( _ section: PreferenceCenterConfig.Section, state: PreferenceCenterState, isLast: Bool ) -> some View { switch section { case .common(let section): CommonSectionView( section: section, state: state, isLast: isLast ) case .labeledSectionBreak(let section): LabeledSectionBreakView( section: section, state: state ) } } } struct AnyPreferenceCenterViewStyle: PreferenceCenterContentStyle { @ViewBuilder private var _makeBody: @MainActor @Sendable (Configuration) -> AnyView init<S: PreferenceCenterContentStyle>(style: S) { _makeBody = { @MainActor configuration in AnyView(style.makeBody(configuration: configuration)) } } @ViewBuilder func makeBody(configuration: Configuration) -> some View { _makeBody(configuration) } } struct PreferenceCenterViewStyleKey: EnvironmentKey { static let 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 } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterState.swift ================================================ /* Copyright Airship and Contributors */ public import Combine import Foundation public import SwiftUI #if canImport(AirshipCore) public import AirshipCore #endif /// Preference Center State @MainActor public class PreferenceCenterState: ObservableObject { /// The config public let config: PreferenceCenterConfig private var contactSubscriptions: [String: Set<ChannelScope>] private var channelSubscriptions: Set<String> @Published var channelsList: [ContactChannel] = [] private var subscriptions: Set<AnyCancellable> = [] private let subscriber: any PreferenceSubscriber /// Default constructor. /// - Parameters: /// - config: The preference config /// - contactSubscriptions: The relevant contact subscriptions /// - channelSubscriptions: The relevant channel subscriptions. /// - channelsLists: The relevant channels list. public convenience init( config: PreferenceCenterConfig, contactSubscriptions: [String: Set<ChannelScope>] = [:], channelSubscriptions: Set<String> = Set(), channelsList: [ContactChannel] = [], channelUpdates: AsyncStream<ContactChannelsResult>? = nil ) { self.init( config: config, contactSubscriptions: contactSubscriptions, channelSubscriptions: channelSubscriptions, channelUpdates: channelUpdates, subscriber: PreferenceCenterState.makeSubscriber() ) } init( config: PreferenceCenterConfig, contactSubscriptions: [String: Set<ChannelScope>] = [:], channelSubscriptions: Set<String> = Set(), channelUpdates: AsyncStream<ContactChannelsResult>? = nil, subscriber: any 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.subscriber.channelSubscriptionListEdits .sink { edit in self.processChannelEdit(edit) } .store(in: &subscriptions) self.subscriber.contactSubscriptionListEdits .sink { edit in self.processContactEdit(edit) } .store(in: &subscriptions) } /// Checks if the channel is subscribed to the preference state /// - Parameters: /// - listID: The preference list ID /// - Returns: true if any of the channel is subscribed, otherwise false. public func isChannelSubscribed(_ listID: String) -> Bool { return self.channelSubscriptions.contains(listID) } /// Checks if the contact is subscribed to the preference state /// - Parameters: /// - listID: The preference list ID /// - 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 { let containsSubscription = self.contactSubscriptions[listID]? .contains { $0 == scope } if containsSubscription == true { return true } if config.options?.mergeChannelDataToContact == true && scope == .app { return isChannelSubscribed(listID) } return false } /// Checks if the contact is subscribed to the preference state /// - Parameters: /// - listID: The preference list ID /// - 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 { return scopes.contains { scope in isContactSubscribed(listID, scope: scope) } } /// Creates a channel subscription binding for the list ID. /// - Parameters: /// - channelListID: The subscription list ID /// - Returns: A subscription binding public func makeBinding(channelListID: String) -> Binding<Bool> { return Binding<Bool>( get: { self.isChannelSubscribed(channelListID) }, set: { subscribe in self.subscriber.updateChannelSubscription( channelListID, subscribe: subscribe ) } ) } /// Creates a contact subscription binding for the list ID and scopes. /// - Parameters: /// - contactListID: The subscription list ID /// - scopes: The subscription list scopes /// - Returns: A subscription binding public func makeBinding( contactListID: String, scopes: [ChannelScope] ) -> Binding<Bool> { return Binding<Bool>( get: { self.isContactSubscribed(contactListID, scopes: scopes) }, set: { subscribe in self.subscriber.updateContactSubscription( contactListID, scopes: scopes, subscribe: subscribe ) } ) } private func processContactEdit(_ edit: ScopedSubscriptionListEdit) { self.objectWillChange.send() switch edit { case .subscribe(let listID, let scope): var scopes = self.contactSubscriptions[listID] ?? Set<ChannelScope>() scopes.insert(scope) self.contactSubscriptions[listID] = scopes case .unsubscribe(let listID, let scope): if var scopes = self.contactSubscriptions[listID] { scopes.remove(scope) if scopes.isEmpty { self.contactSubscriptions[listID] = nil } else { self.contactSubscriptions[listID] = scopes } } #if canImport(AirshipCore) @unknown default: AirshipLogger.error("Unknown scooped subscription list edit \(edit)") #endif } } private func processChannelEdit(_ edit: SubscriptionListEdit) { self.objectWillChange.send() switch edit { case .subscribe(let listID): self.channelSubscriptions.insert(listID) case .unsubscribe(let listID): self.channelSubscriptions.remove(listID) #if canImport(AirshipCore) @unknown default: AirshipLogger.error("Unknown subscription list edit \(edit)") #endif } } static func makeSubscriber() -> any PreferenceSubscriber { guard Airship.isFlying else { return PreviewPreferenceSubscriber() } return AirshipPreferenceSubscriber() } } protocol PreferenceSubscriber { var channelSubscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { get } var contactSubscriptionListEdits: AnyPublisher<ScopedSubscriptionListEdit, Never> { get } func updateChannelSubscription( _ listID: String, subscribe: Bool ) func updateContactSubscription( _ listID: String, scopes: [ChannelScope], subscribe: Bool ) } class PreviewPreferenceSubscriber: PreferenceSubscriber { private let channelEditsSubject = PassthroughSubject< SubscriptionListEdit, Never >() var channelSubscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { return channelEditsSubject.eraseToAnyPublisher() } private let contactEditsSubject = PassthroughSubject< ScopedSubscriptionListEdit, Never >() var contactSubscriptionListEdits: AnyPublisher<ScopedSubscriptionListEdit, Never> { return contactEditsSubject.eraseToAnyPublisher() } func updateChannelSubscription(_ listID: String, subscribe: Bool) { if subscribe { channelEditsSubject.send(.subscribe(listID)) } else { channelEditsSubject.send(.unsubscribe(listID)) } } func updateContactSubscription( _ listID: String, scopes: [ChannelScope], subscribe: Bool ) { scopes.forEach { scope in if subscribe { contactEditsSubject.send(.subscribe(listID, scope)) } else { contactEditsSubject.send(.unsubscribe(listID, scope)) } } } } class AirshipPreferenceSubscriber: PreferenceSubscriber { var channelSubscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { return Airship.channel.subscriptionListEdits } var contactSubscriptionListEdits: AnyPublisher<ScopedSubscriptionListEdit, Never> { return Airship.contact.subscriptionListEdits } func updateChannelSubscription(_ listID: String, subscribe: Bool) { Airship.channel.editSubscriptionLists { editor in if subscribe { editor.subscribe(listID) } else { editor.unsubscribe(listID) } } } func updateContactSubscription( _ listID: String, scopes: [ChannelScope], subscribe: Bool ) { Airship.contact.editSubscriptionLists { editor in editor.mutate( listID, scopes: scopes, subscribe: subscribe ) } } } extension [ContactChannel] { func filter(with type: ChannelType) -> [ContactChannel] { return self.filter { channel in channel.channelType == type } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterUtils.swift ================================================ /* 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)) .onTapGesture { if let onClose = onClose { onClose() } } .zIndex(0) self.zIndex(1) } } } #if !os(macOS) internal extension UIWindow { static func makeModalReadyWindow(scene: UIWindowScene) -> UIWindow { let window = AirshipWindowFactory.shared.makeWindow(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) { self.alpha = 1 } } func animateOut() { UIView.animate(withDuration: 0.3, animations: { self.alpha = 0 }, completion: { _ in self.isHidden = true self.isUserInteractionEnabled = false self.removeFromSuperview() }) } } #else internal extension NSWindow { static func makeModalReadyWindow() -> NSWindow { let window = AirshipWindowFactory.shared.makeWindow() // On macOS, modal accessibility is usually handled at the view level window.contentView?.setAccessibilityModal(false) window.alphaValue = 0 window.makeKeyAndOrderFront(nil) window.ignoresMouseEvents = true return window } func animateIn() { self.level = .modalPanel // Equivalent to .alert level on iOS self.makeKeyAndOrderFront(nil) self.ignoresMouseEvents = false NSAnimationContext.runAnimationGroup { context in context.duration = 0.3 self.animator().alphaValue = 1 } } func animateOut() { NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 self.animator().alphaValue = 0 }, completionHandler: { // Ensure we are on MainActor for UI changes Task { @MainActor in self.orderOut(nil) self.ignoresMouseEvents = true } }) } } #endif ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation public import SwiftUI import Combine #if canImport(AirshipCore) import AirshipCore #endif /// The main view for the Airship Preference Center. This view provides a navigation stack. /// If you wish to provide your own navigation, see `PreferenceCenterContent`. public struct PreferenceCenterView: View { @Environment(\.preferenceCenterDismissAction) private var dismissAction: (@MainActor @Sendable () -> Void)? @Environment(\.airshipPreferenceCenterTheme) private var theme @Environment(\.colorScheme) private var colorScheme private let preferenceCenterID: String @State private var title: String? = nil /// Default constructor /// - Parameters: /// - preferenceCenterID: The preference center ID public init(preferenceCenterID: String) { self.preferenceCenterID = preferenceCenterID } @ViewBuilder private func makeBackButton() -> some View { let theme = theme.viewController?.navigationBar let resolvedBackButtonColor = colorScheme.airshipResolveColor( light: theme?.backButtonColor, dark: theme?.backButtonColorDark ) Button(action: { self.dismissAction?() }) { Image(systemName: "chevron.backward") .scaleEffect(0.68) .font(Font.title.weight(.medium)) .foregroundColor(resolvedBackButtonColor) } } private var navigationBarTitle: String? { var title: String? = self.title if theme.viewController?.navigationBar?.overrideConfigTitle == true { title = theme.viewController?.navigationBar?.title ?? title } return title } @ViewBuilder public var body: some View { let resolvedNavigationBarColor = colorScheme.airshipResolveColor( light: theme.viewController?.navigationBar?.backgroundColor, dark: theme.viewController?.navigationBar?.backgroundColorDark ) NavigationStack { PreferenceCenterContent( preferenceCenterID: preferenceCenterID, onPhaseChange: { phase in guard case .loaded(let state) = phase else { return } let title = state.config.display?.title if let title, title.isEmpty == false { self.title = title } else { self.title = "ua_preference_center_title".preferenceCenterLocalizedString } } ) .frame(maxWidth: .infinity, maxHeight: .infinity) .airshipApplyIf(resolvedNavigationBarColor != nil) { view in let visibility: Visibility = if #available(iOS 26.0, *) { .automatic } else { .visible } #if !os(macOS) view.toolbarBackground(resolvedNavigationBarColor!, for: .navigationBar) .toolbarBackground(visibility, for: .navigationBar) #endif } #if !os(macOS) .toolbar { if self.dismissAction != nil { ToolbarItem(placement: .navigationBarLeading) { makeBackButton() } } } #else .toolbar { if self.dismissAction != nil { ToolbarItem(placement: .automatic) { makeBackButton() } } } #endif .navigationTitle(navigationBarTitle ?? "") } } } private struct PreferenceCenterDismissActionKey: EnvironmentKey { static let defaultValue: (@MainActor @Sendable () -> Void)? = nil } extension EnvironmentValues { var preferenceCenterDismissAction: (@MainActor @Sendable () -> Void)? { get { self[PreferenceCenterDismissActionKey.self] } set { self[PreferenceCenterDismissActionKey.self] = newValue } } } public extension View { /// Sets a dismiss action on the preference center. /// - Parameters: /// - action: The dismiss action. func addPreferenceCenterDismissAction(action: (@MainActor @Sendable () -> Void)?) -> some View { environment(\.preferenceCenterDismissAction, action) } } struct PreferenceCenterView_Previews: PreviewProvider { static var previews: some View { let config = PreferenceCenterConfig( identifier: "PREVIEW", sections: [ .labeledSectionBreak( PreferenceCenterConfig.LabeledSectionBreak( id: "LabeledSectionBreak", display: PreferenceCenterConfig.CommonDisplay( title: "Labeled Section Break" ) ) ), .common( PreferenceCenterConfig.CommonSection( id: "common", items: [ .channelSubscription( PreferenceCenterConfig.ChannelSubscription( id: "ChannelSubscription", subscriptionID: "ChannelSubscription", display: PreferenceCenterConfig.CommonDisplay( title: "Channel Subscription Title", subtitle: "Channel Subscription Subtitle" ) ) ), .contactSubscription( PreferenceCenterConfig.ContactSubscription( id: "ContactSubscription", subscriptionID: "ContactSubscription", scopes: [.app, .web], display: PreferenceCenterConfig.CommonDisplay( title: "Contact Subscription Title", subtitle: "Contact Subscription Subtitle" ) ) ), .contactSubscriptionGroup( PreferenceCenterConfig.ContactSubscriptionGroup( id: "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" ) ) ), ] ) PreferenceCenterContent(preferenceCenterID: "PREVIEW") { preferenceCenterID in return await .loaded(PreferenceCenterState(config: config)) } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewControllerFactory.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) public import AirshipCore #endif #if canImport(UIKit) public import UIKit #endif #if canImport(AppKit) public import AppKit #endif /// View factories for Preference Center view controllers. /// /// This factory provides a unified way to create native view controllers (`UIViewController` on iOS/tvOS /// or `NSViewController` on macOS) that host a Preference Center SwiftUI view. public class PreferenceCenterViewControllerFactory: NSObject { /// Makes a view controller for the given Preference Center ID. /// - Parameters: /// - preferenceCenterID: The preference center identifier. /// - dismissAction: Optional action to be executed when the Preference Center is dismissed. /// - Returns: A native view controller hosting the Preference Center. @MainActor public class func makeViewController( preferenceCenterID: String, dismissAction: (@Sendable () -> Void)? = nil ) -> AirshipNativeViewController { makeViewController( view: PreferenceCenterView(preferenceCenterID: preferenceCenterID), preferenceCenterTheme: nil, dismissAction: dismissAction ) } /// Makes a view controller for the given Preference Center ID and theme plist. /// - Parameters: /// - preferenceCenterID: The preference center identifier. /// - preferenceCenterThemePlist: The name of the plist file containing the theme configuration. /// - Returns: A native view controller hosting the Preference Center. /// - Throws: An error if the theme plist could not be loaded. @MainActor public class func makeViewController( preferenceCenterID: String, preferenceCenterThemePlist: String ) throws -> AirshipNativeViewController { let theme = try PreferenceCenterThemeLoader.fromPlist(preferenceCenterThemePlist) return makeViewController( preferenceCenterID: preferenceCenterID, preferenceCenterTheme: theme ) } /// Makes a view controller for the given Preference Center ID and optional theme. /// - Parameters: /// - preferenceCenterID: The preference center identifier. /// - preferenceCenterTheme: An optional `PreferenceCenterTheme` to style the view. /// - dismissAction: Optional action to be executed when the Preference Center is dismissed. /// - Returns: A native view controller hosting the Preference Center. @MainActor public class func makeViewController( preferenceCenterID: String, preferenceCenterTheme: PreferenceCenterTheme? = nil, dismissAction: (@Sendable () -> Void)? = nil ) -> AirshipNativeViewController { makeViewController( view: PreferenceCenterView(preferenceCenterID: preferenceCenterID), preferenceCenterTheme: preferenceCenterTheme, dismissAction: dismissAction ) } /// Makes a view controller for a specific `PreferenceCenterView` instance and theme. /// - Parameters: /// - view: The `PreferenceCenterView` to host. /// - preferenceCenterTheme: The theme configuration. /// - dismissAction: Optional action to be executed when the Preference Center is dismissed. /// - Returns: A native view controller hosting the Preference Center. @MainActor public class func makeViewController( view: PreferenceCenterView, preferenceCenterTheme: PreferenceCenterTheme?, dismissAction: (@MainActor @Sendable () -> Void)? = nil ) -> AirshipNativeViewController { let theme = preferenceCenterTheme ?? PreferenceCenterTheme() #if os(macOS) let isDark = NSApp.effectiveAppearance.isDark #else let isDark = UITraitCollection.current.userInterfaceStyle == .dark #endif let resolvedBackgroundColor = isDark ? theme.viewController?.backgroundColorDark : theme.viewController?.backgroundColor return PreferenceCenterViewController( rootView: view .preferenceCenterTheme(theme) .addPreferenceCenterDismissAction(action: dismissAction), backgroundColor: resolvedBackgroundColor ) } } /// A platform-specific hosting controller that manages the lifecycle and styling of the Preference Center view. private class PreferenceCenterViewController<Content>: AirshipNativeHostingController<Content> where Content: View { /// Initializes the controller. /// - Parameters: /// - rootView: The SwiftUI content. /// - backgroundColor: The background color to apply to the underlying native view. init(rootView: Content, backgroundColor: AirshipNativeColor? = nil) { super.init(rootView: rootView) #if os(macOS) // Ensure the view is layer-backed on macOS to support background colors self.view.wantsLayer = true self.view.layer?.backgroundColor = backgroundColor?.cgColor #else if let backgroundColor = backgroundColor { self.view.backgroundColor = backgroundColor } #endif } @MainActor required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } } ================================================ FILE: Airship/AirshipPreferenceCenter/Source/view/PreferenceCenterViewExtensions.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI #if canImport(AirshipCore) import AirshipCore #endif extension View { @ViewBuilder func textAppearance( _ overrides: PreferenceCenterTheme.TextAppearance?, base: PreferenceCenterTheme.TextAppearance? = nil, colorScheme: ColorScheme ) -> some View { let overridesColor = colorScheme.airshipResolveColor(light: overrides?.color, dark: overrides?.colorDark) self.font(overrides?.font ?? base?.font) .foregroundColor(overridesColor ?? base?.color) } @ViewBuilder func toggleStyle(tint: Color?) -> some View { if let tint = tint { #if os(tvOS) self.tint(tint) #else self.toggleStyle(SwitchToggleStyle(tint: tint)) #endif } else { self } } @ViewBuilder func optAccessibilityLabel(string: String?) -> some View { if let string = string { self.accessibilityLabel(string) } else { self } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>1</string> </dict> </plist> ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/PreferenceCenterTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import AirshipCore import Foundation @testable import AirshipPreferenceCenter @Suite("Preference Center") struct PreferenceCenterTest { private let dataStore: PreferenceDataStore = PreferenceDataStore(appKey: UUID().uuidString) private var privacyManager: TestPrivacyManager! private var preferenceCenter: DefaultPreferenceCenter! private let remoteDataProvider: TestRemoteData = TestRemoteData() init() async { self.privacyManager = TestPrivacyManager( dataStore: self.dataStore, config: .testConfig(), defaultEnabledFeatures: .all ) self.preferenceCenter = await DefaultPreferenceCenter( dataStore: self.dataStore, privacyManager: self.privacyManager, remoteData: self.remoteDataProvider, inputValidator: TestInputValidator() ) } @Test("Json config", arguments: ["form-1", "form-2"]) func config(id: String) async throws { let payloadData = """ { "preference_forms":[ { "created":"2017-10-10T12:13:14.023", "last_updated":"2017-10-10T12:13:14.023", "form_id":"031de218-9fff-44d4-b348-de4b724bb924", "form":{ "id":"form-1", "sections":[] } }, { "created":"2018-10-10T12:13:14.023", "last_updated":"2018-10-10T12:13:14.023", "form_id":"031de218-9fff-44d4-b348-de4b724bb931", "form":{ "id":"form-2", "sections":[] } } ] } """ let remoteData = createPayload(payloadData) self.remoteDataProvider.payloads = [remoteData] var config = try! await self.preferenceCenter.config(preferenceCenterID: id) #expect(id == config.identifier) } @Test("Json config", arguments: ["form-1", "form-2"]) func jsonConfig(id: String) async throws { let payloadData = """ { "preference_forms":[ { "created":"2017-10-10T12:13:14.023", "last_updated":"2017-10-10T12:13:14.023", "form_id":"031de218-9fff-44d4-b348-de4b724bb924", "form":{ "id":"form-1" } }, { "created":"2018-10-10T12:13:14.023", "last_updated":"2018-10-10T12:13:14.023", "form_id":"031de218-9fff-44d4-b348-de4b724bb931", "form":{ "id":"form-2" } } ] } """ let remoteData = createPayload(payloadData) self.remoteDataProvider.payloads = [remoteData] let config = try! await self.preferenceCenter.jsonConfig(preferenceCenterID: id) let jsonConfig = try! AirshipJSON.from(data: config) let jsonform = try! AirshipJSON.wrap(["id": id]) #expect(jsonConfig == jsonform) } @MainActor @Test("Ensure preference center displays the correct form") func onDisplay() async throws { let delegate = MockPreferenceCenterOpenDelegate() self.preferenceCenter.openDelegate = delegate await confirmation("onDisplay called", expectedCount: 1) { confirm in self.preferenceCenter.onDisplay = { identifier in #expect(identifier == "some-form") confirm() return true } self.preferenceCenter.display("some-form") } #expect(!delegate.openCalled) } @MainActor @Test func onDisplayNilFallback() async throws { let delegate = MockPreferenceCenterOpenDelegate() self.preferenceCenter.openDelegate = delegate self.preferenceCenter.onDisplay = nil self.preferenceCenter.display("some-form") #expect("some-form" == delegate.lastOpenID) } @MainActor @Test func deepLink() async throws { let delegate = MockPreferenceCenterOpenDelegate() self.preferenceCenter.openDelegate = delegate let valid = URL(string: "uairship://preferences/some-id")! #expect(self.preferenceCenter.deepLink(valid)) #expect("some-id" == delegate.lastOpenID) let trailingSlash = URL( string: "uairship://preferences/some-other-id/" )! #expect(self.preferenceCenter.deepLink(trailingSlash)) #expect("some-other-id" == delegate.lastOpenID) } @MainActor @Test func deepLinkInvalid() { let delegate = MockPreferenceCenterOpenDelegate() self.preferenceCenter.openDelegate = delegate let wrongScheme = URL(string: "whatever://preferences/some-id")! #expect(!self.preferenceCenter.deepLink(wrongScheme)) let wrongHost = URL(string: "uairship://message_center/some-id")! #expect(!self.preferenceCenter.deepLink(wrongHost)) let tooManyArgs = URL( string: "uairship://preferences/some-other-id/what" )! #expect(!self.preferenceCenter.deepLink(tooManyArgs)) } private func createPayload(_ json: String) -> RemoteDataPayload { return RemoteDataPayload( type: "preference_forms", timestamp: Date(), data: try! AirshipJSON.from(json: json), remoteDataInfo: nil ) } } fileprivate final class TestInputValidator: AirshipInputValidation.Validator { func validateRequest(_ request: AirshipCore.AirshipInputValidation.Request) async throws -> AirshipCore.AirshipInputValidation.Result { return .invalid } } @MainActor fileprivate class MockPreferenceCenterOpenDelegate: PreferenceCenterOpenDelegate { var lastOpenID: String? var openCalled: Bool = false func openPreferenceCenter(_ preferenceCenterID: String) -> Bool { self.lastOpenID = preferenceCenterID self.openCalled = true return true } } ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/data/PreferenceCenterConfigTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import AirshipCore import Foundation @testable import AirshipPreferenceCenter @Suite("Preference Center Decoder") struct PreferenceCenterDecoderTest { @Test func form() throws { 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": "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_calling_code": "+1", "country_code": "US", "display_name": "\u{1F1FA}\u{1F1F8} +1", "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" } } }, "button": { "text": "Add SMS", "content_description": "Add a new phone number" } }, "remove": { "button": { "content_description": "Opt out and remove this phone number" }, "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 _ = try PreferenceCenterDecoder.decodeConfig( data: testPayload.data(using: .utf8)! ) } 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) } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/test data/TestLegacyTheme.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>title</key> <string>Preference Center</string> <key>titleColor</key> <string>#0000FF</string> <key>titleFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> <key>sectionTitleTextColor</key> <string>#de0000</string> <key>sectionTitleTextFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>32</string> </dict> <key>sectionSubtitleTextColor</key> <string>#da833b</string> <key>sectionSubtitleTextFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>25</string> </dict> <key>preferenceTitleTextColor</key> <string>#034710</string> <key>preferenceTitleTextFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>20</string> </dict> <key>preferenceSubtitleTextColor</key> <string>#8fe388</string> <key>preferenceSubtitleTextFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> <key>preferenceChipTextColor</key> <string>#7c6bea</string> <key>preferenceChipTextFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> <key>preferenceChipCheckmarkColor</key> <string>#0a0fc9</string> <key>preferenceChipCheckmarkCheckedBackgroundColor</key> <string>#3bd2d6</string> <key>preferenceChipBorderColor</key> <string>#0a0fc9</string> <key>alertTitleColor</key> <string>#0a0fc9</string> <key>alertTitleFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> <key>alertSubtitleColor</key> <string>#d1b4d4</string> <key>alertButtonBackgroundColor</key> <string>#da833b</string> <key>alertButtonLabelColor</key> <string>#78c8c0</string> <key>alertButtonLabelFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>25</string> </dict> </dict> </plist> ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/test data/TestTheme.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>viewController</key> <dict> <key>navigationBar</key> <dict> <key>title</key> <string>Preference Center</string> <key>titleFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> <key>titleColor</key> <string>#0000FF</string> </dict> </dict> <key>preferenceCenter</key> <dict> <key>subtitleAppearance</key> <dict> </dict> </dict> <key>commonSection</key> <dict> <key>titleAppearance</key> <dict> <key>color</key> <string>#de0000</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>32</string> </dict> </dict> <key>subtitleAppearance</key> <dict> <key>color</key> <string>#da833b</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>25</string> </dict> </dict> </dict> <key>labeledSectionBreak</key> <dict> <key>titleAppearance</key> <dict> </dict> </dict> <key>channelSubscription</key> <dict> <key>titleAppearance</key> <dict> <key>color</key> <string>#034710</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>20</string> </dict> </dict> <key>subtitleAppearance</key> <dict> <key>color</key> <string>#8fe388</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> </dict> </dict> <key>contactSubscription</key> <dict> <key>titleAppearance</key> <dict> <key>color</key> <string>#034710</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>20</string> </dict> </dict> <key>subtitleAppearance</key> <dict> <key>color</key> <string>#8fe388</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> </dict> </dict> <key>contactSubscriptionGroup</key> <dict> <key>titleAppearance</key> <dict> <key>color</key> <string>#034710</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>20</string> </dict> </dict> <key>subtitleAppearance</key> <dict> <key>color</key> <string>#8fe388</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> </dict> <key>chip</key> <dict> <key>checkColor</key> <string>#3bd2d6</string> <key>borderColor</key> <string>#0a0fc9</string> <key>labelAppearance</key> <dict> <key>color</key> <string>#7c6bea</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> </dict> </dict> </dict> <key>alert</key> <dict> <key>titleAppearance</key> <dict> <key>color</key> <string>#0a0fc9</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>15</string> </dict> </dict> <key>subtitleAppearance</key> <dict> <key>color</key> <string>#d1b4d4</string> </dict> <key>buttonLabelAppearance</key> <dict> <key>color</key> <string>#78c8c0</string> <key>font</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>25</string> </dict> </dict> <key>buttonBackgroundColor</key> <string>#da833b</string> </dict> </dict> </plist> ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/test data/TestThemeEmpty.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> </dict> </plist> ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/test data/TestThemeInvalid.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>viewController</key> <dict> <key>navigationBar</key> <dict> <key>title</key> <string>Preference Center</string> <key>titleFont</key> <dict> <key>fontName</key> <string>Helvetica</string> <key>fontSize</key> <string>-1</string> </dict> </dict> </dict> </dict> </plist> ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/theme/PreferenceThemeLoaderTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import SwiftUI @testable import AirshipPreferenceCenter @Suite("Preference Theme Loader") struct PreferenceThemeLoaderTest { private class BundleFinder {} let bundle: Bundle init() { bundle = Bundle(for: BundleFinder.self) } @Test func fromPlist() throws { let legacyTheme = try PreferenceCenterThemeLoader.fromPlist( "TestLegacyTheme", bundle: bundle ) let theme = try PreferenceCenterThemeLoader.fromPlist( "TestTheme", bundle: bundle ) #expect(legacyTheme == theme) #expect(PreferenceCenterTheme() != theme) } @Test func loadEmptyPlist() throws { _ = try PreferenceCenterThemeLoader.fromPlist( "TestThemeEmpty", bundle: bundle ) } @Test func invalidFile() throws { #expect(throws: (any Error).self) { try PreferenceCenterThemeLoader.fromPlist("Not a file", bundle: bundle) } } } ================================================ FILE: Airship/AirshipPreferenceCenter/Tests/view/PreferenceCenterStateTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing import AirshipCore import Combine import SwiftUI @testable import AirshipPreferenceCenter @Suite("Preference Center State") struct PreferenceCenterStateTest { let subscriber = TestPreferenceSubscriber() let state: PreferenceCenterState! @MainActor init() { state = PreferenceCenterState( config: PreferenceCenterConfig( identifier: "empty", sections: [] ), contactSubscriptions: [ "baz": [.app] ], channelSubscriptions: ["foo", "bar"], subscriber: subscriber ) } @MainActor @Test func channelBinding() { let channelFoo = self.state.makeBinding(channelListID: "foo") #expect(channelFoo.wrappedValue) channelFoo.wrappedValue.toggle() #expect(!channelFoo.wrappedValue) #expect([.unsubscribe("foo")] == self.subscriber.channelEdits) channelFoo.wrappedValue.toggle() #expect([.unsubscribe("foo"), .subscribe("foo")] == self.subscriber.channelEdits) } @MainActor @Test func channelBindingNotSubscribed() { let channelNotFoo = self.state.makeBinding(channelListID: "not foo") #expect(!channelNotFoo.wrappedValue) channelNotFoo.wrappedValue.toggle() #expect(channelNotFoo.wrappedValue) #expect([.subscribe("not foo")] == self.subscriber.channelEdits) } @MainActor @Test func contactBinding() { let contactBaz = self.state.makeBinding( contactListID: "baz", scopes: [.app] ) #expect(contactBaz.wrappedValue) contactBaz.wrappedValue.toggle() #expect(!contactBaz.wrappedValue) #expect([.unsubscribe("baz", .app)] == self.subscriber.contactEdits) contactBaz.wrappedValue.toggle() #expect([.unsubscribe("baz", .app), .subscribe("baz", .app)] == self.subscriber.contactEdits) } @MainActor @Test func contactlBindingNotSubscribed() { let contactNotBaz = self.state.makeBinding( contactListID: "not baz", scopes: [.app] ) #expect(!contactNotBaz.wrappedValue) contactNotBaz.wrappedValue.toggle() #expect(contactNotBaz.wrappedValue) #expect([.subscribe("not baz", .app)] == self.subscriber.contactEdits) } @MainActor @Test func contactlBindingPartialScope() { let contactBaz = self.state.makeBinding( contactListID: "baz", scopes: [.app, .web] ) #expect(contactBaz.wrappedValue) contactBaz.wrappedValue.toggle() #expect(!contactBaz.wrappedValue) #expect([.unsubscribe("baz", .app), .unsubscribe("baz", .web)] == self.subscriber.contactEdits) contactBaz.wrappedValue.toggle() #expect( [ .unsubscribe("baz", .app), .unsubscribe("baz", .web), .subscribe("baz", .app), .subscribe("baz", .web) ] == self.subscriber.contactEdits) } @MainActor @Test func contactDifferentScope() { let contactBaz = self.state.makeBinding( contactListID: "baz", scopes: [.web] ) #expect(!contactBaz.wrappedValue) } @MainActor @Test func channelMergeData() { let state = PreferenceCenterState( config: PreferenceCenterConfig( identifier: "empty", sections: [], options: PreferenceCenterConfig.Options( mergeChannelDataToContact: true ) ), contactSubscriptions: [ "baz": [.web] ], channelSubscriptions: ["foo", "baz"], subscriber: subscriber ) let contactAppBaz = state.makeBinding( contactListID: "baz", scopes: [.app] ) #expect(contactAppBaz.wrappedValue) contactAppBaz.wrappedValue.toggle() #expect(contactAppBaz.wrappedValue) #expect([.unsubscribe("baz", .app)] == self.subscriber.contactEdits) #expect(self.subscriber.channelEdits.isEmpty) } @MainActor @Test func channelExternalUpdates() { let channelFoo = self.state.makeBinding(channelListID: "foo") #expect(channelFoo.wrappedValue) self.subscriber.channelEditsSubject.send(.subscribe("foo")) #expect(channelFoo.wrappedValue) self.subscriber.channelEditsSubject.send(.unsubscribe("foo")) #expect(!channelFoo.wrappedValue) self.subscriber.channelEditsSubject.send(.unsubscribe("foo")) #expect(!channelFoo.wrappedValue) self.subscriber.channelEditsSubject.send(.subscribe("foo")) #expect(channelFoo.wrappedValue) } @MainActor @Test func contactExternalUpdates() { let contactBaz = self.state.makeBinding( contactListID: "baz", scopes: [.app] ) #expect(contactBaz.wrappedValue) self.subscriber.contactEditsSubject.send(.subscribe("baz", .app)) #expect(contactBaz.wrappedValue) self.subscriber.contactEditsSubject.send(.unsubscribe("baz", .app)) #expect(!contactBaz.wrappedValue) self.subscriber.contactEditsSubject.send(.unsubscribe("baz", .app)) #expect(!contactBaz.wrappedValue) self.subscriber.contactEditsSubject.send(.subscribe("baz", .app)) #expect(contactBaz.wrappedValue) } } class TestPreferenceSubscriber: PreferenceSubscriber { let channelEditsSubject = PassthroughSubject<SubscriptionListEdit, Never>() var channelSubscriptionListEdits: AnyPublisher<SubscriptionListEdit, Never> { return channelEditsSubject.eraseToAnyPublisher() } let contactEditsSubject = PassthroughSubject< ScopedSubscriptionListEdit, Never >() var contactSubscriptionListEdits: AnyPublisher<ScopedSubscriptionListEdit, Never> { return contactEditsSubject.eraseToAnyPublisher() } var channelEdits = [SubscriptionListEdit]() var contactEdits = [ScopedSubscriptionListEdit]() func updateChannelSubscription(_ listID: String, subscribe: Bool) { var edit: SubscriptionListEdit! if subscribe { edit = .subscribe(listID) } else { edit = .unsubscribe(listID) } self.channelEdits.append(edit) self.channelEditsSubject.send(edit) } func updateContactSubscription( _ listID: String, scopes: [ChannelScope], subscribe: Bool ) { scopes.forEach { scope in var edit: ScopedSubscriptionListEdit! if subscribe { edit = .subscribe(listID, scope) } else { edit = .unsubscribe(listID, scope) } self.contactEdits.append(edit) self.contactEditsSubject.send(edit) } } } ================================================ FILE: Airship.podspec ================================================ AIRSHIP_VERSION="20.6.2" Pod::Spec.new do |s| s.version = AIRSHIP_VERSION s.name = "Airship" s.summary = "Airship iOS SDK" s.documentation_url = "https://docs.airship.com/platform/ios" s.homepage = "https://www.airship.com" s.author = { "Airship" => "support@airship.com" } s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } s.source = { :git => "https://github.com/urbanairship/ios-library.git", :tag => s.version.to_s } s.module_name = "AirshipKit" s.header_dir = "AirshipKit" s.ios.deployment_target = "16.0" s.tvos.deployment_target = "18.0" s.watchos.deployment_target = "11.0" s.swift_versions = "6.0" s.requires_arc = true s.default_subspecs = ["Basement", "Core", "Automation", "MessageCenter", "PreferenceCenter", "FeatureFlags"] s.subspec "Basement" do |basement| basement.source_files = "Airship/AirshipBasement/Source/**/*.{swift}" end s.subspec "Core" do |core| core.source_files = "Airship/AirshipCore/Source/**/*.{swift}" core.resource_bundle = { 'AirshipCoreResources' => "Airship/AirshipCore/Resources/**/*" } core.exclude_files = "Airship/AirshipCore/Resources/Info.plist" core.dependency "Airship/Basement" end s.subspec "Automation" do |automation| automation.ios.source_files = "Airship/AirshipAutomation/Source/**/*.{swift}" automation.ios.resource_bundle = { 'AirshipAutomationResources' => "Airship/AirshipAutomation/Resources/**/*" } automation.dependency "Airship/Core" end s.subspec "MessageCenter" do |messageCenter| messageCenter.ios.source_files = "Airship/AirshipMessageCenter/Source/**/*.{swift}" messageCenter.ios.resource_bundle = { 'AirshipMessageCenterResources' => "Airship/AirshipMessageCenter/Resources/**/*" } messageCenter.dependency "Airship/Core" end s.subspec "PreferenceCenter" do |preferenceCenter| preferenceCenter.ios.source_files = "Airship/AirshipPreferenceCenter/Source/**/*.{swift}" preferenceCenter.dependency "Airship/Core" end s.subspec "FeatureFlags" do |airshipFeatureFlags| airshipFeatureFlags.ios.source_files = "Airship/AirshipFeatureFlags/Source/**/*.{swift}" airshipFeatureFlags.dependency "Airship/Core" end s.subspec "ObjectiveC" do |objectiveC| objectiveC.ios.source_files = "Airship/AirshipObjectiveC/Source/**/*.{swift}" objectiveC.dependency "Airship/Core" objectiveC.dependency "Airship/Automation" objectiveC.dependency "Airship/MessageCenter" objectiveC.dependency "Airship/PreferenceCenter" objectiveC.dependency "Airship/FeatureFlags" end end ================================================ FILE: Airship.xcworkspace/contents.xcworkspacedata ================================================ <?xml version="1.0" encoding="UTF-8"?> <Workspace version = "1.0"> <FileRef location = "group:Airship/Airship.xcodeproj"> </FileRef> <FileRef location = "group:AirshipExtensions/AirshipExtensions.xcodeproj"> </FileRef> <FileRef location = "group:DevApp/DevApp.xcodeproj"> </FileRef> <FileRef location = "group:DevApp watchOS/DevApp watchOS.xcodeproj"> </FileRef> </Workspace> ================================================ FILE: Airship.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>IDEDidComputeMac32BitWarning</key> <true/> </dict> </plist> ================================================ FILE: Airship.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict/> </plist> ================================================ FILE: Airship.xcworkspace/xcshareddata/swiftpm/Package.resolved ================================================ { "originHash" : "00fc82b0de716b0b6fae8646fae1c289e5b4679f93a4b0a9aca7e47cd71f9315", "pins" : [ { "identity" : "yams", "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams.git", "state" : { "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", "version" : "6.2.1" } } ], "version" : 3 } ================================================ FILE: AirshipExtensions/AirshipExtensions.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 53; objects = { /* Begin PBXBuildFile section */ 6014AD692C1CB6360072DCF0 /* ChallengeResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6014AD682C1CB6360072DCF0 /* ChallengeResolver.swift */; }; 60F8E7522B88AF8D00460EDF /* MediaAttachmentPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7512B88AF8D00460EDF /* MediaAttachmentPayload.swift */; }; 60F8E7542B89272300460EDF /* UANotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7532B89272300460EDF /* UANotificationServiceExtension.swift */; }; 60F8E7562B8D11B300460EDF /* MediaAttachmentPayloadTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7552B8D11B300460EDF /* MediaAttachmentPayloadTest.swift */; }; 60F8E7582B8D259B00460EDF /* UANotificationServiceExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F8E7572B8D259B00460EDF /* UANotificationServiceExtensionTests.swift */; }; 6E4AEE3A2B6B3E94008AEAC1 /* airship.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 6E4AEE382B6B3E2D008AEAC1 /* airship.jpg */; }; 6E66BB1A2D14F6B60083A9FD /* AirshipNotificationMutationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E66BB192D14F6B20083A9FD /* AirshipNotificationMutationProvider.swift */; }; 6EE62B122D668D1B00C2F53B /* AirshipExtensionLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE62B112D668D1600C2F53B /* AirshipExtensionLogger.swift */; }; 6EE62B162D66AD0800C2F53B /* AirshipExtensionConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EE62B152D66AD0400C2F53B /* AirshipExtensionConfig.swift */; }; DF0C1B19244E483C0011ACCA /* AirshipNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 49AA00FC1D65158C0081989A /* AirshipNotificationServiceExtension.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ DF0C1B1A244E483C0011ACCA /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 49AA00F31D65158C0081989A /* Project object */; proxyType = 1; remoteGlobalIDString = 49AA00FB1D65158C0081989A; remoteInfo = AirshipNotificationServiceExtension; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ 1BFF1862238543FF00013FB9 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; 1BFF1864238543FF00013FB9 /* UserNotificationsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotificationsUI.framework; path = System/Library/Frameworks/UserNotificationsUI.framework; sourceTree = SDKROOT; }; 49AA00FC1D65158C0081989A /* AirshipNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AirshipNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 49AA01001D65158C0081989A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 49AA010C1D65158C0081989A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 6014AD682C1CB6360072DCF0 /* ChallengeResolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChallengeResolver.swift; sourceTree = "<group>"; }; 60F8E7512B88AF8D00460EDF /* MediaAttachmentPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAttachmentPayload.swift; sourceTree = "<group>"; }; 60F8E7532B89272300460EDF /* UANotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UANotificationServiceExtension.swift; sourceTree = "<group>"; }; 60F8E7552B8D11B300460EDF /* MediaAttachmentPayloadTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaAttachmentPayloadTest.swift; sourceTree = "<group>"; }; 60F8E7572B8D259B00460EDF /* UANotificationServiceExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UANotificationServiceExtensionTests.swift; sourceTree = "<group>"; }; 6E4AEE382B6B3E2D008AEAC1 /* airship.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; name = airship.jpg; path = AirshipNotificationServiceExtension/airship.jpg; sourceTree = SOURCE_ROOT; }; 6E66BB192D14F6B20083A9FD /* AirshipNotificationMutationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipNotificationMutationProvider.swift; sourceTree = "<group>"; }; 6EE62B112D668D1600C2F53B /* AirshipExtensionLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipExtensionLogger.swift; sourceTree = "<group>"; }; 6EE62B152D66AD0400C2F53B /* AirshipExtensionConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipExtensionConfig.swift; sourceTree = "<group>"; }; DF0C1B14244E483C0011ACCA /* AirshipNotificationServiceExtensionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AirshipNotificationServiceExtensionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 49AA00F81D65158C0081989A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; DF0C1B11244E483C0011ACCA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( DF0C1B19244E483C0011ACCA /* AirshipNotificationServiceExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 1BFF1861238543FF00013FB9 /* Frameworks */ = { isa = PBXGroup; children = ( 1BFF1862238543FF00013FB9 /* UserNotifications.framework */, 1BFF1864238543FF00013FB9 /* UserNotificationsUI.framework */, ); name = Frameworks; sourceTree = "<group>"; }; 49AA00F21D65158C0081989A = { isa = PBXGroup; children = ( 49AA00FE1D65158C0081989A /* AirshipNotificationServiceExtension */, 1BFF1861238543FF00013FB9 /* Frameworks */, 49AA00FD1D65158C0081989A /* Products */, ); sourceTree = "<group>"; }; 49AA00FD1D65158C0081989A /* Products */ = { isa = PBXGroup; children = ( 49AA00FC1D65158C0081989A /* AirshipNotificationServiceExtension.framework */, DF0C1B14244E483C0011ACCA /* AirshipNotificationServiceExtensionTests.xctest */, ); name = Products; sourceTree = "<group>"; }; 49AA00FE1D65158C0081989A /* AirshipNotificationServiceExtension */ = { isa = PBXGroup; children = ( 49AA01001D65158C0081989A /* Info.plist */, 6E21736A237CCEDA0084933A /* Source */, 49AA01091D65158C0081989A /* Tests */, ); path = AirshipNotificationServiceExtension; sourceTree = "<group>"; }; 49AA01091D65158C0081989A /* Tests */ = { isa = PBXGroup; children = ( 6E4AEE382B6B3E2D008AEAC1 /* airship.jpg */, 49AA010C1D65158C0081989A /* Info.plist */, 60F8E7552B8D11B300460EDF /* MediaAttachmentPayloadTest.swift */, 60F8E7572B8D259B00460EDF /* UANotificationServiceExtensionTests.swift */, ); path = Tests; sourceTree = "<group>"; }; 6E21736A237CCEDA0084933A /* Source */ = { isa = PBXGroup; children = ( 6EE62B152D66AD0400C2F53B /* AirshipExtensionConfig.swift */, 6EE62B112D668D1600C2F53B /* AirshipExtensionLogger.swift */, 6014AD682C1CB6360072DCF0 /* ChallengeResolver.swift */, 6E66BB192D14F6B20083A9FD /* AirshipNotificationMutationProvider.swift */, 60F8E7512B88AF8D00460EDF /* MediaAttachmentPayload.swift */, 60F8E7532B89272300460EDF /* UANotificationServiceExtension.swift */, ); path = Source; sourceTree = "<group>"; }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ 49AA00F91D65158C0081989A /* Headers */ = { isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ 49AA00FB1D65158C0081989A /* AirshipNotificationServiceExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 49AA01101D65158C0081989A /* Build configuration list for PBXNativeTarget "AirshipNotificationServiceExtension" */; buildPhases = ( 49AA00F71D65158C0081989A /* Sources */, 49AA00F81D65158C0081989A /* Frameworks */, 49AA00F91D65158C0081989A /* Headers */, 49AA00FA1D65158C0081989A /* Resources */, ); buildRules = ( ); dependencies = ( ); name = AirshipNotificationServiceExtension; productName = AirshipAppExtensions; productReference = 49AA00FC1D65158C0081989A /* AirshipNotificationServiceExtension.framework */; productType = "com.apple.product-type.framework"; }; DF0C1B13244E483C0011ACCA /* AirshipNotificationServiceExtensionTests */ = { isa = PBXNativeTarget; buildConfigurationList = DF0C1B1C244E483C0011ACCA /* Build configuration list for PBXNativeTarget "AirshipNotificationServiceExtensionTests" */; buildPhases = ( DF0C1B10244E483C0011ACCA /* Sources */, DF0C1B11244E483C0011ACCA /* Frameworks */, DF0C1B12244E483C0011ACCA /* Resources */, ); buildRules = ( ); dependencies = ( DF0C1B1B244E483C0011ACCA /* PBXTargetDependency */, ); name = AirshipNotificationServiceExtensionTests; productName = AirshipNotificationServiceExtensionTests; productReference = DF0C1B14244E483C0011ACCA /* AirshipNotificationServiceExtensionTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 49AA00F31D65158C0081989A /* Project object */ = { isa = PBXProject; attributes = { LastSwiftUpdateCheck = 1140; LastUpgradeCheck = 1250; ORGANIZATIONNAME = "Urban Airship"; TargetAttributes = { 49AA00FB1D65158C0081989A = { CreatedOnToolsVersion = 8.0; DevelopmentTeam = PGJV57GD94; LastSwiftMigration = 1520; ProvisioningStyle = Automatic; }; DF0C1B13244E483C0011ACCA = { CreatedOnToolsVersion = 11.4; LastSwiftMigration = 1140; }; }; }; buildConfigurationList = 49AA00F61D65158C0081989A /* Build configuration list for PBXProject "AirshipExtensions" */; compatibilityVersion = "Xcode 11.4"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 49AA00F21D65158C0081989A; productRefGroup = 49AA00FD1D65158C0081989A /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 49AA00FB1D65158C0081989A /* AirshipNotificationServiceExtension */, DF0C1B13244E483C0011ACCA /* AirshipNotificationServiceExtensionTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 49AA00FA1D65158C0081989A /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; DF0C1B12244E483C0011ACCA /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 6E4AEE3A2B6B3E94008AEAC1 /* airship.jpg in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 49AA00F71D65158C0081989A /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 60F8E7522B88AF8D00460EDF /* MediaAttachmentPayload.swift in Sources */, 60F8E7542B89272300460EDF /* UANotificationServiceExtension.swift in Sources */, 6EE62B162D66AD0800C2F53B /* AirshipExtensionConfig.swift in Sources */, 6E66BB1A2D14F6B60083A9FD /* AirshipNotificationMutationProvider.swift in Sources */, 6EE62B122D668D1B00C2F53B /* AirshipExtensionLogger.swift in Sources */, 6014AD692C1CB6360072DCF0 /* ChallengeResolver.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; DF0C1B10244E483C0011ACCA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 60F8E7582B8D259B00460EDF /* UANotificationServiceExtensionTests.swift in Sources */, 60F8E7562B8D11B300460EDF /* MediaAttachmentPayloadTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ DF0C1B1B244E483C0011ACCA /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 49AA00FB1D65158C0081989A /* AirshipNotificationServiceExtension */; targetProxy = DF0C1B1A244E483C0011ACCA /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 49AA010E1D65158C0081989A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = 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_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu99; 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 = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; name = Debug; }; 49AA010F1D65158C0081989A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = 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_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu99; 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 = 16.0; MTL_ENABLE_DEBUG_INFO = NO; ONLY_ACTIVE_ARCH = NO; SDKROOT = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; }; name = Release; }; 49AA01111D65158C0081989A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = PGJV57GD94; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "$(SRCROOT)/AirshipNotificationServiceExtension/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipNotificationServiceExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; 49AA01121D65158C0081989A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; CLANG_ENABLE_MODULES = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = ""; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = PGJV57GD94; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = "$(SRCROOT)/AirshipNotificationServiceExtension/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipNotificationServiceExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_UPCOMING_FEATURE_CONCISE_MAGIC_FILE = YES; SWIFT_UPCOMING_FEATURE_DEPRECATE_APPLICATION_MAIN = YES; SWIFT_UPCOMING_FEATURE_DISABLE_OUTWARD_ACTOR_ISOLATION = YES; SWIFT_UPCOMING_FEATURE_EXISTENTIAL_ANY = YES; SWIFT_UPCOMING_FEATURE_FORWARD_TRAILING_CLOSURES = YES; SWIFT_UPCOMING_FEATURE_GLOBAL_CONCURRENCY = YES; SWIFT_UPCOMING_FEATURE_IMPLICIT_OPEN_EXISTENTIALS = YES; SWIFT_UPCOMING_FEATURE_INFER_SENDABLE_FROM_CAPTURES = YES; SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT = YES; SWIFT_UPCOMING_FEATURE_ISOLATED_DEFAULT_VALUES = YES; SWIFT_UPCOMING_FEATURE_REGION_BASED_ISOLATION = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; DF0C1B1D244E483C0011ACCA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = JZP4756KMT; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = AirshipNotificationServiceExtension/Tests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipNotificationServiceExtensionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; DF0C1B1E244E483C0011ACCA /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_WEAK = YES; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = JZP4756KMT; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = AirshipNotificationServiceExtension/Tests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks", ); MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.AirshipNotificationServiceExtensionTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OBJC_BRIDGING_HEADER = ""; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 49AA00F61D65158C0081989A /* Build configuration list for PBXProject "AirshipExtensions" */ = { isa = XCConfigurationList; buildConfigurations = ( 49AA010E1D65158C0081989A /* Debug */, 49AA010F1D65158C0081989A /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 49AA01101D65158C0081989A /* Build configuration list for PBXNativeTarget "AirshipNotificationServiceExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( 49AA01111D65158C0081989A /* Debug */, 49AA01121D65158C0081989A /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; DF0C1B1C244E483C0011ACCA /* Build configuration list for PBXNativeTarget "AirshipNotificationServiceExtensionTests" */ = { isa = XCConfigurationList; buildConfigurations = ( DF0C1B1D244E483C0011ACCA /* Debug */, DF0C1B1E244E483C0011ACCA /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 49AA00F31D65158C0081989A /* Project object */; } ================================================ FILE: AirshipExtensions/AirshipExtensions.xcodeproj/xcshareddata/xcschemes/AirshipNotificationServiceExtension.xcscheme ================================================ <?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1600" version = "1.3"> <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" buildForRunning = "YES" buildForProfiling = "YES" buildForArchiving = "YES" buildForAnalyzing = "YES"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "49AA00FB1D65158C0081989A" BuildableName = "AirshipNotificationServiceExtension.framework" BlueprintName = "AirshipNotificationServiceExtension" ReferencedContainer = "container:AirshipExtensions.xcodeproj"> </BuildableReference> </BuildActionEntry> </BuildActionEntries> </BuildAction> <TestAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> <Testables> <TestableReference skipped = "NO"> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "DF0C1B13244E483C0011ACCA" BuildableName = "AirshipNotificationServiceExtensionTests.xctest" BlueprintName = "AirshipNotificationServiceExtensionTests" ReferencedContainer = "container:AirshipExtensions.xcodeproj"> </BuildableReference> </TestableReference> </Testables> </TestAction> <LaunchAction buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> </LaunchAction> <ProfileAction buildConfiguration = "Release" shouldUseLaunchSchemeArgsEnv = "YES" savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" BlueprintIdentifier = "49AA00FB1D65158C0081989A" BuildableName = "AirshipNotificationServiceExtension.framework" BlueprintName = "AirshipNotificationServiceExtension" ReferencedContainer = "container:AirshipExtensions.xcodeproj"> </BuildableReference> </MacroExpansion> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> </AnalyzeAction> <ArchiveAction buildConfiguration = "Release" revealArchiveInOrganizer = "YES"> </ArchiveAction> </Scheme> ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>en</string> <key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundlePackageType</key> <string>FMWK</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>$(CURRENT_PROJECT_VERSION)</string> <key>NSPrincipalClass</key> <string></string> </dict> </plist> ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Source/AirshipExtensionConfig.swift ================================================ /* Copyright Airship and Contributors */ import Foundation /// Airship extension config. Can be supplied with the property `airshipExtensionConfig` in `UANotificationServiceExtension`. public struct AirshipExtensionConfig { /// Log level. For no logs, use `nil`. Defaults to `error`. public var logLevel: AirshipExtensionLogLevel? /// Log handler. If `nil`, no logs will be logged. Defaults to `.defaultLogger`. public var logHandler: (any AirshipExtensionLogHandler)? public init( logLevel: AirshipExtensionLogLevel? = .error, logHandler: (any AirshipExtensionLogHandler)? = .defaultLogger ) { self.logLevel = logLevel self.logHandler = logHandler } } ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Source/AirshipExtensionLogger.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import os /// Represents the possible log levels. public enum AirshipExtensionLogLevel: String, Sendable, Decodable { /** * Log error messages. * * Used for critical errors, parse exceptions and other situations that cannot be gracefully handled. */ case error /** * Log warning messages. * * Used for API deprecations, invalid setup and other potentially problematic situations. */ case warn /** * Log informative messages. * * Used for reporting general SDK status. */ case info /** * Log debugging messages. * * Used for reporting general SDK status with more detailed information. */ case debug /** * Log detailed verbose messages. * * Used for reporting highly detailed SDK status that can be useful when debugging and troubleshooting. */ case verbose } /// Protocol used by AirshipExtension to log all messages.. public protocol AirshipExtensionLogHandler: Sendable { /// Called to log a message. /// - Parameters: /// - logLevel: The Airship log level. /// - message: The log message. /// - fileID: The file ID. /// - line: The line number. /// - function: The function. func log( logLevel: AirshipExtensionLogLevel, message: String, fileID: String, line: UInt, function: String ) } /// /// Airship extension logger. /// final class AirshipExtensionLogger: Sendable { private let logHandler: (any AirshipExtensionLogHandler)? private let logLevel: AirshipExtensionLogLevel? init( logHandler: (any AirshipExtensionLogHandler)? = .defaultLogger, logLevel: AirshipExtensionLogLevel? = .error ) { self.logHandler = logHandler self.logLevel = logLevel } func trace( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: .verbose, message: message(), fileID: fileID, line: line, function: function ) } func debug( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: .debug, message: message(), fileID: fileID, line: line, function: function ) } func info( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: .info, message: message(), fileID: fileID, line: line, function: function ) } func warn( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: .warn, message: message(), fileID: fileID, line: line, function: function ) } func error( _ message: @autoclosure () -> String, fileID: String = #fileID, line: UInt = #line, function: String = #function ) { log( logLevel: .error, message: message(), fileID: fileID, line: line, function: function ) } func log( logLevel: AirshipExtensionLogLevel, message: @autoclosure () -> String, fileID: String, line: UInt, function: String ) { guard let logHandler, let configuredLevel = self.logLevel, configuredLevel.intValue >= logLevel.intValue else { return } logHandler.log( logLevel: logLevel, message: message(), fileID: fileID, line: line, function: function ) } } public extension AirshipExtensionLogHandler where Self == DefaultAirshipExtensionLogHandler { /// Default logger static var defaultLogger: Self { return .init(logPublic: false) } /// Logger that logs publically static var publicLogger: Self { return .init(logPublic: true) } } public final class DefaultAirshipExtensionLogHandler: AirshipExtensionLogHandler { private let logPublic: Bool init(logPublic: Bool = false) { self.logPublic = logPublic } private static let logger: Logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "", category: "AirshipNotificationExtension" ) public func log( logLevel: AirshipExtensionLogLevel, message: String, fileID: String, line: UInt, function: String ) { let logMessage = "[\(logLevel.initial)] \(fileID) \(function) [Line \(line)] \(message)" if (logPublic) { Self.logger.notice( "\(logMessage, privacy: .public)" ) } else { Self.logger.log( level: logLevel.logType, "\(logMessage, privacy: .private)" ) } } } extension AirshipExtensionLogLevel { fileprivate var initial: String { switch self { case .verbose: return "V" case .debug: return "D" case .info: return "I" case .warn: return "W" case .error: return "E" } } fileprivate var logType: OSLogType { switch self { case .verbose: return OSLogType.debug case .debug: return OSLogType.debug case .info: return OSLogType.info case .warn: return OSLogType.default case .error: return OSLogType.error } } fileprivate var intValue: Int { switch(self) { case .error: 1 case .warn: 2 case .info: 3 case .debug: 4 case .verbose: 5 } } } ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Source/AirshipNotificationMutationProvider.swift ================================================ import UniformTypeIdentifiers @preconcurrency import UserNotifications final class AirshipNotificationMutationProvider: Sendable { static let supportedExtensions = ["jpg", "jpeg", "png", "gif", "aif", "aiff", "mp3", "mpg", "mpeg", "mp4", "m4a", "wav", "avi"] let logger: AirshipExtensionLogger init(logger: AirshipExtensionLogger) { self.logger = logger } func mutations(for args: MediaAttachmentPayload) async throws -> AirshipNotificationMutations? { let attachments = try await withThrowingTaskGroup(of: AirshipAttachment?.self) { [weak self, args] group in try Task.checkCancellation() var attachments: [AirshipAttachment] = [] args.media.forEach { media in group.addTask { [weak self] in try Task.checkCancellation() return try await self?.load( attachment: media, defaultOptions: args.options, thumbnailID: args.thumbnailID ) } } for try await result in group { try Task.checkCancellation() if let result = result { attachments.append(result) } } return attachments } return AirshipNotificationMutations( title: args.textContent?.title, subtitle: args.textContent?.subtitle, body: args.textContent?.body, attachments: attachments ) } private func load( attachment: MediaAttachmentPayload.ContentMedia, defaultOptions: MediaAttachmentPayload.PayloadOptions, thumbnailID: String? ) async throws -> AirshipAttachment { try Task.checkCancellation() logger.debug("Downloading attachment \(attachment.url)") let (localURL, response) = try await download(url: attachment.url) logger.debug("Downloading attachment result: \(response)") try Task.checkCancellation() var mimeType = response.mimeType if mimeType == nil, let httpResponse = response as? HTTPURLResponse { mimeType = httpResponse.allHeaderFields["Content-Type"] as? String } let identifier = attachment.urldID ?? "" let hideThumbnail = thumbnailID != nil && thumbnailID != identifier return try makeAttachement( localURL: localURL, remoteURL: attachment.url, mimeType: mimeType, options: defaultOptions.generateNotificationAttachmentOptions(hideThumbnail: hideThumbnail), identifier: identifier ) } private func makeAttachement( localURL: URL, remoteURL: URL, mimeType: String?, options: [String: any Sendable], identifier: String ) throws -> AirshipAttachment { let fileURL = try correctFileExtension(for: localURL, original: remoteURL) var mutableOptions = options let hasExtension = Self.supportedExtensions.contains { item in return fileURL.lastPathComponent.lowercased().hasSuffix(item) } if !hasExtension, let hint = hintMimeType(for: fileURL, mimeType: mimeType) { mutableOptions[UNNotificationAttachmentOptionsTypeHintKey] = hint } return AirshipAttachment(identifier: identifier, url: fileURL, options: mutableOptions) } private func hintMimeType(for file: URL, mimeType: String?) -> String? { if let type = mimeType, let uti = UTType(mimeType: type), let fileExtension = uti.preferredFilenameExtension, Self.supportedExtensions.contains(fileExtension) { return uti.identifier } if let data = try? Data(contentsOf: file, options: .mappedRead) { return FileHeader.supportedHeaders.first(where: { $0.matches(data: data) })?.type } return nil } private func correctFileExtension(for localURL: URL, original: URL) throws -> URL { let destination = URL(fileURLWithPath: localURL.path.appending("-\(original.lastPathComponent)")) if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } try FileManager.default.moveItem(at: localURL, to: destination) return destination } private func download(url: URL) async throws -> (URL, URLResponse) { let session = URLSession( configuration: .default, delegate: ChallengeResolver.shared, delegateQueue: nil ) return try await session.download(from: url) } private struct FileHeader { let type: String let offset: Int let headers: [[UInt8]] func matches(data: Data) -> Bool { var result = false for expectedHeader in headers { if data.count < offset + expectedHeader.count { continue } var currentHeader = [UInt8](repeating: 0, count: expectedHeader.count) data.copyBytes(to: ¤tHeader, from: offset..<(offset + expectedHeader.count)) if currentHeader == expectedHeader { result = true break } } return result } static let supportedHeaders = [ FileHeader(type: "public.jpeg", offset: 0, headers: [ [0xFF, 0xD8, 0xFF, 0xE0], [0xFF, 0xD8, 0xFF, 0xE2], [0xFF, 0xD8, 0xFF, 0xE3] ]), FileHeader(type: "public.png", offset: 0, headers: [[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]]), FileHeader(type: "com.compuserve.gif", offset: 0, headers: [[0x47, 0x49, 0x46, 0x38]]), FileHeader(type: "public.aiff-audio", offset: 0, headers: [[0x46, 0x4F, 0x52, 0x4D, 0x00]]), FileHeader(type: "com.microsoft.waveform-audio", offset: 8, headers: [[0x57, 0x41, 0x56, 0x45]]), FileHeader(type: "public.avi", offset: 8, headers: [[0x41, 0x56, 0x49, 0x20]]), FileHeader(type: "public.mp3", offset: 0, headers: [[0x49, 0x44, 0x33]]), FileHeader(type: "public.mpeg-4", offset: 4, headers: [ [0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x31], [0x66, 0x74, 0x79, 0x70, 0x6D, 0x70, 0x34, 0x32], [0x66, 0x74, 0x79, 0x70, 0x6D, 0x6D, 0x70, 0x34], [0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d] ]), FileHeader(type: "public.mpeg-4-audio", offset: 4, headers: [[0x66, 0x74, 0x79, 0x70, 0x4D, 0x34, 0x41, 0x20]]), FileHeader(type: "public.mpeg", offset: 0, headers: [ [0x00, 0x00, 0x01, 0xBA], [0x00, 0x00, 0x01, 0xB3] ]) ] } } struct AirshipNotificationMutations: Sendable { var title: String? var subtitle: String? var body: String? var attachments: [AirshipAttachment] func apply(to notificationContent: UNMutableNotificationContent) throws { try attachments .map { try $0.notificationAttachment } .forEach { notificationContent.attachments.append($0) } if let title = title { notificationContent.title = title } if let subtitle = subtitle { notificationContent.subtitle = subtitle } if let body = body { notificationContent.body = body } } } struct AirshipAttachment: Sendable { var identifier: String var url: URL var options: [String : any Sendable] var notificationAttachment: UNNotificationAttachment { get throws { try UNNotificationAttachment( identifier: self.identifier, url: self.url, options: self.options ) } } } ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Source/ChallengeResolver.swift ================================================ /* Copyright Airship and Contributors */ public import Foundation /** * Authentication challenge resolver class * @note For internal use only. :nodoc: */ public final class ChallengeResolver: NSObject, Sendable { public static let shared = ChallengeResolver() @MainActor var resolver: ChallengeResolveClosure? private override init() {} public func resolve( _ challenge: URLAuthenticationChallenge ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { guard let resolver = await self.resolver, challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, challenge.protectionSpace.serverTrust != nil else { return (.performDefaultHandling, nil) } return resolver(challenge) } } extension ChallengeResolver: URLSessionTaskDelegate { public func urlSession( _ session: URLSession, didReceive challenge: URLAuthenticationChallenge ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await self.resolve(challenge) } public func urlSession( _ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { return await self.resolve(challenge) } } public typealias ChallengeResolveClosure = @Sendable (URLAuthenticationChallenge) -> (URLSession.AuthChallengeDisposition, URLCredential?) ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Source/MediaAttachmentPayload.swift ================================================ /* Copyright Airship and Contributors */ import Foundation #if !os(tvOS) import UserNotifications struct MediaAttachmentPayload: Sendable, Decodable { let media: [ContentMedia] let textContent: ContentText? let options: PayloadOptions let thumbnailID: String? enum CodingKeys: String, CodingKey { case url = "url" case urlArray = "urls" case thumbnail = "thumbnail_id" case options = "options" case content = "content" } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let payloadUrl: [[String: String]] if container.contains(.urlArray) { payloadUrl = try container.decode([[String: String]].self, forKey: .urlArray) } else if container.contains(.url) { let urls: [String] do { urls = try container.decode([String].self, forKey: .url) } catch { urls = [try container.decode(String.self, forKey: .url)] } payloadUrl = urls.map({ [ContentMedia.CodingKeys.url.rawValue: $0] }) } else { throw DecodingError.keyNotFound(CodingKeys.url, .init(codingPath: container.codingPath, debugDescription: "Failed to parse URLs")) } self.media = payloadUrl.compactMap(ContentMedia.init) self.thumbnailID = try container.decodeIfPresent(String.self, forKey: .thumbnail) self.options = try container.decodeIfPresent(PayloadOptions.self, forKey: .options) ?? PayloadOptions() self.textContent = try container.decodeIfPresent(ContentText.self, forKey: .content) } struct ContentText: Decodable, Sendable { let title: String? let subtitle: String? let body: String? enum CodingKeys: String, CodingKey { case title case subtitle case body } } struct ContentMedia: Sendable { let url: URL let urldID: String? enum CodingKeys: String, CodingKey { case url = "url" case urlID = "url_id" } init?(source: [String: String]) { guard let urlString = source[CodingKeys.url.rawValue], let url = URL(string: urlString) else { return nil } self.url = url var isValid = true (isValid, self.urldID) = validateAndParse(source[CodingKeys.urlID.rawValue]) if !isValid { return nil } } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let urlString = try container.decode(String.self, forKey: .url) guard let url = URL(string: urlString) else { throw DecodingError.typeMismatch(URL.self, .init(codingPath: container.codingPath, debugDescription: "Failed to parse URL \(urlString)")) } self.url = url self.urldID = try container.decodeIfPresent(String.self, forKey: .urlID) } } struct PayloadOptions: Decodable, Sendable { private static let cropRequiredFields = ["x", "y", "width", "height"] let crop: [String: Double]? let time: Double? let hidden: Bool? enum CodingKeys: String, CodingKey { case crop = "crop" case time = "time" case hidden = "hidden" } init() { self.crop = nil self.time = nil self.hidden = nil } init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.time = try container.decodeIfPresent(Double.self, forKey: .time) self.hidden = try container.decodeIfPresent(Bool.self, forKey: .hidden) self.crop = try container.decodeIfPresent([String: Double].self, forKey: .crop) try validate() } private func validate() throws { guard let crop = self.crop else { return } for requiredKey in Self.cropRequiredFields { guard let value = crop[requiredKey], value >= 0.0 && value <= 1.0 else { throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Failed to crop key \(requiredKey) \(crop)")) } } } func generateNotificationAttachmentOptions(hideThumbnail: Bool) -> [String: any Sendable] { var result: [String: any Sendable] = [UNNotificationAttachmentOptionsThumbnailHiddenKey: hideThumbnail] if let crop = self.crop { let normalized = crop.reduce(into: [String: Double]()) { partialResult, entry in partialResult[entry.key.capitalized] = entry.value } result[UNNotificationAttachmentOptionsThumbnailClippingRectKey] = normalized } if let time = self.time { result[UNNotificationAttachmentOptionsThumbnailTimeKey] = time } if let hidden = self.hidden { result[UNNotificationAttachmentOptionsThumbnailHiddenKey] = hidden } return result } } } extension MediaAttachmentPayload { private static func validateAndParse<T>(_ value: Any?) -> (Bool, T?) { guard let value = value else { return (true, nil) } guard let parsed = value as? T else { return (false, nil) } return (true, parsed) } } #endif ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Source/UANotificationServiceExtension.swift ================================================ /* Copyright Airship and Contributors */ import Foundation @preconcurrency public import UserNotifications #if !os(tvOS) @objc open class UANotificationServiceExtension: UNNotificationServiceExtension { open var airshipConfig: AirshipExtensionConfig { .init() } private var onExpire: (@Sendable () -> Void)? open override func didReceive( _ request: UNNotificationRequest, withContentHandler contentHandler: @Sendable @escaping (UNNotificationContent) -> Void ) { let config = airshipConfig let logger = AirshipExtensionLogger( logHandler: config.logHandler, logLevel: config.logLevel ) let downloadTask = Task { @MainActor in logger.debug( "New request received: \(request)" ) guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else { logger.error( "Unable to make mutable copy of request" ) try Task.checkCancellation() contentHandler(request.content) return } do { let args = try request.mediaAttachmentPayload guard let args else { logger.debug( "Finishing request, no Airship args: \(request.identifier)" ) try Task.checkCancellation() contentHandler(request.content) return } logger.info( "Found Airship arguments for request \(request.identifier): \(args)" ) let mutationsProvider = AirshipNotificationMutationProvider( logger: logger ) try await mutationsProvider.mutations(for: args)?.apply(to: mutableContent) } catch { logger.error( "Failed to apply mutations to request \(request.identifier): \(error)" ) } try Task.checkCancellation() logger.info( "Finished processing request: \(request.identifier): \(mutableContent)" ) contentHandler(mutableContent) } self.onExpire = { logger.error( "serviceExtensionTimeWillExpire expiring, canceling airshipTask" ) downloadTask.cancel() contentHandler(request.content) } } open override func serviceExtensionTimeWillExpire() { self.onExpire?() } } extension UNNotificationRequest { fileprivate static let airshipMediaAttachment = "com.urbanairship.media_attachment" // Checks if the request is from Airship public var isAirship: Bool { return containsAirshipMediaAttachments || self.content.userInfo["com.urbanairship.metadata"] != nil || self.content.userInfo["_"] != nil } /// Checks if the request is from Airship and contains media attachments public var containsAirshipMediaAttachments: Bool { return self.content.userInfo[Self.airshipMediaAttachment] != nil } var mediaAttachmentPayload: MediaAttachmentPayload? { get throws { guard let source = content.userInfo[Self.airshipMediaAttachment], let payloadInfo = source as? [String: Any] else { return nil } let data = try JSONSerialization.data(withJSONObject: payloadInfo) return try JSONDecoder().decode(MediaAttachmentPayload.self, from: data) } } } #endif ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Tests/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleDevelopmentRegion</key> <string>$(DEVELOPMENT_LANGUAGE)</string> <key>CFBundleExecutable</key> <string>$(EXECUTABLE_NAME)</string> <key>CFBundleIdentifier</key> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <key>CFBundleInfoDictionaryVersion</key> <string>6.0</string> <key>CFBundleName</key> <string>$(PRODUCT_NAME)</string> <key>CFBundlePackageType</key> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <key>CFBundleShortVersionString</key> <string>1.0</string> <key>CFBundleVersion</key> <string>1</string> </dict> </plist> ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Tests/MediaAttachmentPayloadTest.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipNotificationServiceExtension import UserNotifications @Suite("Media Attachment Payload") struct MediaAttachmentPayloadTest { @Test func airshipEmptyPayload() { let payload = decodeFrom(source: [:]) #expect(payload == nil) } @Test func airshipURLPayloads() { //invalid payload let decoded = decodeFrom(source: [ "url": [:] ]) #expect(decoded == nil) // Test valid contents of the url when it is an empty array var payload = decodeFrom(source: [ "url": [] ]) #expect(payload != nil) #expect(0 == payload?.media.count) // Airship payload payload = decodeFrom(source: [ "url": "https://sample.url" ]) #expect(payload != nil) #expect(1 == payload?.media.count) #expect("https://sample.url" == payload?.media.first?.url.absoluteString) // Test contents of the url when it is an array with valid urls payload = decodeFrom(source: [ "url": ["https://sample.url", "http://sample1.url"] ]) #expect(payload != nil) #expect(2 == payload?.media.count) #expect("https://sample.url" == payload?.media.first?.url.absoluteString) #expect("http://sample1.url" == payload?.media.last?.url.absoluteString) } @Test func airshipURLSPayloads() { //invalid #expect(decodeFrom(source: ["urls": [:]]) == nil) // Test contents of the url when it is an array with invalid urls var payload = decodeFrom(source: ["urls": [1]]) #expect(payload == nil) // VALID PAYLOADS // "url" key is ignored if "urls" is present payload = decodeFrom(source: [ "url": "https://test.url", "urls": [] ]) #expect(payload != nil) #expect(0 == payload?.media.count) // Test contents of the url when it is an array with valid urls payload = decodeFrom(source: [ "urls": [ [ "url": "http://sample1.url", "url_id": "sample-1-id" ], [ "url": "http://sample2.url", "url_id": "sample-2-id" ], ] ]) #expect("http://sample1.url" == payload?.media.first?.url.absoluteString) #expect("sample-1-id" == payload?.media.first?.urldID) #expect("http://sample2.url" == payload?.media.last?.url.absoluteString) #expect("sample-2-id" == payload?.media.last?.urldID) } @Test func airshipOptionsPayloads() { // NOT VALID PAYLOADS #expect( decodeFrom( source: [ "url": "https://test.ur", "options": [] ] ) == nil ) // VALID PAYLOADS let payload = decodeFrom(source: [ "url": "https://test.ur", "options": [:] ]) #expect(payload != nil) #expect(payload?.options != nil) #expect(payload?.options.crop == nil) #expect(payload?.options.hidden == nil) #expect(payload?.options.time == nil) } @Test func airshipCropOptionsPayloads() { // NOT VALID PAYLOADS var payload = decodeFrom(source: [ "url": "https://test.ur", "options": [ "crop": "" ] ]) #expect(payload == nil) // Empty crop dictionary payload = decodeFrom(source: [ "url": "https://test.ur", "options": [ "crop": [:] ] ]) #expect(payload == nil) // Missing crop option payload = decodeFrom(source: [ "url": "https://test.ur", "options": [ "crop": [ "y": 0, "width": 0.5, "height": 1 ] ] ]) #expect(payload == nil) // Non-valid crop option payload = decodeFrom(source: [ "url": "https://test.ur", "options": [ "crop": [ "x": 10, "y": 0, "width": 0.5, "height": 1 ] ] ]) #expect(payload == nil) // valid crop options payload = decodeFrom(source: [ "url": "https://test.ur", "options": [ "crop": [ "x": 1, "y": 1, "width": 0.5, "height": 1 ] ] ]) #expect(payload != nil) let generatedCrop = payload?.options.generateNotificationAttachmentOptions(hideThumbnail: false)[UNNotificationAttachmentOptionsThumbnailClippingRectKey] as? [String: Double] #expect(1 == generatedCrop?["X"]) #expect(1 == generatedCrop?["Y"]) #expect(0.5 == generatedCrop?["Width"]) #expect(1 == generatedCrop?["Height"]) } @Test func airshipTimeOptionPayloads() { // NOT VALID PAYLOADS #expect( decodeFrom( source: [ "url": "https://test.ur", "options": ["time": ""] ] ) == nil ) // VALID PAYLOADS let payload = decodeFrom(source: [ "url": "https://test.ur", "options": [ "time": 1.0 ] ]) #expect(payload != nil) #expect(1 == payload?.options.time) #expect(1 == payload?.options.generateNotificationAttachmentOptions(hideThumbnail: false)[UNNotificationAttachmentOptionsThumbnailTimeKey] as? Double) } @Test func airshipHiddenOptionPayloads() { // NOT VALID PAYLOADS #expect( decodeFrom( source: [ "url": "https://test.ur", "options": ["hidden": ""] ] ) == nil ) // VALID PAYLOADS let payload = decodeFrom(source: [ "url": "https://test.ur", "options": [ "hidden": true ] ]) #expect(payload != nil) #expect(true == payload?.options.hidden) #expect(true == payload?.options.generateNotificationAttachmentOptions(hideThumbnail: false)[UNNotificationAttachmentOptionsThumbnailHiddenKey] as? Bool) } @Test func airshipContentPayloads() { // NOT VALID PAYLOADS #expect( decodeFrom( source: [ "url": "https://test.ur", "content": "" ] ) == nil ) // non-valid content #expect( decodeFrom( source: [ "url": "https://test.ur", "content": [ "body": [:] ] ] ) == nil ) // VALID PAYLOADS // empty content var payload = decodeFrom(source: [ "url": "https://test.ur", "content": [:] ]) #expect(payload != nil) #expect(payload?.textContent != nil) #expect(payload?.textContent?.title == nil) #expect(payload?.textContent?.subtitle == nil) #expect(payload?.textContent?.body == nil) // minimal content payload = decodeFrom(source: [ "url": "https://test.ur", "content": ["title" : "sample title" ] ]) #expect(payload != nil) #expect(payload?.textContent != nil) #expect("sample title" == payload?.textContent?.title) #expect(payload?.textContent?.subtitle == nil) #expect(payload?.textContent?.body == nil) // complete content payload = decodeFrom(source: [ "url": "https://test.ur", "content": [ "title" : "sample title", "subtitle": "sample subtitle", "body": "sample body" ] ]) #expect(payload != nil) #expect(payload?.textContent != nil) #expect("sample title" == payload?.textContent?.title) #expect("sample subtitle" == payload?.textContent?.subtitle) #expect("sample body" == payload?.textContent?.body) } @Test func airshipThumbnailIDPayloads() { // NOT VALID PAYLOADS #expect( decodeFrom( source: [ "url": "https://test.ur", "thumbnail_id": 1 ] ) == nil ) // VALID PAYLOADS let payload = decodeFrom(source: [ "url": "https://test.ur", "thumbnail_id": "test-thumbnail" ]) #expect(payload != nil) #expect("test-thumbnail" == payload?.thumbnailID) } @Test func accengageThumbnailIDPayloads() { // NOT VALID PAYLOADS #expect( decodeFrom( source: [ "a4sid": "id", "url": "https://test.ur", "thumbnail_id": 1 ] ) == nil ) // VALID PAYLOADS let payload = decodeFrom(source: [ "a4sid": "id", "url": "https://test.ur", "thumbnail_id": "test-thumbnail" ]) #expect(payload != nil) #expect("test-thumbnail" == payload?.thumbnailID) } private func decodeFrom(source: [String: Any]) -> MediaAttachmentPayload? { let data = try! JSONSerialization.data(withJSONObject: source) return try? JSONDecoder().decode(MediaAttachmentPayload.self, from: data) } } ================================================ FILE: AirshipExtensions/AirshipNotificationServiceExtension/Tests/UANotificationServiceExtensionTests.swift ================================================ /* Copyright Airship and Contributors */ import Testing @testable import AirshipNotificationServiceExtension import UserNotifications import Foundation @Suite("U A Notification Service Extension") struct UANotificationServiceExtensionTests { private class BundleFinder {} let subject = UANotificationServiceExtension() @Test func emptyContent() async throws { // 1. Setup let content = UNNotificationContent() let request = UNNotificationRequest( identifier: "identifier", content: content, trigger: nil ) let deliveredContent: UNNotificationContent = try await withCheckedThrowingContinuation { continuation in subject.didReceive(request) { result in continuation.resume(returning: result) } } // 3. Assertions #expect(deliveredContent.attachments.isEmpty) #expect(deliveredContent.badge == nil) #expect(deliveredContent.sound == nil) #expect(deliveredContent.body.isEmpty) #expect(deliveredContent.title.isEmpty) #expect(deliveredContent.subtitle.isEmpty) #expect(deliveredContent.categoryIdentifier.isEmpty) #expect(deliveredContent.launchImageName.isEmpty) #expect(deliveredContent.threadIdentifier.isEmpty) #expect(deliveredContent.userInfo.isEmpty) #expect(deliveredContent.targetContentIdentifier == nil) } @Test func sampleContent() async throws { let fileUrl = try #require( Bundle(for: BundleFinder.self) .url(forResource: "airship", withExtension: "jpg") ) let content = UNMutableNotificationContent() content.body = "oh hi" content.categoryIdentifier = "news" content.userInfo = [ "_": "a323385b-010a-401c-93ae-936cb58dff04", "apps": [ "alert": "oh hi", "category": "news", "mutable-content": true ], "com.urbanairship.metadata": "eyJ2ZXJzaW9uX2lkIjoxLCJ0aW1lIjoxNTg3NTc2Mzk2NDM1LCJwdXNoX2lkIjoiNmUyNzQ1N2MtZDllNi00MWQ3LWJlZDYtNTAyMTkyNDA0NDI2In0=", "com.urbanairship.media_attachment": [ "url": fileUrl.absoluteString, "content": [ "title": "Moustache Twirl", "subtitle": "The saga of a bendy stache.", "body": "Have you ever seen a moustache like this?!" ], "options": [ "crop": [ "x": 0.25, "y": 0.25, "width": 0.5, "height": 0.5 ], "time": 15.0 ] ], ] let request = UNNotificationRequest(identifier: "4B2D08E6-8955-4964-8C15-6F7FEBC0EBB4", content: content, trigger: nil) let deliveredContent: UNNotificationContent = try await withCheckedThrowingContinuation { continuation in subject.didReceive(request) { result in continuation.resume(returning: result) } } try #require(deliveredContent.attachments.count == 1) let attachment = deliveredContent.attachments[0] #expect(FileManager.default.contentsEqual(atPath: attachment.url.path, andPath: fileUrl.path)) #expect("public.jpeg" == attachment.type) #expect(deliveredContent.badge == nil) #expect(deliveredContent.sound == nil) #expect(deliveredContent.targetContentIdentifier == nil) #expect("Moustache Twirl" == deliveredContent.title) #expect("The saga of a bendy stache." == deliveredContent.subtitle) #expect("Have you ever seen a moustache like this?!" == deliveredContent.body) #expect("news" == deliveredContent.categoryIdentifier) #expect("" == deliveredContent.launchImageName) #expect("" == deliveredContent.threadIdentifier) } } ================================================ FILE: AirshipServiceExtension.podspec ================================================ AIRSHIP_VERSION="20.6.2" Pod::Spec.new do |s| s.version = AIRSHIP_VERSION s.name = "AirshipServiceExtension" s.summary = "Airship iOS Service Extension" s.documentation_url = "https://docs.airship.com/platform/ios" s.homepage = "https://www.airship.com" s.license = { :type => "Apache License, Version 2.0", :file => "LICENSE" } s.author = { "Airship" => "support@airship.com" } s.source = { :git => "https://github.com/urbanairship/ios-library.git", :tag => s.version.to_s } s.source_files = "AirshipExtensions/AirshipNotificationServiceExtension/Source/**/*.{swift}" s.weak_frameworks = "UserNotifications" s.module_name = "AirshipServiceExtension" s.requires_arc = true s.ios.deployment_target = "15.0" s.watchos.deployment_target = "11.0" s.swift_versions = "5.0" s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } end ================================================ FILE: CHANGELOG.md ================================================ # iOS 20.x Changelog [Migration Guides](https://github.com/urbanairship/ios-library/tree/main/Documentation/Migration) [All Releases](https://github.com/urbanairship/ios-library/releases) ## Version 20.6.2 - April 2, 2026 Patch release that improves border rendering in Scenes. ### Changes - Support per-corner border for shapes drawn in Scenes ## Version 20.6.1 - March 26, 2026 Patch release that improves diagnostic logging for In-App Automation trigger processing. ### Changes - Improved trigger processor logging to aid diagnosis of chain trigger drop-off and unexpected count resets. ## Version 20.6.0 - March 20, 2026 Minor release that adds Objective-C wrappers for the Message Center native bridge to support .NET bindings, adds subscript and superscript text support in Scenes, and improves Message Center reliability. ### Changes - Added `UAMessageCenterNativeBridge` and `UAMessageCenterNativeBridgeDelegate` to support .NET MAUI bindings for Message Center web view deep link handling. - Added subscript and superscript text support in Scenes. - Fixed Message Center mark-as-read not updating the list UI. - Improved Message Center content type handling. - Improved Message Center behavior when a message is unavailable or deleted. ## Version 20.5.0 - March XX, 2026 Minor release that improves video playback and improves pager navigation reliability in Scenes. ### Changes - Improved pager navigation reliability by fixing a race condition that could cause desync when swiping rapidly during scroll. - Improved NPS score selector tap targets to remain consistent when toggling between selected and unselected states. - Improved video playback lifecycle handling to prevent potential memory leaks on dismiss. - Improved In-App Automation schedule parsing to retry failed schedules when remote data is updated. - Removed remaining Objective-C from AirshipBasement. - Replaced Objective-C based swizzling with a more reliable Swift implementation. - Fixed video aspect ratio to avoid cropping content when using center-inside display mode. ## Version 20.4.0 - February 24, 2026 Minor release that adds support for Native Message Center. Native content type requires displaying the message content in a `MessageCenterMessageView`. Apps that do not use Airship's message views (e.g. using a WebView directly) should filter out messages where `message.contentType` is not `.html`. ### Changes - Added support for Native Message Center. - Removed gzip encoding using internal UACompression class and `libz` dependency from AirshipBasement. - Added `ExpressibleBy` protocol conformances to `AirshipJSON` allowing initialization with literals. - Added `UAEmbeddedViewControllerFactory` to the `AirshipObjectiveC` module for embedding `AirshipEmbeddedView` in Objective-C applications. - Improved accessibility for single choice and multiple choice questions in Scenes. ## Version 20.3.2 - February 23, 2026 Patch release that fixes Message Center unread indicator behavior, improves spinner fallback on older iOS versions, and resolves visionOS availability checks. ### Changes - Fixed Message Center unread indicator rendering so the unread indicator is only shown for unread messages. - Fixed spinner behavior on iOS versions earlier than 18 by adding a rotating fallback icon. - Fixed visionOS compilation and availability handling for newer iOS and visionOS APIs. ## Version 20.3.1 - February 18, 2026 Patch release that fixes an Xcode 26.4 beta compilation issue and improves Scene stability. ### Changes - Fixed an Xcode 26.4 beta compilation issue. - Improved Scene stability by adding guards for non-finite layout values used in SwiftUI frame, offset, and position calculations. - Added additional Scene layout safety checks for container, pager, story indicator, video controls, and wrapping layout views. - Improved pager timer progression by safely handling zero and invalid page delays. ## Version 20.3.0 - January 30, 2026 Minor release that adds Objective-C wrapper for deep link processing and fixes a Message Center migration issue when upgrading from 17.x or older to 20.x. ### Changes - Added `UAirship.processDeepLink(_:completionHandler:)` Objective-C wrapper for programmatic deep link handling. - Fixed a Message Center migration issue when upgrading from 17.x or older to 20.x ## Version 20.2.0 - January 29, 2026 Minor release that adds Objective-C wrappers for .NET bindings and improves bundle resource lookup for SPM and Tuist projects. ### Changes - Added Objective-C wrappers for permissions manager and push notification status APIs to support .NET bindings. - Improved bundle resource lookup to better support Swift Package Manager and Tuist generated projects. - Fixed page tabbing, radio buttons, and checkbox accessibility in Scenes. - Improved banner IAA message accessibility. ## Version 20.1.1 - January 16, 2026 Patch release that improves VoiceOver focus control and sizing for the progress bar indicator in Story views. ### Changes - Added support for sizing inactive segments in Story view progress indicators. - Improved VoiceOver focus handling for Message Center Web content. ## Version 20.1.0 - January 9, 2026 Minor release that includes several fixes and improvements for Scenes, In-App Automations, and Message Center. ### Changes - Added support for Story pause/resume and back/next controls. - Added Scenes content support in Message Center. - Added support for additional text styles in Scenes. - In-App Automations and Scenes that were not available during app launch can now be triggered by events that happened in the previous 30 seconds. - Fixed pinned container background image scaling when the keyboard is visible in Scenes. - Fixed banner presentation and layout in Scenes. - Fixed progress icon rendering in Scenes. - Fixed container layout alignment in Scenes. ## Version 20.0.3 - December 17, 2025 Patch release that fixes keyboard safe area with Scenes. ### Changes - Fixed safe area handling during keyboard presentation in Scenes. ## Version 20.0.2 - November 24, 2025 Patch release that fixes an issue with delayed video playback in Scenes when initially loading or paging and addresses a direct open attribution race condition which could cause direct open events to be missed in some edge cases. ### Changes - Fixed an issue where the video ready callback was not assigned before observers were set up, causing the pager to miss the ready signal and advance before they loaded completely. - Fixed a potential race condition that could result in missed direct open attributions by ensuring notification response handling completes synchronously before the app becomes active. ## Version 20.0.1 - November 14, 2025 Patch release that fixes several minor bugs and adds accessibility improvements. ### Changes - Fixed looping behavior in video views within Scenes. - Fixed Message Center icon display when icons are enabled. - Fixed pager indicator accessibility to prevent duplicate VoiceOver announcements. - Added dismiss action to banner in-app messages for improved VoiceOver accessibility. - Fixed YouTube video embedding to comply with YouTube API Client identification requirements. ## Version 20.0.0 - October 9, 2025 Major SDK release with several breaking changes. See the [Migration Guide](https://github.com/urbanairship/ios-library/blob/main/Documentation/Migration/migration-guide-19-20.md) for more info. ### Changes - Xcode 26+ is now required. - Updated minimum deployment target to iOS 16+. - Refactored Message Center and Preference Center UI to provide clearer separation between navigation and content views. See the migration guide for API changes. - Introduced modern, block-based and async APIs as alternatives to common delegate protocols (`PushNotificationDelegate`, `DeepLinkDelegate`, etc.). The delegate pattern is still supported but will be deprecated in a future release. - Refactored core Airship components to use protocols instead of concrete classes, improving testability and modularity. See the migration guide for protocol renames and class-to-protocol conversions. - Added support for split view in the Message Center, improving the layout on larger devices. - Updated the Preference Center with a refreshed design and fixed UI issues on tvOS and visionOS. - Fixed Package.swift to remove macOS as a supported platform. - CustomViews within a Scene can now programmatically control their parent Scene, enabling more dynamic and interactive custom content. - Accessibility updates for Scenes. - New AirshipDebug package that exposes insights and debugging capabilities into the Airship SDK for development builds, providing enhanced visibility into SDK behavior and performance. - Removed automatic collection of `connection_type` and `carrier` device properties ================================================ FILE: DevApp/AirshipConfig.plist.sample ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>detectProvisioningMode</key> <true/> <key>developmentAppKey</key> <string>Your Development App Key</string> <key>developmentAppSecret</key> <string>Your Development App Secret</string> <key>productionAppKey</key> <string>Your Production App Key</string> <key>productionAppSecret</key> <string>Your Production App Secret</string> </dict> </plist> ================================================ FILE: DevApp/Dev App/AirshipConfig.plist.sample ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>detectProvisioningMode</key> <true/> <key>developmentAppKey</key> <string>Your Development App Key</string> <key>developmentAppSecret</key> <string>Your Development App Secret</string> <key>productionAppKey</key> <string>Your Production App Key</string> <key>productionAppSecret</key> <string>Your Production App Secret</string> </dict> </plist> ================================================ FILE: DevApp/Dev App/AppRouter.swift ================================================ /* Copyright Airship and Contributors */ import Combine import SwiftUI import AirshipMessageCenter @MainActor final class AppRouter: ObservableObject { let preferenceCenterID: String = "app_default" @Published var selectedTab: Tabs = .home @Published var homePath: [HomeRoute] = [] enum Tabs: Sendable, Equatable, Hashable { case home case messageCenter case preferenceCenter } enum HomeRoute: Hashable { case namedUser case thomas(ThomasRoute) } enum ThomasRoute: Hashable { case home case layoutList(LayoutType) } let messageCenterController: MessageCenterController = MessageCenterController() public func navigateMessagCenter(messageID: String? = nil) { messageCenterController.navigate(messageID: messageID) selectedTab = .messageCenter } } extension AppRouter.HomeRoute { @MainActor @ViewBuilder func destination() -> some View { switch self { case .namedUser: NamedUserView() case .thomas(let route): route.destination() } } } extension AppRouter.ThomasRoute { @MainActor @ViewBuilder func destination() -> some View { switch self { case .home: ThomasLayoutListView() case .layoutList(let type): if case .sceneEmbedded = type { EmbeddedPlaygroundMenuView() .navigationTitle("Embedded") } else { LayoutsList(layoutType: type, onOpen: ThomasLayoutViewModel.saveToRecent) .navigationTitle(type.navigationTitle) } } } } private extension LayoutType { var navigationTitle: String { switch(self) { case .sceneModal: return "Modals" case .sceneBanner: return "Banners" case .sceneEmbedded: return "Embedded" case .messageModal: return "Modals" case .messageBanner: return "Banners" case .messageFullscreen: return "Fullscreen" case .messageHTML: return "HTML" } } } ================================================ FILE: DevApp/Dev App/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "1.000", "green" : "0.294", "red" : "0.000" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "Icon-App-20x20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "filename" : "Icon-App-20x20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "filename" : "Icon-App-29x29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "filename" : "Icon-App-29x29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "Icon-App-40x40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "Icon-App-40x40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "filename" : "Icon-App-60x60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "filename" : "Icon-App-60x60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "filename" : "Icon-App-20x20@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "filename" : "Icon-App-20x20@2x-1.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "filename" : "Icon-App-29x29@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "filename" : "Icon-App-29x29@2x-1.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "filename" : "Icon-App-40x40@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "filename" : "Icon-App-40x40@2x-1.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "filename" : "Icon-App-76x76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "Icon-App-76x76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "Icon-App-83.5x83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "ItunesArtwork@2x.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }, { "filename" : "Icon-24@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" }, { "filename" : "Icon-27.5@2x.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" }, { "filename" : "Icon-29@2x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" }, { "filename" : "Icon-29@3x.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" }, { "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "33x33", "subtype" : "45mm" }, { "filename" : "Icon-40@2x.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" }, { "filename" : "Icon-86@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" }, { "filename" : "Icon-98@2x.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" }, { "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" }, { "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "117x117", "subtype" : "45mm" }, { "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "129x129", "subtype" : "49mm" }, { "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Assets.xcassets/HomeHeroImage.imageset/Contents.json ================================================ { "images" : [ { "filename" : "airshipMark.png", "idiom" : "universal", "scale" : "1x" }, { "filename" : "airshipMark-1.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "airshipMark-2.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/DevApp.entitlements ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>aps-environment</key> <string>development</string> </dict> </plist> ================================================ FILE: DevApp/Dev App/MainApp.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore #if DEBUG && canImport(AirshipDebug) import AirshipDebug #endif @main struct MainApp: App { let appRouter: AppRouter = AppRouter() let toast: Toast = Toast() @Environment(\.scenePhase) private var scenePhase init() { do { // Initialize Airship try AirshipInitializer.initialize() // Setup optional features LiveActivityHandler.setup() PushNotificationHandler.setup() DeepLinkHandler.setup(router: appRouter) { [weak toast] error in toast?.message = .init(text: "Invalid deepLink \(error)", duration: 2.0) } } catch { toast.message = .init(text: "Failed to initialize airship \(error)", duration: 2.0) } } var body: some Scene { WindowGroup { AppView() .environmentObject(appRouter) .environmentObject(toast) .airshipOnChangeOf(scenePhase) { phase in if phase == .active { print("App became active!") // Clear the badge on active Task { try await Airship.push.resetBadge() } } } .airshipDebug(triggers: [.shake, .cmdShiftD]) } } } ================================================ FILE: DevApp/Dev App/Setup/AirshipInitializer.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Foundation /// Example Airship SDK initialization handler. /// /// This is a sample implementation showing how to configure and initialize /// the Airship SDK with basic settings for development and production builds. /// /// - Note: This is an example - customize for your app's needs. struct AirshipInitializer { private init() {} /// Initializes Airship with example configuration. /// /// - Throws: An error if Airship initialization fails @MainActor static func initialize() throws { var config = try AirshipConfig.default() config.productionLogLevel = .verbose config.developmentLogLevel = .verbose #if DEBUG config.inProduction = false config.isAirshipDebugEnabled = true config.isWebViewInspectionEnabled = true #else config.inProduction = true #endif try Airship.takeOff(config) } } ================================================ FILE: DevApp/Dev App/Setup/DeepLinkHandler.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import AirshipMessageCenter import AirshipPreferenceCenter import Foundation /// Example deep linking and UI routing handler. /// /// This is a sample implementation showing how to handle deep links and /// route Airship features to your app's navigation system. /// /// - Note: This is an example - customize for your app's needs. struct DeepLinkHandler { /// Error types that can occur during deep link processing public enum DeepLinkError: Error, LocalizedError { case invalidURL(String) case unsupportedHost(String) case unsupportedPath(String) case routerNotAvailable public var errorDescription: String? { switch self { case .invalidURL(let url): return "Invalid deep link URL: \(url)" case .unsupportedHost(let host): return "Unsupported deep link host: \(host)" case .unsupportedPath(let path): return "Unsupported deep link path: \(path)" case .routerNotAvailable: return "App router is not available" } } } /// Sets up example deep linking and UI routing. /// /// - Parameters: /// - router: The app router that manages navigation state /// - onError: Optional callback for handling deep link errors @MainActor static func setup( router: AppRouter, onError: (@MainActor @Sendable (DeepLinkError) -> Void)? = nil ) { Airship.onDeepLink = { [weak router] url in do { try processDeepLink(url: url, router: router, onError: onError) } catch let error as DeepLinkError { onError?(error) } catch { onError?(.invalidURL(url.absoluteString)) } } // Preference Center routing Airship.preferenceCenter.onDisplay = { [weak router] identifier in guard identifier == router?.preferenceCenterID else { return false } router?.selectedTab = .preferenceCenter return true } // Message Center routing Airship.messageCenter.onDisplay = { [weak router] messageID in router?.navigateMessagCenter(messageID: messageID) return true } } @MainActor private static func processDeepLink( url: URL, router: AppRouter?, onError: ((DeepLinkError) -> Void)? ) throws { guard let router = router else { throw DeepLinkError.routerNotAvailable } guard url.host?.lowercased() == "deeplink" else { throw DeepLinkError.unsupportedHost(url.host ?? "nil") } let components = url.path.lowercased().split(separator: "/") guard let firstComponent = components.first else { throw DeepLinkError.unsupportedPath(url.path) } switch firstComponent { case "home": router.homePath = [] router.selectedTab = .home case "preferences": router.selectedTab = .preferenceCenter case "message_center": router.selectedTab = .messageCenter default: throw DeepLinkError.unsupportedPath(String(firstComponent)) } } } ================================================ FILE: DevApp/Dev App/Setup/LiveActivityHandler.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Foundation #if canImport(ActivityKit) import ActivityKit #endif /// Example Live Activity integration handler. /// /// This is a sample implementation showing how to integrate iOS Live Activities /// with Airship. /// /// - Note: This is an example - customize for your app's needs. struct LiveActivityHandler { /// Sets up example Live Activity integration. static func setup() { #if canImport(ActivityKit) && !os(macOS) // Restores live activity tracking Airship.channel.restoreLiveActivityTracking { restorer in // Call this for every type of Live Activity that you want // to update through Airship await restorer.restore( forType: Activity<DeliveryAttributes>.self ) } // Important for APNS started Live Activties. Watch for any actvities and makes sure // they are tracked on Airship. This will get called for all activities that are started // whenever a new live activity is started and on first invoke. Activity<DeliveryAttributes>.airshipWatchActivities { activity in // Track the live activity with Airship with the order number as the name Airship.channel.trackLiveActivity(activity, name: activity.attributes.orderNumber) } #endif } } ================================================ FILE: DevApp/Dev App/Setup/PushNotificationHandler.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Foundation /// Example push notification configuration handler. /// /// This is a sample implementation showing how to configure push notification /// settings and callbacks for the Airship SDK. /// /// - Note: This is an example - customize for your app's needs. struct PushNotificationHandler { /// Sets up example push notification handling. @MainActor static func setup() { // Default presentation options Airship.push.defaultPresentationOptions = [.sound, .banner, .list] // APNS registration Airship.push.onAPNSRegistrationFinished = { result in switch(result) { case .success(deviceToken: let deviceToken): print("APNS registration succeeded :) \(deviceToken)") case .failure(error: let error): print("APNS registration failed :( \(error)") @unknown default: fatalError() } } // Notification registration Airship.push.onNotificationRegistrationFinished = { result in print("Notification registration finished \(result.status)") } Airship.push.onNotificationAuthorizedSettingsDidChange = { settings in print("Authorized notification settings changed \(settings)") } } } ================================================ FILE: DevApp/Dev App/Thomas/CustomView/Examples/AdView.swift ================================================ ///* Copyright Airship and Contributors */ // //import SwiftUI //import GoogleMobileAds // //struct AdView: UIViewControllerRepresentable { // var keywords: [String]? // // @State private var viewWidth: CGFloat = UIScreen.main.bounds.width // @State private var isLoadingAd = true // Track ad loading state // // let googleTestAdUnitID = "ca-app-pub-3940256099942544/2934735716" // // func makeUIViewController(context: Context) -> UIViewController { // let bannerViewController = UIViewController() // let bannerView = GADBannerView(adSize: GADAdSizeFluid) // bannerView.adUnitID = googleTestAdUnitID // bannerView.rootViewController = bannerViewController // bannerView.delegate = context.coordinator // bannerView.translatesAutoresizingMaskIntoConstraints = false // bannerViewController.view.addSubview(bannerView) // bannerView.isAutoloadEnabled = true // // let loadingIndicator = UIActivityIndicatorView(style: .large) // loadingIndicator.startAnimating() // loadingIndicator.translatesAutoresizingMaskIntoConstraints = false // bannerViewController.view.addSubview(loadingIndicator) // // NSLayoutConstraint.activate([ // loadingIndicator.centerXAnchor.constraint(equalTo: bannerViewController.view.centerXAnchor), // loadingIndicator.centerYAnchor.constraint(equalTo: bannerViewController.view.centerYAnchor) // ]) // // NSLayoutConstraint.activate([ // bannerView.topAnchor.constraint(equalTo: bannerViewController.view.safeAreaLayoutGuide.topAnchor), // bannerView.leadingAnchor.constraint(equalTo: bannerViewController.view.safeAreaLayoutGuide.leadingAnchor), // bannerView.trailingAnchor.constraint(equalTo: bannerViewController.view.safeAreaLayoutGuide.trailingAnchor), // bannerView.bottomAnchor.constraint(equalTo: bannerViewController.view.safeAreaLayoutGuide.bottomAnchor) // ]) // // return bannerViewController // } // // func updateUIViewController(_ uiViewController: UIViewController, context: Context) { // if let loadingIndicator = uiViewController.view.subviews.first(where: { $0 is UIActivityIndicatorView }) as? UIActivityIndicatorView { // loadingIndicator.isHidden = !isLoadingAd // } // } // // func makeCoordinator() -> Coordinator { // Coordinator(self) // } // // class Coordinator: NSObject, GADBannerViewDelegate { // var parent: AdView // // init(_ parent: AdView) { // self.parent = parent // } // // func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { // print("Banner did receive ad") // // Update loading state // DispatchQueue.main.async { // self.parent.isLoadingAd = false // } // } // // func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { // print("Banner failed to receive ad with error: \(error.localizedDescription)") // // Update loading state // DispatchQueue.main.async { // self.parent.isLoadingAd = true // } // } // } //} ================================================ FILE: DevApp/Dev App/Thomas/CustomView/Examples/BiometricLoginView.swift ================================================ /* Copyright Airship and Contributors */ #if !os(tvOS) && !os(visionOS) && !os(macOS) import SwiftUI import LocalAuthentication enum LoginState { case ready case authenticated case fallback case loading } @MainActor class BiometricLoginViewModel:ObservableObject { init(context: LAContext = LAContext(), state: LoginState = .ready) { self.context = context self.state = state } var context:LAContext = LAContext() @Published var state:LoginState = .ready private func onAppear() { context.localizedCancelTitle = "Enter Username/Password" } func updateState(state:LoginState){ withAnimation{ self.state = state } } private func testPolicy(){ var error: NSError? guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { print(error?.localizedDescription ?? "Can't evaluate policy") self.updateState(state: .fallback) return } } func evaluatePolicy() { self.updateState(state: .loading) Task { do { try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Log in to your account") self.updateState(state: .authenticated) } catch let error { print(error.localizedDescription) self.updateState(state: .fallback) } } } } struct BiometricLoginView: View { @StateObject var viewModel:BiometricLoginViewModel = BiometricLoginViewModel() var loginButton: some View { Button(action: viewModel.evaluatePolicy) { HStack { Image(systemName: "faceid") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) Text("Login with Face ID").font(.title) } .padding() .foregroundColor(.white) .background(Color.cyan.opacity(0.7)) .cornerRadius(8) } } var readyView: some View{ VStack(alignment: .center) { Spacer() loginButton Spacer() } } var authenticatedView: some View{ VStack(alignment: .center) { Spacer() Text("Authenticated!").font(.title).foregroundColor(.white) Spacer() } } var fallbackView: some View{ VStack(alignment: .center) { Spacer() Text("Log in with password instead").font(.title).foregroundColor(.white) Spacer() } } var loadingView: some View{ VStack(alignment: .center) { Spacer() Text("Loading...").font(.title).foregroundColor(.white) Spacer() } } private func makeGlassmorphic<T: View>(_ content: T) -> some View { ZStack(alignment: .center) { CameraView().edgesIgnoringSafeArea(.all).offset(x:40) LinearGradient(colors: [Color.cyan.opacity(0.7), Color.purple.opacity(0.3)], startPoint: .topLeading, endPoint: .bottomTrailing) Circle() .frame(width: 300) .foregroundColor(Color.blue.opacity(0.3)) .blur(radius: 10) .offset(x: -100, y: -150) RoundedRectangle(cornerRadius: 30, style: .continuous) .frame(width: 500, height: 500) .foregroundStyle(LinearGradient(colors: [Color.purple.opacity(0.6), Color.mint.opacity(0.5)], startPoint: .top, endPoint: .leading)) .offset(x: 300) .blur(radius: 30) .rotationEffect(.degrees(30)) Circle() .frame(width: 450) .foregroundStyle(Color.pink.opacity(0.6)) .blur(radius: 20) .offset(x: 200, y: -200) content } .edgesIgnoringSafeArea(.all) } var body: some View { makeGlassmorphic( Group { switch viewModel.state { case .ready: readyView case .authenticated: authenticatedView case .fallback: fallbackView case .loading: loadingView } } ) } } #endif ================================================ FILE: DevApp/Dev App/Thomas/CustomView/Examples/CameraView.swift ================================================ #if !os(visionOS) && !os(macOS) /* Copyright Airship and Contributors */ import SwiftUI import AVFoundation struct CameraView : UIViewControllerRepresentable { func makeUIViewController(context: UIViewControllerRepresentableContext<CameraView>) -> UIViewController { let controller = CameraViewController() return controller } func updateUIViewController(_ uiViewController: CameraView.UIViewControllerType, context: UIViewControllerRepresentableContext<CameraView>) {} } class CameraViewController : UIViewController { override func viewDidLoad() { super.viewDidLoad() loadCamera() } func loadCamera() { let avSession = AVCaptureSession() guard let captureDevice = AVCaptureDevice.default(for: .video) else { return } guard let input = try? AVCaptureDeviceInput(device : captureDevice) else { return } avSession.addInput(input) avSession.startRunning() let cameraPreview = AVCaptureVideoPreviewLayer(session: avSession) view.layer.addSublayer(cameraPreview) cameraPreview.frame = view.frame cameraPreview.videoGravity = .resizeAspectFill } } #endif ================================================ FILE: DevApp/Dev App/Thomas/CustomView/Examples/MapRouteView.swift ================================================ #if !os(tvOS) && !os(macOS) import CoreLocation import MapKit import SwiftUI struct MapRouteView: View { @StateObject private var locationManager = LocationManager() var body: some View { Group { if let userLocation = locationManager.location { ZStack(alignment: .center) { MapView(userLocation: userLocation, destinationCoordinate: CLLocationCoordinate2D(latitude: 45.559020, longitude: -122.672970)) VStack { Spacer() Text("Lat:\(userLocation.latitude) Lon:\(userLocation.longitude)") .padding(8) .font(.footnote) .foregroundColor(.white) .background(Capsule() .foregroundColor(.gray) .opacity(0.8) .shadow(radius: 4)) } }.padding(8) } else { Text("Determining your location...") } } } } @MainActor class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var location: CLLocationCoordinate2D? private let locationManager = CLLocationManager() override init() { super.init() locationManager.delegate = self locationManager.requestWhenInUseAuthorization() locationManager.startUpdatingLocation() } nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { if let location = locations.last?.coordinate { DispatchQueue.main.async { self.location = location } } } } struct MapView: UIViewRepresentable { var userLocation: CLLocationCoordinate2D let destinationCoordinate: CLLocationCoordinate2D func makeUIView(context: Context) -> MKMapView { let mapView = MKMapView() mapView.delegate = context.coordinator // Update the region to center on the user's location let region = MKCoordinateRegion(center: userLocation, latitudinalMeters: 900, longitudinalMeters: 900) mapView.setRegion(region, animated: true) // Set up and calculate the route let request = MKDirections.Request() request.source = MKMapItem(placemark: MKPlacemark(coordinate: userLocation)) request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destinationCoordinate)) request.transportType = .automobile let directions = MKDirections(request: request) Task { guard let response = try? await directions.calculate(), let route = response.routes.first else { return } mapView.addOverlay(route.polyline) mapView.setVisibleMapRect(route.polyline.boundingMapRect, animated: true) } // Manage annotations updateAnnotations(mapView: mapView) return mapView } func updateUIView(_ uiView: MKMapView, context: Context) { } func updateAnnotations(mapView: MKMapView) { mapView.removeAnnotations(mapView.annotations) // Add user location annotation let userAnnotation = MKPointAnnotation() userAnnotation.coordinate = userLocation userAnnotation.title = "Your Location" mapView.addAnnotation(userAnnotation) // Add destination annotation let destinationAnnotation = MKPointAnnotation() destinationAnnotation.coordinate = destinationCoordinate destinationAnnotation.title = "NETs Site 🚑" destinationAnnotation.subtitle = "Your local NETs meeting point" mapView.addAnnotation(destinationAnnotation) } func makeCoordinator() -> Coordinator { Coordinator(self) } class Coordinator: NSObject, MKMapViewDelegate { var parent: MapView init(_ parent: MapView) { self.parent = parent } func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { let renderer = MKPolylineRenderer(overlay: overlay) renderer.strokeColor = .systemGreen renderer.lineWidth = 5 return renderer } func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { let identifier = "Placemark" var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: identifier) if annotationView == nil { annotationView = MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier) annotationView?.canShowCallout = true } else { annotationView?.annotation = annotation } return annotationView } } } #endif ================================================ FILE: DevApp/Dev App/Thomas/CustomView/Examples/Weather/WeatherView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore struct BackgroundView: View { var body: some View { let colorScheme = [ Color(red: 141/255, green: 87/255, blue: 151/255), Color(red: 20/255, green: 31/255, blue: 78/255), Color.black ] let gradient = Gradient(colors: colorScheme) let linearGradient = LinearGradient(gradient: gradient, startPoint: .top, endPoint: .bottom) let background = Rectangle() .fill(linearGradient) .blur(radius: 20, opaque: true) .edgesIgnoringSafeArea(.all) return background } } struct WeatherView: View { @StateObject var data: WeatherViewModel = WeatherViewModel() var foreground:some View { VStack(alignment: .leading) { HStack { Image(data.icon) .resizable() .frame(width: 45, height: 45) Text(data.summary) .font(.system(size: 25)) .fontWeight(.light) }.padding(0) HStack { Text(data.temperature) .font(.system(size: 120)) .fontWeight(.ultraLight) Spacer() HStack { Spacer() VStack(alignment: .leading) { HStack { Text("FEELS LIKE") Spacer() Text(data.apparentTemperature) }.padding(.bottom, 1) HStack { Text("WIND SPEED") Spacer() Text(data.windSpeed) }.padding(.bottom, 1) HStack { Text("HUMIDITY") Spacer() Text(data.humidity) }.padding(.bottom, 1) HStack { Text("PRECIPITATION") Spacer() Text(data.precipProbability) }.padding(.bottom, 1) } }.frame(maxWidth:150).font(.caption) }.padding(0) } } var body: some View { ZStack { BackgroundView() VStack { Spacer() VStack { Text("PORTLAND, OREGON").font(.title).fontWeight(.light) Text(data.time).foregroundColor(.gray) } Spacer() foreground Spacer() }.padding(22) }.colorScheme(.dark) } } ================================================ FILE: DevApp/Dev App/Thomas/CustomView/Examples/Weather/WeatherViewModel.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import Combine import SwiftUI @MainActor class WeatherViewModel: ObservableObject { @Published var time: String = "Loading..." let summary: String let icon: String let precipProbability: String let temperature: String let apparentTemperature: String let humidity: String let windSpeed: String private var timer: AnyCancellable? private func updateTime() { withAnimation { self.time = getCurrentDateTimeString() } } private func getCurrentDateTimeString() -> String { let now = Date() let formatter = DateFormatter() formatter.dateFormat = "h:mm a MMMM dd, yyyy" return formatter.string(from: now) } init() { self.summary = "Storm Advisory" self.icon = "rain" self.precipProbability = "100%" self.temperature = "53º" self.apparentTemperature = "52º" self.humidity = "100%" self.windSpeed = "22 mph" timer = Timer.publish(every: 1, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.updateTime() } } } ================================================ FILE: DevApp/Dev App/Thomas/Embedded Playground View/EmbeddedPlaygroundMenuView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI internal import Yams struct Item { var icon: String var title: String var description: String var destination: AnyView } @MainActor class EmbeddedPlaygroundMenuViewModel: ObservableObject { @Published var selectedFileID: String = "" { didSet { selectedEmbeddedID = extractEmbeddedId(selectedFileID: selectedFileID) ?? "" } } @Published var isShowingPlaceholder: Bool = false @Published var selectedEmbeddedID: String = "" private let viewsPath = "/Scenes/Embedded" func extractEmbeddedId(selectedFileID: String) -> String? { guard let resourcePath = Bundle.main.resourcePath else { print("Error: Could not find resource path.") return nil } let filePath = resourcePath + viewsPath + "/\(self.selectedFileID).yml" // Parse the YAML string into a dictionary if let fileContents = try? String(contentsOfFile: filePath, encoding: String.Encoding.utf8), let parsedYaml = try? Yams.load(yaml: fileContents) as? [String: Any] { // Navigate through the dictionary to retrieve the embedded_id if let presentation = parsedYaml["presentation"] as? [String: Any], let embeddedId = presentation["embedded_id"] as? String { return embeddedId } } return nil } lazy var embeddedViewIds: [String] = { let fileManager = FileManager.default guard let resourcePath = Bundle.main.resourcePath else { print("Error: Could not find resource path.") return [] } let fullPath = resourcePath + viewsPath do { let fileNames = try fileManager.contentsOfDirectory(atPath: fullPath) return fileNames.map { (fileName) -> String in return (fileName as NSString).deletingPathExtension } } catch { print("Error while enumerating files \(fullPath): \(error.localizedDescription)") return [] } }() } struct EmbeddedPlaygroundMenuView: View { @StateObject var model = EmbeddedPlaygroundMenuViewModel() private var listItems: [Item] { [ Item(icon: "arrow.left.and.right.square", title: "Unbounded horizontally", description: "Embedded scene that can grow unbounded horizontally", destination: AnyView(EmbeddedUnboundedHorizontalScrollView().environmentObject(model))), Item(icon: "arrow.up.and.down.square", title: "Unbounded vertically", description: "Embedded scene that can grow unbounded in a vertical scroll view", destination: AnyView(EmbeddedUnboundedVerticalScrollView().environmentObject(model))), Item(icon: "arrow.left.arrow.right.square", title: "Horizontal scroll", description: "Embedded scene in a horizontal scroll view", destination: AnyView(EmbeddedHorizontalScrollView().environmentObject(model))), Item(icon: "square", title: "Fixed frame", description: "Embedded scene that is bounded to a fixed frame size", destination: AnyView(EmbeddedFixedFrameView() .environmentObject(model))) ] } private var listView: some View { Form { Section { EmbeddedPlaygroundPicker(selectedID: $model.selectedFileID, embeddedIds:model.embeddedViewIds) .frame(height:120) } ForEach(listItems, id: \.title) { item in NavigationLink(destination: item.destination) { HStack(spacing: 16) { Image(systemName: item.icon) .resizable() .frame(width: 44, height: 44) .foregroundColor(.primary) VStack(alignment: .leading) { Text(item.title).font(.title3) Text(item.description) .font(.caption2).foregroundColor(.secondary) } }.frame(height: 54) } } } } var body: some View { listView .listStyle(.plain) } } #Preview { EmbeddedPlaygroundMenuView() } ================================================ FILE: DevApp/Dev App/Thomas/Embedded Playground View/EmbeddedPlaygroundPicker.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import Foundation struct EmbeddedPlaygroundPicker: View { @Binding var selectedID: String var embeddedIds: [String] var body: some View { VStack { Picker("Embedded View", selection: $selectedID) { ForEach(embeddedIds, id: \.self) { id in Text(id).tag(id) } } #if !os(tvOS) && !os(macOS) .pickerStyle(WheelPickerStyle()) #endif } .onAppear { if !embeddedIds.isEmpty { selectedID = embeddedIds.first! } } } } #Preview { EmbeddedPlaygroundPicker(selectedID:Binding.constant("home_rating"), embeddedIds: ["home_rating", "home_special_offer"]) } ================================================ FILE: DevApp/Dev App/Thomas/Embedded Playground View/EmbeddedPlaygroundView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI import AirshipCore protocol EmbeddedViewMaker {} extension EmbeddedViewMaker { @MainActor func makeEmbeddedView<Content: View>( id: String, parentWidth: CGFloat? = nil, parentHeight: CGFloat? = nil, isShowingPlaceholder: Bool, @ViewBuilder placeholder: @escaping () -> Content ) -> some View { AirshipEmbeddedView( embeddedID: isShowingPlaceholder ? "nonexistent view id" : id, embeddedSize: AirshipEmbeddedSize(parentWidth: parentWidth, parentHeight: parentHeight), placeholder:placeholder ) } } struct EmbeddedUnboundedHorizontalScrollView: View, EmbeddedViewMaker { @EnvironmentObject var model: EmbeddedPlaygroundMenuViewModel @State private var size: CGSize? @ViewBuilder private var embeddedView: some View { let keyItems = [KeyItem(name: "Embedded view frame", color: .red), KeyItem(name: "Scroll view frame", color: .gray), KeyItem(name: "Placeholder view", color: .green)] ScrollView(.horizontal) { let exampleItem = Text("Example item") .font(.largeTitle) .frame(width: 200, height: 200) .background(Color.orange) HStack(spacing: 20) { exampleItem exampleItem makeEmbeddedView( id: model.selectedEmbeddedID, parentWidth: $size.wrappedValue?.width, isShowingPlaceholder: model.isShowingPlaceholder ) { Text("Placeholder") .font(.largeTitle) .frame(width: 200, height: 200) .background(Color.green) } .id(model.isShowingPlaceholder) exampleItem exampleItem } } .airshipMeasureView($size) .border(Color.gray, width: 3) .addKeyView(keyItems:keyItems) .addPlaceholderToggle(state: $model.isShowingPlaceholder) .navigationTitle(model.selectedFileID) } var body: some View { embeddedView } } struct EmbeddedUnboundedVerticalScrollView: View, EmbeddedViewMaker { @EnvironmentObject var model: EmbeddedPlaygroundMenuViewModel @State private var size: CGSize? private var embeddedView: some View { let keyItems = [KeyItem(name: "Embedded view frame", color: .red), KeyItem(name: "Scroll view frame", color: .gray), KeyItem(name: "Placeholder view", color: .green)] let exampleItem = Text("Example item") .font(.largeTitle) .frame(width: 200, height: 200) .background(Color.orange) return ScrollView(.vertical) { VStack(spacing: 20) { exampleItem exampleItem makeEmbeddedView( id: model.selectedEmbeddedID, parentWidth: size?.width, parentHeight: size?.height, isShowingPlaceholder: model.isShowingPlaceholder ) { Text("Placeholder") .font(.largeTitle) .frame(width: 200, height: 200) .background(Color.green) } .id(model.isShowingPlaceholder) exampleItem exampleItem } } .airshipMeasureView($size) .border(Color.gray, width: 3) .addKeyView(keyItems:keyItems) .addPlaceholderToggle(state: $model.isShowingPlaceholder) .navigationTitle(model.selectedFileID) } var body: some View { embeddedView } } struct EmbeddedHorizontalScrollView: View, EmbeddedViewMaker { @EnvironmentObject var model: EmbeddedPlaygroundMenuViewModel @State private var size: CGSize? var embeddedView: some View { let keyItems = [KeyItem(name: "Embedded view frame", color: .red), KeyItem(name: "Scroll view frame", color: .gray), KeyItem(name: "Placeholder view", color: .green)] return ScrollView(.horizontal) { let exampleItem = Text("Example item") .font(.largeTitle) .frame(width: 200, height: 200) .background(Color.orange) HStack(spacing: 20) { exampleItem exampleItem makeEmbeddedView( id: model.selectedEmbeddedID, parentWidth: size?.width, parentHeight: size?.height, isShowingPlaceholder: model.isShowingPlaceholder ) { Text("Placeholder") .font(.largeTitle) .frame(width: 200, height: 200) .background(Color.green) }.id(model.isShowingPlaceholder) exampleItem exampleItem } } .airshipMeasureView($size) .border(Color.gray, width: 3) .addKeyView(keyItems:keyItems) .addPlaceholderToggle(state: $model.isShowingPlaceholder) .navigationTitle(model.selectedFileID) } var body: some View { embeddedView } } struct EmbeddedFixedFrameView: View, EmbeddedViewMaker { @EnvironmentObject var model: EmbeddedPlaygroundMenuViewModel let keyItems = [KeyItem(name: "Fixed size frame", color: .red), KeyItem(name: "Placeholder view", color: .green)] private var embeddedView: some View { Group { makeEmbeddedView( id: model.selectedEmbeddedID, isShowingPlaceholder: model.isShowingPlaceholder ) { Text("Placeholder") .font(.largeTitle) .frame(width: 200, height: 200) .background(Color.green) } .frame(maxWidth: 200, maxHeight:200) .border(Color.red, width: 3) .id(model.isShowingPlaceholder) .addKeyView(keyItems: keyItems) .addPlaceholderToggle(state: $model.isShowingPlaceholder) } .navigationTitle(model.selectedFileID) } var body: some View { embeddedView } } #Preview { EmbeddedUnboundedHorizontalScrollView() } ================================================ FILE: DevApp/Dev App/Thomas/Embedded Playground View/KeyView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI struct KeyItem { var name: String var color: Color } struct KeyView: View { var keyItems: [KeyItem] var body: some View { VStack(alignment: .leading, spacing: 4) { ForEach(keyItems, id: \.name) { item in HStack { Image(systemName:"square.fill") .resizable() .foregroundColor(item.color) .frame(width: 16, height: 16) Text(item.name).font(.caption).foregroundColor(.black) } } } .padding(8) .background( RoundedRectangle(cornerRadius: 2) .foregroundColor(.white) .overlay( RoundedRectangle(cornerRadius: 2) .stroke(Color.gray, lineWidth: 1) ) ) } } struct KeyViewModifier: ViewModifier { var keyItems: [KeyItem] func body(content: Content) -> some View { ZStack(alignment: .center) { content VStack { Spacer() HStack { Spacer() KeyView(keyItems: keyItems).padding() } } } } } extension View { func addKeyView(keyItems: [KeyItem]) -> some View { self.modifier(KeyViewModifier(keyItems: keyItems)) } } #Preview { KeyView(keyItems: [ KeyItem(name: "Unbounded scroll view", color: .red), KeyItem(name: "Embedded view", color: .green), KeyItem(name: "Embedded view frame", color: .blue) ]) } ================================================ FILE: DevApp/Dev App/Thomas/Embedded Playground View/PlaceholderToggleView.swift ================================================ /* Copyright Airship and Contributors */ import Foundation import SwiftUI struct EmbeddedToggleModifier: ViewModifier { @Binding var state:Bool func body(content: Content) -> some View { ZStack(alignment: .center) { content VStack { Spacer() HStack { Button { withAnimation { state.toggle() } } label: { HStack(spacing:4) { Image(systemName: state ? "square.fill" : "square") .resizable() .frame(width: 16, height: 16) Text("Toggle placeholder").font(.caption).foregroundColor(.black) }.padding(8) }.background( RoundedRectangle(cornerRadius: 2) .foregroundColor(.white) .overlay( RoundedRectangle(cornerRadius: 2) .stroke(Color.gray, lineWidth: 1) ) ).padding() Spacer() } } } } } extension View { func addPlaceholderToggle(state:Binding<Bool>) -> some View { self.modifier(EmbeddedToggleModifier(state:state)) } } ================================================ FILE: DevApp/Dev App/Thomas/Layouts.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Foundation internal import Yams import AirshipAutomation @MainActor final class LayoutLoader: Sendable { private var cache: [LayoutType: [LayoutFile]] = [:] func load(type: LayoutType) -> [LayoutFile] { if let cached = cache[type] { return cached } let layouts = switch(type) { case .sceneModal: loadLayouts(directory: "/Scenes/Modal", type: .sceneModal) case .sceneBanner: loadLayouts(directory: "/Scenes/Banner", type: .sceneBanner) case .sceneEmbedded: loadLayouts(directory: "/Scenes/Embedded", type: .sceneEmbedded) case .messageModal: loadLayouts(directory: "/Messages/Modal", type: .messageModal) case .messageBanner: loadLayouts(directory: "/Messages/Banner", type: .messageBanner) case .messageFullscreen: loadLayouts(directory: "/Messages/Fullscreen", type: .messageFullscreen) case .messageHTML: loadLayouts(directory: "/Messages/HTML", type: .messageHTML) } self.cache[type] = layouts return layouts } private func loadLayouts(directory: String, type: LayoutType) -> [LayoutFile] { let path = Bundle.main.resourcePath! + directory do { return try FileManager.default.contentsOfDirectory(atPath: path).sorted().map { fileName in LayoutFile(directory: directory, fileName: fileName, type: type) } } catch { return [] } } } struct LayoutFile: Equatable, Hashable, Codable, Identifiable { let directory: String let fileName: String let type: LayoutType var id: String { directory + "/" + fileName } } enum LayoutType: Equatable, Hashable, Codable { case sceneModal case sceneBanner case sceneEmbedded case messageModal case messageBanner case messageFullscreen case messageHTML } extension LayoutFile { @MainActor func open() throws { let filePath = Bundle.main.resourcePath! + directory + "/" + fileName let data = try loadData(filePath: filePath) switch self.type { case .sceneModal, .sceneBanner, .sceneEmbedded: try displayScene(data) case .messageModal, .messageBanner, .messageFullscreen, .messageHTML: try displayMessage(data) } } private func loadData(filePath: String) throws -> Data { /// Retrieve the content let stringContent = try getContentOfFile(filePath: filePath) /// If we already have json in the file, don't bother to convert it from yaml if isJSONString(stringContent) { guard let jsonData = stringContent.data(using: .utf8), let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { throw NSError(domain: "Invalid JSON", code: 1001, userInfo: nil) } return try JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted) } // Convert YML file to json return try getJsonContentFromYmlContent(ymlContent: stringContent) } /// Extracts a layout object from a potentially wrapped JSON payload. /// /// Supports multiple wrapper formats by traversing known key paths: /// - `in_app_message.message.display.layout` /// - `message.display.layout` /// - `display.layout` /// - `layout` /// /// If the payload is already a raw layout (has version, presentation, view), returns it as-is. private func extractLayoutFromPayload(_ data: Data) throws -> Data { guard let jsonObject = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return data } if let layout = extractLayout(from: jsonObject) { return try JSONSerialization.data(withJSONObject: layout, options: .prettyPrinted) } return data } /// Traverses the JSON structure to find the layout object. private func extractLayout(from json: [String: Any]) -> [String: Any]? { // Check if this is already a valid layout if isValidLayout(json) { return json } // Define the possible key paths to the layout, from most to least nested let keyPaths: [[String]] = [ ["in_app_message", "message", "display", "layout"], ["message", "display", "layout"], ["display", "layout"], ["layout"] ] for keyPath in keyPaths { if let layout = traverse(json: json, keyPath: keyPath), isValidLayout(layout) { return layout } } return nil } /// Traverses a JSON dictionary along a key path. private func traverse(json: [String: Any], keyPath: [String]) -> [String: Any]? { var current: [String: Any] = json for key in keyPath { guard let next = current[key] as? [String: Any] else { return nil } current = next } return current } /// Checks if a dictionary contains the required layout keys. private func isValidLayout(_ json: [String: Any]) -> Bool { json["version"] != nil && json["presentation"] != nil && json["view"] != nil } /// Check if a string is JSON func isJSONString(_ jsonString: String) -> Bool { if let jsonData = jsonString.data(using: .utf8) { do { _ = try JSONSerialization.jsonObject(with: jsonData, options: []) return true } catch { return false } } return false } /// Convert YML content to json content using Yams func getJsonContentFromYmlContent(ymlContent: String) throws -> Data { guard let jsonContentOfFile = try Yams.load(yaml: ymlContent) as? NSDictionary else { throw AirshipErrors.error("Invalid content: \(ymlContent)") } return try JSONSerialization.data( withJSONObject: jsonContentOfFile, options: .prettyPrinted ) } // Returns the file contents private func getContentOfFile(filePath: String) throws -> String { return try String(contentsOfFile: filePath, encoding: String.Encoding.utf8) } @MainActor private func displayScene(_ data: Data) throws { // Extract layout from potentially wrapped payload (in_app_message.message.display.layout) let layoutData = try extractLayoutFromPayload(data) let layout = try JSONDecoder().decode(AirshipLayout.self, from: layoutData) let message = InAppMessage(name: "thomas", displayContent: .airshipLayout(layout)) Task { @MainActor in try await message._display() } } @MainActor private func displayMessage(_ data: Data) throws { let message: InAppMessage // Try to unwrap server-formatted JSON with in_app_message wrapper if let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let inAppMessage = jsonObject["in_app_message"] as? [String: Any], let messageObject = inAppMessage["message"] as? [String: Any] { // Extract just the message object and decode it let messageData = try JSONSerialization.data(withJSONObject: messageObject) message = try JSONDecoder().decode(InAppMessage.self, from: messageData) } else { // Fall back to direct InAppMessage decoding (for legacy format) message = try JSONDecoder().decode(InAppMessage.self, from: data) } Task { @MainActor in try await message._display() } } } ================================================ FILE: DevApp/Dev App/Thomas/LayoutsList.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import SwiftUI struct LayoutsList: View { @ObservedObject private var viewModel: ViewModel @State var errorMessage: String? @State var showError: Bool = false init( layoutType: LayoutType, onOpen: @escaping @MainActor (LayoutFile) -> Void ) { viewModel = .init(layoutType: layoutType, onOpen: onOpen) } var body: some View { List { ForEach(viewModel.layouts, id: \.self) { layout in Button(layout.fileName) { open(layout) } } } .alert(isPresented: $showError) { Alert( title: Text("Error"), message: Text(self.errorMessage ?? "error"), dismissButton: .default(Text("OK")) ) } } func open(_ layout: LayoutFile, addToRecents: Bool = true) { do { try viewModel.openLayout(layout) } catch { self.showError = true self.errorMessage = "Failed to open layout \(error)" } } } @MainActor private class ViewModel: ObservableObject { let layoutLoader = LayoutLoader() let layouts: [LayoutFile] let onOpen: @MainActor (LayoutFile) -> Void init(layoutType: LayoutType, onOpen: @escaping @MainActor (LayoutFile) -> Void) { layouts = layoutLoader.load(type: layoutType) self.onOpen = onOpen } func openLayout(_ layout: LayoutFile) throws { try layout.open() onOpen(layout) } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/media-left-jurassic-park-bottom.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "body" : { "alignment" : "center", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "Jurassic Park is a 1993 American science-fiction adventure film directed by Steven Spielberg and produced by Kathleen Kennedy and Gerald R. Molen." }, "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#ff0000", "border_color" : "#000000", "border_radius" : 4, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 15, "style" : [ "bold" ], "text" : "Touch the button" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "sans-serif" ], "size" : 22, "text" : "Boom" }, "media" : { "description" : "Image", "type" : "image", "url" : "https://logonoid.com/images/jurassic-park-logo.png" }, "template" : "media_right", "duration" : 100.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/media-left.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "body" : { "alignment" : "center", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "Big body" }, "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#63aff2", "border_color" : "#63aff2", "border_radius" : 2, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#ffffff", "font_family" : [ "sans-serif" ], "size" : 10, "style" : [ "bold" ], "text" : "Touch it" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "sans-serif" ], "size" : 22, "text" : "Boom" }, "media" : { "description" : "Image", "type" : "image", "url" : "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Picture_icon_BLACK.svg/1200px-Picture_icon_BLACK.svg.png" }, "template" : "media_left", "placement" : "top", "duration" : 100.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/media-right-jurassic-park-bottom.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "body" : { "alignment" : "center", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "Jurassic Park is a 1993 American science-fiction adventure film directed by Steven Spielberg and produced by Kathleen Kennedy and Gerald R. Molen." }, "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#ff0000", "border_color" : "#000000", "border_radius" : 4, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 15, "style" : [ "bold" ], "text" : "Touch the button" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "Jurassic Park" ], "size" : 22, "text" : "22 Center" }, "media" : { "description" : "Image", "type" : "image", "url" : "https://logonoid.com/images/jurassic-park-logo.png" }, "template" : "media_right", "placement" : "bottom", "duration" : 100.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/media-right-jurassic-park-text-alignment-1.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "body" : { "alignment" : "left", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "LEFT Jurassic Park is a 1993 American science-fiction adventure film directed by Steven Spielberg and produced by Kathleen Kennedy and Gerald R. Molen." }, "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#ff0000", "border_color" : "#000000", "border_radius" : 4, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 10, "style" : [ "bold" ], "text" : "Touch it" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "left", "color" : "#63aff2", "font_family" : [ "Jurassic Park" ], "size" : 42, "text" : "Left Title" }, "media" : { "description" : "Image", "type" : "image", "url" : "https://logonoid.com/images/jurassic-park-logo.png" }, "template" : "media_right", "placement" : "top", "duration" : 100.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/media-right-jurassic-park-text-alignment-2.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#ff0000", "border_color" : "#000000", "border_radius" : 4, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 10, "style" : [ "bold" ], "text" : "Touch it" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "right", "color" : "#63aff2", "font_family" : [ "Jurassic Park" ], "size" : 42, "text" : "Right Title" }, "media" : { "description" : "Image", "type" : "image", "url" : "https://logonoid.com/images/jurassic-park-logo.png" }, "template" : "media_right", "placement" : "top", "duration" : 100.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/media-right-jurassic-park-top.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "body" : { "alignment" : "center", "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 16, "text" : "Jurassic Park is a 1993 American science-fiction adventure film directed by Steven Spielberg and produced by Kathleen Kennedy and Gerald R. Molen." }, "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#ff0000", "border_color" : "#000000", "border_radius" : 4, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#000000", "font_family" : [ "sans-serif" ], "size" : 15, "style" : [ "bold" ], "text" : "Touch the button" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "Jurassic Park" ], "size" : 22, "text" : "22 Center" }, "media" : { "description" : "Image", "type" : "image", "url" : "https://logonoid.com/images/jurassic-park-logo.png" }, "template" : "media_right", "placement" : "top", "duration" : 100.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/media-right.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "allow_fullscreen_display" : true, "background_color" : "#ffffff", "border_radius" : 5, "button_layout" : "stacked", "buttons" : [ { "actions" : {}, "background_color" : "#63aff2", "border_color" : "#63aff2", "border_radius" : 2, "id" : "d17a055c-ed67-4101-b65f-cd28b5904c84", "label" : { "color" : "#ffffff", "font_family" : [ "sans-serif" ], "size" : 10, "style" : [ "bold" ], "text" : "Touch it" } } ], "dismiss_button_color" : "#000000", "heading" : { "alignment" : "right", "color" : "#63aff2", "size" : 22, "text" : "22 Right" }, "template" : "media_right", "placement" : "top", "duration" : 100.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Banner/small-banner.json ================================================ { "display_type" : "banner", "name" : "woot", "source": "app-defined", "display" : { "background_color" : "#ffffff", "border_radius" : 5, "dismiss_button_color" : "#000000", "body" : { "alignment" : "center", "color" : "#63aff2", "font_family" : [ "sans-serif" ], "text" : "Text body, text body, text body, text body, text body" }, "template" : "media_left", "placement" : "bottom", "duration" : 3.0 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Fullscreen/header-body-media-joined.json ================================================ { "source": "app-defined", "name": "woot", "display_type": "fullscreen", "display": { "allow_fullscreen_display": true, "background_color": "#FF0000", "body": { "alignment": "center", "color": "#008000", "font_family": ["sans-serif"], "size": 11, "text": "This is body text that's pretty long... Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." }, "border_radius": 5, "button_layout": "joined", "buttons": [ { "actions": {}, "background_color": "#00FFFF", "border_color": "#00FFFF", "border_radius": 0, "id": "button-one", "label": { "color": "#808080", "size": 11, "font_family": ["sans-serif"], "style": ["bold"], "text": "11p sans-sherif bold Dismiss" } }, { "actions": {"behavior": "cancel"}, "background_color": "#0000FF", "border_color": "#0000FF", "border_radius": 10, "id": "button-two", "label": { "color": "#808080", "size": 11, "style": ["italic", "underline"], "text": "11p italic underline Cancel" } } ], "dismiss_button_color": "#008000", "heading": { "alignment": "center", "color": "#A52A2A", "font_family": ["sans-serif"], "size": 35, "text": "Header body media with wide image stacked buttons centered" }, "media": { "description": "Wide space", "type": "image", "url": "https://images.pexels.com/photos/207529/pexels-photo-207529.jpeg" }, "template": "header_body_media" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Fullscreen/header-body-separate.json ================================================ { "source": "app-defined", "name": "myMessage", "display_type": "fullscreen", "display": { "dismiss_button_color": "#00FF00", "background_color": "#FF0000", "button_layout": "separate", "template": "media_header_body", "heading": { "text": "Media header body with stacked buttons no footer long header. Media header body with stacked buttons no footer long header.", "color": "#A52A2A", "size": 11 }, "body": { "text": "short body text.", "color": "#00FF00", "size": 11 }, "buttons": [ { "id": "button-one", "label": { "text": "36p Dismiss", "color": "#0FFFFF", "size": 36 }, "background_color": "#0000FF" }, { "id": "button-two", "label": { "text": "24p Cancel", "color": "#F0FFFF", "size": 24 }, "background_color": "#0000FF", "border_radius": 10, "behavior": "cancel" } ] } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Fullscreen/media-header-body-stacked.json ================================================ { "source": "app-defined", "name": "myMessage", "display_type": "fullscreen", "display": { "dismiss_button_color": "#00FF00", "background_color": "#FF0000", "button_layout": "joined", "template": "media_header_body", "media": { "description": "Wide space", "type": "image", "url": "https://images.pexels.com/photos/207529/pexels-photo-207529.jpeg" }, "heading": { "text": "Media header body with stacked buttons no footer long header. Media header body with stacked buttons no footer long header.", "color": "#A52A2A", "size": 11 }, "body": { "text": "short body text.", "color": "#00FF00", "size": 11 }, "buttons": [ { "id": "button-one", "label": { "text": "48p Dismiss", "color": "#0FFFFF", "size": 48 }, "background_color": "#0000FF" }, { "id": "button-two", "label": { "text": "24p Cancel", "color": "#F0FFFF", "size": 24 }, "background_color": "#0000FF", "border_radius": 10, "behavior": "cancel" } ] } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/HTML/fullscreen-airship.json ================================================ { "source": "app-defined", "name": "my HTML message", "display_type": "html", "display": { "allow_fullscreen_display" : true, "aspect_lock" : true, "background_color" : "#ff00ffff", "border_radius" : 10, "dismiss_button_color" : "#ff00ff00", "require_connectivity" : true, "url" : "https://airship.com", } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/HTML/sized-airship.json ================================================ { "source": "app-defined", "name": "my HTML message", "display_type": "html", "display": { "allow_fullscreen_display" : false, "background_color" : "#ff00ffff", "border_radius" : 10, "dismiss_button_color" : "#ff00ff00", "require_connectivity" : true, "url" : "https://airship.com", } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/HTML/sized-too-tall-airship.json ================================================ { "source": "app-defined", "name": "my HTML message", "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" : 900 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/HTML/sized-too-wide-airship.json ================================================ { "source": "app-defined", "name": "my HTML message", "display_type": "html", "extra": {"squareview": "true"}, "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" : 900, "height" : 400 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/a11y-test.json ================================================ { "source": "app-defined", "name": "meghan - Email collection - TKO - copy", "display_type": "layout", "display": { "layout": { "version": 1, "presentation": { "type": "modal", "placement_selectors": [ { "placement": { "ignore_safe_area": true, "device": { "lock_orientation": "portrait" }, "size": { "width": 414, "height": 770 }, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "type": "hex", "hex": "#868686", "alpha": 0.2 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } } ] }, "web": { "ignore_shade": false }, "border": { "radius": 11 } }, "window_size": "small", "orientation": "portrait" }, { "placement": { "ignore_safe_area": true, "size": { "width": 414, "height": 770 }, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "type": "hex", "hex": "#868686", "alpha": 0.2 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } } ] }, "web": {}, "border": { "radius": 11 } }, "window_size": "large", "orientation": "landscape" } ], "android": { "disable_back_button": false }, "dismiss_on_touch_outside": false, "default_placement": { "ignore_safe_area": false, "size": { "width": "90%", "height": "65%" }, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "type": "hex", "hex": "#868686", "alpha": 0.2 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#868686", "alpha": 0.2 } } ] }, "web": {}, "border": { "radius": 19 } } }, "view": { "type": "state_controller", "view": { "type": "pager_controller", "identifier": "108abc02-4d43-4af3-87b1-a383d0fd37c4", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "width": "100%", "height": "100%" }, "view": { "identifier": "ec58bad5-f5d4-465a-99dc-5ff4b7f0691b", "type": "form_controller", "form_enabled": [ "form_submission" ], "submit": "submit_event", "response_type": "user_feedback", "view": { "type": "container", "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "pager", "disable_swipe": true, "items": [ { "identifier": "78a41944-e6a5-485b-a97b-7db2f60d3206", "type": "pager_item", "view": { "type": "container", "items": [ { "size": { "width": "100%", "height": "100%" }, "position": { "horizontal": "center", "vertical": "center" }, "ignore_safe_area": false, "view": { "type": "container", "items": [ { "margin": { "bottom": 0, "top": 0, "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "identifier": "scroll_container", "size": { "width": "100%", "height": "100%" }, "view": { "type": "scroll_layout", "direction": "vertical", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "width": "70%", "height": "auto" }, "view": { "type": "media", "media_fit": "center_inside", "url": "https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/35d55eb3-e1a3-47c4-be12-46115c9badd5", "media_type": "image", "content_description": "brand logo for 23 grande" }, "identifier": "55bab89a-122d-41ca-8ead-4d6accfadf99", "margin": { "top": 30, "bottom": 0, "start": 0, "end": 0 } }, { "identifier": "19690655-0720-4586-a132-19d6a2d5c448", "size": { "width": "100%", "height": "auto" }, "margin": { "top": 48, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "label", "text": "ENJOY 20% OFF SITE WIDE", "content_description": "ENJOY 20% OFF SITE WIDE", "text_appearance": { "font_size": 31, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] }, "alignment": "center", "styles": [ "bold" ], "font_families": [ "sans-serif" ] } } }, { "identifier": "e804d35c-36b2-464e-9419-4f3e4bbfb1c6", "size": { "width": "100%", "height": "auto" }, "margin": { "top": 8, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "label", "text": "when you sign up for email ", "content_description": "when you sign up for email ", "text_appearance": { "font_size": 18, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] }, "alignment": "center", "styles": [], "font_families": [ "sans-serif" ] } } }, { "view": { "type": "container", "items": [ { "identifier": "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58_container", "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "margin": { "top": 4, "bottom": 8 }, "size": { "width": "100%", "height": 50 }, "view": { "border": { "radius": 4, "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] } }, "type": "text_input", "text_appearance": { "alignment": "start", "font_size": 18, "font_families": [ "sans-serif" ], "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] }, "place_holder_color": { "default": { "type": "hex", "hex": "#BCBDC2", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#BCBDC2", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 0.5 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#BCBDC2", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 0.5 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#BCBDC2", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 0.5 } } ] } }, "background_color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] }, "identifier": "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58", "input_type": "text", "required": false, "content_description": "", "place_holder": "Email address", "view_overrides": { "icon_end": [ { "when_state_matches": { "scope": [ "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58_error" ], "value": { "equals": true } }, "value": { "type": "floating", "icon": { "type": "icon", "icon": "exclamationmark_circle_fill", "scale": 1, "color": { "default": { "type": "hex", "hex": "#ff0000", "alpha": 1 } } } } } ], "border": [ { "when_state_matches": { "scope": [ "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58_error" ], "value": { "equals": true } }, "value": { "radius": 4, "stroke_width": 1, "stroke_color": { "default": { "type": "hex", "hex": "#ff0000", "alpha": 1 } } } } ] }, "on_error": { "state_actions": [ { "type": "set", "key": "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58_error", "value": true } ] }, "on_edit": { "state_actions": [ { "type": "set", "key": "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58_error" } ] }, "on_valid": { "state_actions": [ { "type": "set", "key": "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58_error" } ] } } } ] } } ] }, "identifier": "0003f1bc-466c-4c9c-9b5d-6e9a9aeabf58", "size": { "width": "100%", "height": 50 }, "margin": { "top": 8, "bottom": 8, "start": 16, "end": 16 } }, { "identifier": "1b7e3a7a-84c5-4ea3-8a97-c37e5d922c78", "margin": { "top": 8, "bottom": 8, "start": 16, "end": 16 }, "size": { "width": "100%", "height": "auto" }, "view": { "type": "label_button", "identifier": "submit_feedback--SIGN UP", "reporting_metadata": { "trigger_link_id": "1b7e3a7a-84c5-4ea3-8a97-c37e5d922c78" }, "label": { "type": "label", "text": "SIGN UP", "content_description": "SIGN UP", "text_appearance": { "font_size": 16, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] }, "alignment": "center", "styles": [ "bold" ], "font_families": [ "sans-serif" ] } }, "actions": {}, "enabled": [ "form_validation" ], "button_click": [ "form_submit", "dismiss" ], "background_color": { "default": { "type": "hex", "hex": "#E9338C", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } } ] }, "border": { "radius": 3, "stroke_width": 0, "stroke_color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] } }, "event_handlers": [ { "type": "tap", "state_actions": [ { "type": "set", "key": "submitted", "value": true } ] } ] } }, { "identifier": "c4f5f28a-0941-426c-81ce-cbb6273ab890", "margin": { "top": 8, "bottom": 16, "start": 16, "end": 16 }, "size": { "width": "100%", "height": "auto" }, "view": { "type": "label_button", "identifier": "dismiss--NO THANKS", "reporting_metadata": { "trigger_link_id": "c4f5f28a-0941-426c-81ce-cbb6273ab890" }, "label": { "type": "label", "text": "NO THANKS", "content_description": "NO THANKS", "text_appearance": { "font_size": 16, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] }, "alignment": "center", "styles": [ "bold" ], "font_families": [ "sans-serif" ] } }, "actions": {}, "enabled": [], "button_click": [ "dismiss" ], "background_color": { "default": { "type": "hex", "hex": "#E9338C", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#E9338C", "alpha": 1 } } ] }, "border": { "radius": 3, "stroke_width": 0, "stroke_color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } } ] } } } }, { "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "horizontal", "items": [] } } ] } } } ] } } ] } } ], "background_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } } ] } } } ] }, "ignore_safe_area": false }, { "position": { "horizontal": "end", "vertical": "top" }, "size": { "width": 48, "height": 48 }, "view": { "type": "image_button", "image": { "scale": 0.4, "type": "icon", "icon": "close", "color": { "default": { "type": "hex", "hex": "#63AFF1", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#63AFF1", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#63AFF1", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#63AFF1", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#63AFF1", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#63AFF1", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#63AFF1", "alpha": 1 } } ] } }, "identifier": "dismiss_button", "button_click": [ "dismiss" ] } } ] } } } ] } } } } }, "audience": { "miss_behavior": "skip", "tags": { "and": [ { "or": [ { "tag": "meghan" }, { "tag": "meghan-test" } ] } ] } } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/accessibility-modal.json ================================================ { "in_app_message": { "message": { "name": "Ryan heading test", "display_type": "layout", "display": { "layout": { "version": 1, "presentation": { "type": "modal", "placement_selectors": [ { "placement": { "ignore_safe_area": true, "device": { "lock_orientation": "landscape" }, "size": { "width": "70%", "height": "50%" }, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "type": "hex", "hex": "#09BE25", "alpha": 0.2 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#09BE25", "alpha": 0.2 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#09BE25", "alpha": 0.2 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#09BE25", "alpha": 0.2 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#09BE25", "alpha": 0.2 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#09BE25", "alpha": 0.2 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#09BE25", "alpha": 0.2 } } ] }, "web": {}, "border": { "radius": 15 } }, "window_size": "small", "orientation": "landscape" } ], "android": { "disable_back_button": false }, "dismiss_on_touch_outside": false, "default_placement": { "ignore_safe_area": false, "device": { "lock_orientation": "portrait" }, "size": { "width": "100%", "height": "100%" }, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 0.2 } }, "web": { "ignore_shade": true } } }, "view": { "type": "pager_controller", "identifier": "a37a1196-ee19-4323-96f3-11b526f6dc80", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "width": "100%", "height": "100%" }, "view": { "type": "container", "items": [ { "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "pager", "disable_swipe": true, "items": [ { "identifier": "47f4ec52-1f67-4e97-8394-f796a7f1e842", "type": "pager_item", "view": { "type": "container", "items": [ { "size": { "width": "100%", "height": "100%" }, "position": { "horizontal": "center", "vertical": "center" }, "ignore_safe_area": false, "view": { "type": "container", "items": [ { "margin": { "bottom": 0, "top": 0, "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "identifier": "scroll_container", "size": { "width": "100%", "height": "100%" }, "view": { "type": "scroll_layout", "direction": "vertical", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "identifier": "acd7c4a2-9684-46a1-9247-7749472025e6", "size": { "width": "100%", "height": "auto" }, "margin": { "top": 48, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "label", "text": "Cool beans", "content_description": "Cool beans", "text_appearance": { "font_size": 24, "color": { "default": { "type": "hex", "hex": "#000000", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#000000", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 } } ] }, "alignment": "start", "styles": [ "bold" ], "font_families": [ "more fancy fonts MORE" ] }, "accessibility_role": { "type": "heading", "level": 1 } } }, { "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "horizontal", "items": [] } } ] } } } ] } } ] } } ], "background_color": { "default": { "type": "hex", "hex": "#BFBFB0", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#BFBFB0", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#BFBFB0", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#BFBFB0", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#BFBFB0", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#BFBFB0", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#BFBFB0", "alpha": 1 } } ] } } } ] }, "ignore_safe_area": false }, { "position": { "horizontal": "end", "vertical": "top" }, "size": { "width": 48, "height": 48 }, "view": { "type": "image_button", "image": { "scale": 0.4, "type": "icon", "icon": "close", "color": { "default": { "type": "hex", "hex": "#CD5C5C", "alpha": 1 }, "selectors": [ { "platform": "ios", "dark_mode": false, "color": { "type": "hex", "hex": "#CD5C5C", "alpha": 1 } }, { "platform": "ios", "dark_mode": true, "color": { "type": "hex", "hex": "#F08080", "alpha": 1 } }, { "platform": "android", "dark_mode": false, "color": { "type": "hex", "hex": "#CD5C5C", "alpha": 1 } }, { "platform": "android", "dark_mode": true, "color": { "type": "hex", "hex": "#F08080", "alpha": 1 } }, { "platform": "web", "dark_mode": false, "color": { "type": "hex", "hex": "#CD5C5C", "alpha": 1 } }, { "platform": "web", "dark_mode": true, "color": { "type": "hex", "hex": "#F08080", "alpha": 1 } } ] } }, "identifier": "dismiss_button", "button_click": [ "dismiss" ] } } ] } } ] } } } }, "audience": { "miss_behavior": "penalize", "tags": { "and": [ { "tag": "ryan" } ] } } }, "triggers": [ { "type": "active_session", "goal": 1 } ], "edit_grace_period": 14, "scopes": [ "app" ], "reporting_context": { "content_types": [ "scene" ], "experiment_id": "" }, "message_type": "transactional" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/header-body-media-joined.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "joined", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 55, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://logonoid.com/images/jurassic-park-logo.png" }, "template": "media_header_body" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/header-body-media-stacked.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "allow_fullscreen_display": false, "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "stacked", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 24, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://logonoid.com/images/jurassic-park-logo.png" }, "template": "media_header_body" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/header-media-body-joined.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "allow_fullscreen_display": false, "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "joined", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 24, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://logonoid.com/images/jurassic-park-logo.png" }, "template": "header_body_media" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/header-media-body-stacked.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "allow_fullscreen_display": false, "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "stacked", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 24, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://logonoid.com/images/jurassic-park-logo.png" }, "template": "header_body_media" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/media-header-body-joined.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "allow_fullscreen_display": false, "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "joined", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 24, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://logonoid.com/images/jurassic-park-logo.png" }, "template": "media_header_body" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/media-header-body-separate-tall-image.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "allow_fullscreen_display": false, "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "separate", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 24, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://i.pinimg.com/originals/28/49/fa/2849fa05df8d2c93552d388b8babfed7.png" }, "template": "media_header_body" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/media-header-body-separate-wide-image.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "allow_fullscreen_display": false, "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "separate", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 24, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://upload.wikimedia.org/wikipedia/commons/9/9e/Jiangjunosaurus_junggarensis.png" }, "template": "media_header_body" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/media-header-body-separate.json ================================================ { "source": "app-defined", "name": "Jurassic Park", "display_type": "modal", "display": { "allow_fullscreen_display": false, "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.", "color": "#FFFFFF", "alignment": "center", "font_family": [ "sans-serif" ], "size": 16 }, "border_radius": 10, "button_layout": "separate", "buttons": [ { "id": "button-one", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "It's a Unix system, I know this.", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_one" } }, { "id": "button-two", "background_color": "#FF0000", "border_color": "#FF0000", "border_radius": 8, "label": { "text": "Hold on to your butts!", "color": "#000000", "font_family": [ "sans-serif" ], "size": 10, "style": [ "bold" ] }, "actions": { "add_tag_action": "button_two" } } ], "dismiss_button_color": "#A9A9A9", "heading": { "text": "Jurassic Park", "color": "#808080", "font_family": [ "Jurassic Park" ], "size": 24, "alignment": "center" }, "media": { "description": "Jurassic park", "type": "image", "url": "https://logonoid.com/images/jurassic-park-logo.png" }, "template": "media_header_body" } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/media-header-body-stacked.json ================================================ { "source": "app-defined", "name": "240515_CRM_CRMOther_Salvatore_Ferragamo_Afines - FINAL", "display_type": "modal", "display": { "template": "media_header_body", "background_color": "#F7F7F7", "dismiss_button_color": "#000000", "border_radius": 12, "heading": { "text": "UP TO 60 FREE MINUTES", "color": "#222222", "alignment": "center", "size": 20, "style": ["bold"], "font_family": ["sans-serif"] }, "body": { "text": "Share your invite code with your friends and receive up to 60 min of calls!", "color": "#222222", "alignment": "center", "size": 16, "font_family": ["sans-serif"] }, "media": { "url": "https://dl.asnapieu.com/binary/public/MeyaP1anT0yhEsDDaOrmWw/4ff29f47-6eaf-463e-b66c-f4619976e367", "type": "image", "description": "Image" }, "buttons": [ { "label": { "text": "I SHARE", "style": ["bold"], "color": "#FFFFFF", "font_family": ["sans-serif"], "size": 16 }, "id": "808b94dc-5e1e-4e1d-93cf-f74cd8cd4b29", "actions": { "add_tags_action": { "device": ["click_inapp_referral_parrain"] }, "deep_link_action": "libon://referrer" }, "background_color": "#1CB877", "border_color": "#1CB877", "border_radius": 12, "behavior": "cancel" } ] } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/swipe-gesture-test.json ================================================ {"source": "app-defined","name": "Armando Test", "display_type": "layout", "display": {"layout": {"version": 1, "presentation": {"type": "modal", "placement_selectors": [{"placement": {"ignore_safe_area": true, "device": {"lock_orientation": "portrait"}, "size": {"width": "70%", "height": "60%"}, "position": {"horizontal": "center", "vertical": "center"}, "shade_color": {"default": {"type": "hex", "hex": "#868686", "alpha": 0.2}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#868686", "alpha": 0.2}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#868686", "alpha": 0.2}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#868686", "alpha": 0.2}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#868686", "alpha": 0.2}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#868686", "alpha": 0.2}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#868686", "alpha": 0.2}}]}, "web": {"ignore_shade": false}, "border": {"radius": 15}}, "window_size": "large", "orientation": "portrait"}], "android": {"disable_back_button": false}, "dismiss_on_touch_outside": false, "default_placement": {"ignore_safe_area": false, "device": {"lock_orientation": "portrait"}, "size": {"width": "100%", "height": "100%"}, "position": {"horizontal": "center", "vertical": "top"}, "shade_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 0.2}}, "web": {"ignore_shade": true}}}, "view": {"type": "pager_controller", "identifier": "fe2b618e-7762-49a7-9a18-e6ad84baf104", "view": {"type": "linear_layout", "direction": "vertical", "items": [{"size": {"width": "100%", "height": "100%"}, "view": {"type": "container", "items": [{"position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "100%"}, "view": {"type": "pager", "disable_swipe": true, "items": [{"identifier": "2adbe45d-6a6e-463d-93dc-740c5039e326", "type": "pager_item", "view": {"type": "container", "items": [{"size": {"width": "100%", "height": "100%"}, "position": {"horizontal": "center", "vertical": "center"}, "ignore_safe_area": false, "view": {"type": "container", "items": [{"margin": {"bottom": 0, "top": 0, "end": 0, "start": 0}, "position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "vertical", "items": [{"identifier": "scroll_container", "size": {"width": "100%", "height": "100%"}, "view": {"type": "scroll_layout", "direction": "vertical", "view": {"type": "linear_layout", "direction": "vertical", "items": [{"size": {"width": "100%", "height": "auto"}, "view": {"type": "media", "media_fit": "center_inside", "url": "https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/bc0a06d5-e9fa-4054-8ff9-7994ce104f54", "media_type": "image"}, "identifier": "4ff3f18a-6940-4eff-b233-9a9fe6a1dc24", "margin": {"top": 0, "bottom": 0, "start": 0, "end": 0}}, {"identifier": "dee44a8f-d164-434a-9083-c1363a9008ae", "size": {"width": "100%", "height": "auto"}, "margin": {"top": 8, "bottom": 8, "start": 16, "end": 16}, "view": {"type": "label", "text": "This is a test for swipe", "content_description": "This is a test for swipe", "text_appearance": {"font_size": 18, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}, "alignment": "start", "styles": [], "font_families": ["sans-serif"]}}}, {"identifier": "87ec2574-9b74-4d28-b059-a8b64573fb8b", "margin": {"top": 8, "bottom": 8, "start": 16, "end": 16}, "size": {"width": "100%", "height": "auto"}, "view": {"type": "label_button", "identifier": "next--Next Screen", "reporting_metadata": {"trigger_link_id": "87ec2574-9b74-4d28-b059-a8b64573fb8b"}, "label": {"type": "label", "text": "Next Screen", "content_description": "Next Screen", "text_appearance": {"font_size": 16, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}, "alignment": "center", "styles": ["bold"], "font_families": ["sans-serif"]}}, "actions": {}, "enabled": ["pager_next"], "button_click": ["pager_next"], "background_color": {"default": {"type": "hex", "hex": "#63AFF1", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}]}, "border": {"radius": 3, "stroke_width": 0, "stroke_color": {"default": {"type": "hex", "hex": "#63AFF1", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#63AFF1", "alpha": 1}}]}}}}, {"size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "horizontal", "items": []}}]}}}]}}]}}], "background_color": {"default": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 0.5}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 0.5}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 0.5}}]}}}, {"identifier": "41f29d1c-aa77-4d74-8f36-3b9251acf1b5", "type": "pager_item", "view": {"type": "container", "items": [{"size": {"width": "100%", "height": "100%"}, "position": {"horizontal": "center", "vertical": "center"}, "ignore_safe_area": false, "view": {"type": "container", "items": [{"margin": {"bottom": 0, "top": 0, "end": 0, "start": 0}, "position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "vertical", "items": [{"identifier": "scroll_container", "size": {"width": "100%", "height": "100%"}, "view": {"type": "scroll_layout", "direction": "vertical", "view": {"type": "linear_layout", "direction": "vertical", "items": [{"size": {"width": "100%", "height": "auto"}, "view": {"type": "media", "media_fit": "center_inside", "url": "https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/38b6c4c7-5d0c-4cd6-b54b-1f5536a1d6c2", "media_type": "image"}, "identifier": "471cbd05-ee58-4089-9cda-3c486cc9c358", "margin": {"top": 0, "bottom": 0, "start": 0, "end": 0}}, {"size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "horizontal", "items": []}}]}}}]}}]}}], "background_color": {"default": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 0.5}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 0.5}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 0.5}}]}}}]}, "ignore_safe_area": false}, {"position": {"horizontal": "end", "vertical": "top"}, "size": {"width": 48, "height": 48}, "view": {"type": "image_button", "image": {"scale": 0.4, "type": "icon", "icon": "close", "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}}, "identifier": "dismiss_button", "button_click": ["dismiss"]}}, {"margin": {"top": 4, "bottom": 4, "end": 0, "start": 0}, "position": {"horizontal": "center", "vertical": "bottom"}, "size": {"height": 7, "width": "100%"}, "view": {"type": "pager_indicator", "spacing": 6, "bindings": {"selected": {"shapes": [{"type": "ellipse", "scale": 1, "aspect_ratio": 1, "color": {"default": {"type": "hex", "hex": "#7B7C84", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#7B7C84", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#7B7C84", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#7B7C84", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}}]}, "unselected": {"shapes": [{"type": "ellipse", "aspect_ratio": 1, "scale": 1, "color": {"default": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#BCBDC2", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}}]}}, "automated_accessibility_actions": [{"type": "announce"}]}}]}}]}}}}, "audience": {"miss_behavior": "skip", "advanced_audience": {"and": [{"and": [{"channel": "ed28f1bc-e335-469f-946d-5fae5908b087"}]}]}}} ================================================ FILE: DevApp/Dev App/Thomas/Resources/Messages/Modal/youtube-video-modal.json ================================================ {"name": "Test Anusha YT", "source": "app-defined", "display_type": "layout", "display": {"layout": {"version": 1, "presentation": {"type": "modal", "placement_selectors": [], "android": {"disable_back_button": false}, "dismiss_on_touch_outside": false, "default_placement": {"ignore_safe_area": false, "size": {"width": "100%", "height": "100%"}, "position": {"horizontal": "center", "vertical": "top"}, "shade_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 0.2}}, "web": {"ignore_shade": true}}}, "view": {"type": "pager_controller", "identifier": "7f930f75-10d6-4582-9372-c749c6bf64ec", "view": {"type": "linear_layout", "direction": "vertical", "items": [{"size": {"width": "100%", "height": "100%"}, "view": {"type": "container", "items": [{"identifier": "d6da3d8e-616b-4599-bd0e-8b29ff1e0e9e_pager_container_item", "position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "100%"}, "view": {"type": "pager", "disable_swipe": true, "items": [{"identifier": "79e91ce6-0c15-4143-8aed-65f5886e92d7", "type": "pager_item", "view": {"type": "container", "items": [{"identifier": "6a7b3a4a-249d-419e-a8a6-5df4737c2445_main_view_container_item", "size": {"width": "100%", "height": "100%"}, "position": {"horizontal": "center", "vertical": "center"}, "ignore_safe_area": false, "view": {"type": "container", "items": [{"identifier": "d1632115-dd22-47b4-9fac-07349058101a_container_item", "margin": {"bottom": 0, "top": 0, "end": 0, "start": 0}, "position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "vertical", "items": [{"identifier": "scroll_container", "size": {"width": "100%", "height": "100%"}, "view": {"type": "scroll_layout", "direction": "vertical", "view": {"type": "linear_layout", "direction": "vertical", "items": [{"identifier": "4445e841-8fa0-41d7-b747-3edd88114969", "size": {"width": "100%", "height": "auto"}, "margin": {"top": 48, "bottom": 8, "start": 16, "end": 16}, "view": {"type": "label", "text": "Video below", "content_description": "Video below", "text_appearance": {"font_size": 24, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}, "alignment": "start", "styles": ["bold"], "font_families": ["sans-serif"]}}}, {"identifier": "39bb8d66-2ace-4286-ae39-a6aeccee5129", "size": {"width": "100%", "height": "auto"}, "view": {"type": "media", "media_fit": "center_inside", "url": "https://www.youtube.com/embed/l9bXcLw2u44/?autoplay=0&controls=1&loop=0&mute=1", "media_type": "youtube", "video": {"aspect_ratio": 1.7777777777777777, "show_controls": true, "autoplay": false, "muted": true, "loop": false}}, "margin": {"top": 0, "bottom": 0, "start": 0, "end": 0}}, {"identifier": "3ebba369-d80b-4e46-80f2-403b14ea1c43_linear_layout_item", "size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "horizontal", "items": []}}]}}}]}}]}}], "background_color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}}, "state_actions": [{"type": "set", "key": "79e91ce6-0c15-4143-8aed-65f5886e92d7_next"}]}]}, "ignore_safe_area": false}, {"position": {"horizontal": "end", "vertical": "top"}, "size": {"width": 48, "height": 48}, "view": {"type": "image_button", "image": {"scale": 0.4, "type": "icon", "icon": "close", "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}}, "identifier": "dismiss_button", "button_click": ["dismiss"], "localized_content_description": {"ref": "ua_dismiss", "fallback": "Dismiss"}}}]}}]}}}}, "audience": {"miss_behavior": "penalize"}} ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Banner/banner-bottom.yml ================================================ version: 1 presentation: type: banner default_placement: position: bottom ignore_safe_area: true # Allow banner to extend beyond safe areas size: width: 100% height: auto margin: top: 32 bottom: 0 start: 40 end: 16 corner_radius: bottom_left: 24 nub: size: width: 36 height: 10 margin: top: 8 color: default: hex: "#00FF00" alpha: 0.09 border: border_radius: 24 stroke_width: 10 stroke_color: default: hex: "#00FF00" alpha: 1 background_color: default: hex: "#FFFFFF" alpha: 1 duration: 8 # Add placement selectors for top and bottom positions # placement_selectors: # - placement: # position: top # ignore_safe_area: true # size: # width: 100% # height: auto # margin: # top: 0 # Extends past top # bottom: 16 # start: 16 # end: 16 # border: # radius: 12 # stroke_width: 1 # stroke_color: # default: # hex: "#E2E8F0" # alpha: 1 # background_color: # default: # hex: "#FFFFFF" # alpha: 1 # - placement: # position: bottom # ignore_safe_area: true # size: # width: 100% # height: auto # margin: # top: 16 # bottom: 0 # Extends past bottom # start: 16 # end: 16 # border: # radius: 12 # stroke_width: 1 # stroke_color: # default: # hex: "#E2E8F0" # alpha: 1 # background_color: # default: # hex: "#FFFFFF" # alpha: 1 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: horizontal items: # Main content - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: bottom: 8 view: type: label text: "🎉 Special Offer Inside!" text_appearance: font_size: 18 color: default: hex: "#1A237E" alpha: 1 styles: - bold - size: width: 100% height: auto margin: bottom: 12 view: type: label text: "🎁 Check out our amazing deal just for you!" text_appearance: font_size: 14 color: default: hex: "#4A5568" alpha: 1 - size: width: auto height: auto view: type: label_button identifier: show_offer_button background_color: default: hex: "#4C1D95" alpha: 1 border: radius: 8 label: type: label text: "✨ Show Me!" text_appearance: font_size: 14 alignment: center color: default: hex: "#FFFFFF" alpha: 1 button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Banner/banner-safe-area-bottom.yml ================================================ version: 1 presentation: type: banner default_placement: position: bottom size: width: 100% height: auto margin: top: 0 bottom: 0 start: 16 end: 16 nub: size: width: 36 height: 4 margin: top: 8 color: default: hex: "#000000" alpha: 0.42 border: radius: 12 stroke_width: 1 stroke_color: default: hex: "#E2E8F0" alpha: 1 background_color: default: hex: "#FFFFFF" alpha: 1 duration: 8 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: horizontal items: # Main content - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: bottom: 8 view: type: label text: "🎉 Special Offer Inside!" text_appearance: font_size: 18 color: default: hex: "#1A237E" alpha: 1 styles: - bold - size: width: 100% height: auto margin: bottom: 12 view: type: label text: "🎁 Check out our amazing deal just for you!" text_appearance: font_size: 14 color: default: hex: "#4A5568" alpha: 1 - size: width: auto height: auto view: type: label_button identifier: show_offer_button background_color: default: hex: "#4C1D95" alpha: 1 border: radius: 8 label: type: label text: "✨ Show Me!" text_appearance: font_size: 14 alignment: center color: default: hex: "#FFFFFF" alpha: 1 button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Banner/banner-safe-area-top.yml ================================================ version: 1 presentation: type: banner default_placement: position: top size: width: 100% height: auto margin: top: 0 bottom: 0 start: 16 end: 16 nub: size: width: 36 height: 4 margin: bottom: 8 color: default: hex: "#000000" alpha: 0.42 border: radius: 12 stroke_width: 1 stroke_color: default: hex: "#E2E8F0" alpha: 1 background_color: default: hex: "#FFFFFF" alpha: 1 duration: 8 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: horizontal items: # Main content - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: bottom: 8 view: type: label text: "🎉 Special Offer Inside!" text_appearance: font_size: 18 color: default: hex: "#1A237E" alpha: 1 styles: - bold - size: width: 100% height: auto margin: bottom: 12 view: type: label text: "🎁 Check out our amazing deal just for you!" text_appearance: font_size: 14 color: default: hex: "#4A5568" alpha: 1 - size: width: auto height: auto view: type: label_button identifier: show_offer_button background_color: default: hex: "#4C1D95" alpha: 1 border: radius: 8 label: type: label text: "✨ Show Me!" text_appearance: font_size: 14 alignment: center color: default: hex: "#FFFFFF" alpha: 1 button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Banner/banner-top-old.yml ================================================ --- version: 1 presentation: type: banner default_placement: size: width: 80% height: auto position: top ignore_safe_area: true nub: size: width: 36 height: 4 margin: bottom: 8 color: default: hex: "#000000" alpha: 0.42 background_color: default: hex: "#FF0000" shade_color: default: hex: "#444444" alpha: .3 background_color: default: hex: "#FFFF00" border: stroke_color: default: hex: "#00FF00" stroke_width: 3 radius: 15 view: type: container items: - position: horizontal: end vertical: top size: height: auto width: auto margin: top: 50 bottom: 50 start: 50 end: 50 view: type: label text: Sup Buddy text_appearance: font_size: 14 color: default: hex: "#333333" alignment: start styles: - italic font_families: - permanent_marker - casual - position: horizontal: center vertical: center size: height: auto width: 100% view: type: linear_layout direction: vertical items: - size: width: 100% height: auto weight: 1 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 75 bottom: 50 start: 50 end: 50 view: type: label text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In arcu cursus euismod quis viverra nibh. Lobortis feugiat vivamus at augue eget arcu dictum. Imperdiet dui accumsan sit amet nulla. Ultrices neque ornare aenean euismod elementum. Tincidunt id aliquet risus feugiat in ante metus dictum. text_appearance: font_size: 14 color: default: hex: "#333333" alignment: start styles: - italic font_families: - permanent_marker - casual - size: width: 100% height: auto margin: top: 50 bottom: 50 start: 50 end: 50 view: type: label_button identifier: BUTTON background_color: default: hex: "#FF0000" button_click: - dismiss label: type: label text_appearance: font_size: 24 alignment: center color: default: hex: "#ffffff" styles: - bold font_families: - casual text: Close ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Banner/banner-top.yml ================================================ version: 1 presentation: type: banner default_placement: position: top ignore_safe_area: true # Allow banner to extend beyond safe areas size: width: 100% height: auto margin: # Remove top margin to allow extension beyond top top: 0 bottom: 16 start: 16 end: 16 nub: size: width: 36 height: 4 margin: bottom: 8 color: default: hex: "#000000" alpha: 0.42 border: radius: 12 stroke_width: 10 stroke_color: default: hex: "#E2E8F0" alpha: 1 background_color: default: hex: "#FFFFFF" alpha: 1 duration: 8 # Add placement selectors for top and bottom positions # placement_selectors: # - placement: # position: top # ignore_safe_area: true # size: # width: 100% # height: auto # margin: # top: 0 # Extends past top # bottom: 16 # start: 16 # end: 16 # border: # radius: 12 # stroke_width: 1 # stroke_color: # default: # hex: "#E2E8F0" # alpha: 1 # background_color: # default: # hex: "#FFFFFF" # alpha: 1 # - placement: # position: bottom # ignore_safe_area: true # size: # width: 100% # height: auto # margin: # top: 16 # bottom: 0 # Extends past bottom # start: 16 # end: 16 # border: # radius: 12 # stroke_width: 1 # stroke_color: # default: # hex: "#E2E8F0" # alpha: 1 # background_color: # default: # hex: "#FFFFFF" # alpha: 1 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: horizontal items: # Main content - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: bottom: 8 view: type: label text: "🎉 Special Offer Inside!" text_appearance: font_size: 18 color: default: hex: "#1A237E" alpha: 1 styles: - bold - size: width: 100% height: auto margin: bottom: 12 view: type: label text: "🎁 Check out our amazing deal just for you!" text_appearance: font_size: 14 color: default: hex: "#4A5568" alpha: 1 - size: width: auto height: auto view: type: label_button identifier: show_offer_button background_color: default: hex: "#4C1D95" alpha: 1 border: radius: 8 label: type: label text: "✨ Show Me!" text_appearance: font_size: 14 alignment: center color: default: hex: "#FFFFFF" alpha: 1 button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/100pct x 100pct.yml ================================================ --- version: 1 presentation: type: embedded embedded_id: "100pct x 100pct" default_placement: size: width: 100% height: 100% margin: top: 16 bottom: 16 start: 16 end: 16 view: type: container border: stroke_color: default: hex: "#FF0D49" alpha: 1 stroke_width: 2 background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: label text: "100% x 100%." text_appearance: font_size: 14 color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/100pct x auto.yml ================================================ --- version: 1 presentation: type: embedded embedded_id: "100pct x auto" default_placement: size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: container border: stroke_color: default: hex: "#FF0D49" alpha: 1 stroke_width: 2 background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: label text: "100% x auto" text_appearance: font_size: 14 color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/50pct x 50pct.yml ================================================ --- version: 1 presentation: type: embedded embedded_id: "50pct x 50pct" default_placement: size: width: 50% height: 50% view: type: container border: stroke_color: default: hex: "#FF0D49" alpha: 1 stroke_width: 2 background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: label text: "50% x 50%." text_appearance: font_size: 14 color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/75pct x 200pt.yml ================================================ --- version: 1 presentation: type: embedded embedded_id: "75pct x 200pt" default_placement: size: width: 75% height: 200 margin: top: 16 bottom: 16 start: 16 end: 16 view: type: container border: stroke_color: default: hex: "#FF0D49" alpha: 1 stroke_width: 2 background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: label text: "75% x 200" text_appearance: font_size: 14 color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/auto x 200.yml ================================================ --- version: 1 presentation: type: embedded embedded_id: "auto x 200" default_placement: size: width: auto height: 200 margin: top: 16 bottom: 16 start: 16 end: 16 view: type: container border: stroke_color: default: hex: "#FF0D49" alpha: 1 stroke_width: 2 background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: label text: "auto x 200" text_appearance: font_size: 14 color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/gif_and_vid.yml ================================================ version: 1 view: type: pager_controller view: type: linear_layout direction: vertical items: - view: items: - size: width: 100% height: 100% view: items: - type: pager_item view: background_color: default: type: hex alpha: 1 hex: "#FFFFFF" selectors: - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 type: hex - color: hex: "#000000" alpha: 1 type: hex dark_mode: true platform: android type: container items: - position: horizontal: center vertical: center view: items: - size: width: 100% height: 100% view: items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout items: - margin: start: 0 end: 0 top: 0 bottom: 0 size: width: 100% height: auto view: media_type: image url: https://media3.giphy.com/media/tBvPFCFQHSpEI/giphy.gif media_fit: center_inside type: media - margin: bottom: 0 end: 0 top: 0 start: 0 view: media_fit: center_inside type: media video: muted: true aspect_ratio: 1.7777777777777777 autoplay: false show_controls: true loop: false url: https://www.youtube.com/embed/a3ICNMQW7Ok/?autoplay=0&controls=1&loop=0&mute=1 media_type: youtube size: width: 100% height: auto - size: width: 100% height: 100% view: type: linear_layout items: [] direction: horizontal direction: vertical type: linear_layout background_color: default: hex: "#FFFFFF" type: hex alpha: 1 selectors: - platform: ios dark_mode: true color: alpha: 1 type: hex hex: "#000000" - color: type: hex alpha: 1 hex: "#000000" dark_mode: true platform: android direction: vertical margin: bottom: 16 position: horizontal: center vertical: center type: container size: width: 100% height: 100% identifier: 294acc65-f80c-4663-9840-0d48bf1972b8 type: pager disable_swipe: true ignore_safe_area: false position: horizontal: center vertical: center - size: width: 48 height: 48 view: identifier: dismiss_button button_click: - dismiss type: image_button image: scale: 0.4 icon: close type: icon color: default: hex: "#000000" alpha: 1 type: hex selectors: - color: alpha: 1 type: hex hex: "#FFFFFF" platform: ios dark_mode: true - platform: android dark_mode: true color: alpha: 1 hex: "#FFFFFF" type: hex position: horizontal: end vertical: top type: container size: width: 100% height: 100% identifier: e1fe3c1a-bcf9-4159-a268-c00239433c91 presentation: android: disable_back_button: false default_placement: size: min_width: 100% min_height: 100% max_height: 100% height: 100% width: 100% max_width: 100% device: lock_orientation: portrait shade_color: default: type: hex alpha: 0.2 hex: "#000000" ignore_safe_area: false position: horizontal: center vertical: top type: embedded embedded_id: "gif_and_vid" dismiss_on_touch_outside: false ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/home_image.yml ================================================ --- version: 1 presentation: type: embedded placement_selectors: [] default_placement: size: width: 90% height: 90% border: radius: 0 embedded_id: home_image view: type: pager_controller identifier: 3e3a8863-a365-46c3-aa42-4d10e4bf1b23 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: 5caeef0f-2c44-4dd5-8021-6ee1f0535a5c type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: true view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: layout_container size: width: 100% height: 100% view: type: linear_layout direction: vertical border: stroke_width: 8 stroke_color: default: hex: "#ff0000" alpha: 1 items: - size: width: 100% height: auto view: type: media media_fit: center_crop url: https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/5cd5c3b4-9cbf-488c-af32-79145a349fc9 media_type: image identifier: ed77bcab-4861-437f-a412-7255abb0340a margin: top: 0 bottom: 0 start: 0 end: 0 background_color: default: type: hex hex: "#BCBDC2" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#BCBDC2" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0.5 - platform: android dark_mode: false color: type: hex hex: "#BCBDC2" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0.5 - platform: web dark_mode: false color: type: hex hex: "#BCBDC2" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0.5 ignore_safe_area: true - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/home_rating.yml ================================================ --- presentation: type: embedded embedded_id: "home_rating" default_placement: size: height: 400 width: 100% version: 1 view: type: linear_layout direction: vertical border: stroke_color: default: hex: "#FF0D49" alpha: 1 stroke_width: 2 background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - size: width: auto height: auto margin: top: 16 bottom: 0 start: 16 end: 16 view: type: label text: "How was your experience with your last order?" text_appearance: font_size: 16 styles: [ ] color: default: hex: "#000000" alignment: center - size: width: auto height: auto margin: top: 0 bottom: 16 start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 36 height: 36 margin: end: 2 view: type: label_button identifier: rating_1 button_click: [ "cancel" ] label: type: label text: "★" text_appearance: font_size: 32 styles: [ ] color: default: hex: "#000000" alpha: 0.33 alignment: center - size: width: 36 height: 36 margin: end: 2 view: type: label_button identifier: rating_2 button_click: [ "cancel" ] label: type: label text: "★" text_appearance: font_size: 32 styles: [ ] color: default: hex: "#000000" alpha: 0.33 alignment: center - size: width: 36 height: 36 margin: end: 2 view: type: label_button identifier: rating_3 button_click: [ "cancel" ] label: type: label text: "★" text_appearance: font_size: 32 styles: [ ] color: default: hex: "#000000" alpha: 0.33 alignment: center - size: width: 36 height: 36 margin: end: 2 view: type: label_button identifier: rating_4 button_click: [ "cancel" ] label: type: label text: "★" text_appearance: font_size: 32 styles: [ ] color: default: hex: "#000000" alpha: 0.33 alignment: center - size: width: 36 height: 36 margin: end: 0 view: type: label_button identifier: rating_5 button_click: [ "cancel" ] label: type: label text: "★" text_appearance: font_size: 32 styles: [ ] color: default: hex: "#000000" alpha: 0.33 alignment: center ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Embedded/home_special_offer.yml ================================================ --- version: 1 presentation: type: embedded embedded_id: "home_special_offer" default_placement: size: height: auto width: 100% view: type: linear_layout direction: vertical background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - size: height: auto width: 100% margin: top: 16 bottom: 8 start: 16 end: 16 view: type: label text: "Your birthday is coming up!" text_appearance: font_size: 24 styles: [bold] color: default: hex: "#000000" alignment: center - size: width: auto height: auto margin: top: 8 bottom: 16 start: 16 end: 16 view: type: label_button identifier: button1 actions: add_custom_event_action: event_name: 'birthday_offer_tapped' add_tags_action: "birthday_offer" label: text: "Enjoy 15% off on our men's shirts,\nbecause we know you like them." text_appearance: font_size: 16 styles: [ ] color: default: hex: "#000000" alignment: center ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/1-pager-different-path.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: 95% position: horizontal: center vertical: center shade_color: default: hex: "#444444" alpha: .3 view: type: pager_controller identifier: "pager-controller-id" branching: pager_completions: - when_state_matches: value: equals: - "is-complete" scope: - $form view: type: container background_color: default: hex: "#FFFFFF" alpha: 1 items: - position: horizontal: center vertical: center size: height: auto width: 100% view: type: linear_layout direction: vertical items: - size: height: auto width: 100% margin: top: 16 start: 16 end: 16 view: type: label text: Take a spin text_appearance: alignment: center styles: - bold font_size: 18 color: default: hex: "#000000" - size: height: 250 width: 100% margin: start: 64 end: 64 top: 16 bottom: 16 view: type: pager items: - identifier: "start-page" branching: next_page: selectors: - when_state_matches: value: equals: "cats" scope: - next_page page_id: page-cats-1 - when_state_matches: value: equals: "dogs" scope: - next_page page_id: page-dogs-1 view: type: linear_layout background_color: default: hex: "#88FF0000" direction: vertical items: - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button_cats event_handlers: - type: tap state_actions: - type: set key: next_page value: cats background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'I like cats' button_click: ["pager_next"] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button_dogs event_handlers: - type: tap state_actions: - type: set key: next_page value: dogs background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'I like dogs' button_click: ["pager_next"] - identifier: "page-cats-1" branching: next_page: selectors: - page_id: page-cats-2 view: type: container background_color: default: hex: "#00FF00" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: Cats page 1 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-cats-2" branching: next_page: selectors: - page_id: page-cats-3 view: type: container background_color: default: hex: "#00FF00" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: Cats page 2 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-cats-3" view: type: container background_color: default: hex: "#00FF00" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: Cats page 3 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-dogs-1" branching: next_page: selectors: - when_state_matches: value: equals: "dogs" scope: - next_page page_id: page-dogs-2 view: type: container background_color: default: hex: "#0000FF" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: Dogs page 1 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-dogs-2" branching: next_page: selectors: - page_id: page-dogs-3 view: type: container background_color: default: hex: "#0000FF" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: Dogs page 2 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-dogs-3" view: type: container background_color: default: hex: "#0000FF" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: Dogs page 3 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label event_handlers: - type: tap state_actions: - type: set key: next_page value: page-5 text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Unlock page 5' - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Next!' button_click: ["pager_next"] enabled: ["pager_next"] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Previous!' button_click: ["pager_previous"] enabled: ["pager_previous"] - size: height: 30 width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: pager_indicator spacing: 16 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 color: default: hex: "#0000FF" icon: icon: checkmark color: default: hex: "#ffffff" alpha: 1 scale: .8 unselected: shapes: - type: rectangle aspect_ratio: 1 border: stroke_color: default: hex: "#000000" stroke_width: 3 radius: 4 color: default: hex: "#FF0000" icon: icon: close color: default: hex: "#ffffff" alpha: 1 scale: .8 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/1-pager-pranching-test.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: 95% position: horizontal: center vertical: center shade_color: default: hex: "#444444" alpha: .3 view: type: pager_controller identifier: "pager-controller-id" branching: pager_completions: - when_state_matches: value: equals: - "is-complete" scope: - $form view: type: container background_color: default: hex: "#FFFFFF" alpha: 1 items: - position: horizontal: center vertical: center size: height: auto width: 100% view: type: linear_layout direction: vertical items: - size: height: auto width: 100% margin: top: 16 start: 16 end: 16 view: type: label text: Take a spin text_appearance: alignment: center styles: - bold font_size: 18 color: default: hex: "#000000" - size: height: 250 width: 100% margin: start: 64 end: 64 top: 16 bottom: 16 view: type: pager items: - identifier: "page-1" branching: next_page: selectors: - when_state_matches: value: equals: "page-3" scope: - next_page page_id: page-3 - when_state_matches: value: equals: "page-2" scope: - next_page page_id: page-2 previous_page_disabled: always: true view: type: container background_color: default: hex: "#88FF0000" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: This is the first page about stuff. text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" - identifier: "page-2" view: type: container background_color: default: hex: "#00FF00" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: More stuff is here on the second page. text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-3" branching: next_page: selectors: - when_state_matches: value: equals: "page-4" scope: - next_page page_id: page-4 - when_state_matches: value: equals: "page-5" scope: - next_page page_id: page-5 previous_page_disabled: always: true view: type: container background_color: default: hex: "#0000FF" alpha: .8 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: That's not all! There's a third page, too! text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-4" view: type: container background_color: default: hex: "#0022FF" alpha: .8 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: that's page 4 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-5" view: type: container background_color: default: hex: "#1100FF" alpha: .8 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: that's page 5 text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" event_handlers: - type: tap state_actions: - type: set key: next_page value: page-2 label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Go Next Page 2 -> 3' button_click: ["pager_next"] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" event_handlers: - type: tap state_actions: - type: set key: next_page value: page-3 label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Go Next Page 3' button_click: ["pager_next"] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" event_handlers: - type: tap state_actions: - type: set key: next_page value: page-4 label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Unlock page 4' button_click: ["pager_next"] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label event_handlers: - type: tap state_actions: - type: set key: next_page value: page-5 text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Unlock page 5' - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Next!' button_click: ["pager_next"] enabled: ["pager_next"] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Previous!' button_click: ["pager_previous"] enabled: ["pager_previous"] - size: height: 30 width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: pager_indicator spacing: 16 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 color: default: hex: "#0000FF" icon: icon: checkmark color: default: hex: "#ffffff" alpha: 1 scale: .8 unselected: shapes: - type: rectangle aspect_ratio: 1 border: stroke_color: default: hex: "#000000" stroke_width: 3 radius: 4 color: default: hex: "#FF0000" icon: icon: close color: default: hex: "#ffffff" alpha: 1 scale: .8 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/1qa.yml ================================================ ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/MOBILE_4621.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: - placement: ignore_safe_area: true size: width: 70% height: 60% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 1 web: ignore_shade: false border: radius: 15 window_size: large orientation: portrait - placement: ignore_safe_area: true size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 1 web: ignore_shade: false border: radius: 15 window_size: large orientation: landscape android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: true size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: ignore_shade: true view: type: pager_controller identifier: e5dc9815-cdfc-4ea5-be08-3a02fdfe7c3d view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: d3479e4e-61b3-47f3-bb6b-97b3ae016400 type: pager_item view: type: container items: - margin: start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: media media_fit: center_crop url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 media_type: video video: aspect_ratio: 0.5625 show_controls: false autoplay: true muted: true loop: true ignore_safe_area: true - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: true view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#E81C3C" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#E81C3C" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#E81C3C" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#E81C3C" alpha: 1 ignore_safe_area: true - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/_a11y_focus.json ================================================ { "in_app_message": { "edit_grace_period": 14, "features": { "embedded": "1.0" }, "forms": [ { "id": "b4cbb892-6e66-48d0-bb9a-4c86f875c358", "questions": [ { "id": "163c6741-b9e3-495d-b0ba-ef19cb5182d1", "label": "Where do you prefer to shop?", "responses": [ { "draft": "Online", "label": "Online", "value": "c7005ea5-10c7-4aa1-a41e-7252cd7b36b6" }, { "draft": "In-store", "label": "In-store", "value": "35e04233-b406-494b-9b5d-8c6553eeb052" } ], "type": "radio" }, { "id": "9b7081eb-0c70-44e3-90cb-aab772f29925", "label": "Where do you like to surf? ", "responses": [ { "draft": "Hawaii", "label": "Hawaii", "value": "c7005ea5-10c7-4aa1-a41e-7252cd7b36b6" }, { "draft": "Mexico", "label": "Mexico", "value": "35e04233-b406-494b-9b5d-8c6553eeb052" }, { "draft": "California", "label": "California", "value": "4097a556-c041-4824-8f34-726ead6123c7" } ], "type": "checkbox" } ], "response_type": "user_feedback", "type": "form" } ], "interval": 2, "labels": [ { "id": "a20d59fb-1beb-40b3-b865-f3fa2c8f02d8", "label": "Screen 1" }, { "id": "77bce6f3-9de5-4813-9b7a-7d4066113091", "label": "Screen 2" }, { "id": "ac248e67-2e74-441c-ab77-5791a423c11d", "label": "Screen 3" } ], "limit": 0, "message": { "audience": {}, "display": { "layout": { "presentation": { "default_placement": { "border": { "radius": 0 }, "size": { "height": "50%", "width": "100%" } }, "embedded_id": "a11y_embedded", "placement_selectors": [], "type": "modal" }, "version": 1, "view": { "type": "state_controller", "view": { "identifier": "5f935a5d-d4b7-4c02-b839-85f27d38b7b3", "type": "pager_controller", "view": { "form_enabled": [ "form_submission" ], "identifier": "b4cbb892-6e66-48d0-bb9a-4c86f875c358", "response_type": "user_feedback", "submit": "submit_event", "type": "form_controller", "view": { "items": [ { "identifier": "47c2c045-df8b-4523-9282-c74fc8135054_pager_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "disable_swipe": false, "items": [ { "identifier": "a20d59fb-1beb-40b3-b865-f3fa2c8f02d8", "state_actions": [ { "key": "a20d59fb-1beb-40b3-b865-f3fa2c8f02d8_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "14da2c81-244f-401a-af18-8e505b473653_main_view_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "dae1580e-0657-4550-8b07-63df24ce8f15_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "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": [ { "identifier": "0963a1cf-4b4a-4212-b174-65e5333d259a", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "accessibility_role": { "level": 1, "type": "heading" }, "content_description": "Do you have time today for a quick survey??", "text": "Do you have time today for a quick survey??", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 22, "styles": [ "bold" ] }, "type": "label" } }, { "identifier": "96029a26-b49f-492a-b70e-6165a661d5ac", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "96029a26-b49f-492a-b70e-6165a661d5ac_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "background_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 0 }, "direction": "horizontal", "items": [ { "identifier": "00387580-5d8c-4e08-a72c-c0b2bb03d041", "margin": { "bottom": 16, "end": 16, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "50%" }, "view": { "actions": {}, "background_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 3, "stroke_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "button_click": [ "dismiss" ], "content_description": "Maybe later — Dismiss the survey", "enabled": [], "identifier": "dismiss--Maybe later", "label": { "content_description": "Maybe later — Dismiss the survey", "text": "Maybe later", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "00387580-5d8c-4e08-a72c-c0b2bb03d041", "trigger_link_id": "00387580-5d8c-4e08-a72c-c0b2bb03d041" }, "type": "label_button" } }, { "identifier": "739cd27e-8fc8-4fb3-a454-964a4d4f594e", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "50%" }, "view": { "actions": {}, "background_color": { "default": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 3, "stroke_color": { "default": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "button_click": [ "pager_next" ], "content_description": "Yes — Advance to next screen", "enabled": [ "pager_next" ], "identifier": "next--Yes", "label": { "content_description": "Yes — Advance to next screen", "text": "Yes", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Next", "ref": "ua_next" }, "reporting_metadata": { "button_action": "next", "button_id": "739cd27e-8fc8-4fb3-a454-964a4d4f594e", "trigger_link_id": "739cd27e-8fc8-4fb3-a454-964a4d4f594e" }, "type": "label_button" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "linear_layout" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "container" } }, { "identifier": "77bce6f3-9de5-4813-9b7a-7d4066113091", "state_actions": [ { "key": "77bce6f3-9de5-4813-9b7a-7d4066113091_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "cd834ed7-1c15-4762-93d5-dc66c40d01e8_main_view_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "13b384e1-fd8a-4e91-80a8-3e5734bacb81_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "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": [ { "identifier": "163c6741-b9e3-495d-b0ba-ef19cb5182d1", "margin": { "bottom": 0, "end": 16, "start": 16, "top": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "direction": "vertical", "items": [ { "margin": { "bottom": 8, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Where do you prefer to shop?", "identifier": "163c6741-b9e3-495d-b0ba-ef19cb5182d1_title", "text": "Where do you prefer to shop?", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 19, "styles": [ "bold" ] }, "type": "label" } }, { "margin": { "bottom": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "attribute_name": {}, "identifier": "163c6741-b9e3-495d-b0ba-ef19cb5182d1", "required": false, "type": "radio_input_controller", "view": { "direction": "vertical", "items": [ { "margin": { "bottom": 8, "top": 0 }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [ { "margin": { "end": 8 }, "size": { "height": 20, "width": 20 }, "view": { "content_description": "Online", "reporting_value": "c7005ea5-10c7-4aa1-a41e-7252cd7b36b6", "style": { "bindings": { "selected": { "shapes": [ { "aspect_ratio": 1, "border": { "radius": 20, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "scale": 1, "type": "ellipse" }, { "aspect_ratio": 1, "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "scale": 0.6, "type": "ellipse" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "border": { "radius": 20, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "scale": 1, "type": "ellipse" } ] } }, "type": "checkbox" }, "type": "radio_input" } }, { "margin": { "end": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Online", "text": "Online", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 19, "styles": [ "bold" ] }, "type": "label" } } ], "type": "linear_layout" } }, { "margin": { "bottom": 8, "top": 0 }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [ { "margin": { "end": 8 }, "size": { "height": 20, "width": 20 }, "view": { "content_description": "In-store", "reporting_value": "35e04233-b406-494b-9b5d-8c6553eeb052", "style": { "bindings": { "selected": { "shapes": [ { "aspect_ratio": 1, "border": { "radius": 20, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "scale": 1, "type": "ellipse" }, { "aspect_ratio": 1, "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "scale": 0.6, "type": "ellipse" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "border": { "radius": 20, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "scale": 1, "type": "ellipse" } ] } }, "type": "checkbox" }, "type": "radio_input" } }, { "margin": { "end": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "In-store", "text": "In-store", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 19, "styles": [ "bold" ] }, "type": "label" } } ], "type": "linear_layout" } } ], "randomize_children": false, "type": "linear_layout" } } } ], "type": "linear_layout" } }, { "identifier": "2ca890c3-42da-4c80-84e1-9c345370c871", "margin": { "bottom": 4, "end": 16, "start": 16, "top": 0 }, "size": { "height": "auto", "width": "50%" }, "view": { "actions": {}, "background_color": { "default": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 3, "stroke_color": { "default": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "button_click": [ "pager_next" ], "content_description": "Next — Advance to next screen", "enabled": [ "pager_next" ], "identifier": "next--Next", "label": { "content_description": "Next — Advance to next screen", "text": "Next", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Next", "ref": "ua_next" }, "reporting_metadata": { "button_action": "next", "button_id": "2ca890c3-42da-4c80-84e1-9c345370c871", "trigger_link_id": "2ca890c3-42da-4c80-84e1-9c345370c871" }, "type": "label_button" } } ], "type": "linear_layout" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "container" } }, { "identifier": "ac248e67-2e74-441c-ab77-5791a423c11d", "state_actions": [ { "key": "ac248e67-2e74-441c-ab77-5791a423c11d_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "04297707-e15d-4b80-be07-7e2fcab07e83_main_view_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "4f9bf2c4-7ca4-4e54-9488-d5a979087704_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "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": [ { "identifier": "9b7081eb-0c70-44e3-90cb-aab772f29925", "margin": { "bottom": 0, "end": 16, "start": 16, "top": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "direction": "vertical", "items": [ { "margin": { "bottom": 8, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Where do you like to surf? ", "identifier": "9b7081eb-0c70-44e3-90cb-aab772f29925_title", "text": "Where do you like to surf? ", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 19, "styles": [ "bold" ] }, "type": "label" } }, { "margin": { "bottom": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "identifier": "9b7081eb-0c70-44e3-90cb-aab772f29925", "required": false, "type": "checkbox_controller", "view": { "direction": "vertical", "items": [ { "margin": { "bottom": 8, "top": 0 }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [ { "margin": { "end": 8 }, "size": { "height": 20, "width": 20 }, "view": { "content_description": "Hawaii", "reporting_value": "c7005ea5-10c7-4aa1-a41e-7252cd7b36b6", "style": { "bindings": { "selected": { "icon": { "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "icon": "checkmark", "scale": 0.8, "type": "icon" }, "shapes": [ { "aspect_ratio": 1, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "scale": 1, "type": "rectangle" } ] } }, "type": "checkbox" }, "type": "checkbox" } }, { "margin": { "end": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Hawaii", "text": "Hawaii", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 19, "styles": [ "bold" ] }, "type": "label" } } ], "type": "linear_layout" } }, { "margin": { "bottom": 8, "top": 0 }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [ { "margin": { "end": 8 }, "size": { "height": 20, "width": 20 }, "view": { "content_description": "Mexico", "reporting_value": "35e04233-b406-494b-9b5d-8c6553eeb052", "style": { "bindings": { "selected": { "icon": { "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "icon": "checkmark", "scale": 0.8, "type": "icon" }, "shapes": [ { "aspect_ratio": 1, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "scale": 1, "type": "rectangle" } ] } }, "type": "checkbox" }, "type": "checkbox" } }, { "margin": { "end": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Mexico", "text": "Mexico", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 19, "styles": [ "bold" ] }, "type": "label" } } ], "type": "linear_layout" } }, { "margin": { "bottom": 8, "top": 0 }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [ { "margin": { "end": 8 }, "size": { "height": 20, "width": 20 }, "view": { "content_description": "California", "reporting_value": "4097a556-c041-4824-8f34-726ead6123c7", "style": { "bindings": { "selected": { "icon": { "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "icon": "checkmark", "scale": 0.8, "type": "icon" }, "shapes": [ { "aspect_ratio": 1, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" } }, "stroke_width": 2 }, "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "scale": 1, "type": "rectangle" } ] } }, "type": "checkbox" }, "type": "checkbox" } }, { "margin": { "end": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "California", "text": "California", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 19, "styles": [ "bold" ] }, "type": "label" } } ], "type": "linear_layout" } } ], "randomize_children": false, "type": "linear_layout" } } } ], "type": "linear_layout" } }, { "identifier": "1322c70d-a8a2-4dc2-a95a-e703e838404e", "margin": { "bottom": 4, "end": 0, "start": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "actions": {}, "background_color": { "default": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 3, "stroke_color": { "default": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "button_click": [ "form_submit" ], "content_description": "Submit — Submit responses and dismisses the survey. ", "enabled": [ "form_validation" ], "event_handlers": [ { "state_actions": [ { "key": "submitted", "type": "set", "value": true } ], "type": "tap" } ], "identifier": "submit_feedback--Submit", "label": { "content_description": "Submit — Submit responses and dismisses the survey. ", "text": "Submit", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "styles": [ "bold" ] }, "type": "label", "view_overrides": { "icon_start": [ { "value": { "icon": { "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "icon": "progress_spinner", "scale": 1, "type": "icon" }, "space": 8, "type": "floating" }, "when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "validating" } } } ], "text": [ { "value": "Submit", "when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "validating" } } } ], "text_appearance": [ { "value": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "styles": [ "bold" ] } } ] } }, "reporting_metadata": { "button_action": "submit_feedback", "button_id": "1322c70d-a8a2-4dc2-a95a-e703e838404e", "trigger_link_id": "1322c70d-a8a2-4dc2-a95a-e703e838404e" }, "type": "label_button" } } ], "type": "linear_layout" } } ], "type": "linear_layout" } } ], "type": "container", "visibility": { "default": true, "invert_when_state_matches": { "key": "submitted", "value": { "equals": true } } } } }, { "identifier": "2f8a8449-6713-4be5-91d8-dedb98ab833e_confirmation_screen_content_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "background_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "fcd7d766-4674-49e1-9c1c-005d8f746540_container_item", "ignore_safe_area": true, "margin": { "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [], "type": "linear_layout" } }, { "identifier": "ff8a83c4-fdf6-4b96-8362-453b4c7f29bc_container_item", "ignore_safe_area": false, "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "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": [ { "identifier": "307a6b80-818f-4db9-98a9-5a8568be05a0", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 48 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Survey submitted successfully! ", "text": "Survey submitted successfully! ", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 24, "styles": [ "bold" ] }, "type": "label" } } ], "type": "linear_layout" } } ], "type": "linear_layout" } } ], "type": "container", "visibility": { "default": false, "invert_when_state_matches": { "key": "submitted", "value": { "equals": true } } } } } ], "type": "container", "visibility": { "default": true, "invert_when_state_matches": { "key": "confirmation_screen_container", "value": { "equals": true } } } } } ], "type": "pager" } }, { "position": { "horizontal": "end", "vertical": "top" }, "size": { "height": 48, "width": 48 }, "view": { "button_click": [ "dismiss" ], "identifier": "dismiss_button", "image": { "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "icon": "close", "scale": 0.4, "type": "icon" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "dismiss_button" }, "type": "image_button" } }, { "margin": { "bottom": 4, "end": 0, "start": 0, "top": 4 }, "position": { "horizontal": "center", "vertical": "bottom" }, "size": { "height": 14, "width": "100%" }, "view": { "automated_accessibility_actions": [ { "type": "announce" } ], "bindings": { "selected": { "shapes": [ { "aspect_ratio": 2, "border": { "radius": 16 }, "color": { "default": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#63AFF1", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "color": { "default": { "alpha": 1, "hex": "#487399", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#487399", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#487399", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#487399", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#487399", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#487399", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#487399", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "scale": 0.5, "type": "ellipse" } ] } }, "spacing": 0, "type": "pager_indicator" } } ], "type": "container" } } } } } }, "display_type": "layout", "name": "Validation Dec 2025: App Embedded Survey - LA Testing" }, "override_limits": true, "product_id": "embedded", "queue": "embedded", "reporting_context": { "content_types": [ "scene", "survey", "embedded" ], "experiment_id": "" }, "requires_eligibility": false, "scopes": [ "app" ], "triggers": [ { "goal": 1, "predicate": { "or": [ { "and": [ { "ignore_case": true, "key": "event_name", "value": { "equals": "as_iaa_fullscreen" } } ] } ] }, "type": "custom_event_count" } ] } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/_vimeo.ml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: {} view: type: pager_controller identifier: 7d97a133-bef8-4c18-b52a-b7a29061768e view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: 2a35dbf3-1700-4813-8e2c-78747ff4a00c type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: media media_fit: center_inside url: https://player.vimeo.com/video/714680147?autoplay=0&loop=1&controls=1&muted=1&unmute_button=0 media_type: vimeo video: aspect_ratio: 1.7777777777777777 autoplay: false loop: true muted: true show_controls: true identifier: 94c693a7-7392-4ff2-b2d2-fc2d9828bf21 margin: top: 0 bottom: 0 start: 0 end: 0 - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss localized_content_description: ref: ua_dismiss fallback: Dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/a-gif-and-youtube.yml ================================================ version: 1 view: type: pager_controller view: type: linear_layout direction: vertical items: - view: items: - size: width: 100% height: 100% view: items: - type: pager_item view: background_color: default: type: hex alpha: 1 hex: "#FFFFFF" selectors: - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 type: hex - color: hex: "#000000" alpha: 1 type: hex dark_mode: true platform: android type: container items: - position: horizontal: center vertical: center view: items: - size: width: 100% height: 100% view: items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout items: - margin: start: 0 end: 0 top: 0 bottom: 0 size: width: 100% height: auto view: media_type: image url: https://media3.giphy.com/media/tBvPFCFQHSpEI/giphy.gif media_fit: center_inside type: media - margin: bottom: 0 end: 0 top: 0 start: 0 view: media_fit: center_inside type: media video: muted: true aspect_ratio: 1.7777777777777777 autoplay: false show_controls: true loop: false url: https://www.youtube.com/embed/a3ICNMQW7Ok/?autoplay=0&controls=1&loop=0&mute=1 media_type: youtube size: width: 100% height: auto - size: width: 100% height: 100% view: type: linear_layout items: [] direction: horizontal direction: vertical type: linear_layout background_color: default: hex: "#FFFFFF" type: hex alpha: 1 selectors: - platform: ios dark_mode: true color: alpha: 1 type: hex hex: "#000000" - color: type: hex alpha: 1 hex: "#000000" dark_mode: true platform: android direction: vertical margin: bottom: 16 position: horizontal: center vertical: center type: container size: width: 100% height: 100% identifier: 294acc65-f80c-4663-9840-0d48bf1972b8 type: pager disable_swipe: true ignore_safe_area: false position: horizontal: center vertical: center - size: width: 48 height: 48 view: identifier: dismiss_button button_click: - dismiss type: image_button image: scale: 0.4 icon: close type: icon color: default: hex: "#000000" alpha: 1 type: hex selectors: - color: alpha: 1 type: hex hex: "#FFFFFF" platform: ios dark_mode: true - platform: android dark_mode: true color: alpha: 1 hex: "#FFFFFF" type: hex position: horizontal: end vertical: top type: container size: width: 100% height: 100% identifier: e1fe3c1a-bcf9-4159-a268-c00239433c91 presentation: android: disable_back_button: false default_placement: size: min_width: 100% min_height: 100% max_height: 100% height: 100% width: 100% max_width: 100% device: lock_orientation: portrait shade_color: default: type: hex alpha: 0.2 hex: "#000000" ignore_safe_area: false position: horizontal: center vertical: top type: modal dismiss_on_touch_outside: false ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/a-landscape-video.yml ================================================ --- version: 1 presentation: android: disable_back_button: false default_placement: device: lock_orientation: landscape ignore_safe_area: true position: horizontal: center vertical: center shade_color: default: alpha: 0.2 hex: "#000000" type: hex size: height: 100% width: 100% dismiss_on_touch_outside: false placement_selectors: [] type: modal view: identifier: "18e97cb3-36bd-42af-b1e5-6015f7b6953d" type: pager_controller view: items: - ignore_safe_area: true position: horizontal: center vertical: center size: height: 100% width: 100% view: disable_swipe: true items: - identifier: "e95c6874-9c5e-4b31-ac46-37370a0dfdfd" type: pager_item view: background_color: default: alpha: 1 hex: "#BFBFB0" type: hex selectors: - color: alpha: 1 hex: "#BFBFB0" type: hex dark_mode: true platform: ios - color: alpha: 1 hex: "#BFBFB0" type: hex dark_mode: true platform: android - color: alpha: 1 hex: "#BFBFB0" type: hex dark_mode: true platform: web 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://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/HorizBoardSmall.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true loop: true muted: true show_controls: false - ignore_safe_area: true position: horizontal: center vertical: center size: height: 100% width: 100% view: items: - margin: bottom: 0 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - identifier: scroll_container size: height: 100% width: 100% view: direction: vertical type: scroll_layout view: direction: vertical items: - identifier: "9a8d7d1b-e5f2-42b1-a443-5066e735ed06" margin: bottom: 8 end: 16 start: 16 top: 48 size: height: auto width: 100% view: text: "Enjoy a nice summer" text_appearance: alignment: start 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 - color: alpha: 1 hex: "#FFFFFF" type: hex dark_mode: true platform: web font_families: - "more fancy fonts MORE" font_size: 24 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 type: pager - position: horizontal: end vertical: top size: height: 48 width: 48 view: button_click: - dismiss identifier: dismiss_button localized_content_description: ref: "ua_dismiss" fallback: "cool" image: 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 - color: alpha: 1 hex: "#FFFFFF" type: hex dark_mode: true platform: web icon: close scale: 0.4 type: icon type: image_button type: container display_type: layout name: Landscape forced ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/aaa-modal-bg-image.yaml ================================================ --- view: type: state_controller view: identifier: 931a61a3-16fb-4289-8394-acbb259999bb type: pager_controller view: items: - view: identifier: bef63604-5372-402c-b079-bbe47ba08868 response_type: user_feedback form_enabled: - form_submission type: form_controller submit: submit_event view: items: - position: horizontal: center vertical: center view: type: pager items: - identifier: 212a2dd6-ac96-480f-b294-2df5216f9be7 view: items: - view: type: media media_fit: center_crop url: https://hangar-dl.urbanairship.com/binary/public/VWDwdOFjRTKLRxCeXTVP6g/5a82e758-6d9b-4c4e-8e0c-925a6f7bf82d media_type: image position: vertical: center horizontal: center ignore_safe_area: false margin: end: 0 start: 0 size: width: 100% height: 100% - view: items: - position: horizontal: center vertical: center view: items: - size: width: 100% height: 100% view: view: items: - view: type: container items: - view: items: - margin: end: 16 start: 16 top: 8 bottom: 8 view: items: - size: width: 100% height: auto margin: top: 0 bottom: 8 view: text: Text field underneath this heading content_description: Text field underneath this heading type: label text_appearance: font_families: - sans-serif styles: [] alignment: start color: default: hex: "#FFFFFF" alpha: 0.9 type: hex selectors: - color: alpha: 0.9 hex: "#FFFFFF" type: hex dark_mode: false platform: ios - color: alpha: 1 hex: "#000000" type: hex platform: ios dark_mode: true - platform: android color: hex: "#FFFFFF" type: hex alpha: 0.9 dark_mode: false - platform: android color: type: hex hex: "#000000" alpha: 1 dark_mode: true - color: hex: "#FFFFFF" alpha: 0.9 type: hex dark_mode: false platform: web - dark_mode: true color: hex: "#000000" type: hex alpha: 1 platform: web font_size: 20 - margin: bottom: 8 top: 0 size: width: 100% height: 75 view: border: radius: 2 stroke_color: selectors: - dark_mode: false color: alpha: 0.9 type: hex hex: "#FFFFFF" platform: ios - dark_mode: true color: alpha: 1 type: hex hex: "#000000" platform: ios - dark_mode: false platform: android color: type: hex alpha: 0.9 hex: "#FFFFFF" - platform: android dark_mode: true color: alpha: 1 hex: "#000000" type: hex - color: hex: "#FFFFFF" alpha: 0.9 type: hex platform: web dark_mode: false - platform: web color: alpha: 1 type: hex hex: "#000000" dark_mode: true default: hex: "#FFFFFF" alpha: 0.9 type: hex stroke_width: 1 identifier: bb34b013-8de1-4bc5-a4fc-c241ed1b58e3 input_type: text_multiline required: false type: text_input text_appearance: color: default: type: hex alpha: 1 hex: "#000000" selectors: - platform: ios color: type: hex hex: "#000000" alpha: 1 dark_mode: false - color: type: hex alpha: 1 hex: "#000000" platform: ios dark_mode: true - platform: android dark_mode: false color: hex: "#000000" alpha: 1 type: hex - platform: android color: type: hex hex: "#000000" alpha: 1 dark_mode: true - dark_mode: false color: type: hex alpha: 1 hex: "#000000" platform: web - platform: web dark_mode: true color: hex: "#000000" type: hex alpha: 1 font_size: 14 alignment: start direction: vertical type: linear_layout size: width: 100% height: auto - margin: top: 0 start: 0 end: 0 bottom: 0 size: width: 100% height: auto view: type: container items: - view: background_color: default: alpha: 0 type: hex hex: "#000000" selectors: - dark_mode: false color: hex: "#000000" alpha: 0 type: hex platform: ios - dark_mode: true color: hex: "#FFFFFF" alpha: 0 type: hex platform: ios - platform: android color: hex: "#000000" alpha: 0 type: hex dark_mode: false - dark_mode: true color: hex: "#FFFFFF" type: hex alpha: 0 platform: android - platform: web color: type: hex alpha: 0 hex: "#000000" dark_mode: false - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 type: linear_layout items: - margin: top: 8 bottom: 0 end: 16 start: 16 view: direction: horizontal items: - margin: top: 4 bottom: 16 end: 0 start: 0 size: width: 100% height: auto view: reporting_metadata: trigger_link_id: f21a0ca9-0330-45f1-92e2-cbe45b7a5c19 border: stroke_width: 20 stroke_color: default: type: hex hex: "#000000" alpha: 0.05 selectors: - color: type: hex hex: "#000000" alpha: 0.05 platform: ios dark_mode: false - color: type: hex hex: "#FFFFFF" alpha: 0 platform: ios dark_mode: true - platform: android color: hex: "#000000" type: hex alpha: 0.05 dark_mode: false - dark_mode: true color: hex: "#FFFFFF" type: hex alpha: 0 platform: android - dark_mode: false color: type: hex hex: "#000000" alpha: 0.05 platform: web - color: alpha: 0 type: hex hex: "#FFFFFF" dark_mode: true platform: web radius: 30 button_click: - form_submit - dismiss enabled: - form_validation identifier: submit_feedback--A button with a border background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: hex: "#63AFF1" alpha: 1 type: hex - color: type: hex alpha: 1 hex: "#63AFF1" platform: ios dark_mode: true - platform: android dark_mode: false color: hex: "#63AFF1" alpha: 1 type: hex - platform: android color: hex: "#63AFF1" alpha: 1 type: hex dark_mode: true - platform: web dark_mode: false color: hex: "#63AFF1" alpha: 1 type: hex - color: hex: "#63AFF1" alpha: 1 type: hex platform: web dark_mode: true event_handlers: - state_actions: - key: submitted type: set value: true type: tap label: content_description: A button with a border text_appearance: font_size: 16 styles: [] font_families: - sans-serif alignment: center color: selectors: - color: type: hex hex: "#000000" alpha: 0.95 platform: ios dark_mode: false - dark_mode: true platform: ios color: alpha: 1 hex: "#FFFFFF" type: hex - platform: android dark_mode: false color: alpha: 0.95 type: hex hex: "#000000" - platform: android dark_mode: true color: alpha: 1 hex: "#FFFFFF" type: hex - color: type: hex alpha: 0.95 hex: "#000000" dark_mode: false platform: web - dark_mode: true platform: web color: hex: "#FFFFFF" alpha: 1 type: hex default: hex: "#000000" type: hex alpha: 0.95 type: label text: A button with a border type: label_button actions: {} type: linear_layout background_color: default: type: hex alpha: 0 hex: "#000000" selectors: - platform: ios dark_mode: false color: hex: "#000000" alpha: 0 type: hex - platform: ios color: type: hex hex: "#FFFFFF" alpha: 0 dark_mode: true - dark_mode: false platform: android color: type: hex hex: "#000000" alpha: 0 - platform: android dark_mode: true color: hex: "#FFFFFF" type: hex alpha: 0 - dark_mode: false color: hex: "#000000" alpha: 0 type: hex platform: web - platform: web color: alpha: 0 hex: "#FFFFFF" type: hex dark_mode: true size: width: 100% height: auto - view: direction: horizontal type: linear_layout background_color: default: type: hex hex: "#000000" alpha: 0 selectors: - color: type: hex hex: "#000000" alpha: 0 platform: ios dark_mode: false - dark_mode: true color: alpha: 0 hex: "#FFFFFF" type: hex platform: ios - platform: android dark_mode: false color: type: hex alpha: 0 hex: "#000000" - dark_mode: true color: alpha: 0 hex: "#FFFFFF" type: hex platform: android - platform: web dark_mode: false color: alpha: 0 type: hex hex: "#000000" - color: alpha: 0 type: hex hex: "#FFFFFF" dark_mode: true platform: web items: - size: height: auto width: 100% margin: bottom: 16 start: 0 end: 0 top: 4 view: border: radius: 0 stroke_color: selectors: - platform: ios dark_mode: false color: alpha: 1 type: hex hex: "#63AFF1" - platform: ios dark_mode: true color: alpha: 1 type: hex hex: "#63AFF1" - dark_mode: false platform: android color: alpha: 1 hex: "#63AFF1" type: hex - color: type: hex hex: "#63AFF1" alpha: 1 platform: android dark_mode: true - color: type: hex alpha: 1 hex: "#63AFF1" platform: web dark_mode: false - color: hex: "#63AFF1" type: hex alpha: 1 dark_mode: true platform: web default: type: hex hex: "#63AFF1" alpha: 1 stroke_width: 0 type: label_button enabled: [] identifier: dismiss--Rectangle button background_color: selectors: - dark_mode: false color: hex: "#63AFF1" type: hex alpha: 1 platform: ios - platform: ios color: hex: "#63AFF1" type: hex alpha: 1 dark_mode: true - platform: android dark_mode: false color: alpha: 1 type: hex hex: "#63AFF1" - dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 platform: android - color: hex: "#63AFF1" alpha: 1 type: hex dark_mode: false platform: web - platform: web dark_mode: true color: alpha: 1 hex: "#63AFF1" type: hex default: type: hex hex: "#63AFF1" alpha: 1 actions: {} button_click: - dismiss label: text_appearance: styles: [] font_families: - sans-serif color: default: hex: "#FFFFFF" type: hex alpha: 1 selectors: - color: alpha: 1 hex: "#FFFFFF" type: hex platform: ios dark_mode: false - dark_mode: true platform: ios color: hex: "#000000" type: hex alpha: 1 - platform: android dark_mode: false color: alpha: 1 type: hex hex: "#FFFFFF" - color: type: hex alpha: 1 hex: "#000000" dark_mode: true platform: android - color: hex: "#FFFFFF" alpha: 1 type: hex platform: web dark_mode: false - dark_mode: true platform: web color: alpha: 1 hex: "#000000" type: hex alignment: center font_size: 16 type: label content_description: Rectangle button text: Rectangle button reporting_metadata: trigger_link_id: 04d91d55-6b2d-433b-91c0-fe802e8c476c margin: bottom: 0 start: 16 end: 16 top: 8 size: height: auto width: 100% direction: vertical position: horizontal: center vertical: center size: width: 100% height: auto margin: bottom: 0 end: 0 top: 0 start: 0 background_color: selectors: - color: alpha: 0 hex: "#000000" type: hex platform: ios dark_mode: false - platform: ios dark_mode: true color: alpha: 0 type: hex hex: "#FFFFFF" - color: hex: "#000000" type: hex alpha: 0 platform: android dark_mode: false - color: alpha: 0 hex: "#FFFFFF" type: hex platform: android dark_mode: true - platform: web dark_mode: false color: alpha: 0 type: hex hex: "#000000" - dark_mode: true color: hex: "#FFFFFF" alpha: 0 type: hex platform: web default: hex: "#000000" alpha: 0 type: hex direction: vertical type: linear_layout margin: top: 0 bottom: 0 start: 0 end: 0 size: width: 100% height: auto position: horizontal: center vertical: center margin: start: 20 top: 44 end: 20 bottom: 0 size: width: 100% height: auto - size: width: 100% height: 100% view: direction: horizontal items: [] type: linear_layout direction: vertical type: linear_layout direction: vertical type: scroll_layout direction: vertical type: linear_layout margin: top: 0 end: 0 start: 0 bottom: 0 size: width: 100% height: 100% type: container size: width: 100% height: 100% ignore_safe_area: false position: horizontal: center vertical: center background_color: selectors: - dark_mode: false color: alpha: 1 type: hex hex: "#63AFF1" platform: ios - platform: ios color: type: hex alpha: 1 hex: "#63AFF1" dark_mode: true - color: type: hex alpha: 1 hex: "#63AFF1" platform: android dark_mode: false - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - dark_mode: false color: alpha: 1 type: hex hex: "#63AFF1" platform: web - color: alpha: 1 type: hex hex: "#63AFF1" platform: web dark_mode: true default: hex: "#63AFF1" type: hex alpha: 1 type: container disable_swipe: true ignore_safe_area: false size: height: 100% width: 100% - view: button_click: - dismiss image: icon: close color: selectors: - dark_mode: false platform: ios color: alpha: 1 type: hex hex: "#BCBDC2" - platform: ios dark_mode: true color: type: hex alpha: 0.5 hex: "#FFFFFF" - dark_mode: false color: hex: "#BCBDC2" alpha: 1 type: hex platform: android - platform: android color: alpha: 0.5 type: hex hex: "#FFFFFF" dark_mode: true - color: type: hex hex: "#BCBDC2" alpha: 1 platform: web dark_mode: false - dark_mode: true platform: web color: type: hex hex: "#FFFFFF" alpha: 0.5 default: alpha: 1 type: hex hex: "#BCBDC2" scale: 0.4 type: icon type: image_button identifier: dismiss_button size: width: 48 height: 48 position: vertical: top horizontal: end type: container size: width: 100% height: 100% direction: vertical type: linear_layout version: 1 presentation: placement_selectors: [] default_placement: border: radius: 30 size: width: 80% height: 80% ignore_safe_area: false shade_color: default: alpha: 0.2 type: hex hex: "#000000" selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 0.2 - platform: ios color: hex: "#FFFFFF" type: hex alpha: 0 dark_mode: true - dark_mode: false platform: android color: hex: "#000000" type: hex alpha: 0.2 - color: alpha: 0 type: hex hex: "#FFFFFF" platform: android dark_mode: true - platform: web dark_mode: false color: hex: "#000000" alpha: 0.2 type: hex - platform: web color: type: hex alpha: 0 hex: "#FFFFFF" dark_mode: true position: vertical: center horizontal: center type: modal dismiss_on_touch_outside: false ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/aaa-modal.json ================================================ {"in_app_message": {"message": {"name": "meghan radio button test 2", "display_type": "layout", "display": {"layout": {"version": 1, "presentation": {"type": "modal", "placement_selectors": [{"placement": {"ignore_safe_area": false, "size": {"width": "60%", "height": "40%"}, "position": {"horizontal": "center", "vertical": "center"}, "shade_color": {"default": {"type": "hex", "hex": "#989893", "alpha": 0.2}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}]}, "web": {"ignore_shade": false}, "border": {"radius": 10, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}}}, "window_size": "large", "orientation": "portrait"}, {"placement": {"ignore_safe_area": false, "size": {"width": "70%", "height": "80%"}, "position": {"horizontal": "center", "vertical": "center"}, "shade_color": {"default": {"type": "hex", "hex": "#989893", "alpha": 0.2}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}]}, "web": {"ignore_shade": false}, "border": {"radius": 10, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}}}, "window_size": "small", "orientation": "landscape"}, {"placement": {"ignore_safe_area": false, "size": {"width": "70%", "height": "80%"}, "position": {"horizontal": "center", "vertical": "center"}, "shade_color": {"default": {"type": "hex", "hex": "#989893", "alpha": 0.2}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}]}, "web": {"ignore_shade": false}, "border": {"radius": 10, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}}}, "window_size": "medium", "orientation": "landscape"}, {"placement": {"ignore_safe_area": false, "size": {"width": "40%", "height": "70%"}, "position": {"horizontal": "center", "vertical": "center"}, "shade_color": {"default": {"type": "hex", "hex": "#989893", "alpha": 0.2}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}]}, "web": {"ignore_shade": false}, "border": {"radius": 10, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}}}, "window_size": "large", "orientation": "landscape"}], "android": {"disable_back_button": false}, "dismiss_on_touch_outside": false, "default_placement": {"ignore_safe_area": false, "size": {"width": "90%", "height": "65%"}, "position": {"horizontal": "center", "vertical": "center"}, "shade_color": {"default": {"type": "hex", "hex": "#989893", "alpha": 0.2}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#989893", "alpha": 0.2}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#989893", "alpha": 1}}]}, "web": {}, "border": {"radius": 14, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}}}}, "view": {"type": "state_controller", "view": {"type": "pager_controller", "identifier": "6a823111-dd55-4a38-af6c-20a4b175f47e", "view": {"type": "linear_layout", "direction": "vertical", "items": [{"size": {"width": "100%", "height": "100%"}, "view": {"identifier": "d58cb520-9ae9-46f8-a032-a6983811e77a", "type": "form_controller", "form_enabled": ["form_submission"], "submit": "submit_event", "response_type": "user_feedback", "view": {"type": "container", "items": [{"identifier": "c4517e2f-d34f-413b-a96f-fedd1c41cde5_pager_container_item", "position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "100%"}, "view": {"type": "pager", "disable_swipe": true, "items": [{"identifier": "3636f2bc-de6e-436d-8e92-b46da805e631", "type": "pager_item", "view": {"type": "container", "items": [{"identifier": "d098d881-1bee-4877-a8ed-e36e1c4ffe9f_main_view_container_item", "size": {"width": "100%", "height": "100%"}, "position": {"horizontal": "center", "vertical": "center"}, "ignore_safe_area": false, "view": {"type": "container", "items": [{"identifier": "6df6d847-7533-416a-9531-fd257c493fe5_container_item", "margin": {"bottom": 0, "top": 0, "end": 0, "start": 0}, "position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "vertical", "items": [{"identifier": "scroll_container", "size": {"width": "100%", "height": "100%"}, "view": {"type": "scroll_layout", "direction": "vertical", "view": {"type": "linear_layout", "direction": "vertical", "items": [{"identifier": "c4904f51-6c58-4095-868e-327fb5e8e8d7", "size": {"width": "100%", "height": "auto"}, "margin": {"end": 16, "top": 28, "start": 16, "bottom": 8}, "view": {"type": "label", "text": "The New Marketing Mindset", "content_description": "The New Marketing Mindset", "text_appearance": {"font_size": 24, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}, "alignment": "center", "styles": ["bold"], "font_families": ["sans-serif"]}, "accessibility_hidden": false}}, {"identifier": "bd516cef-c48e-48aa-9c21-78ab698d41e4", "size": {"width": "70%", "height": 150}, "view": {"type": "media", "media_fit": "center_inside", "url": "https://c00001-dl.asnapieu.com/binary/public/YtDP8p2vSbW-KQkjUVsBAw/3c5dc557-cb4c-4e5b-9613-d159206fcba0", "media_type": "image"}, "margin": {"end": 0, "top": 0, "start": 0, "bottom": 0}}, {"identifier": "65054c82-adca-493f-b6f5-5ebf3a43c54d", "size": {"width": "100%", "height": "auto"}, "margin": {"end": 16, "top": 10, "start": 16, "bottom": 8}, "view": {"type": "linear_layout", "direction": "vertical", "items": [{"margin": {"top": 0, "bottom": 8}, "size": {"width": "100%", "height": "auto"}, "view": {"type": "label", "text": "Who shall we interview in our next episode:", "content_description": "Who shall we interview in our next episode:", "text_appearance": {"font_size": 16, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}, "alignment": "start", "styles": ["bold"], "font_families": ["sans-serif"]}, "accessibility_hidden": false, "identifier": "65054c82-adca-493f-b6f5-5ebf3a43c54d_title"}}, {"margin": {"top": 0, "bottom": 25}, "size": {"width": "100%", "height": "auto"}, "view": {"type": "radio_input_controller", "identifier": "65054c82-adca-493f-b6f5-5ebf3a43c54d", "required": false, "view": {"type": "linear_layout", "direction": "vertical", "items": [{"size": {"height": "100%", "width": "100%"}, "margin": {"top": 0, "bottom": 8}, "view": {"type": "linear_layout", "direction": "horizontal", "items": [{"margin": {"end": 8}, "size": {"width": 20, "height": 20}, "view": {"type": "radio_input", "reporting_value": "5754a361-1898-4156-b77a-74284d19d10a", "content_description": "Dex Varnos", "style": {"type": "checkbox", "bindings": {"selected": {"shapes": [{"type": "ellipse", "scale": 1, "aspect_ratio": 1, "border": {"radius": 20, "stroke_width": 2, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}}, {"type": "ellipse", "scale": 0.6, "aspect_ratio": 1, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}]}, "unselected": {"shapes": [{"type": "ellipse", "scale": 1, "aspect_ratio": 1, "border": {"radius": 20, "stroke_width": 2, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}}]}}}}}, {"margin": {"end": 16}, "size": {"height": "auto", "width": "100%"}, "view": {"type": "label", "text": "Dex Varnos", "content_description": "Dex Varnos", "text_appearance": {"font_size": 14, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}, "alignment": "start", "styles": [], "font_families": ["sans-serif"]}, "accessibility_hidden": false}}]}}, {"size": {"height": "100%", "width": "100%"}, "margin": {"top": 0, "bottom": 8}, "view": {"type": "linear_layout", "direction": "horizontal", "items": [{"margin": {"end": 8}, "size": {"width": 20, "height": 20}, "view": {"type": "radio_input", "reporting_value": "d6a916a7-561e-4753-862c-fc446e1eb3a0", "content_description": "Zane Kolmec", "style": {"type": "checkbox", "bindings": {"selected": {"shapes": [{"type": "ellipse", "scale": 1, "aspect_ratio": 1, "border": {"radius": 20, "stroke_width": 2, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}}, {"type": "ellipse", "scale": 0.6, "aspect_ratio": 1, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}]}, "unselected": {"shapes": [{"type": "ellipse", "scale": 1, "aspect_ratio": 1, "border": {"radius": 20, "stroke_width": 2, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}}]}}}}}, {"margin": {"end": 16}, "size": {"height": "auto", "width": "100%"}, "view": {"type": "label", "text": "Zane Kolmec", "content_description": "Zane Kolmec", "text_appearance": {"font_size": 14, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}, "alignment": "start", "styles": [], "font_families": ["sans-serif"]}, "accessibility_hidden": false}}]}}, {"size": {"height": "100%", "width": "100%"}, "margin": {"top": 0, "bottom": 8}, "view": {"type": "linear_layout", "direction": "horizontal", "items": [{"margin": {"end": 8}, "size": {"width": 20, "height": 20}, "view": {"type": "radio_input", "reporting_value": "343d0109-c9a9-419e-95ec-66d6f9f6c400", "content_description": "Brett Gavix", "style": {"type": "checkbox", "bindings": {"selected": {"shapes": [{"type": "ellipse", "scale": 1, "aspect_ratio": 1, "border": {"radius": 20, "stroke_width": 2, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}}, {"type": "ellipse", "scale": 0.6, "aspect_ratio": 1, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}]}, "unselected": {"shapes": [{"type": "ellipse", "scale": 1, "aspect_ratio": 1, "border": {"radius": 20, "stroke_width": 2, "stroke_color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}}}, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}}]}}}}}, {"margin": {"end": 16}, "size": {"height": "auto", "width": "100%"}, "view": {"type": "label", "text": "Brett Gavix", "content_description": "Brett Gavix", "text_appearance": {"font_size": 14, "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}, "alignment": "start", "styles": [], "font_families": ["sans-serif"]}, "accessibility_hidden": false}}]}}], "randomize_children": false}, "attribute_name": {}}}]}}, {"identifier": "9b955192-e538-4c4b-9c1a-14c64074904c_linear_layout_item", "size": {"width": "100%", "height": "100%"}, "view": {"type": "linear_layout", "direction": "horizontal", "items": []}}]}}}, {"identifier": "25c2cf58-1629-4962-94e3-dca0e3bd292a", "size": {"width": "100%", "height": "auto"}, "view": {"type": "container", "items": [{"identifier": "25c2cf58-1629-4962-94e3-dca0e3bd292a_linear_container", "margin": {"top": 0, "bottom": 0, "start": 0, "end": 0}, "position": {"horizontal": "center", "vertical": "center"}, "size": {"width": "100%", "height": "auto"}, "view": {"type": "linear_layout", "direction": "vertical", "items": [{"identifier": "27ebefe0-1520-4c59-93e8-6ffd70496151", "margin": {"end": 16, "top": 8, "start": 16, "bottom": 8}, "size": {"width": "50%", "height": "auto"}, "view": {"type": "label_button", "identifier": "submit_feedback--Submit", "reporting_metadata": {"trigger_link_id": "27ebefe0-1520-4c59-93e8-6ffd70496151", "button_id": "27ebefe0-1520-4c59-93e8-6ffd70496151", "button_action": "submit_feedback"}, "label": {"type": "label", "text": "Submit", "content_description": "Submit", "text_appearance": {"font_size": 16, "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}, "alignment": "center", "styles": ["bold"], "font_families": ["sans-serif"]}, "view_overrides": {"icon_start": [{"value": {"type": "floating", "space": 8, "icon": {"type": "icon", "icon": "progress_spinner", "color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}, "scale": 1}}, "when_state_matches": {"scope": ["$forms", "current", "status", "type"], "value": {"equals": "validating"}}}], "text": [{"value": "Submit", "when_state_matches": {"scope": ["$forms", "current", "status", "type"], "value": {"equals": "validating"}}}]}}, "actions": {}, "enabled": ["form_validation"], "button_click": ["form_submit", "dismiss"], "background_color": {"default": {"type": "hex", "hex": "#2A55DD", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}]}, "border": {"radius": 5, "stroke_width": 0, "stroke_color": {"default": {"type": "hex", "hex": "#2A55DD", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#2A55DD", "alpha": 1}}]}}, "event_handlers": [{"type": "tap", "state_actions": [{"type": "set", "key": "submitted", "value": true}]}], "content_description": "Submit"}}], "border": {"radius": 0}}}]}, "margin": {"top": 20, "bottom": 16, "start": 0, "end": 0}}]}}]}}], "background_color": {"default": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#FFFFFF", "alpha": 1}}]}}, "state_actions": [{"type": "set", "key": "3636f2bc-de6e-436d-8e92-b46da805e631_next"}]}]}, "ignore_safe_area": false}, {"position": {"horizontal": "end", "vertical": "top"}, "size": {"width": 48, "height": 48}, "view": {"type": "image_button", "image": {"scale": 0.4, "type": "icon", "icon": "close", "color": {"default": {"type": "hex", "hex": "#000000", "alpha": 1}, "selectors": [{"platform": "ios", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "ios", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "android", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": false, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}, {"platform": "web", "dark_mode": true, "color": {"type": "hex", "hex": "#000000", "alpha": 1}}]}}, "identifier": "dismiss_button", "button_click": ["dismiss"], "localized_content_description": {"ref": "ua_dismiss", "fallback": "Dismiss"}, "reporting_metadata": {"button_id": "dismiss_button", "button_action": "dismiss"}}}]}}}]}}}}}, "audience": {"miss_behavior": "penalize", "tags": {"and": [{"tag": "meghan"}]}}}, "triggers": [{"type": "custom_event_count", "goal": 1, "predicate": {"or": [{"and": [{"key": "event_name", "ignore_case": true, "value": {"equals": "radio-new"}}]}]}}], "edit_grace_period": 14, "scopes": ["app", "web"], "reporting_context": {"content_types": ["scene", "survey"], "experiment_id": "", "message_type": "transactional"}, "limit": 0, "interval": 5, "forms": [{"id": "d58cb520-9ae9-46f8-a032-a6983811e77a", "type": "form", "questions": [{"id": "65054c82-adca-493f-b6f5-5ebf3a43c54d", "label": "Who shall we interview in our next episode:", "type": "radio", "responses": [{"draft": "Dex Varnos", "label": "Dex Varnos", "value": "5754a361-1898-4156-b77a-74284d19d10a"}, {"draft": "Zane Kolmec", "label": "Zane Kolmec", "value": "d6a916a7-561e-4753-862c-fc446e1eb3a0"}, {"draft": "Brett Gavix", "label": "Brett Gavix", "value": "343d0109-c9a9-419e-95ec-66d6f9f6c400"}]}], "response_type": "user_feedback"}], "labels": [{"id": "3636f2bc-de6e-436d-8e92-b46da805e631", "label": "Screen 1"}], "requires_eligibility": false, "message_type": "transactional"}} ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/accessibility-action-story.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: '#000000' alpha: 0.6 ignore_safe_area: true view: type: pager_controller identifier: "pager-controller-id" view: type: container border: radius: 30 stroke_width: 2 stroke_color: default: hex: "#333333" alpha: 0.8 background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% margin: top: 0 bottom: 0 start: 0 end: 0 view: border: stroke_width: 1 media_fit: center_crop media_type: image type: media url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/bc692c5f-09ce-4ea4-bb55-108b1b5d28a8 - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 view: type: pager gestures: - type: tap identifier: "tap-gesture-start-id" location: start behavior: behaviors: - pager_previous - type: tap identifier: "tap-gesture-end-id" location: end behavior: behaviors: - pager_next - type: hold identifier: "hold-gesture-any-id" press_behavior: behaviors: - pager_pause release_behavior: behaviors: - pager_resume items: - identifier: "pager-page-1-id" automated_actions: - delay: 0 identifier: "automated-action-1-delay0-id" actions: - add_tags_action: 'pager-page-1x-automated' - add_tags_action: 'pager-page-1y-automated' - delay: 4 identifier: "automated-action-1-delay4-id" reporting_metadata: "key1": "value1" "key2": "value2" behaviors: - pager_next display_actions: add_tags_action: 'pager-page-1x' accessibility_actions: - type: default reporting_metadata: key: "page_1_next" localized_content_description: fallback: "Next Page" ref: "ua_next" actions: - add_tags_action: "page_1_next_action" behaviors: - pager_next view: type: container background_color: default: hex: "#FF0000" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: This is the first page about stuff. accessibility_role: type: heading level: 1 text_appearance: alignment: center color: default: hex: "#000000" alpha: 1 font_size: 14 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_1 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 - identifier: "pager-page-2-id" automated_actions: - delay: 1.03 identifier: "automated-action-2-delay1-id" actions: - add_tags_action: 'pager-page-2x-automated' - delay: 6 identifier: "automated-action-2-delay6-id" behaviors: - pager_next display_actions: add_tags_action: 'pager-page-2x' accessibility_actions: - type: default reporting_metadata: key: "page_2_next" localized_content_description: fallback: "Next Page" ref: "ua_next" actions: - add_tags_action: "page_2_next_action" behaviors: - pager_next - type: default reporting_metadata: key: "page_2_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_2_previous_action" behaviors: - pager_previous view: type: container background_color: default: hex: "#00FF00" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: More stuff is here on the second page. accessibility_role: type: heading level: 2 text_appearance: alignment: center color: default: hex: "#000000" alpha: 1 font_size: 14 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_2 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 - identifier: "pager-page-3-id" automated_actions: - delay: 4.01 identifier: "automated-action-3-delay4-id" actions: - add_tags_action: 'pager-page-3x-automated' - delay: 8 identifier: "automated-action-3-delay8-id" behaviors: - pager_next_or_first - form_submit display_actions: add_tags_action: 'pager-page-3x' accessibility_actions: - type: default reporting_metadata: key: "page_3_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_3_previous_action" behaviors: - pager_previous view: type: container background_color: default: hex: "#0000FF" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_3_nps_form nps_identifier: score_identifier submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier required: true style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-3-form-submit' label: type: label text: SuBmIt!1!1@ text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_3 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 - size: height: 10 width: 80% position: vertical: top horizontal: center margin: bottom: 8 view: type: story_indicator source: type: pager style: type: linear_progress direction: horizontal sizing: equal spacing: 4 inactive_segment_scaler: 0.2 progress_color: default: hex: "#E6E6FA" alpha: 1 track_color: default: hex: "#E6E6FA" alpha: 0.7 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/accessibility-actions-modal-pager-fullsize.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 90% height: 80% shade_color: default: hex: '#000000' alpha: 0.6 view: type: pager_controller identifier: "pager-controller-id" view: type: container border: radius: 30 stroke_width: 20 stroke_color: default: hex: "#333333" alpha: 0.8 background_color: default: hex: "#ffffff" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 view: type: pager items: - identifier: "pager-page-1-id" display_actions: add_tags_action: 'pager-page-1x' accessibility_actions: - type: default reporting_metadata: key: "page_1_next" localized_content_description: fallback: "Next Page" ref: "ua_next" actions: - add_tags_action: "page_1_next_action" behaviors: - pager_next - type: escape reporting_metadata: key: "page_1_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_1_dismiss_action" behaviors: - dismiss automated_actions: - identifier: "auto_announce_page_1" delay: 0.5 behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#FF0000" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_1_nps_form nps_identifier: score_identifier_1 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: auto height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_1 required: true style: type: number_range start: 0 end: 10 spacing: 0 wrapping: line_spacing: 0 max_items_per_line: 11 spacing: 2 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-1-form-submit' label: type: label text: width:auto height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_1 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 content_description: "Close button" - identifier: "pager-page-2-id" display_actions: add_tags_action: 'pager-page-2x' accessibility_actions: - type: default reporting_metadata: key: "page_2_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_2_previous_action" behaviors: - pager_previous - type: escape reporting_metadata: key: "page_2_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_2_dismiss_action" behaviors: - dismiss automated_actions: - identifier: "auto_announce_page_2" delay: 0.5 behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#00FF00" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_2_nps_form nps_identifier: score_identifier_2 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 200 height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_2 required: true style: type: number_range start: 0 end: 10 spacing: 0 wrapping: line_spacing: 0 max_items_per_line: 11 spacing: 2 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-2-form-submit' label: type: label text: width:200 height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - identifier: "pager-page-3-id" display_actions: add_tags_action: 'pager-page-3x' accessibility_actions: - type: default reporting_metadata: key: "page_3_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_3_previous_action" behaviors: - pager_previous - type: escape reporting_metadata: key: "page_3_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_3_dismiss_action" behaviors: - dismiss automated_actions: - identifier: "auto_announce_page_3" delay: 0.5 behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#00FFF0" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_2_nps_form nps_identifier: score_identifier_2 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 50 height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_2 required: true style: type: number_range start: 0 end: 10 spacing: 0 wrapping: line_spacing: 0 max_items_per_line: 11 spacing: 2 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-2-form-submit' label: type: label text: width:50 height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_2 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 content_description: "Close button" gestures: - type: swipe identifier: "swipe_next" direction: up behavior: behaviors: ["pager_next"] - type: tap identifier: "tap_next" location: end behavior: behaviors: ["pager_next"] - size: height: 16 width: auto position: vertical: bottom horizontal: center margin: bottom: 8 view: type: pager_indicator spacing: 4 bindings: selected: shapes: - type: rectangle aspect_ratio: 2.25 scale: 0.9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle aspect_ratio: 2.25 scale: .9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#000000" alpha: 0 automated_accessibility_actions: - type: announce background_color: default: hex: "#333333" alpha: 0.7 border: radius: 8 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/accessibility-test-modal.json ================================================ { "version":1, "presentation":{ "type":"modal", "placement_selectors":[ { "placement":{ "ignore_safe_area":true, "size":{ "width":"80%", "height":"70%" }, "position":{ "horizontal":"center", "vertical":"center" }, "shade_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "web":{ "ignore_shade":false }, "border":{ "radius":0, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } }, "window_size":"small", "orientation":"portrait" }, { "placement":{ "ignore_safe_area":true, "size":{ "width":"80%", "height":"70%" }, "position":{ "horizontal":"center", "vertical":"center" }, "shade_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "web":{ "ignore_shade":false }, "border":{ "radius":0, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } }, "window_size":"large", "orientation":"portrait" } ], "android":{ "disable_back_button":false }, "dismiss_on_touch_outside":false, "default_placement":{ "ignore_safe_area":false, "size":{ "width":"100%", "height":"100%" }, "position":{ "horizontal":"center", "vertical":"top" }, "shade_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":0.2 } }, "web":{ } } }, "view":{ "type":"state_controller", "view":{ "type":"pager_controller", "identifier":"c7ae5cc6-c4a9-42cd-ade5-54cece99748b", "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "size":{ "width":"100%", "height":"100%" }, "view":{ "identifier":"7f9a7b79-6e1c-4952-b04c-d01b87f29736", "nps_identifier":"31b37c30-248e-473a-a161-9eb959446fbc", "type":"nps_form_controller", "submit":"submit_event", "form_enabled":[ "form_submission" ], "response_type":"nps", "view":{ "type":"container", "items":[ { "identifier":"e0f07fd3-68d4-4995-a6d9-f40afc8482ee_pager_container_item", "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"pager", "disable_swipe":false, "items":[ { "identifier":"e33f0e64-6a4d-4fdb-9b60-b00c6dac9d12", "type":"pager_item", "view":{ "type":"container", "items":[ { "identifier":"9cc66c9f-494e-49a8-86c1-6cac0add65d4_main_view_container_item", "size":{ "width":"100%", "height":"100%" }, "position":{ "horizontal":"center", "vertical":"center" }, "ignore_safe_area":false, "view":{ "type":"container", "items":[ { "identifier":"78a74d3f-81b2-4858-ab1d-4802c9859d8b_container_item", "margin":{ "bottom":0, "top":0, "end":0, "start":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"scroll_container", "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"scroll_layout", "direction":"vertical", "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"730835c5-2fe3-4fd6-b3ce-f3683acac033_wrapped_linear_layout", "size":{ "width":"100%", "height":350 }, "view":{ "type":"container", "items":[ { "identifier":"730835c5-2fe3-4fd6-b3ce-f3683acac033", "margin":{ "top":0, "bottom":0, "start":0, "end":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "identifier":"26e1cd82-a352-4435-8053-fd4619bd7bc2", "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"media", "media_fit":"center_inside", "url":"https://hangar-dl.urbanairship.com/binary/public/M4QKrhI2RNe1hzV8EmrLow/8e07b6d4-fff8-4ec9-b447-cbd6509cff81", "media_type":"image" }, "margin":{ "top":0, "bottom":0, "start":0, "end":0 } } ], "background_color":{ "default":{ "type":"hex", "hex":"#FF0D49", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FF0D49", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FF0D49", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FF0D49", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FF0D49", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FF0D49", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FF0D49", "alpha":1 } } ] } } } ] }, "margin":{ "top":0, "bottom":0, "start":0, "end":0 } }, { "identifier":"6aa6605d-a3ce-43e6-9fed-cdc9fab22385", "size":{ "width":"100%", "height":"auto" }, "margin":{ "top":20, "bottom":8, "start":16, "end":16 }, "view":{ "type":"label", "text":"Help Us Improve Your Pharmacy Experience", "content_description":"Help Us Improve Your Pharmacy Experience", "text_appearance":{ "font_size":25, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"center", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } } }, { "identifier":"fa08fdad-c969-4ca0-a2dc-b7e56326f936", "size":{ "width":"100%", "height":"auto" }, "margin":{ "top":8, "bottom":8, "start":16, "end":16 }, "view":{ "type":"label", "text":"We'd love to hear about your recent prescription refill pick-up. \n\n**Do you have a few moments to share your feedback?**", "content_description":"We'd love to hear about your recent prescription refill pick-up. \n\n**Do you have a few moments to share your feedback?**", "text_appearance":{ "font_size":18, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"center", "styles":[ ], "font_families":[ "sans-serif" ] } } }, { "identifier":"30c0a4d7-623c-478c-8930-92485f093ff9_wrapped_linear_layout", "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"container", "items":[ { "identifier":"30c0a4d7-623c-478c-8930-92485f093ff9", "margin":{ "top":0, "bottom":0, "start":0, "end":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "identifier":"b703810f-aaf7-4121-93b3-e98336f88335", "margin":{ "top":8, "bottom":8, "start":16, "end":16 }, "size":{ "width":"60%", "height":50 }, "view":{ "type":"label_button", "identifier":"dismiss--Maybe later", "reporting_metadata":{ "trigger_link_id":"b703810f-aaf7-4121-93b3-e98336f88335" }, "label":{ "type":"label", "text":"Maybe later", "content_description":"Dismisses the survey", "text_appearance":{ "font_size":19, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"center", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } }, "actions":{ "add_tags_action":[ "prescription-maybe-later" ] }, "enabled":[ ], "button_click":[ "dismiss" ], "localized_content_description":{ "fallback":"Dismiss", "ref":"ua_dismiss" }, "background_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "border":{ "radius":15, "stroke_width":3, "stroke_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "content_description":"Dismisses the survey" } }, { "identifier":"59f6d4ce-bf6e-47ef-8f45-1dec5a3045cf", "margin":{ "top":8, "bottom":8, "start":16, "end":16 }, "size":{ "width":"60%", "height":50 }, "view":{ "type":"label_button", "identifier":"next--Yes!", "reporting_metadata":{ "trigger_link_id":"59f6d4ce-bf6e-47ef-8f45-1dec5a3045cf" }, "label":{ "type":"label", "text":"Yes!", "content_description":"Next screen", "text_appearance":{ "font_size":19, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "alignment":"center", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } }, "actions":{ }, "enabled":[ "pager_next" ], "button_click":[ "pager_next" ], "localized_content_description":{ "fallback":"Next", "ref":"ua_next" }, "background_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "border":{ "radius":15, "stroke_width":14, "stroke_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "content_description":"Next screen" } } ], "background_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } } ] }, "margin":{ "top":16, "bottom":0, "start":0, "end":0 } }, { "identifier":"49ff5e07-1b62-411b-99b8-9ee03e5c888a_linear_layout_item", "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ ] } } ] } } } ] } } ] } } ], "background_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } }, { "identifier":"bc24f9c7-75e3-4ec8-8a70-f125b8b2ecdc", "type":"pager_item", "view":{ "type":"container", "items":[ { "identifier":"4017713e-c982-4a57-b5c0-dd0491f22258_main_view_container_item", "size":{ "width":"100%", "height":"100%" }, "position":{ "horizontal":"center", "vertical":"center" }, "ignore_safe_area":false, "view":{ "type":"container", "items":[ { "identifier":"e8abb103-280c-44a7-a44a-4891e5375ca7_container_item", "margin":{ "bottom":0, "top":0, "end":0, "start":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"scroll_container", "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"scroll_layout", "direction":"vertical", "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"f839d1b5-5e96-48c6-999e-5302149867c1_wrapped_linear_layout", "size":{ "width":"100%", "height":285 }, "view":{ "type":"container", "items":[ { "identifier":"f839d1b5-5e96-48c6-999e-5302149867c1", "margin":{ "top":0, "bottom":0, "start":0, "end":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "identifier":"763b720d-fee8-49eb-9e4a-0051c185c079", "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"media", "media_fit":"center_inside", "url":"https://hangar-dl.urbanairship.com/binary/public/M4QKrhI2RNe1hzV8EmrLow/37c7828a-3ff7-4954-8dcd-45d658776742", "media_type":"image" }, "margin":{ "top":8, "bottom":0, "start":0, "end":0 } } ] } } ] }, "margin":{ "top":0, "bottom":0, "start":0, "end":0 } }, { "identifier":"31b37c30-248e-473a-a161-9eb959446fbc", "size":{ "width":"100%", "height":"auto" }, "margin":{ "top":20, "bottom":8, "start":16, "end":16 }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "margin":{ "top":4, "bottom":8 }, "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"label", "text":"How would you rate your recent prescription refill pick-up experience?", "content_description":"How would you rate your recent prescription refill pick-up experience?", "text_appearance":{ "font_size":19, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"center", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } } } ] } }, { "size":{ "width":"100%", "height":"auto" }, "margin":{ "top":0, "bottom":0 }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "size":{ "width":"50%", "height":"auto" }, "margin":{ "end":4, "bottom":4 }, "view":{ "type":"label", "text":" ", "content_description":" ", "text_appearance":{ "font_size":16, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"start", "styles":[ ], "font_families":[ "sans-serif" ] } } }, { "size":{ "width":"50%", "height":"auto" }, "margin":{ "start":4, "bottom":4 }, "view":{ "type":"label", "text":" ", "content_description":" ", "text_appearance":{ "font_size":16, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"end", "styles":[ ], "font_families":[ "sans-serif" ] } } } ] } }, { "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "size":{ "height":"auto", "width":"100%" }, "view":{ "type":"score", "style":{ "type":"number_range", "start":0, "end":10, "spacing":2, "wrapping":{ "line_spacing":16, "max_items_per_line":6 }, "bindings":{ "selected":{ "shapes":[ { "type":"rectangle", "scale":1, "border":{ "radius":15, "stroke_width":1, "stroke_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } } ], "text_appearance":{ "alignment":"center", "font_families":[ "sans-serif" ], "font_size":24, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } }, "unselected":{ "shapes":[ { "type":"rectangle", "scale":1, "border":{ "radius":15, "stroke_width":1, "stroke_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } ], "text_appearance":{ "font_size":24, "font_families":[ "sans-serif" ], "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } } } } }, "identifier":"31b37c30-248e-473a-a161-9eb959446fbc", "required":false } } ] } } ] } } ] } }, { "identifier":"74bf05d8-4463-418e-804f-4ab7cefdbd4d_linear_layout_item", "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ ] } } ] } } }, { "identifier":"055860e0-fe57-4285-8712-0582f7257892_wrapped_linear_layout", "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"container", "items":[ { "identifier":"055860e0-fe57-4285-8712-0582f7257892", "margin":{ "top":0, "bottom":0, "start":0, "end":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"5f4734e0-d104-49fd-ba59-d1e070dcd74c", "margin":{ "top":0, "bottom":8, "start":16, "end":16 }, "size":{ "width":"60%", "height":50 }, "view":{ "type":"label_button", "identifier":"next--Next", "reporting_metadata":{ "trigger_link_id":"5f4734e0-d104-49fd-ba59-d1e070dcd74c" }, "label":{ "type":"label", "text":"Next", "content_description":"Next screen", "text_appearance":{ "font_size":19, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "alignment":"center", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } }, "actions":{ }, "enabled":[ "pager_next" ], "button_click":[ "pager_next" ], "localized_content_description":{ "fallback":"Next", "ref":"ua_next" }, "background_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "border":{ "radius":15, "stroke_width":14, "stroke_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "content_description":"Next screen" } } ] } } ] }, "margin":{ "top":8, "bottom":20, "start":0, "end":0 } } ] } } ] } } ], "background_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } }, { "identifier":"ae006f87-adf6-466b-be6a-454021780387", "type":"pager_item", "view":{ "type":"container", "items":[ { "identifier":"9d637659-3a10-4303-835e-1da5aec3330c_main_view_container_item", "size":{ "width":"100%", "height":"100%" }, "position":{ "horizontal":"center", "vertical":"center" }, "ignore_safe_area":false, "view":{ "type":"container", "items":[ { "identifier":"62ff0871-ddd2-44da-89ad-698756a14801_container_item", "margin":{ "bottom":0, "top":0, "end":0, "start":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"scroll_container", "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"scroll_layout", "direction":"vertical", "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"9b1e7de8-501f-4db0-bcb6-c03f7c0bf811_wrapped_linear_layout", "size":{ "width":"100%", "height":285 }, "view":{ "type":"container", "items":[ { "identifier":"9b1e7de8-501f-4db0-bcb6-c03f7c0bf811", "margin":{ "top":0, "bottom":0, "start":0, "end":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "identifier":"a5061c25-dc72-4703-9dbc-cdfb10ae0ec2", "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"media", "media_fit":"center_inside", "url":"https://hangar-dl.urbanairship.com/binary/public/M4QKrhI2RNe1hzV8EmrLow/37c7828a-3ff7-4954-8dcd-45d658776742", "media_type":"image" }, "margin":{ "top":8, "bottom":0, "start":0, "end":0 } } ] } } ] }, "margin":{ "top":0, "bottom":0, "start":0, "end":0 } }, { "identifier":"c2ff2225-c382-44a4-ad4e-9e6cec825fa4", "size":{ "width":"100%", "height":"auto" }, "margin":{ "top":30, "bottom":8, "start":20, "end":20 }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "margin":{ "top":0, "bottom":8 }, "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"label", "text":"Which of the following best describes your experience with our prescription refill reminders?", "content_description":"Which of the following best describes your experience with our prescription refill reminders?", "text_appearance":{ "font_size":19, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"start", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } } }, { "margin":{ "top":0, "bottom":8 }, "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"radio_input_controller", "identifier":"c2ff2225-c382-44a4-ad4e-9e6cec825fa4", "required":false, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "size":{ "width":"100%", "height":"100%" }, "margin":{ "top":0, "bottom":8 }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "margin":{ "end":8 }, "size":{ "width":20, "height":20 }, "view":{ "type":"radio_input", "reporting_value":"fb0a1686-2cd4-4303-afad-47625fb6c599", "content_description":"Always receive them on time", "style":{ "type":"checkbox", "bindings":{ "selected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } }, { "type":"ellipse", "scale":0.6, "aspect_ratio":1, "color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } } ] }, "unselected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } } ] } } } } }, { "margin":{ "end":16 }, "size":{ "height":"auto", "width":"100%" }, "view":{ "type":"label", "text":"Always receive them on time", "content_description":"Always receive them on time", "text_appearance":{ "font_size":17, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"start", "styles":[ ], "font_families":[ "sans-serif" ] } } } ] } }, { "size":{ "width":"100%", "height":"100%" }, "margin":{ "top":0, "bottom":8 }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "margin":{ "end":8 }, "size":{ "width":20, "height":20 }, "view":{ "type":"radio_input", "reporting_value":"376a5bbd-6aff-4d03-a519-82681fe06c3c", "content_description":"Sometimes receive them late", "style":{ "type":"checkbox", "bindings":{ "selected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } }, { "type":"ellipse", "scale":0.6, "aspect_ratio":1, "color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } } ] }, "unselected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } } ] } } } } }, { "margin":{ "end":16 }, "size":{ "height":"auto", "width":"100%" }, "view":{ "type":"label", "text":"Sometimes receive them late", "content_description":"Sometimes receive them late", "text_appearance":{ "font_size":17, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"start", "styles":[ ], "font_families":[ "sans-serif" ] } } } ] } }, { "size":{ "width":"100%", "height":"100%" }, "margin":{ "top":0, "bottom":8 }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "margin":{ "end":8 }, "size":{ "width":20, "height":20 }, "view":{ "type":"radio_input", "reporting_value":"011468c4-4d69-40bb-afd0-9d1a7764b6e7", "content_description":"Always receive them late ", "style":{ "type":"checkbox", "bindings":{ "selected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } }, { "type":"ellipse", "scale":0.6, "aspect_ratio":1, "color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } } ] }, "unselected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } } ] } } } } }, { "margin":{ "end":16 }, "size":{ "height":"auto", "width":"100%" }, "view":{ "type":"label", "text":"Always receive them late ", "content_description":"Always receive them late ", "text_appearance":{ "font_size":17, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"start", "styles":[ ], "font_families":[ "sans-serif" ] } } } ] } }, { "size":{ "width":"100%", "height":"100%" }, "margin":{ "top":0, "bottom":8 }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ { "margin":{ "end":8 }, "size":{ "width":20, "height":20 }, "view":{ "type":"radio_input", "reporting_value":"f5cefb65-4231-4401-8ff1-030efa0afb9a", "content_description":"I don't receive refill reminders", "style":{ "type":"checkbox", "bindings":{ "selected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } }, { "type":"ellipse", "scale":0.6, "aspect_ratio":1, "color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } } ] }, "unselected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "border":{ "radius":20, "stroke_width":2, "stroke_color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 } } }, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } } ] } } } } }, { "margin":{ "end":16 }, "size":{ "height":"auto", "width":"100%" }, "view":{ "type":"label", "text":"I don't receive refill reminders", "content_description":"I don't receive refill reminders", "text_appearance":{ "font_size":17, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"start", "styles":[ ], "font_families":[ "sans-serif" ] } } } ] } } ], "randomize_children":false }, "attribute_name":{ } } } ] } }, { "identifier":"7dd43636-fb19-4f7a-a715-0a32b38fea2b", "size":{ "width":"100%", "height":"auto" }, "margin":{ "top":8, "bottom":8, "start":16, "end":16 }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "margin":{ "top":0, "bottom":8 }, "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"label", "text":"What could we have done better to improve your prescription refill pick-up experience?", "content_description":"What could we have done better to improve your prescription refill pick-up experience?", "text_appearance":{ "font_size":19, "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "alignment":"start", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } } }, { "margin":{ "top":0, "bottom":8 }, "size":{ "width":"100%", "height":75 }, "view":{ "identifier":"7dd43636-fb19-4f7a-a715-0a32b38fea2b", "input_type":"text_multiline", "required":false, "place_holder":"", "content_description":"", "type":"text_input", "background_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "border":{ "radius":4, "stroke_width":1, "stroke_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "text_appearance":{ "alignment":"start", "font_size":14, "font_families":[ "sans-serif" ], "color":{ "default":{ "type":"hex", "hex":"#000000", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "place_holder_color":{ "default":{ "type":"hex", "hex":"#7B7C84", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "view_overrides":{ "icon_end":[ { "when_state_matches":{ "scope":[ "123" ], "value":{ "equals":true } }, "value":{ "type":"floating", "icon":{ "type":"icon", "icon":"exclamationmark_circle_fill", "scale":1, "color":{ "default":{ "type":"hex", "hex":"#ff0000", "alpha":1 } } } } } ], "border":[ { "when_state_matches":{ "scope":[ "123" ], "value":{ "equals":true } }, "value":{ "radius":4, "stroke_width":1, "stroke_color":{ "default":{ "type":"hex", "hex":"#ff0000", "alpha":1 } } } } ] }, "on_error":{ "state_actions":[ { "type":"set", "key":"123", "value":true } ] }, "on_edit":{ "state_actions":[ { "type":"set", "key":"123" } ] }, "on_valid":{ "state_actions":[ { "type":"set", "key":"123" } ] } } } ] } }, { "identifier":"3e925b8b-3bdd-4c4a-8330-1edd6b6573e3_linear_layout_item", "size":{ "width":"100%", "height":"100%" }, "view":{ "type":"linear_layout", "direction":"horizontal", "items":[ ] } } ] } } }, { "identifier":"6f8cd8b3-5c59-4cc2-a0d5-6aded6e3d8d4_wrapped_linear_layout", "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"container", "items":[ { "identifier":"6f8cd8b3-5c59-4cc2-a0d5-6aded6e3d8d4", "margin":{ "top":0, "bottom":0, "start":0, "end":0 }, "position":{ "horizontal":"center", "vertical":"center" }, "size":{ "width":"100%", "height":"auto" }, "view":{ "type":"linear_layout", "direction":"vertical", "items":[ { "identifier":"6da28bdd-de96-44d9-a666-fbb9fb0267b9", "margin":{ "top":8, "bottom":20, "start":16, "end":16 }, "size":{ "width":"60%", "height":50 }, "view":{ "type":"label_button", "identifier":"submit_feedback--Submit", "reporting_metadata":{ "trigger_link_id":"6da28bdd-de96-44d9-a666-fbb9fb0267b9" }, "label":{ "type":"label", "text":"Submit", "content_description":"Submit survey responses and dismiss the survey", "text_appearance":{ "font_size":19, "color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] }, "alignment":"center", "styles":[ "bold" ], "font_families":[ "sans-serif" ] } }, "actions":{ }, "enabled":[ "form_validation" ], "button_click":[ "form_submit", "dismiss" ], "background_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] }, "border":{ "radius":15, "stroke_width":14, "stroke_color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "event_handlers":[ { "type":"tap", "state_actions":[ { "type":"set", "key":"submitted", "value":true } ] } ], "content_description":"Submit survey responses and dismiss the survey" } } ], "background_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } } ] }, "margin":{ "top":8, "bottom":8, "start":0, "end":0 } } ] } } ] } } ], "background_color":{ "default":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#000000", "alpha":1 } } ] } } } ] }, "ignore_safe_area":false }, { "position":{ "horizontal":"end", "vertical":"top" }, "size":{ "width":48, "height":48 }, "view":{ "type":"image_button", "image":{ "scale":0.4, "type":"icon", "icon":"close", "color":{ "default":{ "type":"hex", "hex":"#222222", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#222222", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } }, "identifier":"dismiss_button", "button_click":[ "dismiss" ], "localized_content_description":{ "ref":"ua_dismiss", "fallback":"Dismiss" } } }, { "margin":{ "top":4, "bottom":4, "end":0, "start":0 }, "position":{ "horizontal":"center", "vertical":"bottom" }, "size":{ "height":7, "width":"100%" }, "view":{ "type":"pager_indicator", "spacing":6, "bindings":{ "selected":{ "shapes":[ { "type":"ellipse", "scale":1, "aspect_ratio":1, "color":{ "default":{ "type":"hex", "hex":"#7B7C84", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } } ] }, "unselected":{ "shapes":[ { "type":"ellipse", "aspect_ratio":1, "scale":1, "color":{ "default":{ "type":"hex", "hex":"#7B7C84", "alpha":1 }, "selectors":[ { "platform":"ios", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"ios", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"android", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"android", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } }, { "platform":"web", "dark_mode":false, "color":{ "type":"hex", "hex":"#7B7C84", "alpha":1 } }, { "platform":"web", "dark_mode":true, "color":{ "type":"hex", "hex":"#FFFFFF", "alpha":1 } } ] } } ] } }, "automated_accessibility_actions":[ { "type":"announce" } ] } } ] } } } ] } } } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/airship-quiz-1.yml ================================================ version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 90% height: 85% shade_color: default: hex: '#020202' alpha: 0.75 view: type: state_controller view: type: pager_controller identifier: airship_survey_controller view: type: container background_color: default: hex: '#004bff' alpha: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.1 items: - position: horizontal: center vertical: center size: width: 90% height: 85% view: type: pager disable_swipe: true items: - identifier: rating_page type: pager_item view: type: form_controller validation_mode: type: immediate identifier: rating_form submit: submit_event view: type: container background_color: default: hex: '#004bff' alpha: 1 border: radius: 20 stroke_width: 0 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 40 bottom: 0 view: type: empty_view - identifier: rating_title_label size: width: 100% height: auto margin: top: 16 bottom: 24 start: 24 end: 24 view: type: label text: "\u2728 Like Airship? \u2728" content_description: Rating Section Title text_appearance: font_size: 30 color: default: hex: '#FFFFFF' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 32 start: 24 end: 24 view: type: label text: Please tap to rate your experience text_appearance: alignment: center font_size: 18 color: default: hex: '#FFFFFF' alpha: 0.9 - identifier: rating_input_section size: width: 100% height: auto margin: top: 8 bottom: 24 start: 0 end: 0 view: type: score_controller identifier: score_radio_controller required: true view: type: container items: - position: horizontal: center vertical: center size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 42 height: 42 margin: end: 8 view: type: score_toggle_layout identifier: score_toggle_1 content_description: Rating 1 reporting_value: 1 on_toggle_on: state_actions: - type: set key: selected_score value: 1 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: heart scale: 1.0 color: default: hex: '#DDDDDD' alpha: 0.7 view_overrides: icon: - value: icon: heart_fill scale: 1.0 color: default: hex: '#f1084f' alpha: 1.0 when_state_matches: key: selected_score value: at_least: 1 - size: width: 42 height: 42 margin: end: 8 view: type: score_toggle_layout identifier: score_toggle_2 content_description: Rating 2 reporting_value: 2 on_toggle_on: state_actions: - type: set key: selected_score value: 2 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: heart scale: 1.0 color: default: hex: '#DDDDDD' alpha: 0.7 view_overrides: icon: - value: icon: heart_fill scale: 1.0 color: default: hex: '#f1084f' alpha: 1.0 when_state_matches: key: selected_score value: at_least: 2 - size: width: 42 height: 42 margin: end: 8 view: type: score_toggle_layout identifier: score_toggle_3 content_description: Rating 3 reporting_value: 3 on_toggle_on: state_actions: - type: set key: selected_score value: 3 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: heart scale: 1.0 color: default: hex: '#DDDDDD' alpha: 0.7 view_overrides: icon: - value: icon: heart_fill scale: 1.0 color: default: hex: '#f1084f' alpha: 1.0 when_state_matches: key: selected_score value: at_least: 3 - size: width: 42 height: 42 margin: end: 8 view: type: score_toggle_layout identifier: score_toggle_4 content_description: Rating 4 reporting_value: 4 on_toggle_on: state_actions: - type: set key: selected_score value: 4 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: heart scale: 1.0 color: default: hex: '#DDDDDD' alpha: 0.7 view_overrides: icon: - value: icon: heart_fill scale: 1.0 color: default: hex: '#f1084f' alpha: 1.0 when_state_matches: key: selected_score value: at_least: 4 - size: width: 42 height: 42 view: type: score_toggle_layout identifier: score_toggle_5 content_description: Rating 5 reporting_value: 5 on_toggle_on: state_actions: - type: set key: selected_score value: 5 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: heart scale: 1.0 color: default: hex: '#DDDDDD' alpha: 0.7 view_overrides: icon: - value: icon: heart_fill scale: 1.0 color: default: hex: '#f1084f' alpha: 1.0 when_state_matches: key: selected_score value: at_least: 5 - size: width: 100% height: auto margin: top: 16 bottom: 32 start: 24 end: 24 view: type: label text: '' text_appearance: alignment: center font_size: 72 color: default: hex: '#FFFFFF' alpha: 0.8 view_overrides: text: - value: "\U0001FAA8" when_state_matches: key: selected_score value: equals: 1 - value: "\U0001F949" when_state_matches: key: selected_score value: equals: 2 - value: "\U0001F948" when_state_matches: key: selected_score value: equals: 3 - value: "\U0001F947" when_state_matches: key: selected_score value: equals: 4 - value: "\U0001F3C6" when_state_matches: key: selected_score value: equals: 5 - size: width: 100% height: auto margin: top: 0 start: 24 end: 24 view: type: label text: Tell us how we can improve text_appearance: alignment: start font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold visibility: default: false invert_when_state_matches: key: selected_score value: at_most: 4 - size: width: 100% height: 100 margin: top: 8 bottom: 12 start: 24 end: 12 view: type: text_input place_holder: Please share your feedback... identifier: feedback_field border: radius: 8 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.5 text_appearance: alignment: start font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 input_type: text_multiline required: false visibility: default: false invert_when_state_matches: key: selected_score value: at_most: 4 - size: width: 100% height: 50 margin: top: 12 bottom: 32 start: 24 end: 24 view: type: label_button identifier: next_button_rating background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - dismiss enabled: - form_validation label: type: label text: Submit text_appearance: font_size: 18 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 visibility: default: false invert_when_state_matches: key: selected_score value: at_most: 3 - size: width: 100% height: 50 margin: top: 12 bottom: 32 start: 24 end: 24 view: type: label_button identifier: next_button_rating background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - pager_next enabled: - form_validation label: type: label text: Next text_appearance: font_size: 18 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 visibility: default: false invert_when_state_matches: key: selected_score value: at_least: 4 - identifier: personal_info_page type: pager_item view: type: form_controller validation_mode: type: immediate identifier: personal_info_form submit: submit_event view: type: container background_color: default: hex: '#004bff' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 32 bottom: 24 start: 24 end: 24 view: type: label text: "\u2728 Airship Survey \u2728" text_appearance: alignment: center font_size: 32 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 24 start: 24 end: 24 view: type: label text: Enter your information below to get started! text_appearance: alignment: center font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 font_families: - sans-serif - size: width: 100% height: auto margin: top: 16 start: 24 end: 24 view: type: label text: Your Name text_appearance: alignment: start font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 8 bottom: 16 start: 24 end: 24 view: type: text_input place_holder: Enter your full name identifier: name_field icon_end: type: floating icon: type: icon icon: asterisk color: default: hex: '#f1084f' alpha: 1.0 scale: 0.75 border: radius: 8 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.5 view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: '#f1084f' alpha: 1.0 scale: 1 when_state_matches: key: name_field_state value: equals: error - value: type: floating icon: type: icon icon: checkmark color: default: hex: '#6ca15f' alpha: 1.0 scale: 1 when_state_matches: key: name_field_state value: equals: valid border: - value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#f1084f' alpha: 1 when_state_matches: key: name_field_state value: equals: error - value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#6ca15f' alpha: 1 when_state_matches: key: name_field_state value: equals: valid text_appearance: alignment: start font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 input_type: text required: true on_error: state_actions: - type: set key: name_field_state value: error on_valid: state_actions: - type: set key: name_field_state value: valid on_edit: state_actions: - type: set key: name_field_state value: editing - size: width: 100% height: auto margin: top: 16 start: 24 end: 24 view: type: label text: Email Address text_appearance: alignment: start font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 8 bottom: 16 start: 24 end: 24 view: type: text_input place_holder: your.email@example.com identifier: email_field icon_end: type: floating icon: type: icon icon: asterisk color: default: hex: '#f1084f' alpha: 1.0 scale: 0.75 border: radius: 8 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.5 view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: '#f1084f' alpha: 1.0 scale: 1 when_state_matches: key: email_field_state value: equals: error - value: type: floating icon: type: icon icon: checkmark color: default: hex: '#6ca15f' alpha: 1.0 scale: 1 when_state_matches: key: email_field_state value: equals: valid border: - value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#f1084f' alpha: 1 when_state_matches: key: email_field_state value: equals: error - value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#6ca15f' alpha: 1 when_state_matches: key: email_field_state value: equals: valid text_appearance: alignment: start font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 input_type: email required: true on_error: state_actions: - type: set key: email_field_state value: error on_valid: state_actions: - type: set key: email_field_state value: valid on_edit: state_actions: - type: set key: email_field_state value: editing - size: width: 100% height: auto margin: top: 16 start: 24 end: 24 view: type: label text: Phone Number text_appearance: alignment: start font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 8 bottom: 32 start: 24 end: 24 view: type: text_input place_holder: Enter phone number identifier: phone_field icon_end: type: floating icon: type: icon icon: asterisk color: default: hex: '#f1084f' alpha: 1.0 scale: 0.75 border: radius: 8 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.5 view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: '#f1084f' alpha: 1.0 scale: 1 when_state_matches: key: phone_field_state value: equals: error - value: type: floating icon: type: icon icon: checkmark color: default: hex: '#6ca15f' alpha: 1.0 scale: 1 when_state_matches: key: phone_field_state value: equals: valid border: - value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#f1084f' alpha: 1 when_state_matches: key: phone_field_state value: equals: error - value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#6ca15f' alpha: 1 when_state_matches: key: phone_field_state value: equals: valid text_appearance: alignment: start font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 input_type: sms locales: - country_code: US prefix: '+1' - country_code: FR prefix: '+33' - country_code: UA prefix: '+380' required: true on_error: state_actions: - type: set key: phone_field_state value: error on_valid: state_actions: - type: set key: phone_field_state value: valid on_edit: state_actions: - type: set key: phone_field_state value: editing - size: width: 100% height: auto margin: top: 16 bottom: 32 start: 24 end: 24 view: type: linear_layout direction: horizontal items: - size: width: 48% height: 50 margin: end: 8 view: type: label_button identifier: prev_button_personal background_color: default: hex: '#001f9e' alpha: 1 border: radius: 25 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.3 button_click: - pager_previous label: type: label text: Previous text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - size: width: 48% height: 50 margin: start: 8 view: type: label_button identifier: next_button_personal background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - pager_next enabled: - form_validation label: type: label text: Next text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - identifier: color_page type: pager_item view: type: form_controller validation_mode: type: immediate identifier: color_form submit: submit_event view: type: container background_color: default: hex: '#004bff' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 32 bottom: 24 start: 24 end: 24 view: type: label text: "Color Preferences \U0001F3A8" text_appearance: alignment: center font_size: 28 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold font_families: - sans-serif - size: width: 100% height: auto margin: top: 16 start: 24 end: 24 view: type: label text: Select your favorite color text_appearance: alignment: start font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 16 bottom: 32 start: 24 end: 24 view: type: radio_input_controller identifier: color_selection required: true on_error: state_actions: - type: set key: color_selection_state value: error on_valid: state_actions: - type: set key: color_selection_state value: valid on_edit: state_actions: - type: set key: color_selection_state value: editing view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: raspberry_red content_description: Raspberry Red style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Raspberry Red content_description: Raspberry Red text_appearance: font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 - size: width: 24 height: 24 margin: end: 8 view: type: empty_view background_color: default: hex: '#f1084f' alpha: 1 border: radius: 12 stroke_width: 0 - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: ultramarine_blue content_description: Ultramarine Blue style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Ultramarine Blue content_description: Ultramarine Blue text_appearance: font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 - size: width: 24 height: 24 margin: end: 8 view: type: empty_view background_color: default: hex: '#001f9e' alpha: 1 border: radius: 12 stroke_width: 0 - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: royal_blue content_description: Obsidian style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Obsidian content_description: Obsidian text_appearance: font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 - size: width: 24 height: 24 margin: end: 8 view: type: empty_view background_color: default: hex: '#000000' alpha: 1 border: radius: 12 stroke_width: 0 - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: moss_green content_description: Moss Green style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Moss Green content_description: Moss Green text_appearance: font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 - size: width: 24 height: 24 margin: end: 8 view: type: empty_view background_color: default: hex: '#6ca15f' alpha: 1 border: radius: 12 stroke_width: 0 - size: width: 100% height: auto margin: top: 16 bottom: 32 start: 24 end: 24 view: type: linear_layout direction: horizontal items: - size: width: 48% height: 50 margin: end: 8 view: type: label_button identifier: prev_button_color background_color: default: hex: '#001f9e' alpha: 1 border: radius: 25 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.3 button_click: - pager_previous label: type: label text: Previous text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - size: width: 48% height: 50 margin: start: 8 view: type: label_button identifier: next_button_color background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - pager_next enabled: - form_validation label: type: label text: Next text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - identifier: thank_you_page type: pager_item view: type: container background_color: default: hex: '#004bff' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 40 bottom: 24 start: 24 end: 24 view: type: label text: "Thank You! \U0001F389" text_appearance: alignment: center font_size: 32 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold font_families: - sans-serif - size: width: 100% height: auto margin: top: 16 bottom: 24 start: 24 end: 24 view: type: label text: Your survey has been submitted successfully. We appreciate your feedback! text_appearance: alignment: center font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 font_families: - sans-serif - size: width: 100% height: 50 margin: top: 16 bottom: 40 start: 24 end: 24 view: type: label_button identifier: done_button background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - dismiss label: type: label text: "Done \u2713" text_appearance: font_size: 18 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - position: horizontal: end vertical: top margin: top: 16 end: 16 size: width: 32 height: 32 view: type: image_button identifier: close_button button_click: - dismiss image: type: icon icon: close color: default: hex: '#FFFFFF' alpha: 0.8 scale: 0.7 - position: horizontal: center vertical: bottom margin: bottom: 16 size: height: 8 width: 100% view: type: pager_indicator spacing: 8 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: hex: '#FFFFFF' alpha: 0.3 visibility: default: false invert_when_state_matches: key: selected_score value: at_least: 5 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/async_view.yaml ================================================ --- version: 1 presentation: type: banner default_placement: position: bottom size: width: 100% height: 80% view: type: state_controller view: type: async_view_controller identifier: "async_view" request: type: content url: https://hangar-dl.urbanairship.com/binary/public/VWDwdOFjRTKLRxCeXTVP6g/6b0620eb-9d11-4196-9e1f-14595b0713a3 placeholder: type: container background_color: default: alpha: 1 hex: "#004bff" border: radius: 20 stroke_color: default: alpha: 0.1 hex: "#FFFFFF" stroke_width: 2 items: - position: horizontal: center vertical: center size: width: 100 height: 100 view: type: icon_view visibility: default: true invert_when_state_matches: scope: - $asyncView - current - error value: is_present: true icon: icon: progress_spinner scale: 1 color: default: hex: "#DDDDDD" alpha: 0.7 - position: horizontal: center vertical: center size: width: auto height: auto view: visibility: default: true invert_when_state_matches: scope: - $asyncView - current - error value: is_present: false type: linear_layout direction: vertical items: - size: width: auto height: auto view: type: label text: "Uh oh :(, something went wrong!" text_appearance: alignment: center font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 styles: - bold font_families: - sans-serif - size: width: 48% height: 50 margin: top: 16 view: type: label_button identifier: retry background_color: default: hex: '#001f9e' alpha: 1 border: radius: 25 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.3 button_click: - async_view_retry label: type: label text: Retry text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - position: horizontal: end vertical: top margin: top: 16 end: 16 size: width: 32 height: 32 view: type: image_button identifier: close_button button_click: - dismiss image: type: icon icon: close color: default: hex: '#FFFFFF' alpha: 0.8 scale: 0.7 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/auto_height_modal.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 70% height: auto max_height: 90% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 web: ignore_shade: true border: radius: 0 shadow: selectors: - platform: ios shadow: box_shadow: color: default: type: hex hex: "#7B7C84" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 radius: 0 blur_radius: 4 offset_x: 0 offset_y: 0 - platform: web shadow: box_shadow: color: default: type: hex hex: "#7B7C84" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 radius: 0 blur_radius: 4 offset_x: 0 offset_y: 0 - platform: android shadow: android_shadow: color: default: type: hex hex: "#7B7C84" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#7B7C84" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 elevation: 4 view: type: pager_controller identifier: b7974084-be21-4d8e-9772-cc673f1a6336 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: identifier: 6cf9375d-52a5-475e-9f34-61c1b817b166 nps_identifier: 4a92669e-2560-4ece-a398-dc8e278c8366 type: nps_form_controller submit: submit_event form_enabled: - form_submission response_type: nps view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: pager disable_swipe: true items: - identifier: 0b494bf9-425b-4c69-b800-71cb28710c75 type: pager_item view: type: container items: - size: width: 100% height: auto position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: auto view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 4a92669e-2560-4ece-a398-dc8e278c8366 size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: auto view: identifier: id type: text_input input_type: text_multiline text: "* dffff" content_description: "* dffff" text_appearance: font_size: 24 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: - bold font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 0 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: auto margin: end: 4 bottom: 4 view: type: label text: Not Likely content_description: Not Likely text_appearance: font_size: 24 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: - bold font_families: - sans-serif - size: width: 50% height: auto margin: start: 4 bottom: 4 view: type: label text: Very Likely content_description: Very Likely text_appearance: font_size: 24 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: end styles: - bold font_families: - sans-serif - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: height: 50 width: 100% view: type: score style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: alignment: center font_families: - sans-serif font_size: 24 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 text_appearance: font_size: 24 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: 4a92669e-2560-4ece-a398-dc8e278c8366 required: true - identifier: b4e4f64b-97d7-4928-9305-e13a7ade1fca margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: 200 view: type: label_button identifier: next--button98 reporting_metadata: trigger_link_id: b4e4f64b-97d7-4928-9305-e13a7ade1fca label: type: label text: Buttons content_description: '' text_appearance: font_size: 16 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - pager_next button_click: - pager_next background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 border: radius: 3 stroke_width: 0 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - identifier: 911c608d-44d3-4218-98d1-49e0387676fa margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: next--button52 reporting_metadata: trigger_link_id: 911c608d-44d3-4218-98d1-49e0387676fa label: type: label text: Button content_description: '' text_appearance: font_size: 16 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - pager_next button_click: - pager_next background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 border: radius: 3 stroke_width: 0 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FF7F50" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FF7F50" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFF5E" alpha: 0.5 - platform: android dark_mode: false color: type: hex hex: "#FF7F50" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFF5E" alpha: 0.5 - platform: web dark_mode: false color: type: hex hex: "#FF7F50" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFF5E" alpha: 0.5 ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/button_layout.yml ================================================ presentation: type: modal android: disable_back_button: false default_placement: ignore_safe_area: true position: horizontal: center vertical: top shade_color: default: hex: '#000000' alpha: 0.2 type: hex size: height: 100% width: 100% web: ignore_shade: true dismiss_on_touch_outside: false placement_selectors: [] version: 1 view: type: pager_controller identifier: 682cbf3f-2932-4531-a505-92a45610f643 view: type: container items: - ignore_safe_area: true position: horizontal: center vertical: center size: height: 100% width: 100% view: type: pager disable_swipe: false items: # # PAGE 1 # - type: pager_item identifier: 8520c6a6-8775-4850-97d0-43a448356b23 view: type: container background_color: default: hex: '#FFCBCB' alpha: 1 type: hex items: - size: height: 100% width: 100% position: horizontal: center vertical: center view: type: container items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: button_layout identifier: page_1_button_layout accessibility_role: type: button content_description: Page 1 Content Description tap_effect: type: none actions: toast_action: Page 1 tapped! view: type: linear_layout direction: vertical items: - identifier: 141247f5-f335-4c83-aefa-143ce00d7297 margin: bottom: 8 end: 36 start: 8 top: 8 size: height: auto width: auto view: type: label content_description: Page 1 text: Page 1 (Button Layout inside Scroll Container) text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 24 styles: - bold - identifier: eba93362-710e-4a6c-93bb-176e90ca8cbf margin: bottom: 8 end: 16 start: 16 top: 36 size: height: auto width: 100% view: type: label content_description: First text text: First text text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 20 - identifier: c6b202e0-cb3d-40ce-991e-dc1add73fd6a margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label_button identifier: next--Show Toast background_color: default: hex: '#63AFF1' alpha: 1 type: hex actions: toast_action: Toast 1! label: type: label content_description: Show Toast text: Show Toast text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 16 reporting_metadata: trigger_link_id: c6b202e0-cb3d-40ce-991e-dc1add73fd6a - identifier: 13eeb12a-e12d-48e2-825c-f3da649ba222 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label content_description: Second text text: Second text text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 20 styles: [ ] - identifier: 14f31557-6cba-434c-9a01-fba32864d71e margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label_button identifier: share--share background_color: default: hex: '#63AFF1' alpha: 1 type: hex border: radius: 0 stroke_color: default: hex: '#63AFF1' alpha: 1 type: hex stroke_width: 16 actions: share_action: Causae vulputate duo ex. Case erroribus ut mea, etiam putant nusquam ex eum. Sea ea illud tantas, populo facilis sententiae at per. Id vis quando docendi pertinacia. button_click: [ ] content_description: Share enabled: [ ] label: type: label content_description: Share text: Share text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 16 reporting_metadata: trigger_link_id: 14f31557-6cba-434c-9a01-fba32864d71e - identifier: c0469a5e-8561-4f6b-aabb-9d0e93e6e729 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label content_description: 3rd text text: 3rd text text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 20 - identifier: cbcfe457-022e-48d6-9e24-3b5ebe2400ee margin: bottom: 36 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label_button identifier: next--Next Screen background_color: default: hex: '#63AFF1' alpha: 1 type: hex border: radius: 0 stroke_color: default: hex: '#63AFF1' alpha: 1 type: hex stroke_width: 16 actions: { } button_click: - pager_next enabled: - pager_next label: type: label content_description: Next Screen text: Next Screen text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 16 styles: [ ] reporting_metadata: trigger_link_id: cbcfe457-022e-48d6-9e24-3b5ebe2400ee # # PAGE 2 # - type: pager_item identifier: 7d7433ef-f7f0-46e3-8d79-043fc8d3db2d view: type: container background_color: default: hex: '#F1F7B5' alpha: 1 type: hex items: - ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: type: button_layout identifier: page_2_button_layout accessibility_role: type: container content_description: Page 2 Button Layout actions: toast_action: Page 2 tapped! view: type: linear_layout direction: vertical items: - identifier: scroll_container size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: a814df98-88e0-4761-a682-f766bd7248eb margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label content_description: Page 2 text: Page 2 (Button Layout wraps Scroll Container) text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 24 styles: - bold - identifier: 1f089e11-0108-411d-b038-ffdf469360e7 margin: bottom: 8 end: 16 start: 16 top: 36 size: height: auto width: 100% view: type: label_button identifier: previous--Previous Screen background_color: default: hex: '#63AFF1' alpha: 1 type: hex border: radius: 0 stroke_color: default: hex: '#63AFF1' alpha: 1 type: hex stroke_width: 16 actions: { } button_click: - pager_previous enabled: - pager_previous label: type: label content_description: Previous Screen text: Previous Screen text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 16 styles: [ ] reporting_metadata: trigger_link_id: 1f089e11-0108-411d-b038-ffdf469360e7 - identifier: bba18808-a1c2-4475-a740-d99f0b460b95 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label content_description: Some other text text: Some other text text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 20 styles: [ ] - identifier: 520aa9e5-3a1c-4e84-baa9-5c5ec6e00965 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label_button identifier: next--Show Toast background_color: default: hex: '#63AFF1' alpha: 1 type: hex border: radius: 0 stroke_color: default: hex: '#63AFF1' alpha: 1 type: hex stroke_width: 16 actions: toast_action: Toast 2! label: type: label content_description: Show Toast text: Show Toast text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex font_families: - sans-serif font_size: 16 styles: [ ] reporting_metadata: trigger_link_id: 520aa9e5-3a1c-4e84-baa9-5c5ec6e00965 - identifier: 82962ef9-5c05-4824-9658-d4dfd4bd45d8 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label content_description: Some text text: Sit in erant sanctus. Eam ne legendos partiendo reprehendunt, his aeterno alienum omnesque in, per probo persius no. Sea augue harum ea, solum repudiandae eam an. Cu integre scaevola sit, mea assum instructior ut. Ipsum lorem laoreet mea ei, vim an electram iudicabit. Vis solum falli an, erant regione eu nam. text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 20 styles: [ ] - identifier: 32235177-c70b-430f-bd2b-0c1bfecc80e7 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label_button identifier: share--share background_color: default: hex: '#63AFF1' alpha: 1 type: hex border: radius: 0 stroke_color: default: hex: '#63AFF1' alpha: 1 type: hex stroke_width: 16 actions: share_action: Causae vulputate duo ex. Case erroribus ut mea, etiam putant nusquam ex eum. Sea ea illud tantas, populo facilis sententiae at per. Id vis quando docendi pertinacia. button_click: [ ] content_description: Share enabled: [ ] label: type: label content_description: Share text: Share text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 16 styles: [ ] reporting_metadata: trigger_link_id: 32235177-c70b-430f-bd2b-0c1bfecc80e7 - identifier: 2d0346d2-ecf6-451d-a48f-04f8202b9e64 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: image type: media position: horizontal: end vertical: bottom url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - identifier: 63356835-3d1d-4bcf-8bc7-08616769bab4 margin: bottom: 36 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label_button identifier: next--Next Screen background_color: default: hex: '#63AFF1' alpha: 1 type: hex border: radius: 0 stroke_color: default: hex: '#63AFF1' alpha: 1 type: hex stroke_width: 16 actions: { } button_click: - pager_next enabled: - pager_next label: type: label content_description: Next Screen text: Next Screen text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 16 styles: [ ] reporting_metadata: trigger_link_id: 63356835-3d1d-4bcf-8bc7-08616769bab4 # # PAGE 3 # - type: pager_item identifier: 6db51464-64bc-4437-845d-0e9be5267a18 view: type: container background_color: default: hex: '#DFEBEB' alpha: 1 type: hex items: - ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: type: container items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: - size: height: 100% width: 100% view: type: linear_layout direction: vertical items: - identifier: 4d10fc1e-0df6-49a2-80ab-1d2f9514813d size: height: 100% width: 100% view: type: button_layout identifier: page_3_button_layout content_description: Page 3 Button Layout tap_effect: type: none actions: show_toast: Page 3 tapped! view: type: linear_layout direction: vertical items: - identifier: 9e4a96f2-2dcb-40df-96b5-4f53bcb60e23 margin: bottom: 8 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label content_description: Page 3 (Button Layout wraps WebView) text: Page 3 text_appearance: alignment: start color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 24 styles: - bold - identifier: page3-webview size: width: 100% height: 100% view: type: web_view url: "https://docs.airship.com" background_color: default: hex: '#FFFFFF' alpha: 1 type: hex - identifier: 71f57cef-270d-4311-85a5-d2f9726d5cc1 margin: bottom: 36 end: 16 start: 16 top: 8 size: height: auto width: 100% view: type: label_button identifier: dismiss--Dismiss background_color: default: hex: '#63AFF1' alpha: 1 type: hex border: stroke_color: default: hex: '#63AFF1' alpha: 1 type: hex stroke_width: 16 button_click: - dismiss label: type: label content_description: Dismiss text: Dismiss text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true font_families: - sans-serif font_size: 16 reporting_metadata: trigger_link_id: 71f57cef-270d-4311-85a5-d2f9726d5cc1 - ignore_safe_area: false position: horizontal: end vertical: top size: height: 48 width: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss image: type: icon color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true icon: close scale: 0.4 - ignore_safe_area: false margin: bottom: 4 end: 4 start: 4 top: 4 position: horizontal: center vertical: bottom size: height: 7 width: 100% view: type: pager_indicator bindings: selected: shapes: - type: ellipse aspect_ratio: 1 color: default: hex: '#7B7C84' alpha: 1 type: hex selectors: - color: hex: '#7B7C84' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true scale: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 color: default: hex: '#BCBDC2' alpha: 1 type: hex selectors: - color: hex: '#BCBDC2' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true scale: 1 spacing: 6 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/container.yml ================================================ version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 80% height: auto position: horizontal: center vertical: center shade_color: default: hex: "#444444" alpha: .3 background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#00FF00" alpha: .3 stroke_width: 3 radius: 15 shadow: selectors: - shadow: box_shadow: color: default: hex: "#0000FF" alpha: 1 blur_radius: 20 radius: 20 view: type: container items: - position: horizontal: end vertical: top size: height: auto width: auto margin: top: 50 bottom: 50 start: 50 end: 50 view: type: label text: Sup Buddy text_appearance: font_size: 14 color: default: hex: "#333333" alignment: start styles: - italic font_families: - permanent_marker - casual - position: horizontal: center vertical: center size: height: auto width: 100% view: type: linear_layout direction: vertical items: - size: width: 100% height: auto weight: 1 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 50 bottom: 50 start: 50 end: 50 view: type: label text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In arcu cursus euismod quis viverra nibh. Lobortis feugiat vivamus at augue eget arcu dictum. Imperdiet dui accumsan sit amet nulla. Ultrices neque ornare aenean euismod elementum. Tincidunt id aliquet risus feugiat in ante metus dictum. text_appearance: font_size: 14 color: default: hex: "#333333" alignment: start styles: - italic font_families: - permanent_marker - casual - size: width: 100% height: auto margin: top: 50 bottom: 50 start: 50 end: 50 view: type: label_button identifier: BUTTON background_color: default: hex: "#FF0000" label: type: label text_appearance: font_size: 24 alignment: center color: default: hex: "#ffffff" styles: - bold font_families: - casual text: Push me! ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/containerception.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: '#000000' alpha: 0.5 ignore_safe_area: true view: type: container background_color: default: hex: "#FF0000" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: false view: type: container background_color: default: hex: "#FFFFFF" alpha: 1 items: - position: horizontal: center vertical: top size: width: auto height: auto ignore_safe_area: true view: type: label text: "DANGER AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FF0000" alpha: 1 styles: - bold alignment: center - position: horizontal: center vertical: bottom size: width: auto height: auto ignore_safe_area: false view: type: label text: "DANGER AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FF0000" alpha: 1 styles: - bold alignment: center - position: horizontal: center vertical: top size: width: auto height: auto ignore_safe_area: true margin: top: 4 view: type: label text: "SAFE AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FFFF00" alpha: 1 styles: - bold alignment: center - position: horizontal: start vertical: top size: width: auto height: auto ignore_safe_area: true margin: top: 4 start: 16 view: type: label text: "SAFE AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FFFF00" alpha: 1 styles: - bold alignment: center - position: horizontal: end vertical: top size: width: auto height: auto ignore_safe_area: true margin: top: 4 end: 16 view: type: label text: "SAFE AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FFFF00" alpha: 1 styles: - bold alignment: center - position: horizontal: center vertical: bottom size: width: auto height: auto ignore_safe_area: true margin: bottom: 8 view: type: label text: "SAFE AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FFFF00" alpha: 1 styles: - bold alignment: center - position: horizontal: start vertical: bottom size: width: auto height: auto ignore_safe_area: true margin: bottom: 8 start: 16 view: type: label text: "SAFE AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FFFF00" alpha: 1 styles: - bold alignment: center - position: horizontal: end vertical: bottom size: width: auto height: auto ignore_safe_area: true margin: bottom: 8 end: 16 view: type: label text: "SAFE AREA" text_appearance: font_size: 12 color: default: type: hex hex: "#FFFF00" alpha: 1 styles: - bold alignment: center - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: container background_color: default: hex: "#00FFFF" alpha: 0 items: - position: horizontal: end vertical: top size: width: 100 height: 100 ignore_safe_area: false view: type: label background_color: default: hex: "#00FF00" alpha: 1 text: "ignore_safe_area: false" text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - position: horizontal: start vertical: top ignore_safe_area: true size: width: 100 height: 100 view: type: label background_color: default: hex: "#be70ef" alpha: 1 text: "ignore_safe_area: true" text_appearance: font_size: 10 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center - position: horizontal: start vertical: bottom size: width: 100 height: 100 ignore_safe_area: false view: type: label background_color: default: hex: "#0000FF" alpha: 1 text: "ignore_safe_area: false" text_appearance: font_size: 10 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center - position: horizontal: end vertical: bottom ignore_safe_area: true size: width: 100 height: 100 view: type: label background_color: default: hex: "#FF00FF" alpha: 1 text: "ignore_safe_area: true" text_appearance: font_size: 10 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center - position: horizontal: center vertical: center ignore_safe_area: true size: width: auto height: auto view: type: linear_layout background_color: default: hex: "#FF0000" alpha: 1 direction: vertical items: - size: width: auto height: 50 view: type: label text: "container" text_appearance: font_size: 10 color: default: type: hex hex: "#333333" alpha: 1 alignment: center - size: width: auto height: 50 view: type: label text: "ignore_safe_area: true" text_appearance: font_size: 10 color: default: type: hex hex: "#333333" alpha: 1 alignment: center ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/custom-fonts.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: ignore_shade: true view: type: pager_controller identifier: 300229b5-c073-4ecb-8bc5-137054dd236b view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: type: container items: - identifier: e03cd022-5ea9-4a06-80c6-e85b59c1d8a0_pager_container_item position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: a0121f9b-4af6-425c-8ad0-ce74d7ee1b52 type: pager_item view: type: container items: - identifier: 470361c2-c195-49ff-80b9-fc248c00e83c_main_view_container_item size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - identifier: 7615e8fd-4256-4e37-9813-06579c0d7f5a_container_item margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: ABCDEFGHIJKLMNOPQRSTUVWXYZ content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: - bold - italic font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 100 Thin content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start font_weight: 100 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 200 Extra Light content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: font_weight: 200 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 300 Light content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start font_weight: 300 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 400 Normal content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: font_weight: 400 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 500 Medium content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: font_weight: 500 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 600 Semi Bold content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: font_weight: 600 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 700 Bold content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: font_weight: 700 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 800 Extra Bold content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: font_weight: 800 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: c3bb182a-82c7-4dc6-b364-9aad64322a53 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: 900 Black content_description: test ulrich text_appearance: font_size: 32 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: font_weight: 900 font_families: - Merriweather - merriweather accessibility_role: type: heading level: 1 accessibility_hidden: false - identifier: '088310d4-f576-40bf-bad0-c1259da56abf_linear_layout_item' size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 state_actions: - type: set key: a0121f9b-4af6-425c-8ad0-ce74d7ee1b52_next ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss localized_content_description: ref: ua_dismiss fallback: Dismiss reporting_metadata: button_id: dismiss_button button_action: dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/form-input-branching-child-form.yaml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: {} view: type: state_controller initial_state: neat: dissatisfied view: type: pager_controller identifier: 5ff76966-4e7e-4a5f-b3b4-3928cf7f4076 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: fa59a9dc-4f78-407e-bc89-4bb20c03c310 type: form_controller validation_mode: type: on_demand form_enabled: - form_submission submit: submit_event response_type: user_feedback view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: false items: - identifier: b5628f32-3da1-42ae-b76e-c20800922d47 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "Favorite Animal" content_description: "Favorite Animal" icon_start: space: 8 type: floating icon: type: icon icon: asterisk color: default: hex: "#000000" alpha: 1.0 scale: .75 view_overrides: icon_start: - when_state_matches: scope: - edf9064c-54d5-4b29-9ac9-ad0997a62696 value: equals: "error" value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: .75 text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: radio_input_controller identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 required: true on_error: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "error" on_valid: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "valid" on_edit: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "editing" view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 content_description: Cat style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Cat content_description: Cat text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d content_description: Dog style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Dog content_description: Dog text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif randomize_children: false attribute_name: {} - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 branching: next_page: selectors: - page_id: 24e09863-1dcf-40ea-8b81-b398d286d449 when_state_matches: or: - scope: - "$forms" - current - data - children - edf9064c-54d5-4b29-9ac9-ad0997a62696 # favorite animal value: equals: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 # cat key: value - page_id: d495142a-38d3-482c-9f14-203501b0518a when_state_matches: and: - scope: - "$forms" - current - data - children - edf9064c-54d5-4b29-9ac9-ad0997a62696 # favorite animal value: equals: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d # dog key: value - identifier: d495142a-38d3-482c-9f14-203501b0518a type: pager_item branching: next_page: selectors: - page_id: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 view: identifier: 98354588-45a2-4ca2-b9b6-e0b6e02a6e69 # submit: submit_event type: form_controller validation_mode: type: immediate form_enabled: - form_submission view: items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: background_color: default: alpha: 1 hex: "#e8e4dc" type: hex direction: vertical type: scroll_layout view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: media_fit: center_crop media_type: image type: media url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/bc692c5f-09ce-4ea4-bb55-108b1b5d28a8 - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 size: height: 100% width: 100% view: text: How 🔥`satisfied`🔥 ~~is your friend~~ are **you** with *our* [product](https://www.airship.com)? text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 100% width: 100% view: identifier: 52fd50d9-c899-4887-8210-9669cb27188c type: radio_input_controller required: true attribute_name: channel: HowSatisfiedAreYou event_handlers: - type: form_input state_actions: - type: set_form_value key: "neat" view: direction: vertical items: - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: auto view: reporting_value: very_satisfied attribute_value: VerySatisfied style: bindings: selected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: 🔥`satisfied`🔥 text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: auto view: reporting_value: satisfied attribute_value: Satisfied style: bindings: selected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Satisfied text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: auto view: reporting_value: eh attribute_value: Eh style: bindings: selected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Eh text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: auto view: reporting_value: dissatisfied attribute_value: Dissatisfied style: bindings: selected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Dissatisfied text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: auto view: reporting_value: very_dissatisfied attribute_value: VeryDissatisfied style: bindings: selected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Very Dissatisfied [(click here for more)](https://www.airship.com) text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout type: linear_layout type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: visibility: default: false invert_when_state_matches: or: - key: neat value: equals: dissatisfied - key: neat value: equals: very_dissatisfied text: But why? text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 70 width: 100% view: visibility: default: false invert_when_state_matches: or: - key: neat value: equals: dissatisfied - key: neat value: equals: very_dissatisfied background_color: default: alpha: 1 hex: "#eae9e9" type: hex border: radius: 2 stroke_color: default: alpha: 1 hex: "#63656b" type: hex stroke_width: 1 identifier: 7c7f0793-188f-4f60-aec2-0b35d9a4d005 input_type: text_multiline required: false text_appearance: alignment: start color: default: alpha: 1 hex: "#000000" type: hex font_size: 14 type: text_input type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: text: What ~~areas~~ do we **need** to [*improve*](https://airship.com)? text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 70 width: 100% view: background_color: default: alpha: 1 hex: "#eae9e9" type: hex border: radius: 2 stroke_color: default: alpha: 1 hex: "#63656b" type: hex stroke_width: 1 identifier: 9fb8ef64-2fbc-4439-b450-87b20fda5c43 input_type: text_multiline required: false text_appearance: alignment: start color: default: alpha: 1 hex: "#000000" type: hex font_size: 14 type: text_input type: linear_layout type: linear_layout type: container - identifier: 24e09863-1dcf-40ea-8b81-b398d286d449 type: pager_item branching: next_page: selectors: - page_id: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "What are your favorite Guy Fieri shows? (select at least 2)" content_description: "What are your favorite Guy Fieri shows? (select at least 2)" icon_start: space: 8 type: floating icon: type: icon icon: asterisk color: default: hex: "#000000" alpha: 1.0 scale: .75 view_overrides: icon_start: - when_state_matches: scope: - 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: equals: "error" value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: .75 text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: checkbox_controller identifier: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f on_error: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: "error" on_valid: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: "valid" on_edit: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: "editing" required: true view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: 646d668e-5d50-44cf-8ddf-7fe4e2a404ab content_description: Diners, Drive-Ins, and Dives style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Diners, Drive-Ins, and Dives content_description: Diners, Drive-Ins, and Dives text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: 3a6f2143-968c-484e-ac2f-69efdbfaaabd content_description: Guy's Grocery Games style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Guy's Grocery Games content_description: Guy's Grocery Games text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: 86eaa518-2a24-4018-a303-4c9eb05ec2a3 content_description: Guy's Big Bite style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Guy's Big Bite content_description: Guy's Big Bite text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: bbd19354-9728-4be6-baab-7d3779841eb2 content_description: Guy's Family Road Trip style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Guy's Family Road Trip content_description: Guy's Family Road Trip text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: d27cc3e2-e1a9-4ed8-b013-a3b64017ee91 content_description: Minute to Win It style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Minute to Win It content_description: Minute to Win It text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif randomize_children: true min_selection: 2 - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 - identifier: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: fe561774-93f5-4b09-bd0c-831b955042e6 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "Required Email" icon_start: space: 8 type: floating icon: type: icon icon: asterisk color: default: hex: "#000000" alpha: 1.0 scale: .75 view_overrides: icon_start: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: "error" value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: .75 content_description: "Required Email" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: 2cb5fa0f-1c25-4929-b951-66ce56910901 size: width: 100% height: 50 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: 50 view: border: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 type: text_input text_appearance: alignment: start font_size: 16 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 place_holder_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#000000" alpha: 0 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 identifier: 2cb5fa0f-1c25-4929-b951-66ce56910901 input_type: email required: true content_description: '' place_holder: "email@email.com" view_overrides: icon_end: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: "error" value: type: floating icon: type: icon icon: exclamationmark_circle_fill scale: 1 color: default: type: hex hex: "#ff0000" alpha: 0.5 border: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: "error" value: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#ff0000" alpha: 1 on_error: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: "error" on_edit: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: "editing" on_valid: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: "valid" - identifier: 26b7ddd7-f995-4a27-8fcb-e1b86d00d210 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: Another Email content_description: Another Email text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5 size: width: 100% height: 50 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: 50 view: border: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 type: text_input text_appearance: alignment: start font_size: 16 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 place_holder_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#000000" alpha: 0 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 identifier: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5 input_type: text required: false content_description: '' place_holder: Optional view_overrides: icon_end: - when_state_matches: scope: - c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: equals: true value: type: floating icon: type: icon icon: exclamationmark_circle_fill scale: 1 color: default: type: hex hex: "#ff0000" alpha: 1 border: - when_state_matches: scope: - c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: equals: true value: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#ff0000" alpha: 1 on_error: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: true on_edit: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error on_valid: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error - identifier: f1047936-d73a-403f-9ac8-68bf6bacb802 margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: submit_feedback--Submit reporting_metadata: trigger_link_id: f1047936-d73a-403f-9ac8-68bf6bacb802 label: view_overrides: icon_start: - value: type: "floating" space: 8 icon: type: icon icon: progress_spinner color: default: hex: "#FFFFFF" alpha: 1.0 scale: 1 when_state_matches: scope: - $forms - current - status - type value: equals: "validating" text: - value: "Processing ..." when_state_matches: scope: - $forms - current - status - type value: equals: "validating" type: label text: Submit content_description: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 border: radius: 0 stroke_width: 16 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 branching: next_page: selectors: [] ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 position: horizontal: end vertical: bottom view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Next' button_click: [ "pager_next" ] enabled: [ "pager_next" ] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 position: horizontal: start vertical: bottom view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Previous' button_click: [ "pager_previous" ] enabled: [ "pager_previous" ] - margin: top: 4 bottom: 4 end: 0 start: 0 position: horizontal: center vertical: bottom size: height: 7 width: 100% view: type: pager_indicator spacing: 6 bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 color: default: type: hex hex: "#363636" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#363636" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#363636" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#363636" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: type: hex hex: "#747474" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#747474" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#747474" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#747474" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 branching: pager_completions: [] ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/form-input-branching-on-demand.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: {} view: type: pager_controller identifier: 5ff76966-4e7e-4a5f-b3b4-3928cf7f4076 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: fa59a9dc-4f78-407e-bc89-4bb20c03c310 type: form_controller validation_mode: type: on_demand form_enabled: - form_submission submit: submit_event response_type: user_feedback view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: false items: - identifier: b5628f32-3da1-42ae-b76e-c20800922d47 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "Favorite Animal" content_description: "Favorite Animal" icon_start: space: 8 type: floating icon: type: icon icon: asterisk color: default: hex: "#000000" alpha: 1.0 scale: .75 view_overrides: icon_start: - when_state_matches: scope: - edf9064c-54d5-4b29-9ac9-ad0997a62696 value: equals: "error" value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: .75 text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: radio_input_controller identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 required: true on_error: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "error" on_valid: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "valid" on_edit: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "editing" view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 content_description: Cat style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Cat content_description: Cat text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d content_description: Dog style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Dog content_description: Dog text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif randomize_children: false attribute_name: {} - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 branching: next_page: selectors: - page_id: 24e09863-1dcf-40ea-8b81-b398d286d449 when_state_matches: or: - scope: - "$forms" - current - data - children - edf9064c-54d5-4b29-9ac9-ad0997a62696 # favorite animal value: equals: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 # cat key: value - page_id: d495142a-38d3-482c-9f14-203501b0518a when_state_matches: and: - scope: - "$forms" - current - data - children - edf9064c-54d5-4b29-9ac9-ad0997a62696 # favorite animal value: equals: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d # dog key: value # - page_id: d495142a-38d3-482c-9f14-203501b0518a # - page_id: 24e09863-1dcf-40ea-8b81-b398d286d449 # - page_id: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 - identifier: d495142a-38d3-482c-9f14-203501b0518a type: pager_item branching: next_page: selectors: - page_id: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 7beb5f48-a5fe-4c56-ae31-994263ffcdb2 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: Favorite Color content_description: Favorite Color text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: f1047936-d73a-403f-9ac8-68bf6bacb802 margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: next reporting_metadata: trigger_link_id: f1047936-d73a-403f-9ac8-68bf6bacb802 label: view_overrides: icon_start: - value: type: "floating" space: 8 icon: type: icon icon: progress_spinner color: default: hex: "#FFFFFF" alpha: 1.0 scale: 1 when_state_matches: scope: - $forms - current - status - type value: equals: "validating" text: - value: "Processing ..." when_state_matches: scope: - $forms - current - status - type value: equals: "validating" type: label text: Next content_description: Next text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} button_click: - pager_next - form_validate background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 border: radius: 0 stroke_width: 16 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: radio_input_controller identifier: 7beb5f48-a5fe-4c56-ae31-994263ffcdb2 required: false view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: d0596a26-f197-48d7-96b3-c61ace2fb16b content_description: Blue style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Blue content_description: Blue text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 55ffe2a4-d851-40a8-8046-28c8349e9ace content_description: Red style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Red content_description: Red text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif randomize_children: false attribute_name: {} - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 - identifier: 24e09863-1dcf-40ea-8b81-b398d286d449 type: pager_item branching: next_page: selectors: - page_id: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "What are your favorite Guy Fieri shows? (select at least 2)" content_description: "What are your favorite Guy Fieri shows? (select at least 2)" icon_start: space: 8 type: floating icon: type: icon icon: asterisk color: default: hex: "#000000" alpha: 1.0 scale: .75 view_overrides: icon_start: - when_state_matches: scope: - 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: equals: "error" value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: .75 text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: checkbox_controller identifier: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f on_error: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: "error" on_valid: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: "valid" on_edit: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: "editing" required: true view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: 646d668e-5d50-44cf-8ddf-7fe4e2a404ab content_description: Diners, Drive-Ins, and Dives style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Diners, Drive-Ins, and Dives content_description: Diners, Drive-Ins, and Dives text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: 3a6f2143-968c-484e-ac2f-69efdbfaaabd content_description: Guy's Grocery Games style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Guy's Grocery Games content_description: Guy's Grocery Games text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: 86eaa518-2a24-4018-a303-4c9eb05ec2a3 content_description: Guy's Big Bite style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Guy's Big Bite content_description: Guy's Big Bite text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: bbd19354-9728-4be6-baab-7d3779841eb2 content_description: Guy's Family Road Trip style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Guy's Family Road Trip content_description: Guy's Family Road Trip text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: checkbox reporting_value: d27cc3e2-e1a9-4ed8-b013-a3b64017ee91 content_description: Minute to Win It style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Minute to Win It content_description: Minute to Win It text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif randomize_children: true min_selection: 2 - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 - identifier: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: fe561774-93f5-4b09-bd0c-831b955042e6 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "Required Email" icon_start: space: 8 type: floating icon: type: icon icon: asterisk color: default: hex: "#000000" alpha: 1.0 scale: .75 view_overrides: icon_start: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: "error" value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: .75 content_description: "Required Email" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: 2cb5fa0f-1c25-4929-b951-66ce56910901 size: width: 100% height: 50 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: 50 view: border: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 type: text_input text_appearance: alignment: start font_size: 16 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 place_holder_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#000000" alpha: 0 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 identifier: 2cb5fa0f-1c25-4929-b951-66ce56910901 input_type: email required: true content_description: '' place_holder: "email@email.com" view_overrides: icon_end: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: "error" value: type: floating icon: type: icon icon: exclamationmark_circle_fill scale: 1 color: default: type: hex hex: "#ff0000" alpha: 0.5 border: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: "error" value: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#ff0000" alpha: 1 on_error: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: "error" on_edit: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: "editing" on_valid: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: "valid" - identifier: 26b7ddd7-f995-4a27-8fcb-e1b86d00d210 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: Another Email content_description: Another Email text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5 size: width: 100% height: 50 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: 50 view: border: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 type: text_input text_appearance: alignment: start font_size: 16 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 place_holder_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#000000" alpha: 0 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 0 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0 identifier: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5 input_type: text required: false content_description: '' place_holder: Optional view_overrides: icon_end: - when_state_matches: scope: - c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: equals: true value: type: floating icon: type: icon icon: exclamationmark_circle_fill scale: 1 color: default: type: hex hex: "#ff0000" alpha: 1 border: - when_state_matches: scope: - c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: equals: true value: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#ff0000" alpha: 1 on_error: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: true on_edit: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error on_valid: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error - identifier: f1047936-d73a-403f-9ac8-68bf6bacb802 margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: submit_feedback--Submit reporting_metadata: trigger_link_id: f1047936-d73a-403f-9ac8-68bf6bacb802 label: view_overrides: icon_start: - value: type: "floating" space: 8 icon: type: icon icon: progress_spinner color: default: hex: "#FFFFFF" alpha: 1.0 scale: 1 when_state_matches: scope: - $forms - current - status - type value: equals: "validating" text: - value: "Processing ..." when_state_matches: scope: - $forms - current - status - type value: equals: "validating" type: label text: Submit content_description: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 border: radius: 0 stroke_width: 16 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 branching: next_page: selectors: [] ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss - margin: top: 4 bottom: 4 end: 0 start: 0 position: horizontal: center vertical: bottom size: height: 7 width: 100% view: type: pager_indicator spacing: 6 bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 color: default: type: hex hex: "#363636" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#363636" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#363636" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#363636" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: type: hex hex: "#747474" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#747474" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#747474" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#747474" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 branching: pager_completions: [] ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/form_immediate_phone.yaml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: auto margin: start: 16 end: 16 shade_color: default: hex: '#000000' alpha: 0.75 view: type: state_controller view: type: form_controller validation_mode: type: immediate identifier: a_form submit: submit_event view: type: scroll_layout direction: vertical view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 24 start: 24 bottom: 24 end: 24 view: type: linear_layout direction: vertical items: # # Text Input type: email # - size: width: 100% height: auto view: type: label text: Phone number with placeholder text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 bottom: 12 view: type: text_input place_holder: Tap in here identifier: text_input_sms_1 border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 text_appearance: alignment: start font_size: 20 color: default: type: hex hex: "#a8323a" alpha: 1 input_type: sms locales: - country_code: "US" prefix: "+1" - country_code: "FR" prefix: "+33" - country_code: "UA" prefix: "+380" required: true on_error: state_actions: - type: set key: is_valid_email value: "error" on_valid: state_actions: - type: set key: is_valid_email value: "valid" on_edit: state_actions: - type: set key: is_valid_email value: "editing" - size: width: 100% height: auto view: type: label text: Phone with no placeholder text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 bottom: 12 view: type: text_input identifier: text_input_sms_2 border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 text_appearance: alignment: start font_size: 20 color: default: type: hex hex: "#a8323a" alpha: 1 input_type: sms locales: - country_code: "US" prefix: "+1" registration: type: opt_in sender_id: "123123123" - country_code: "FR" prefix: "+33" registration: type: opt_in sender_id: "123123123" - country_code: "UA" prefix: "+380" registration: type: opt_in sender_id: "123123123" required: false on_error: state_actions: - type: set key: is_valid_email value: "error" on_valid: state_actions: - type: set key: is_valid_email value: "valid" on_edit: state_actions: - type: set key: is_valid_email value: "editing" - margin: size: height: auto width: 100% view: when_state_matches: key: is_valid_email value: equals: false background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 enabled: - form_validation button_click: - form_submit - dismiss identifier: e49c1d9a-1118-4a7b-8ae8-2e1ce42b0f1a label: text: Submit text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 24 type: label type: label_button ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/form_immediate_validation_email.yaml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: auto margin: start: 16 end: 16 shade_color: default: hex: '#000000' alpha: 0.75 view: type: state_controller view: type: form_controller validation_mode: type: immediate identifier: a_form submit: submit_event view: type: scroll_layout direction: vertical view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 24 start: 24 bottom: 24 end: 24 view: type: linear_layout direction: vertical items: # # Text Input type: email # - size: width: 100% height: auto view: type: label text: Give me your email, it's required text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 bottom: 12 view: type: text_input place_holder: Tap in here identifier: text_input_email icon_end: type: floating icon: type: icon icon: back_arrow color: default: hex: "#000000" alpha: 0.5 scale: 1 border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 0.5 scale: 1 when_state_matches: key: is_valid_email value: equals: "error" - value: type: floating icon: type: icon icon: progress_spinner color: default: hex: "#000000" alpha: 0.5 scale: 1 when_state_matches: key: is_valid_email value: equals: "editing" - value: type: floating icon: type: icon icon: checkmark color: default: hex: "#00ff00" alpha: 0.5 scale: 1 when_state_matches: key: is_valid_email value: equals: "valid" border: - value: radius: 5 stroke_width: 2 stroke_color: default: hex: "#ff0000" alpha: 0.5 when_state_matches: key: is_valid_email value: equals: false - value: radius: 5 stroke_width: 1 stroke_color: default: hex: "#cccccc" alpha: 1 stroke_width: 1 when_state_matches: key: is_valid_email value: equals: true text_appearance: alignment: start font_size: 20 color: default: type: hex hex: "#a8323a" alpha: 1 input_type: email required: true on_error: state_actions: - type: set key: is_valid_email value: "error" on_valid: state_actions: - type: set key: is_valid_email value: "valid" on_edit: state_actions: - type: set key: is_valid_email value: "editing" - size: width: 100% height: auto view: type: label text: Give me your email, it's required text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 bottom: 12 view: type: text_input place_holder: Tap in here identifier: another_text_input_email icon_end: type: floating icon: type: icon icon: back_arrow color: default: hex: "#000000" alpha: 0.5 scale: 1 border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 0.5 scale: 1 when_state_matches: key: another_is_valid_email value: equals: false - value: type: floating icon: type: icon icon: checkmark color: default: hex: "#00ff00" alpha: 0.5 scale: 1 when_state_matches: key: another_is_valid_email value: equals: true border: - value: radius: 5 stroke_width: 2 stroke_color: default: hex: "#ff0000" alpha: 0.5 when_state_matches: key: another_is_valid_email value: equals: false - value: radius: 5 stroke_width: 1 stroke_color: default: hex: "#cccccc" alpha: 1 stroke_width: 1 when_state_matches: key: another_is_valid_email value: equals: true text_appearance: alignment: start font_size: 20 color: default: type: hex hex: "#a8323a" alpha: 1 input_type: email required: true on_error: state_actions: - type: set key: another_is_valid_email value: false on_valid: state_actions: - type: set key: another_is_valid_email value: true on_edit: state_actions: - type: set key: another_is_valid_email - margin: size: height: auto width: 100% view: when_state_matches: key: is_valid_email value: equals: false background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 enabled: - form_validation button_click: - form_submit - dismiss identifier: e49c1d9a-1118-4a7b-8ae8-2e1ce42b0f1a label: text: Submit text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 24 type: label type: label_button ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/form_on_demand_validation_email.yaml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: auto margin: start: 16 end: 16 shade_color: default: hex: '#000000' alpha: 0.75 view: type: state_controller view: type: form_controller validation_mode: type: on_demand identifier: a_form submit: submit_event state_triggers: - identifier: toast_trigger trigger_when_state_matches: scope: - $forms - current - status - type value: equals: "error" reset_when_state_matches: not: scope: - $forms - current - status - type value: equals: "error" on_trigger: state_actions: - type: set key: show_toast ttl_seconds: 1.5 value: true view: type: scroll_layout direction: vertical view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 24 start: 24 bottom: 24 end: 24 view: type: linear_layout direction: vertical items: # # Text Input type: email # - size: width: 100% height: auto view: type: label text: Give me your email, it's required. view_overrides: icon_start: - value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 0.5 scale: 1 when_state_matches: key: is_valid_email value: equals: "error" text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 bottom: 12 view: type: text_input place_holder: Tap in here identifier: text_input_email icon_end: type: floating icon: type: icon icon: back_arrow color: default: hex: "#000000" alpha: 0.5 scale: 1 border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 0.5 scale: 1 when_state_matches: key: is_valid_email value: equals: "error" - value: type: floating icon: type: icon icon: checkmark color: default: hex: "#00ff00" alpha: 0.5 scale: 1 when_state_matches: key: is_valid_email value: equals: "valid" border: - value: radius: 5 stroke_width: 2 stroke_color: default: hex: "#ff0000" alpha: 0.5 when_state_matches: key: is_valid_email value: equals: "error" - value: radius: 5 stroke_width: 1 stroke_color: default: hex: "#cccccc" alpha: 1 stroke_width: 1 when_state_matches: key: is_valid_email value: equals: "valid" text_appearance: alignment: start font_size: 20 color: default: type: hex hex: "#a8323a" alpha: 1 input_type: email required: true on_error: state_actions: - type: set key: is_valid_email value: "error" on_valid: state_actions: - type: set key: is_valid_email value: "valid" on_edit: state_actions: - type: set key: is_valid_email value: "editing" - size: width: 100% height: auto view: type: label text: Give me your number, I require it. text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 bottom: 12 view: type: text_input place_holder: Tap in here required: true identifier: another_text_input_email icon_end: type: floating icon: type: icon icon: back_arrow color: default: hex: "#000000" alpha: 0.5 scale: 1 border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 0.5 scale: 1 when_state_matches: key: another_is_valid_email value: equals: "error" - value: type: floating icon: type: icon icon: checkmark color: default: hex: "#00ff00" alpha: 0.5 scale: 1 when_state_matches: key: another_is_valid_email value: equals: "valid" border: - value: radius: 5 stroke_width: 2 stroke_color: default: hex: "#ff0000" alpha: 0.5 when_state_matches: key: another_is_valid_email value: equals: "error" - value: radius: 5 stroke_width: 1 stroke_color: default: hex: "#cccccc" alpha: 1 stroke_width: 1 when_state_matches: key: another_is_valid_email value: equals: "valid" text_appearance: alignment: start font_size: 20 color: default: type: hex hex: "#a8323a" alpha: 1 input_type: sms required: true locales: - country_code: "US" prefix: "+1" - country_code: "FR" prefix: "+33" - country_code: "UA" prefix: "+380" on_error: state_actions: - type: set key: another_is_valid_email value: "error" on_valid: state_actions: - type: set key: another_is_valid_email value: "valid" on_edit: state_actions: - type: set key: another_is_valid_email value: "editing" - margin: size: height: auto width: 100% view: when_state_matches: key: is_valid_email value: equals: "error" background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 enabled: - form_validation button_click: - form_submit - dismiss identifier: e49c1d9a-1118-4a7b-8ae8-2e1ce42b0f1a label: view_overrides: icon_start: - value: type: "floating" space: 8 icon: type: icon icon: progress_spinner color: default: hex: "#FFFFFF" alpha: 1.0 scale: 1 when_state_matches: scope: - $forms - current - status - type value: equals: "validating" text: - value: "Processing ..." when_state_matches: scope: - $forms - current - status - type value: equals: "validating" text: Submit text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 24 type: label type: label_button - position: horizontal: center vertical: center size: width: auto height: auto margin: top: 24 start: 24 bottom: 24 end: 24 view: type: container background_color: default: hex: "#ff0000" alpha: .8 border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: .8 items: - position: horizontal: center vertical: center size: width: auto height: auto margin: top: 24 start: 24 bottom: 24 end: 24 view: text: Error submitting form text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 24 type: label visibility: invert_when_state_matches: scope: - show_toast value: equals: true default: false ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/form_single_page_on_demand.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: ignore_shade: true view: type: state_controller view: type: pager_controller identifier: 6b72219d-15ef-4bcf-832b-70850b419687 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: 4a560c36-28fa-452d-a2e0-f07a9e3d7521 nps_identifier: b395410d-af24-4d9a-801e-c99e5833abf3 type: nps_form_controller validation_mode: type: on_demand submit: submit_event form_enabled: - form_submission response_type: nps view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: d2432b09-48d6-40a9-8753-72e372319121 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 8ce7c2d5-23f6-4eda-b840-c76ba483fb68 size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: Single Choice content_description: Single Choice text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "Favorite Animal" content_description: "Favorite Animal" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: radio_input_controller identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 required: true on_error: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "error" on_valid: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "valid" on_edit: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "editing" view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 content_description: Cat style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Cat content_description: Cat text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d content_description: Dog style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Dog content_description: Dog text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 8df9abde-f0fc-4b37-afe0-70ee8aff0c00 content_description: Dragon style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Dragon content_description: Dragon text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif randomize_children: true attribute_name: {} - identifier: "some-required-label" size: width: 100% height: auto margin: top: 0 bottom: 0 start: 16 end: 0 view: type: label text: "* Required" text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif view_overrides: text_appearance: - when_state_matches: scope: - edf9064c-54d5-4b29-9ac9-ad0997a62696 value: equals: "error" value: font_size: 16 color: default: type: hex hex: "#ff0000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: f1047936-d73a-403f-9ac8-68bf6bacb802 margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: submit_feedback--Submit reporting_metadata: trigger_link_id: f1047936-d73a-403f-9ac8-68bf6bacb802 label: view_overrides: icon_start: - value: type: "floating" space: 8 icon: type: icon icon: progress_spinner color: default: hex: "#FFFFFF" alpha: 1.0 scale: 1 when_state_matches: scope: - $forms - current - status - type value: equals: "validating" text: - value: "Processing ..." when_state_matches: scope: - $forms - current - status - type value: equals: "validating" type: label text: Submit content_description: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 border: radius: 0 stroke_width: 16 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] visibility: default: true invert_when_state_matches: key: submitted value: equals: true - ignore_safe_area: true size: width: 100% height: 100% position: horizontal: center vertical: center view: type: container items: - margin: start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] ignore_safe_area: true - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: layout_container size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: 9c9b4565-c8e3-41bf-a211-90af3665b38b size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: Nice content_description: Nice text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif ignore_safe_area: false background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 visibility: default: false invert_when_state_matches: key: submitted value: equals: true background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 visibility: default: true invert_when_state_matches: key: confirmation_screen_container value: equals: true ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/forms.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: - placement: ignore_safe_area: false size: width: 60% height: 40% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 0.2 selectors: - platform: ios dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 1 web: ignore_shade: false border: radius: 10 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 window_size: large orientation: portrait - placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 0.2 selectors: - platform: ios dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 1 web: ignore_shade: false border: radius: 0 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 window_size: small orientation: landscape - placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 0.2 selectors: - platform: ios dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 1 web: ignore_shade: false border: radius: 0 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 window_size: medium orientation: landscape - placement: ignore_safe_area: false size: width: 50% height: 50% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 0.2 selectors: - platform: ios dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 1 web: ignore_shade: false border: radius: 10 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 window_size: large orientation: landscape android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 90% height: 70% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 0.2 selectors: - platform: ios dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 1 web: {} border: radius: 14 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 view: type: state_controller view: type: pager_controller identifier: e7d21eaa-315e-4f11-b22c-7f0e05d83463 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: '008fbf86-fefa-4470-83f7-eec1c724bc1f' nps_identifier: 69eba561-4a43-420b-9aed-30d76f620262 type: nps_form_controller submit: submit_event form_enabled: - form_submission response_type: nps view: type: container items: - identifier: 3451f18c-1fb8-4862-a877-1db87df33b3e_pager_container_item position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: false items: - identifier: c89ff0d7-47f5-40d0-bb4a-d390deb2ef28 type: pager_item view: type: container items: - identifier: 84e1dbaf-1d34-4b28-88d7-7c8893d0f457_main_view_container_item size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - identifier: 51c9228e-5b18-4a8c-9f1b-49dcf6296d75_container_item margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 93ce1f2e-008f-46e0-85cd-4daa362bd92b size: width: 100% height: 150 view: type: container items: - identifier: 789fe754-fb89-4ce1-89bd-f60a79c0ef11 margin: start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: auto view: type: media media_fit: center_inside url: https://c00001-dl.asnapieu.com/binary/public/YtDP8p2vSbW-KQkjUVsBAw/e21a8d73-47c2-4c35-9166-421ad586e220 media_type: image - identifier: 93ce1f2e-008f-46e0-85cd-4daa362bd92b_linear_container margin: top: 0 bottom: 0 start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: - view: type: container items: - identifier: cc3834bb-3e1f-4afa-b8a5-9bac2abb91ec_container size: width: 100% height: auto view: type: media media_fit: center_inside position: horizontal: center vertical: center url: https://c00001-dl.asnapieu.com/binary/public/YtDP8p2vSbW-KQkjUVsBAw/134c0029-e8e2-4f23-b355-6c79024c2501 media_type: image position: vertical: center horizontal: center identifier: cc3834bb-3e1f-4afa-b8a5-9bac2abb91ec_wrapper size: width: 90% height: 100% margin: end: 0 top: 8 start: 0 bottom: 0 border: radius: 0 margin: top: 0 bottom: 0 start: 0 end: 0 - identifier: 69eba561-4a43-420b-9aed-30d76f620262 size: width: 100% height: auto margin: end: 16 top: 30 start: 16 bottom: 8 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: auto view: type: label text: Hi Meghan. How likely are you to recommend Airship to a friend or a colleague content_description: Hi Meghan. How likely are you to recommend Airship to a friend or a colleague text_appearance: font_size: 20 color: default: type: hex hex: "#3A363F" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 alignment: center styles: - bold font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 0 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: auto margin: end: 4 bottom: 4 view: type: label text: " Not at all likely" content_description: " Not at all likely" text_appearance: font_size: 14 color: default: type: hex hex: "#F8F9FA" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#F8F9FA" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#F8F9FA" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#F8F9FA" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#F8F9FA" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#F8F9FA" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#F8F9FA" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 50% height: auto margin: start: 4 bottom: 4 view: type: label text: " Very very likely " content_description: " Very very likely " text_appearance: font_size: 14 color: default: type: hex hex: "#F8F9FA" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#F8F9FA" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#F8F9FA" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#F8F9FA" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#F8F9FA" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#F8F9FA" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#F8F9FA" alpha: 1 alignment: end styles: [] font_families: - sans-serif - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: height: 50 width: 100% view: type: score style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: rectangle scale: 1 border: radius: 10 stroke_width: 1 stroke_color: default: type: hex hex: "#3A363F" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: alignment: center font_families: - sans-serif font_size: 24 color: default: type: hex hex: "#3A363F" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 unselected: shapes: - type: rectangle scale: 1 border: radius: 10 stroke_width: 1 stroke_color: default: type: hex hex: "#3A363F" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 color: default: type: hex hex: "#3A363F" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 text_appearance: font_size: 24 font_families: - sans-serif color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: 69eba561-4a43-420b-9aed-30d76f620262 required: false - identifier: 01577441-b8c8-4644-ae81-c078d47a21c1 size: width: 100% height: auto margin: end: 16 top: 8 start: 16 bottom: 8 view: type: label text: From 0 (not likely) to 10 (very likely) content_description: From 0 (not likely) to 10 (very likely) text_appearance: font_size: 12 color: default: type: hex hex: "#3A363F" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#3A363F" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#3A363F" alpha: 1 alignment: center styles: - bold font_families: - sans-serif - identifier: 5d1b8a8d-40e4-4181-9435-910f8a742696_linear_layout_item size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 state_actions: - type: set key: c89ff0d7-47f5-40d0-bb4a-d390deb2ef28_next - identifier: 29acc7f2-58de-4638-b374-a4730927658c type: pager_item view: type: container items: - identifier: aa471d5d-6603-42db-b9d6-8ea176ae924a_main_view_container_item size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - identifier: 3808eeea-0318-4eba-8592-3f20e98ed4db_container_item margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 4a9944b2-b2fe-443f-b70d-5b8f01d60c4e size: width: 100% height: auto margin: top: 30 bottom: 8 start: 16 end: 16 view: type: label text: First name content_description: First name text_appearance: font_size: 22 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: - bold font_families: - sans-serif accessibility_role: type: heading level: 1 labels: type: labels view_type: text_input view_id: 2ebac42b-c8d4-4da7-b39e-e77252483553 - identifier: 2ebac42b-c8d4-4da7-b39e-e77252483553 size: width: 100% height: 50 margin: top: 16 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: 50 view: border: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 type: text_input text_appearance: alignment: start font_size: 22 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 place_holder_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 identifier: 2ebac42b-c8d4-4da7-b39e-e77252483553 input_type: text required: false content_description: '' place_holder: Enter first name here attribute_name: {} view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 0.5 type: hex scale: 1 when_state_matches: scope: - 2ebac42b-c8d4-4da7-b39e-e77252483553_short_text_is_valid value: equals: false - value: type: floating icon: type: icon icon: checkmark color: default: hex: "#00ff00" alpha: 0.5 type: hex scale: 1 when_state_matches: scope: - 2ebac42b-c8d4-4da7-b39e-e77252483553_short_text_is_valid value: equals: true border: - value: radius: 4 stroke_width: 1 stroke_color: default: hex: "#ff0000" alpha: 0.5 type: hex when_state_matches: scope: - 2ebac42b-c8d4-4da7-b39e-e77252483553_short_text_is_valid value: equals: false - value: radius: 4 stroke_width: 1 stroke_color: default: hex: "#cccccc" alpha: 1 type: hex when_state_matches: scope: - 2ebac42b-c8d4-4da7-b39e-e77252483553_short_text_is_valid value: equals: true on_error: state_actions: - type: set key: 2ebac42b-c8d4-4da7-b39e-e77252483553_short_text_is_valid value: false on_edit: state_actions: - type: set key: 2ebac42b-c8d4-4da7-b39e-e77252483553_short_text_is_valid on_valid: state_actions: - type: set key: 2ebac42b-c8d4-4da7-b39e-e77252483553_short_text_is_valid value: true - identifier: e538ee92-2358-4774-96db-4b755e3dfcfb size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: Email address content_description: Email address text_appearance: font_size: 22 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: - bold font_families: - sans-serif accessibility_role: type: heading level: 1 labels: type: labels view_type: text_input view_id: 4cd9d9e5-f057-4002-8950-7c87e92cc84c - identifier: 4cd9d9e5-f057-4002-8950-7c87e92cc84c size: width: 100% height: 50 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: 50 view: border: radius: 4 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 type: text_input text_appearance: alignment: start font_size: 22 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 place_holder_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 identifier: 4cd9d9e5-f057-4002-8950-7c87e92cc84c input_type: email required: false content_description: '' place_holder: Enter email address here view_overrides: icon_end: - value: type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 0.5 type: hex scale: 1 when_state_matches: scope: - 4cd9d9e5-f057-4002-8950-7c87e92cc84c_email_is_valid value: equals: false - value: type: floating icon: type: icon icon: checkmark color: default: hex: "#00ff00" alpha: 0.5 type: hex scale: 1 when_state_matches: scope: - 4cd9d9e5-f057-4002-8950-7c87e92cc84c_email_is_valid value: equals: true border: - value: radius: 4 stroke_width: 1 stroke_color: default: hex: "#ff0000" alpha: 0.5 type: hex when_state_matches: scope: - 4cd9d9e5-f057-4002-8950-7c87e92cc84c_email_is_valid value: equals: false - value: radius: 4 stroke_width: 1 stroke_color: default: hex: "#cccccc" alpha: 1 type: hex when_state_matches: scope: - 4cd9d9e5-f057-4002-8950-7c87e92cc84c_email_is_valid value: equals: true on_error: state_actions: - type: set key: 4cd9d9e5-f057-4002-8950-7c87e92cc84c_email_is_valid value: false on_edit: state_actions: - type: set key: 4cd9d9e5-f057-4002-8950-7c87e92cc84c_email_is_valid on_valid: state_actions: - type: set key: 4cd9d9e5-f057-4002-8950-7c87e92cc84c_email_is_valid value: true - identifier: 443d89b9-d4be-4230-846d-0f93e732da62_linear_layout_item size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 state_actions: - type: set key: 29acc7f2-58de-4638-b374-a4730927658c_next - identifier: 806cc2a1-ea0f-4b22-b733-ea4382e3e439 type: pager_item view: type: container items: - identifier: 1987673b-4e8b-47ae-8f83-f412ca8c0d7e_main_view_container_item size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - identifier: b890fbf5-6e70-4270-b71e-c05a7308ea59_container_item margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 7898f5c2-97c8-454f-a10a-3ebedee6736f margin: top: 48 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: submit_feedback--Submit reporting_metadata: trigger_link_id: 7898f5c2-97c8-454f-a10a-3ebedee6736f label: type: label text: Submit content_description: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 alignment: center styles: - bold font_families: - sans-serif view_overrides: icon_start: - value: type: floating space: 8 icon: type: icon icon: progress_spinner color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 scale: 1 when_state_matches: scope: - "$forms" - current - status - type value: equals: validating text: - value: Submit when_state_matches: scope: - "$forms" - current - status - type value: equals: validating actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#F0282D" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#F0282D" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFC7C9" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#F0282D" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFC7C9" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#F0282D" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFC7C9" alpha: 1 border: radius: 4 stroke_width: 2 stroke_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true content_description: Submit - identifier: 8efe9be2-32f3-449c-9868-9b48b44c9c2a_linear_layout_item size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#BCBDC2" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#BCBDC2" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0.5 - platform: android dark_mode: false color: type: hex hex: "#BCBDC2" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0.5 - platform: web dark_mode: false color: type: hex hex: "#BCBDC2" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0.5 state_actions: - type: set key: 806cc2a1-ea0f-4b22-b733-ea4382e3e439_next ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss localized_content_description: ref: ua_dismiss fallback: Dismiss - margin: top: 4 bottom: 4 end: 0 start: 0 position: horizontal: center vertical: bottom size: height: 7 width: 100% view: type: pager_indicator spacing: 6 bindings: selected: shapes: - type: rectangle border: radius: 16 scale: 1 aspect_ratio: 2 color: default: type: hex hex: "#F0282D" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#F0282D" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFC7C9" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#F0282D" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFC7C9" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#F0282D" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFC7C9" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 0.5 color: default: type: hex hex: "#9966CC" alpha: 0.88 selectors: - platform: ios dark_mode: false color: type: hex hex: "#9966CC" alpha: 0.88 - platform: ios dark_mode: true color: type: hex hex: "#9966CC" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#9966CC" alpha: 0.88 - platform: android dark_mode: true color: type: hex hex: "#9966CC" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#9966CC" alpha: 0.88 - platform: web dark_mode: true color: type: hex hex: "#9966CC" alpha: 1 automated_accessibility_actions: - type: announce - position: horizontal: center vertical: bottom size: width: auto height: auto margin: top: 20 start: 20 bottom: 20 end: 20 view: visibility: invert_when_state_matches: scope: - ASYNC_VALIDATION_TOAST value: equals: true default: false type: container background_color: default: type: hex hex: "#3d4047" alpha: 0.8 border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: "#3d4047" alpha: 0.8 items: - position: horizontal: center vertical: center size: width: auto height: auto margin: top: 10 start: 20 bottom: 10 end: 20 view: type: label text: Error processing form. Please try again content_description: Error processing form. Please try again text_appearance: font_size: 16 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center font_families: - sans-serif validation_mode: type: on_demand state_triggers: - identifier: SHOW_TOAST trigger_when_state_matches: scope: - "$forms" - current - status - type value: equals: error reset_when_state_matches: not: scope: - "$forms" - current - status - type value: equals: error on_trigger: state_actions: - type: set key: ASYNC_VALIDATION_TOAST ttl_seconds: 1.5 value: true ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/image-pager-test.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% ignore_safe_area: true shade_color: default: hex: '#000000' alpha: 0.4 dismiss_on_touch_outside: false view: type: pager_controller identifier: image-pager-controller view: type: container background_color: default: type: hex hex: '#1A1A2E' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager gestures: - type: swipe identifier: swipe-up-dismiss direction: up behavior: behaviors: - dismiss items: # PAGE 1 - Large landscape image - identifier: page-1 type: pager_item view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: image-1 media_type: image media_fit: center_crop url: "https://picsum.photos/id/10/1200/800" - position: horizontal: center vertical: bottom margin: bottom: 80 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 1 — Landscape Photo" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Swipe left/right to change pages. Each page loads a different image." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.8 # PAGE 2 - Portrait-style image - identifier: page-2 type: pager_item view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: image-2 media_type: image media_fit: center_crop url: "https://picsum.photos/id/29/1200/800" - position: horizontal: center vertical: bottom margin: bottom: 80 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 2 — Nature Scene" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "This image loads independently from the others." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.8 # PAGE 3 - Another distinct image - identifier: page-3 type: pager_item view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: image-3 media_type: image media_fit: center_crop url: "https://picsum.photos/id/47/1200/800" - position: horizontal: center vertical: bottom margin: bottom: 80 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 3 — Architecture" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Swiping back should show the cached image without re-fetching." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.8 # PAGE 4 - GIF to test animated image loading - identifier: page-4 type: pager_item view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: image-4 media_type: image media_fit: center_inside url: "https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExcDVwOWprNHRiYWxjdnQ3MjBtamc0YnI2cTNraTRlaGh6aGl4NXQ1aiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/3o7aD2saalBwwftBIY/giphy.gif" - position: horizontal: center vertical: bottom margin: bottom: 80 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 4 — Animated GIF" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Tests animated image loading and frame playback." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.8 # PAGE 5 - Large high-res image - identifier: page-5 type: pager_item view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: image-5 media_type: image media_fit: center_crop url: "https://picsum.photos/id/65/2000/1400" - position: horizontal: center vertical: bottom margin: bottom: 80 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 5 — High Resolution" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "2000x1400 image to test loading larger assets." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.8 # Dismiss button - position: horizontal: end vertical: top size: width: 48 height: 48 margin: top: 8 end: 8 view: type: image_button identifier: dismiss_button button_click: - dismiss localized_content_description: refs: - ua_dismiss fallback: Dismiss image: type: icon icon: close scale: 0.4 color: default: type: hex hex: '#FFFFFF' alpha: 1 # Pager indicator - position: horizontal: center vertical: bottom margin: bottom: 24 size: width: auto height: 8 view: type: pager_indicator spacing: 8 bindings: selected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 0.4 display_type: layout name: Image Pager Test ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/image_cropping.yml ================================================ --- presentation: dismiss_on_touch_outside: true default_placement: position: horizontal: center vertical: center shade_color: default: alpha: 0.5 hex: "#000000" type: hex size: height: 100% width: 100% type: modal version: 1 view: type: pager_controller identifier: "pager-controller-id" view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 margin: top: 36 view: type: pager items: - identifier: "page-1" view: type: container items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - margin: top: 8 size: width: auto height: auto view: type: label text: "Wide Image (100% x auto)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 24 end: 24 size: width: 100% height: auto view: media_fit: center_inside media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/Adelie_penguins_in_the_South_Shetland_Islands.jpg/1024px-Adelie_penguins_in_the_South_Shetland_Islands.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Center (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: image type: media position: horizontal: center vertical: center url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/Adelie_penguins_in_the_South_Shetland_Islands.jpg/1024px-Adelie_penguins_in_the_South_Shetland_Islands.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Top Start (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: image type: media position: horizontal: start vertical: top url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/Adelie_penguins_in_the_South_Shetland_Islands.jpg/1024px-Adelie_penguins_in_the_South_Shetland_Islands.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Bottom End (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: image type: media position: horizontal: end vertical: bottom url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/Adelie_penguins_in_the_South_Shetland_Islands.jpg/1024px-Adelie_penguins_in_the_South_Shetland_Islands.jpg - identifier: "page-2" view: type: container items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - margin: top: 8 size: width: auto height: auto view: type: label text: "Tall Image (100% x auto)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 48 end: 48 size: width: 100% height: auto view: media_fit: center_inside media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Center (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: image type: media position: horizontal: center vertical: center url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Top Start (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: image type: media position: horizontal: start vertical: top url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Bottom End (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: image type: media position: horizontal: end vertical: bottom url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - size: height: 16 width: auto position: vertical: top horizontal: center margin: top: 12 view: type: pager_indicator carousel_identifier: CAROUSEL_ID border: radius: 8 spacing: 4 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 scale: 0.75 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 0.75 border: stroke_width: 1 stroke_color: default: hex: "#333333" alpha: 1 color: default: hex: "#ffffff" alpha: 1 - position: vertical: top horizontal: end size: width: 36 height: 36 margin: top: 0 end: 0 view: type: image_button identifier: x_button button_click: [ dismiss ] image: type: icon icon: close scale: 0.5 color: default: hex: "#000000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/image_resizing.yml ================================================ --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: max_width: 100% max_height: 100% width: 100% min_width: 100% height: 100% min_height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0.2 view: type: pager_controller identifier: 80df39a5-774d-4bb5-9e35-c7a465189583 view: type: linear_layout direction: vertical items: - size: height: 100% width: 100% view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: false items: - identifier: d07e16f6-f4c4-4acd-ad54-86996e6cf29a type: pager_item view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - size: height: auto width: 100% view: type: media media_fit: fit_crop position: horizontal: center vertical: center url: https://hangar-dl.urbanairship.com/binary/public/Hx7SIqHqQDmFj6aruaAFcQ/34be6e8d-31d0-499b-886e-2b29459cb472 media_type: image background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - identifier: d07e16f6-f4c4-4acd-ad54-86996e6cf29b type: pager_item view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - size: height: auto width: 100% view: type: media media_fit: center_inside url: https://hangar-dl.urbanairship.com/binary/public/Hx7SIqHqQDmFj6aruaAFcQ/fbfe7dea-db33-4d6f-a1b4-84ae82908ec8 media_type: image background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - identifier: d07e16f6-f4c4-4acd-ad54-86996e6cf29c type: pager_item view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - size: height: auto width: 100% view: type: media media_fit: center_inside url: https://hangar-dl.urbanairship.com/binary/public/Hx7SIqHqQDmFj6aruaAFcQ/b9427b7f-d572-400d-b7e2-b8128babddd0 media_type: image background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#222222" alpha: 1 identifier: dismiss_button button_click: - dismiss - margin: top: 0 bottom: 8 end: 0 start: 0 position: horizontal: center vertical: bottom size: height: 12 width: 100% view: type: pager_indicator spacing: 4 bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 color: default: type: hex hex: "#AAAAAA" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: type: hex hex: "#CCCCCC" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/image_scaling.yml ================================================ --- presentation: default_placement: position: horizontal: center vertical: center shade_color: default: alpha: 0.5 hex: "#000000" type: hex size: height: 100% width: 100% type: modal version: 1 view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical background_color: default: hex: "#ffffff" alpha: 1 items: - size: height: auto width: 100% view: type: container items: - size: height: 24 width: 24 margin: end: 16 top: 16 position: horizontal: end vertical: center view: type: image_button identifier: close_button button_click: [ cancel ] image: type: icon icon: close scale: 0.75 color: default: hex: "#000000" alpha: 1 # # FIXED SIZES # - margin: top: 8 size: width: auto height: auto view: type: label text: "Fixed (250 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 size: width: auto height: auto view: type: label text: "center_crop" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 250 height: 150 view: border: stroke_width: 1 stroke_color: default: hex: "#000000" alpha: 1 media_fit: center_crop media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Great_Wave_off_Kanagawa2.jpg/2560px-Great_Wave_off_Kanagawa2.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "center_inside" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 250 height: 150 view: media_fit: center_inside border: stroke_width: 1 stroke_color: default: hex: "#000000" alpha: 1 media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Great_Wave_off_Kanagawa2.jpg/2560px-Great_Wave_off_Kanagawa2.jpg # # FIXED WIDTH x AUTO HEIGHT # - margin: top: 8 size: width: auto height: auto view: type: label text: "Auto Height (150 x auto)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 size: width: auto height: auto view: type: label text: "center_crop" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: auto view: border: stroke_width: 1 stroke_color: default: hex: "#000000" alpha: 1 media_fit: center_crop media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Great_Wave_off_Kanagawa2.jpg/2560px-Great_Wave_off_Kanagawa2.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "center_inside" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: auto view: media_fit: center_inside border: stroke_width: 1 stroke_color: default: hex: "#000000" alpha: 1 media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Great_Wave_off_Kanagawa2.jpg/2560px-Great_Wave_off_Kanagawa2.jpg # # AUTO WIDTH x FIXED HEIGHT # - margin: top: 8 size: width: auto height: auto view: type: label text: "Auto Width (auto x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 size: width: auto height: auto view: type: label text: "center_crop" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: auto height: 150 view: border: stroke_width: 1 stroke_color: default: hex: "#000000" alpha: 1 media_fit: center_crop media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Great_Wave_off_Kanagawa2.jpg/2560px-Great_Wave_off_Kanagawa2.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "center_inside" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: auto height: 150 view: media_fit: center_inside border: stroke_width: 1 stroke_color: default: hex: "#000000" alpha: 1 media_type: image type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0d/Great_Wave_off_Kanagawa2.jpg/2560px-Great_Wave_off_Kanagawa2.jpg ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/is-accessibility-alert-form.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: ignore_shade: true view: type: state_controller view: type: pager_controller identifier: 6b72219d-15ef-4bcf-832b-70850b419687 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: 4a560c36-28fa-452d-a2e0-f07a9e3d7521 nps_identifier: b395410d-af24-4d9a-801e-c99e5833abf3 type: nps_form_controller validation_mode: type: on_demand submit: submit_event form_enabled: - form_submission state_triggers: - identifier: toast_trigger trigger_when_state_matches: scope: - $forms - current - status - type value: equals: "invalid" reset_when_state_matches: not: scope: - $forms - current - status - type value: equals: "invalid" on_trigger: state_actions: - type: set key: show_toast ttl_seconds: 3.0 value: true response_type: nps view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: d2432b09-48d6-40a9-8753-72e372319121 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 8ce7c2d5-23f6-4eda-b840-c76ba483fb68 size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: Single Choice content_description: Single Choice text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 size: width: 100% height: auto margin: top: 8 bottom: 0 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "Favorite Animal" content_description: "Favorite Animal" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: radio_input_controller identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 required: true on_error: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "error" on_valid: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "valid" on_edit: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "editing" view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 content_description: Cat style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Cat content_description: Cat text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d content_description: Dog style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Dog content_description: Dog text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 8 view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 20 height: 20 view: type: radio_input reporting_value: 8df9abde-f0fc-4b37-afe0-70ee8aff0c00 content_description: Dragon style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: end: 16 size: height: auto width: 100% view: type: label text: Dragon content_description: Dragon text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif randomize_children: true attribute_name: {} - identifier: "some-required-label" size: width: auto height: auto margin: top: 0 bottom: 8 start: 16 end: 16 position: vertical: center horizontal: start view: type: label refs: - ua_required_field text: "* Required" text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif view_overrides: icon_end: - value: space: 8 type: floating icon: type: icon icon: exclamationmark_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: 1 when_state_matches: key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: equals: "error" text_appearance: - when_state_matches: scope: - edf9064c-54d5-4b29-9ac9-ad0997a62696 value: equals: "error" value: font_size: 16 color: default: type: hex hex: "#ff0000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: f1047936-d73a-403f-9ac8-68bf6bacb802 margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: submit_feedback--Submit reporting_metadata: trigger_link_id: f1047936-d73a-403f-9ac8-68bf6bacb802 label: view_overrides: text: - value: "Processing ..." when_state_matches: scope: - $forms - current - status - type value: equals: "validating" icon_start: - value: type: "floating" space: 8 icon: type: icon icon: progress_spinner color: default: hex: "#00ff00" alpha: 1.0 scale: 1 type: label text: Submit content_description: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#63AFF1" alpha: 1 border: radius: 0 stroke_width: 16 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] visibility: default: true invert_when_state_matches: key: submitted value: equals: true - ignore_safe_area: true size: width: 100% height: 100% position: horizontal: center vertical: center view: type: container items: - margin: start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] ignore_safe_area: true - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: layout_container size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: 9c9b4565-c8e3-41bf-a211-90af3665b38b size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: Nice content_description: Nice text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif ignore_safe_area: false background_color: default: type: hex hex: "#FFFFFF" alpha: 1 visibility: default: false invert_when_state_matches: key: submitted value: equals: true background_color: default: type: hex hex: "#FFFFFF" alpha: 1 visibility: default: true invert_when_state_matches: key: confirmation_screen_container value: equals: true ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 identifier: dismiss_button button_click: - dismiss - position: horizontal: center vertical: bottom size: width: auto height: auto margin: top: 20 start: 20 bottom: 20 end: 20 view: visibility: invert_when_state_matches: scope: - show_toast value: equals: true default: false type: container background_color: default: type: hex hex: "#3d4047" alpha: 0.8 border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: "#3d4047" alpha: 0.8 items: - position: horizontal: center vertical: center size: width: auto height: auto margin: top: 10 start: 20 bottom: 10 end: 20 view: type: label is_accessibility_alert: true refs: - ua_invalid_form_message text: Please fix the invalid fields to continue text_appearance: font_size: 16 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center font_families: - sans-serif ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/linear_layout_scroll.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 80% height: auto max_height: 100% position: horizontal: center vertical: center shade_color: default: hex: "#444444" alpha: .3 view: type: container background_color: hex: "#FFFFFF" border: stroke_color: default: hex: "#00FF00" stroke_width: 3 background_color: default: hex: "#000000" items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: auto width: 100% view: type: linear_layout direction: vertical items: # SCROLL LAYOUT - position: horizontal: center vertical: center size: width: 100% height: auto view: type: scroll_layout direction: vertical size: width: 100% height: auto view: # SCROLL CONTENT (CONTAINER) type: container background_color: default: hex: "#ff0000" items: - position: horizontal: center vertical: center margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: <h2>World Wide Web</h2> <p>The <b>World Wide Web (WWW)</b>, commonly known as the <b>Web</b>, is an information system where documents and other web resources are identified by Uniform Resource Locators (URLs), which may be interlinked by hyperlinks, and are accessible over the Internet. The resources of the Web are transferred via the Hypertext Transfer Protocol (HTTP), may be accessed by users by a software application called a <i>web browser</i>, and are published by a software application called a <i>web server</i>. The World Wide Web is not synonymous with the Internet, which pre-dated the Web in some form by over two decades and upon the technologies of which the Web is built.</p> <p>English scientist Tim Berners-Lee invented the World Wide Web in 1989. He wrote the first web browser in 1990 while employed at CERN near Geneva, Switzerland. The browser was released outside CERN to other research institutions starting in January 1991, and then to the general public in August 1991. The Web began to enter everyday use in 1993–4, when websites for general use started to become available. The World Wide Web has been central to the development of the Information Age, and is the primary tool billions of people use to interact on the Internet.</p> text_appearance: color: default: hex: "#00FF00" alignment: start font_size: 16 font_families: - geo - casual # BOTTOM-PINNED BUTTON - position: horizontal: center vertical: center margin: top: 4 bottom: 4 start: 4 end: 4 size: width: 100% height: auto view: type: label_button identifier: BUTTON behavior: dismiss background_color: default: hex: "#00FF00" label: type: label text_appearance: font_size: 24 alignment: center color: default: hex: "#000000" styles: - bold font_families: - fake_font_that_doesnt_exist - geo - casual text: 'Dial Modem' ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/markdown.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 90% height: 90% max_height: 90% shade_color: default: hex: '#000000' alpha: 0.75 dismiss_on_touch_outside: true view: type: container background_color: default: hex: "#FFFFFF" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical size: width: 100% height: 100% view: type: linear_layout direction: vertical items: # # Title # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "==\u00A0Some really long\u00A0\n\u00A0TITLE!\u00A0==" text_appearance: alignment: start styles: - bold font_size: 24 line_height_multiplier: 1.4 color: default: hex: "#000000" alpha: 1 markdown: appearance: highlight: corner_radius: 12 color: default: hex: "#FFFF00" alpha: 1 # # Superscripts & Subscripts # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Superscripts & Subscripts text_appearance: alignment: start styles: - bold font_size: 16 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "H,{2},O — E = mc^^2^^" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "==H,{2},O is highlighted!== and ==E = mc^^2^^ too==" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "mc^^==2==^^ — only the superscript is highlighted" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "~~H,{2},O~~ and ~~mc^^2^^~~ — strikethrough composes" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "^^open only, ,{open only — treated as plain text" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 # # Styles # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Styles text_appearance: alignment: start styles: - bold font_size: 16 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Normal, **Bold**, *Italic*, ==Highlighted==, ==***Highlighted + Bold + Italic***== text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Normal, __Bold__, _Italic_, ___Bold + Italic___ text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "__Label with line breaks:__==\nNormal\n__Bold__\n_Italic_\n___Bold + Italic___==" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "==Markdown formatting will *preserve* **symbols**, **punctuation**, & **newlines** in the original text:\n\n!@#$%^&*()_+-=[]{}|;':,.<>==" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: ~~Strikethrough~~ text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: ==Disabled Normal, **Bold**, *Italic*, ***Bold + Italic*** [link](https://www.airship.com) https://www.airship.com== text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 markdown: disabled: true - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label_button identifier: style_button background_color: default: hex: "#6699ff" alpha: 1 border: radius: 5 stroke_width: 1 stroke_color: default: hex: "#000000" alpha: 1 label: type: label text: Styling works in *buttons*, too. **No way!** text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 # # HTML Links # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: HTML Links text_appearance: alignment: start styles: - bold font_size: 16 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "Click [here](https://www.airship.com) to visit Airship's website, or [here](http://www.google.com) to visit Google." text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 markdown: appearance: anchor: color: default: hex: "#00ff00" alpha: 1 styles: - underlined - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Email links work too! [test@example.com](mailto:test@example.com) text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Also phone numbers! [(503) 867-5309](tel:+1503-867-5309) text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Even stylish links! ~~***[(503) 867-5309](tel:+1503-867-5309)***~~ (but why?) text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label_button identifier: link_button border: stroke_width: 1 radius: 5 stroke_color: default: hex: "#000000" alpha: 1 background_color: default: hex: "#ff99ff" alpha: 1 label: type: label text: What about **[buttons](https://www.airship.com)**? ***Weird***, **but works!** text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 # # Text Links # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Text Links text_appearance: alignment: start styles: - bold font_size: 16 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "*Some* bare links work, but this behavior will differ between platforms: https://www.airship.com, www.airship.com, www.airship.com/product, airship.com, airship.com/product" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "Bare emails work, but not bare phone numbers: test@example.com, +1 503-867-5309" text_appearance: alignment: start font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 24 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "All other markdown syntax will be ***ignored!!!***" text_appearance: alignment: start font_size: 14 styles: - underlined - bold color: default: hex: "#cc0000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/martin.json ================================================ { "version": 1, "presentation": { "type": "modal", "placement_selectors": [], "android": { "disable_back_button": false }, "dismiss_on_touch_outside": false, "default_placement": { "ignore_safe_area": false, "size": { "width": "100%", "height": "100%" }, "position": { "horizontal": "center", "vertical": "top" }, "shade_color": { "default": { "type": "hex", "hex": "#000000", "alpha": 0.2 } }, "web": {} } }, "view": { "type": "state_controller", "view": { "type": "pager_controller", "identifier": "5d853b1f-0fc8-417d-b70e-1b73985e6c79", "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "size": { "width": "100%", "height": "100%" }, "view": { "type": "container", "items": [ { "identifier": "ea24d15d-1bd1-4f1c-8026-6f9fba69351e_pager_container_item", "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "pager", "disable_swipe": true, "gestures": [ { "identifier": "5d853b1f-0fc8-417d-b70e-1b73985e6c79_tap_start", "type": "tap", "location": "start", "behavior": { "behaviors": ["pager_previous"] } }, { "identifier": "5d853b1f-0fc8-417d-b70e-1b73985e6c79_tap_end", "type": "tap", "location": "end", "behavior": { "behaviors": ["pager_next_or_dismiss"] } }, { "identifier": "5d853b1f-0fc8-417d-b70e-1b73985e6c79_swipe_up", "type": "swipe", "direction": "up", "behavior": { "behaviors": ["dismiss"] } }, { "identifier": "5d853b1f-0fc8-417d-b70e-1b73985e6c79_swipe_down", "type": "swipe", "direction": "down", "behavior": { "behaviors": ["dismiss"] } }, { "identifier": "5d853b1f-0fc8-417d-b70e-1b73985e6c79_hold", "type": "hold", "press_behavior": { "behaviors": ["pager_pause"] }, "release_behavior": { "behaviors": ["pager_resume"] } } ], "items": [ { "identifier": "aff3e35f-6800-4e98-a720-194aac6488da", "type": "pager_item", "view": { "type": "container", "items": [ { "identifier": "32309ee6-a12a-44e0-8bda-5b58a3e79c28_main_view_container_item", "size": { "width": "100%", "height": "100%" }, "position": { "horizontal": "center", "vertical": "center" }, "ignore_safe_area": false, "view": { "type": "container", "items": [ { "identifier": "21ca1452-90b0-4f33-bea3-547ddbf9ffa8_container_item", "margin": { "bottom": 0, "top": 0, "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "identifier": "layout_container", "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "identifier": "eda7e75c-e87b-4e2b-9edf-9b27b8557b87", "size": { "width": "100%", "height": "auto" }, "margin": { "top": 48, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "label", "text": "screen 1", "content_description": "screen 1", "text_appearance": { "font_size": 24, "color": { "default": { "type": "hex", "hex": "#747474", "alpha": 1 }, "selectors": [] }, "alignment": "start", "styles": ["bold"], "font_families": [ "sans-serif" ] }, "accessibility_hidden": false } } ] } } ] } } ] } } ], "background_color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [] } }, "automated_actions": [ { "identifier": "[pager_next]_aff3e35f-6800-4e98-a720-194aac6488da", "delay": 7, "behaviors": ["pager_next"] } ], "state_actions": [ { "type": "set", "key": "aff3e35f-6800-4e98-a720-194aac6488da_next" } ] }, { "identifier": "9252241c-034f-42fe-b4e7-474aa6ca1d3d", "type": "pager_item", "view": { "type": "container", "items": [ { "identifier": "d7a1f1b8-c0ed-454a-a193-5297b233d1d6_main_view_container_item", "size": { "width": "100%", "height": "100%" }, "position": { "horizontal": "center", "vertical": "center" }, "ignore_safe_area": false, "view": { "type": "container", "items": [ { "identifier": "d3702bb5-0f90-448d-a82c-ef3912971630_container_item", "margin": { "bottom": 0, "top": 0, "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "identifier": "layout_container", "size": { "width": "100%", "height": "100%" }, "view": { "type": "linear_layout", "direction": "vertical", "items": [ { "identifier": "8fb75c92-08b7-461e-9512-15d67915b077", "size": { "width": "100%", "height": "auto" }, "margin": { "top": 48, "bottom": 8, "start": 16, "end": 16 }, "view": { "type": "label", "text": "screen 2", "content_description": "screen 2", "text_appearance": { "font_size": 24, "color": { "default": { "type": "hex", "hex": "#747474", "alpha": 1 }, "selectors": [] }, "alignment": "start", "styles": ["bold"], "font_families": [ "sans-serif" ] }, "accessibility_hidden": false } } ] } } ] } } ] } } ], "background_color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [] } }, "automated_actions": [ { "identifier": "pager_next_or_dismiss_9252241c-034f-42fe-b4e7-474aa6ca1d3d", "delay": 7, "behaviors": ["pager_next_or_dismiss"] } ], "state_actions": [ { "type": "set", "key": "9252241c-034f-42fe-b4e7-474aa6ca1d3d_next" } ] } ] }, "ignore_safe_area": false }, { "position": { "horizontal": "end", "vertical": "top" }, "margin": { "top": 8 }, "size": { "width": 48, "height": 48 }, "view": { "type": "image_button", "image": { "scale": 0.4, "type": "icon", "icon": "close", "color": { "default": { "type": "hex", "hex": "#747474", "alpha": 1 }, "selectors": [] } }, "identifier": "dismiss_button", "button_click": ["dismiss"], "localized_content_description": { "ref": "ua_dismiss", "fallback": "Dismiss" }, "reporting_metadata": { "button_id": "dismiss_button", "button_action": "dismiss" } } }, { "margin": { "top": 8, "bottom": 0, "end": 16, "start": 16 }, "position": { "horizontal": "center", "vertical": "top" }, "size": { "height": 2, "width": "100%" }, "view": { "type": "story_indicator", "source": { "type": "pager" }, "style": { "type": "linear_progress", "direction": "horizontal", "sizing": "equal", "spacing": 4, "progress_color": { "default": { "type": "hex", "hex": "#363636", "alpha": 1 }, "selectors": [] }, "track_color": { "default": { "type": "hex", "hex": "#747474", "alpha": 1 }, "selectors": [] } }, "automated_accessibility_actions": [ { "type": "announce" } ] } }, { "ignore_safe_area": false, "margin": { "end": 0, "start": 0, "top": 0, "bottom": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": 48, "width": 48 }, "view": { "type": "stack_image_button", "identifier": "play_pause_button", "button_click": ["pager_toggle_pause"], "items": [ { "type": "shape", "shape": { "type": "ellipse", "aspect_ratio": 1, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [] }, "scale": 0.7 } }, { "type": "icon", "icon": { "type": "icon", "color": { "default": { "type": "hex", "hex": "#747474", "alpha": 1 }, "selectors": [] }, "icon": "pause", "scale": 0.35 } } ], "localized_content_description": { "refs": ["ua_pause"], "fallback": "Pause" }, "view_overrides": { "localized_content_description": [ { "value": { "refs": ["ua_play"], "fallback": "Play" }, "when_state_matches": { "scope": ["$pagers", "current", "paused"], "value": { "equals": true } } } ], "items": [ { "value": [ { "type": "shape", "shape": { "type": "ellipse", "aspect_ratio": 1, "color": { "default": { "type": "hex", "hex": "#FFFFFF", "alpha": 1 }, "selectors": [] }, "scale": 0.7 } }, { "type": "icon", "icon": { "type": "icon", "color": { "default": { "type": "hex", "hex": "#747474", "alpha": 1 }, "selectors": [] }, "icon": "play", "scale": 0.35 } } ], "when_state_matches": { "scope": ["$pagers", "current", "paused"], "value": { "equals": false } } } ] } } } ] } } ] } } } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/mobile-4599.yml ================================================ version: 1 presentation: type: modal placement_selectors: - placement: ignore_safe_area: true size: width: 70% height: 60% position: horizontal: center vertical: center shade_color: default: type: hex hex: '#868686' alpha: 1 border: radius: 15 window_size: large orientation: portrait - placement: ignore_safe_area: true size: width: 80% height: 80% position: horizontal: center vertical: center shade_color: default: type: hex hex: '#868686' alpha: 1 border: radius: 15 window_size: large orientation: landscape android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: true size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: '#000000' alpha: 0.2 view: type: pager_controller identifier: dfbeb4be-acce-411e-b211-4fabefb4b1b8 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true gestures: - identifier: dfbeb4be-acce-411e-b211-4fabefb4b1b8_tap_start type: tap location: start behavior: behaviors: - pager_previous - identifier: dfbeb4be-acce-411e-b211-4fabefb4b1b8_tap_end type: tap location: end behavior: behaviors: - pager_next_or_first - identifier: dfbeb4be-acce-411e-b211-4fabefb4b1b8_swipe_up type: swipe direction: up behavior: behaviors: - dismiss - identifier: dfbeb4be-acce-411e-b211-4fabefb4b1b8_swipe_down type: swipe direction: down behavior: behaviors: - dismiss - identifier: dfbeb4be-acce-411e-b211-4fabefb4b1b8_hold type: hold press_behavior: behaviors: - pager_pause release_behavior: behaviors: - pager_resume items: # - identifier: 58bb45b8-de18-49a2-ba2c-2dab129233ac # type: pager_item # view: # type: container # items: # - margin: # start: 0 # end: 0 # position: # horizontal: center # vertical: center # size: # width: 100% # height: 100% # view: # type: media # media_fit: center_crop # url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/pexels-oliver-sjo%CC%88stro%CC%88m-1650732.jpg # media_type: image # ignore_safe_area: true # - size: # width: 100% # height: 100% # position: # horizontal: center # vertical: center # ignore_safe_area: true # view: # type: container # items: # - margin: # bottom: 0 # top: 0 # end: 0 # start: 0 # position: # horizontal: center # vertical: center # size: # width: 100% # height: 100% # view: # type: linear_layout # direction: vertical # items: # - identifier: layout_container # size: # width: 100% # height: 100% # view: # type: linear_layout # direction: vertical # items: # - identifier: c865e1c9-b03f-410a-be67-3679adf576b9 # size: # width: 100% # height: auto # view: # type: container # items: # - margin: # top: 10 # bottom: 0 # start: 0 # end: 0 # position: # horizontal: center # vertical: center # size: # width: 100% # height: auto # view: # type: linear_layout # direction: horizontal # items: # - size: # width: 20% # height: auto # view: # type: media # media_fit: center_inside # url: https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/746a2309-146e-4ddd-87a8-e30650fb1f69 # media_type: image # identifier: 7332c72c-3235-45b7-a868-14e3686db6ed # margin: # top: 0 # bottom: 0 # start: 0 # end: 0 # - identifier: bef99142-1267-4caa-8851-6e1331b5b23b # size: # width: 100% # height: auto # margin: # top: 5 # bottom: 8 # start: 0 # end: 16 # view: # type: label # text: Surf Magazine # text_appearance: # font_size: 15 # color: # default: # type: hex # hex: '#FFFFFF' # alpha: 1 # alignment: start # styles: # - bold # font_families: # - sans-serif # margin: # top: 10 # bottom: 0 # start: 0 # end: 0 # - identifier: f97c7145-2cce-4068-a7aa-2104c7a63d8d # size: # width: 100% # height: auto # view: # type: container # items: # - margin: # top: 8 # bottom: 8 # start: 0 # end: 0 # position: # horizontal: center # vertical: center # size: # width: 100% # height: auto # view: # type: linear_layout # direction: horizontal # items: # - identifier: 916018ab-4843-4e86-aabe-d572740b6219 # margin: # top: 8 # bottom: 8 # start: 10 # end: 50 # size: # width: 50% # height: auto # view: # type: label_button # identifier: dismiss--Watch video # reporting_metadata: # trigger_link_id: 916018ab-4843-4e86-aabe-d572740b6219 # label: # type: label # text: Watch video # text_appearance: # font_size: 16 # color: # default: # type: hex # hex: '#000000' # alpha: 1 # alignment: center # styles: # - bold # font_families: # - sans-serif # actions: {} # enabled: [] # button_click: # - dismiss # background_color: # default: # type: hex # hex: '#FFFFFF' # alpha: 1 # border: # radius: 15 # stroke_width: 0 # stroke_color: # default: # type: hex # hex: '#FFFFFF' # alpha: 1 # - size: # width: 18% # height: auto # view: # type: media # media_fit: center_inside # url: https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/e0eb750c-5d97-4e3b-93be-26f5c6ebabe0 # media_type: image # identifier: 4d6bb769-40b3-429f-aef8-bfdb5146db38 # margin: # top: 0 # bottom: 0 # start: 0 # end: 60 # - size: # width: 10% # height: auto # view: # type: media # media_fit: center_inside # url: https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/552fef48-266f-49fe-982f-beda4107557f # media_type: image # identifier: 98c23a30-5f52-47eb-9838-16a11626eb23 # margin: # top: 0 # bottom: 0 # start: 0 # end: 20 # margin: # top: 8 # bottom: 8 # start: 0 # end: 0 # background_color: # default: # type: hex # hex: '#BCBDC2' # alpha: 1 # selectors: # - platform: ios # dark_mode: true # color: # type: hex # hex: '#FFFFFF' # alpha: 0.5 # - platform: android # dark_mode: true # color: # type: hex # hex: '#FFFFFF' # alpha: 0.5 # - platform: web # dark_mode: true # color: # type: hex # hex: '#FFFFFF' # alpha: 0.5 # automated_actions: # - identifier: '[pager_next]_58bb45b8-de18-49a2-ba2c-2dab129233ac' # delay: 7 # behaviors: # - pager_next - identifier: e2fd43ff-4f4c-4f69-86dc-3762605752ba type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: true view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: layout_container size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: d3c83a1a-3c17-4491-a646-9a6e2e0b360f size: width: 100% height: auto view: type: container items: - margin: top: 20 position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 100% height: auto view: type: media media_fit: center_inside url: https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/746a2309-146e-4ddd-87a8-e30650fb1f69 media_type: image content_description: clean SURF logo identifier: 91546819-ec1d-474d-bd7e-61b8dc14926f - identifier: d8cf9f55-927c-4b4f-a225-7381a00f610e size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: Surf Magazine text_appearance: font_size: 24 color: default: type: hex hex: '#FFFFFF' alpha: 1 alignment: start styles: - bold font_families: - sans-serif margin: top: 20 - identifier: ac5948bf-e00f-42a9-927c-7d3d44f46ed7 size: width: 100% height: auto view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - identifier: 68913127-af13-472c-85c6-aea8ff421c35 margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: dismiss--Watch full video reporting_metadata: trigger_link_id: 68913127-af13-472c-85c6-aea8ff421c35 label: type: label text: Watch full video text_appearance: font_size: 16 color: default: type: hex hex: '#FFFFFF' alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: '#000000' alpha: 1 - platform: android dark_mode: true color: type: hex hex: '#000000' alpha: 1 - platform: web dark_mode: true color: type: hex hex: '#000000' alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: [] button_click: - dismiss background_color: default: type: hex hex: '#63AFF1' alpha: 1 border: radius: 3 stroke_width: 0 stroke_color: default: type: hex hex: '#63AFF1' alpha: 1 - size: width: 100% height: auto view: type: media media_fit: center_inside url: https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/e0eb750c-5d97-4e3b-93be-26f5c6ebabe0 media_type: image content_description: share icon identifier: 3bdd060e-6f41-4899-9c8f-8dc0a4d97e3f - size: width: 100% height: auto view: type: media media_fit: center_inside url: https://hangar-dl.urbanairship.com/binary/public/ISex_TTJRuarzs9-o_Gkhg/552fef48-266f-49fe-982f-beda4107557f media_type: image content_description: bookmark icon identifier: e5cc14e3-9c9c-4814-8b22-f3e93dd048a8 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/mobile-5409-2.json ================================================ { "in_app_message": { "edit_grace_period": 14, "end": "2025-12-20T06:59:48", "features": { "email_input": "1.0" }, "forms": [ { "id": "ab4f5d65-2c67-42df-9ba3-3f3f53f251ba", "questions": [ { "id": "0c4eca52-ac0a-433a-8c9e-7b483e3927c3", "label": "Email collection sweepstakes", "type": "email" } ], "response_type": "user_feedback", "type": "form" } ], "interval": 60, "labels": [ { "id": "bae41801-1be6-4251-ad93-bb911198c346", "label": "Sweepstakes Intro" }, { "id": "df4b0790-09f8-40a2-bdbb-5c1307811a74", "label": "Email" }, { "id": "5a4fa960-ab13-413d-b391-a397d51bea50", "label": "You're Entered" } ], "limit": 1, "message": { "audience": { "miss_behavior": "skip" }, "display": { "layout": { "presentation": { "android": { "disable_back_button": false }, "default_placement": { "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "top" }, "shade_color": { "default": { "alpha": 0.2, "hex": "#000000", "type": "hex" } }, "size": { "height": "100%", "width": "100%" }, "web": { "ignore_shade": true } }, "dismiss_on_touch_outside": false, "placement_selectors": [], "type": "modal" }, "version": 1, "view": { "type": "state_controller", "view": { "identifier": "5d2e18e6-d883-4176-b52d-0339d8351341", "type": "pager_controller", "view": { "form_enabled": [ "form_submission" ], "identifier": "ab4f5d65-2c67-42df-9ba3-3f3f53f251ba", "response_type": "user_feedback", "state_triggers": [ { "identifier": "SHOW_TOAST", "on_trigger": { "state_actions": [ { "key": "ASYNC_VALIDATION_TOAST", "ttl_seconds": 1.5, "type": "set", "value": true } ] }, "reset_when_state_matches": { "not": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "error" } } }, "trigger_when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "error" } } } ], "submit": "submit_event", "type": "form_controller", "validation_mode": { "type": "on_demand" }, "view": { "items": [ { "identifier": "b43c6c82-7783-4abf-b35b-76c9d6fab199_pager_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "disable_swipe": true, "items": [ { "identifier": "bae41801-1be6-4251-ad93-bb911198c346", "state_actions": [ { "key": "bae41801-1be6-4251-ad93-bb911198c346_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "698eef43-6f58-44ed-aa53-bb8250ab6e8a_background_image_container", "ignore_safe_area": true, "margin": { "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "media_fit": "fit_crop", "media_type": "image", "position": { "horizontal": "center", "vertical": "center" }, "type": "media", "url": "https://c00340-dl.urbanairship.com/binary/public/WjPzA418RrGPEdjgcfowqA/9ae597ce-1695-4b62-85b5-b43b48062574" } }, { "identifier": "33bdb020-a3a8-48a9-8540-7d180c60920f_main_view_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "d9a91384-268e-40db-997f-733a6071c9fd_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "items": [ { "identifier": "scroll_container", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "type": "scroll_layout", "view": { "direction": "vertical", "items": [ { "identifier": "df6c5a0e-f3b2-4574-ba73-f5bcfd358980", "margin": { "bottom": 0, "end": 72, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "media_fit": "center_inside", "media_type": "image", "type": "media", "url": "https://c00340-dl.urbanairship.com/binary/public/WjPzA418RrGPEdjgcfowqA/a69428fb-2e6a-4d37-8761-805e975dc10d" } }, { "identifier": "2be8b119-1ffe-414a-b422-57781ffef579", "margin": { "bottom": 0, "end": 16, "start": 16, "top": 16 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "accessibility_role": { "level": 1, "type": "heading" }, "content_description": "Win a Ski Trip for 4 to Breck with Toyota!", "text": "Win a Ski Trip for 4 to Breck with Toyota!", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 20, "styles": [ "bold" ] }, "type": "label" } }, { "identifier": "ee31ab77-b878-4e8e-9b57-77e04daacb9a", "margin": { "bottom": 0, "end": 24, "start": 24, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Sign up for email updates and mobile alerts and to enter for a chance to win the **Epic Winter Adventure Sweepstakes**. The Winner will receive a ski trip for 4 to The Village at Breckenridge 3/5/26-3/8/26, plus Helly Hansen and Oakley gear.", "text": "Sign up for email updates and mobile alerts and to enter for a chance to win the **Epic Winter Adventure Sweepstakes**. The Winner will receive a ski trip for 4 to The Village at Breckenridge 3/5/26-3/8/26, plus Helly Hansen and Oakley gear.", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [] }, "type": "label" } }, { "identifier": "37236178-5434-4c52-bb63-ecfbd753a11a_linear_layout_item", "size": { "height": "auto", "width": "100%" }, "view": { "direction": "horizontal", "items": [], "type": "linear_layout" } } ], "type": "linear_layout" } } }, { "identifier": "43b08423-f2c3-40c7-9aa6-d2fb1076ed28", "margin": { "bottom": 16, "end": 0, "start": 0, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "43b08423-f2c3-40c7-9aa6-d2fb1076ed28_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "border": { "radius": 0 }, "direction": "vertical", "items": [ { "identifier": "66d48213-e944-4544-98df-7045d0a1fcdf", "margin": { "bottom": 14, "end": 0, "start": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "66d48213-e944-4544-98df-7045d0a1fcdf_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "border": { "radius": 0 }, "direction": "vertical", "items": [ { "identifier": "5830a305-5557-49cc-b097-e4f630183153", "margin": { "bottom": 0, "end": 16, "start": 16, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Stay in the know and crush the season!", "text": "Stay in the know and crush the season!", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 15, "styles": [] }, "type": "label" } } ], "type": "linear_layout" } } ], "type": "container" } }, { "identifier": "1821d34b-8ca6-4960-92ee-8e1a1c37c6b1", "margin": { "bottom": 0, "end": 16, "start": 16, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Please verify your age to enter.", "text": "Please verify your age to enter.", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [] }, "type": "label" } }, { "identifier": "317d78f5-8c3a-4a30-a133-61773355c553", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "actions": { "add_tags_action": [ "Sweepstakes Eligible 2025" ] }, "background_color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 8, "stroke_color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 8 }, "button_click": [ "pager_next" ], "content_description": "I AM OVER 18 — I AM OVER 18", "enabled": [ "pager_next" ], "identifier": "next--Over 18", "label": { "content_description": "I AM OVER 18 — I AM OVER 18", "text": "I AM OVER 18", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Next", "ref": "ua_next" }, "reporting_metadata": { "button_action": "next", "button_id": "317d78f5-8c3a-4a30-a133-61773355c553", "trigger_link_id": "317d78f5-8c3a-4a30-a133-61773355c553" }, "type": "label_button" } }, { "identifier": "8de2aa00-81c1-42f7-ae3c-808c6b61335c", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "actions": { "add_tags_action": [ "Sweepstakes Not Eligible 2025" ] }, "background_color": { "default": { "alpha": 1, "hex": "#999999", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 8, "stroke_color": { "default": { "alpha": 1, "hex": "#999999", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 8 }, "button_click": [ "dismiss" ], "content_description": "I AM UNDER 18", "enabled": [], "identifier": "dismiss--Under 18", "label": { "content_description": "I AM UNDER 18", "text": "I AM UNDER 18", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "8de2aa00-81c1-42f7-ae3c-808c6b61335c", "trigger_link_id": "8de2aa00-81c1-42f7-ae3c-808c6b61335c" }, "type": "label_button" } }, { "identifier": "dce3b36d-24c3-4074-84e2-d80bbaadccde", "margin": { "bottom": 0, "end": 16, "start": 16, "top": 4 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "By entering this site you are agreeing to the Terms of Use and Privacy Policies for [Vail Resorts](https://www.epicpass.com/policies?tc_1=3), [Toyota](https://urldefense.com/v3/__https:/www.toyota.com/support/privacy-notice/__;!!FK2kAO7IF7m7Bw!tYDQ1he6xq5Qsr4LI6bSpBbVp442uZdypE-NNxTEHZXNZQqP4QuJmFejU4qsz-unDIo5vMMAC7A5V2NBeP4mT-ODfVh4quJkSE4S$), and [Helly Hansen](https://www.hellyhansen.com/privacy-policy).\n\n", "text": "By entering this site you are agreeing to the Terms of Use and Privacy Policies for [Vail Resorts](https://www.epicpass.com/policies?tc_1=3), [Toyota](https://urldefense.com/v3/__https:/www.toyota.com/support/privacy-notice/__;!!FK2kAO7IF7m7Bw!tYDQ1he6xq5Qsr4LI6bSpBbVp442uZdypE-NNxTEHZXNZQqP4QuJmFejU4qsz-unDIo5vMMAC7A5V2NBeP4mT-ODfVh4quJkSE4S$), and [Helly Hansen](https://www.hellyhansen.com/privacy-policy).\n\n", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 12, "styles": [ "italic" ] }, "type": "label" } }, { "identifier": "391c72d4-eb98-40fe-8d34-9bcf8e88442d", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "[Epic Winter Adventure Sweepstakes Terms and Conditions](https://www.epicpass.com/info/winter-adventure-sweepstakes-terms)", "text": "[Epic Winter Adventure Sweepstakes Terms and Conditions](https://www.epicpass.com/info/winter-adventure-sweepstakes-terms)", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 11, "styles": [] }, "type": "label" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "container" } }, { "identifier": "df4b0790-09f8-40a2-bdbb-5c1307811a74", "state_actions": [ { "key": "df4b0790-09f8-40a2-bdbb-5c1307811a74_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "4144b3c5-a7bb-443b-bdf4-de61d4a86287_background_image_container", "ignore_safe_area": true, "margin": { "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "media_fit": "fit_crop", "media_type": "image", "position": { "horizontal": "center", "vertical": "center" }, "type": "media", "url": "https://c00340-dl.urbanairship.com/binary/public/WjPzA418RrGPEdjgcfowqA/9ae597ce-1695-4b62-85b5-b43b48062574" } }, { "identifier": "c13fedfc-71ed-406e-9246-6a0f6f6cce3a_main_view_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "0815cef1-93a2-4bdd-8218-2a325aaf7bea_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "items": [ { "identifier": "scroll_container", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "type": "scroll_layout", "view": { "direction": "vertical", "items": [ { "identifier": "9b69d5e1-c2cd-4fd8-a917-03d4b42ad756", "margin": { "bottom": 0, "end": 72, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "media_fit": "center_inside", "media_type": "image", "type": "media", "url": "https://c00340-dl.urbanairship.com/binary/public/WjPzA418RrGPEdjgcfowqA/a69428fb-2e6a-4d37-8761-805e975dc10d" } }, { "identifier": "715ee0d8-b524-4203-b8f2-0f9316976922", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 24 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "accessibility_role": { "level": 1, "type": "heading" }, "content_description": "Enter Your Email", "text": "Enter Your Email", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 20, "styles": [ "bold" ] }, "type": "label" } }, { "identifier": "5a8b2690-ca2b-4cff-8d48-3a6f60547e0b", "margin": { "bottom": 8, "end": 24, "start": 24, "top": 8 }, "size": { "height": "auto", "width": 320 }, "view": { "accessibility_hidden": false, "content_description": "Get resort updates, trip planning tips, and exclusive offers — plus the opportunity to enter for a chance to win!", "text": "Get resort updates, trip planning tips, and exclusive offers — plus the opportunity to enter for a chance to win!", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 15, "styles": [] }, "type": "label" } }, { "identifier": "70a5b1c1-967f-46e1-a48c-a63f96a4b068_linear_layout_item", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [], "type": "linear_layout" } } ], "type": "linear_layout" } } }, { "identifier": "cb6e47d3-0d1f-489b-a205-2fea6b32b2ae", "margin": { "bottom": 16, "end": 0, "start": 0, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "cb6e47d3-0d1f-489b-a205-2fea6b32b2ae_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "border": { "radius": 0, "stroke_color": { "default": { "alpha": 0, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 0, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "direction": "vertical", "items": [ { "identifier": "51e543ba-5548-4108-a26b-c8ff2fab719c", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "51e543ba-5548-4108-a26b-c8ff2fab719c_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "border": { "radius": 0 }, "direction": "horizontal", "items": [ { "identifier": "0f077f9e-897a-4082-a481-1266325a53ff", "margin": { "bottom": 0, "end": 4, "start": 16, "top": 0 }, "size": { "height": "auto", "width": "auto" }, "view": { "accessibility_hidden": false, "content_description": "Email Address", "text": "Email Address", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 12, "styles": [] }, "type": "label" } }, { "identifier": "0c6d363d-3397-4833-9100-0301b672f45d", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "size": { "height": "auto", "width": "auto" }, "view": { "accessibility_hidden": false, "content_description": "*", "text": "*", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FF0000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FF0000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FF0000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FF0000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FF0000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FF0000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FF0000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 12, "styles": [] }, "type": "label" } } ], "type": "linear_layout" } } ], "type": "container" } }, { "identifier": "0c4eca52-ac0a-433a-8c9e-7b483e3927c3", "margin": { "bottom": 16, "end": 16, "start": 16, "top": 0 }, "size": { "height": 60, "width": "100%" }, "view": { "direction": "vertical", "items": [ { "margin": { "bottom": 8, "top": 4 }, "size": { "height": 60, "width": "100%" }, "view": { "background_color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 1 }, "content_description": "Email Address:", "identifier": "0c4eca52-ac0a-433a-8c9e-7b483e3927c3", "input_type": "email", "on_edit": { "state_actions": [ { "key": "0c4eca52-ac0a-433a-8c9e-7b483e3927c3_email_is_valid", "type": "set" } ] }, "on_error": { "state_actions": [ { "key": "0c4eca52-ac0a-433a-8c9e-7b483e3927c3_email_is_valid", "type": "set", "value": false } ] }, "on_valid": { "state_actions": [ { "key": "0c4eca52-ac0a-433a-8c9e-7b483e3927c3_email_is_valid", "type": "set", "value": true } ] }, "place_holder": "* ", "required": true, "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "place_holder_color": { "default": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#696A6F", "type": "hex" }, "dark_mode": true, "platform": "web" } ] } }, "type": "text_input", "view_overrides": { "border": [ { "value": { "radius": 4, "stroke_color": { "default": { "alpha": 0.5, "hex": "#ff0000", "type": "hex" } }, "stroke_width": 1 }, "when_state_matches": { "scope": [ "0c4eca52-ac0a-433a-8c9e-7b483e3927c3_email_is_valid" ], "value": { "equals": false } } }, { "value": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#cccccc", "type": "hex" } }, "stroke_width": 1 }, "when_state_matches": { "scope": [ "0c4eca52-ac0a-433a-8c9e-7b483e3927c3_email_is_valid" ], "value": { "equals": true } } } ], "icon_end": [ { "value": { "icon": { "color": { "default": { "alpha": 0.5, "hex": "#ff0000", "type": "hex" } }, "icon": "exclamationmark_circle_fill", "scale": 1, "type": "icon" }, "type": "floating" }, "when_state_matches": { "scope": [ "0c4eca52-ac0a-433a-8c9e-7b483e3927c3_email_is_valid" ], "value": { "equals": false } } }, { "value": { "icon": { "color": { "default": { "alpha": 0.5, "hex": "#00ff00", "type": "hex" } }, "icon": "checkmark", "scale": 1, "type": "icon" }, "type": "floating" }, "when_state_matches": { "scope": [ "0c4eca52-ac0a-433a-8c9e-7b483e3927c3_email_is_valid" ], "value": { "equals": true } } } ] } } } ], "type": "linear_layout" } }, { "identifier": "3a10f821-b226-4c8b-8bbf-905fcfbe2318", "margin": { "bottom": 4, "end": 20, "start": 20, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "By clicking submit, I agree to share my email address with Sponsor, Toyota and Helly Hansen for partner updates.", "text": "By clicking submit, I agree to share my email address with Sponsor, Toyota and Helly Hansen for partner updates.", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [] }, "type": "label" } }, { "identifier": "a7ab3156-5930-4fef-a59b-b0d19720cc26", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "actions": { "add_tags_action": [ "Sweeps Enable Email 2025" ] }, "background_color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 8, "stroke_color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 8 }, "button_click": [ "form_submit", "pager_next" ], "content_description": "SUBMIT — SUBMIT", "enabled": [ "form_validation" ], "event_handlers": [ { "state_actions": [ { "key": "submitted", "type": "set", "value": true } ], "type": "tap" } ], "identifier": "submit_feedback--SUBMIT", "label": { "content_description": "SUBMIT — SUBMIT", "text": "SUBMIT", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [ "bold" ] }, "type": "label", "view_overrides": { "icon_start": [ { "value": { "icon": { "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "icon": "progress_spinner", "scale": 1, "type": "icon" }, "space": 8, "type": "floating" }, "when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "validating" } } } ], "text": [ { "value": "SUBMIT", "when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "validating" } } } ], "text_appearance": [ { "value": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [ "bold" ] } } ] } }, "reporting_metadata": { "button_action": "submit_feedback", "button_id": "a7ab3156-5930-4fef-a59b-b0d19720cc26", "trigger_link_id": "a7ab3156-5930-4fef-a59b-b0d19720cc26" }, "type": "label_button" } }, { "identifier": "6c850347-6abb-4a35-b595-9793bd2efcee", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "actions": { "add_tags_action": [ "Sweeps Email Later 2025" ] }, "background_color": { "default": { "alpha": 0, "hex": "#999999", "type": "hex" }, "selectors": [ { "color": { "alpha": 0, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 8, "stroke_color": { "default": { "alpha": 0, "hex": "#999999", "type": "hex" }, "selectors": [ { "color": { "alpha": 0, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0, "hex": "#999999", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#999999", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 8 }, "button_click": [ "dismiss" ], "content_description": "Maybe Later — Maybe Later", "enabled": [], "identifier": "dismiss--Maybe Later", "label": { "content_description": "Maybe Later — Maybe Later", "text": "Maybe Later", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "6c850347-6abb-4a35-b595-9793bd2efcee", "trigger_link_id": "6c850347-6abb-4a35-b595-9793bd2efcee" }, "type": "label_button" } }, { "identifier": "632fd4c7-235a-40d4-b267-1a14e3f470be", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "[Epic Winter Adventure Sweepstakes Terms and Conditions](https://www.epicpass.com/info/winter-adventure-sweepstakes-terms)", "text": "[Epic Winter Adventure Sweepstakes Terms and Conditions](https://www.epicpass.com/info/winter-adventure-sweepstakes-terms)", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 11, "styles": [] }, "type": "label" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "container" } }, { "display_actions": { "add_tags_action": [ "Sweeps Scene Completed 2025" ] }, "identifier": "5a4fa960-ab13-413d-b391-a397d51bea50", "state_actions": [ { "key": "5a4fa960-ab13-413d-b391-a397d51bea50_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "d1eae0c5-fc84-4f18-8e07-dd348b70d810_background_image_container", "ignore_safe_area": true, "margin": { "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "media_fit": "fit_crop", "media_type": "image", "position": { "horizontal": "center", "vertical": "center" }, "type": "media", "url": "https://c00340-dl.urbanairship.com/binary/public/WjPzA418RrGPEdjgcfowqA/9ae597ce-1695-4b62-85b5-b43b48062574" } }, { "identifier": "7f6fd84e-eedc-47ca-b975-a56fd7ad2fa4_main_view_container_item", "ignore_safe_area": true, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "680f9160-f362-4819-b76e-ba3e1f2b4bb4_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "items": [ { "identifier": "scroll_container", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "type": "scroll_layout", "view": { "direction": "vertical", "items": [ { "identifier": "9a203dd2-4412-4b6a-a841-ca74f2482080", "margin": { "bottom": 0, "end": 72, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "media_fit": "center_inside", "media_type": "image", "type": "media", "url": "https://c00340-dl.urbanairship.com/binary/public/WjPzA418RrGPEdjgcfowqA/a69428fb-2e6a-4d37-8761-805e975dc10d" } }, { "identifier": "31cde54f-4071-4d4c-a4c3-827abbb94531", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 24 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "accessibility_role": { "level": 1, "type": "heading" }, "content_description": "You’re Entered!", "text": "You’re Entered!", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 20, "styles": [ "bold" ] }, "type": "label" } }, { "identifier": "76973380-871e-496e-a9bf-8133a2ca0243", "margin": { "bottom": 8, "end": 24, "start": 24, "top": 8 }, "size": { "height": "auto", "width": 320 }, "view": { "accessibility_hidden": false, "content_description": "You’ve entered for a chance to win the **Epic Winter Adventure Sweepstakes**— good luck!", "text": "You’ve entered for a chance to win the **Epic Winter Adventure Sweepstakes**— good luck!", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 18, "styles": [] }, "type": "label" } }, { "identifier": "d1e70c3b-1c9f-4975-aa64-7b657f249520_linear_layout_item", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [], "type": "linear_layout" } } ], "type": "linear_layout" } } }, { "identifier": "4b04475d-1cdf-4470-b138-ca8afca5ef41", "margin": { "bottom": 80, "end": 0, "start": 0, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "4b04475d-1cdf-4470-b138-ca8afca5ef41_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "border": { "radius": 0, "stroke_color": { "default": { "alpha": 0, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 0, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "direction": "vertical", "items": [ { "identifier": "f6c45e0e-e092-43e7-8a85-60ce6f5bafc7", "margin": { "bottom": 4, "end": 20, "start": 20, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "Keep an eye on your inbox and notifications for resort news and Winner in January.", "text": "Keep an eye on your inbox and notifications for resort news and Winner in January.", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 15, "styles": [] }, "type": "label" } }, { "identifier": "4b020117-cc07-4f4a-94f1-11ad6a294390", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "actions": {}, "background_color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 8, "stroke_color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 8 }, "button_click": [ "dismiss" ], "content_description": "EXPLORE THE APP — EXPLORE THE APP", "enabled": [], "identifier": "dismiss--Explore App", "label": { "content_description": "EXPLORE THE APP — EXPLORE THE APP", "text": "EXPLORE THE APP", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 14, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "4b020117-cc07-4f4a-94f1-11ad6a294390", "trigger_link_id": "4b020117-cc07-4f4a-94f1-11ad6a294390" }, "type": "label_button" } }, { "identifier": "0c047636-0e62-41b1-b15a-a021de4e88c4", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 20 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "[Epic Winter Adventure Sweepstakes Terms and Conditions](https://www.epicpass.com/info/winter-adventure-sweepstakes-terms)", "text": "[Epic Winter Adventure Sweepstakes Terms and Conditions](https://www.epicpass.com/info/winter-adventure-sweepstakes-terms)", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Montserrat" ], "font_size": 11, "styles": [] }, "type": "label" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "container" } } ], "type": "pager" } }, { "position": { "horizontal": "end", "vertical": "top" }, "size": { "height": 48, "width": 48 }, "view": { "button_click": [ "dismiss" ], "identifier": "dismiss_button", "image": { "color": { "default": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#10164C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "icon": "close", "scale": 0.4, "type": "icon" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "dismiss_button" }, "type": "image_button" } }, { "margin": { "bottom": 4, "end": 0, "start": 0, "top": 4 }, "position": { "horizontal": "center", "vertical": "bottom" }, "size": { "height": 14, "width": "100%" }, "view": { "automated_accessibility_actions": [ { "type": "announce" } ], "bindings": { "selected": { "shapes": [ { "aspect_ratio": 2, "border": { "radius": 16 }, "color": { "default": { "alpha": 1, "hex": "#EF700C", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#EF700C", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#EF700C", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#EF700C", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#EF700C", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#EF700C", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#EF700C", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "color": { "default": { "alpha": 1, "hex": "#BCBDC2", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#BCBDC2", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0.5, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#BCBDC2", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0.5, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#BCBDC2", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0.5, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "scale": 0.5, "type": "ellipse" } ] } }, "spacing": 0, "type": "pager_indicator" } }, { "margin": { "bottom": 20, "end": 20, "start": 20, "top": 20 }, "position": { "horizontal": "center", "vertical": "bottom" }, "size": { "height": "auto", "width": "auto" }, "view": { "background_color": { "default": { "alpha": 0.8, "hex": "#3d4047", "type": "hex" } }, "border": { "radius": 20, "stroke_color": { "default": { "alpha": 0.8, "hex": "#3d4047", "type": "hex" } }, "stroke_width": 1 }, "items": [ { "margin": { "bottom": 10, "end": 20, "start": 20, "top": 10 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "auto" }, "view": { "content_description": "Error processing form. Please try again", "text": "Error processing form. Please try again", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "font_families": [ "sans-serif" ], "font_size": 16 }, "type": "label" } } ], "type": "container", "visibility": { "default": false, "invert_when_state_matches": { "scope": [ "ASYNC_VALIDATION_TOAST" ], "value": { "equals": true } } } } } ], "type": "container" } } } } } }, "display_type": "layout", "name": "20251209 - SA Sweepstakes Email Only Scene 12/16-12/19" }, "reporting_context": { "content_types": [ "scene", "survey" ], "experiment_id": "" }, "requires_eligibility": false, "start": "2025-12-16T16:00:17", "triggers": [ { "goal": 1, "type": "active_session" } ] }, "notify": false } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/mobile-5409.json ================================================ { "in_app_message": { "campaigns": { "categories": [ "2025-01-28 - Promotional - SMS Acquisition - CB674705", "2025-08-20 - Promotional - SMS Acquisition - CB674705" ] }, "edit_grace_period": 14, "end": "2025-12-13T00:15:08", "forms": [ { "id": "5d9527b6-bfe4-4640-8770-4481aa0728de", "questions": [ { "id": "88c75d1b-8392-4ca7-88d0-289d7bd48aa3", "label": "Phone number", "type": "sms" } ], "response_type": "user_feedback", "type": "form" } ], "interval": 10, "labels": [ { "id": "92ea4467-5996-44a0-8aa7-4be1471568ec", "label": "Screen 1" }, { "id": "5b8ead60-c42e-4944-8ad8-5e652b7713c6", "label": "Screen 2" } ], "limit": 0, "message": { "audience": { "test_devices": [ "8f518776-d4d6-4703-aad7-5c45a1d19978", "f9f28a89-6961-478e-bede-91fd61786657" ] }, "display": { "layout": { "presentation": { "android": { "disable_back_button": false }, "default_placement": { "border": { "radius": 20, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "selectors": [ { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "size": { "height": "100%", "width": "94%" }, "web": {} }, "dismiss_on_touch_outside": false, "placement_selectors": [ { "orientation": "portrait", "placement": { "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] } }, "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "selectors": [ { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "size": { "height": 800, "width": 400 }, "web": { "ignore_shade": false } }, "window_size": "large" }, { "orientation": "landscape", "placement": { "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] } }, "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "center" }, "shade_color": { "default": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "selectors": [ { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0.5, "hex": "#111B40", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0.85, "hex": "#111B40", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "size": { "height": 800, "width": 400 }, "web": { "ignore_shade": false } }, "window_size": "large" } ], "type": "modal" }, "version": 1, "view": { "type": "state_controller", "view": { "identifier": "7c1c2a01-ea24-4bf9-bad8-f98dadb88778", "type": "pager_controller", "view": { "direction": "vertical", "items": [ { "size": { "height": "100%", "width": "100%" }, "view": { "form_enabled": [ "form_submission" ], "identifier": "5d9527b6-bfe4-4640-8770-4481aa0728de", "response_type": "user_feedback", "state_triggers": [ { "identifier": "SHOW_TOAST", "on_trigger": { "state_actions": [ { "key": "ASYNC_VALIDATION_TOAST", "ttl_seconds": 1.5, "type": "set", "value": true } ] }, "reset_when_state_matches": { "not": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "error" } } }, "trigger_when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "error" } } } ], "submit": "submit_event", "type": "form_controller", "validation_mode": { "type": "on_demand" }, "view": { "items": [ { "identifier": "b1ce7969-7e6d-4732-bc6b-c8fc282bc93b_pager_container_item", "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "disable_swipe": true, "items": [ { "identifier": "92ea4467-5996-44a0-8aa7-4be1471568ec", "state_actions": [ { "key": "92ea4467-5996-44a0-8aa7-4be1471568ec_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "73e8c2b9-a7f2-46b7-b108-55d2686bc136_background_image_container", "ignore_safe_area": false, "margin": { "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "media_fit": "fit_crop", "media_type": "image", "position": { "horizontal": "center", "vertical": "center" }, "type": "media", "url": "https://c00316-dl.urbanairship.com/binary/public/QpqQ9YfNQWut6CYpm0sQbw/dc668b35-bdd2-4b7f-b1b5-2562b3e128ff" } }, { "identifier": "d2097722-3ae1-4c9b-9270-8dc63337b9f3_main_view_container_item", "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "63095da3-63dc-492d-ae17-340c8febd897_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "items": [ { "identifier": "scroll_container", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "type": "scroll_layout", "view": { "direction": "vertical", "items": [ { "identifier": "48e7477f-dfdc-4ecc-ab25-f1bea4b86d03_wrapper", "margin": { "bottom": 0, "end": 16, "start": 20, "top": 48 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "48e7477f-dfdc-4ecc-ab25-f1bea4b86d03_container", "position": { "horizontal": "start", "vertical": "center" }, "size": { "height": "auto", "width": "80%" }, "view": { "accessibility_hidden": false, "content_description": "Wanna be in the know when our fares go low?", "labels": { "type": "labels", "view_id": "88c75d1b-8392-4ca7-88d0-289d7bd48aa3", "view_type": "text_input" }, "text": "Wanna be in the know when our fares go low?", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 30, "styles": [ "bold" ] }, "type": "label" } } ], "type": "container" } }, { "identifier": "921bd845-482c-4b37-b6e0-abcb86b87883_wrapper", "margin": { "bottom": 8, "end": 16, "start": 20, "top": 12 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "921bd845-482c-4b37-b6e0-abcb86b87883_container", "position": { "horizontal": "start", "vertical": "center" }, "size": { "height": "auto", "width": "80%" }, "view": { "accessibility_hidden": false, "content_description": "Enter your phone number if you would LUV® to receive promotional texts* from Southwest®.", "text": "Enter your phone number if you would LUV® to receive promotional texts* from Southwest®.", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "styles": [] }, "type": "label" } } ], "type": "container" } }, { "identifier": "03abf9bd-c4d4-4d8d-9026-65d6c0a77a0e_linear_layout_item", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [], "type": "linear_layout" } } ], "type": "linear_layout" } } }, { "identifier": "74c54b2f-fa5f-42c4-a3eb-4cb1e5014763", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 8 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "d2a22ce9-127d-45af-a16d-2ab682765163", "margin": { "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "media_fit": "center_crop", "media_type": "image", "type": "media", "url": "https://c00316-dl.urbanairship.com/binary/public/QpqQ9YfNQWut6CYpm0sQbw/63083f27-a09f-499c-b2bb-72c7028e6240" } }, { "identifier": "74c54b2f-fa5f-42c4-a3eb-4cb1e5014763_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "bottom" }, "size": { "height": "auto", "width": "100%" }, "view": { "background_color": { "default": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 0 }, "direction": "vertical", "items": [ { "identifier": "1e3593cc-1870-4e96-8a32-c1b35d37b1dd", "margin": { "bottom": 8, "end": 25, "start": 20, "top": 15 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "*Msg & data rates may apply. Texts may be automated; msg frequency varies. Consent to texts is not a requirement or condition of purchase. Text STOP to 70139 to opt-out (a confirmation message may be sent). Subject to [Terms & Conditions](https://www.southwest.com/about-southwest/terms-and-conditions/?clk=CB674705) and [Privacy Policy](https://www.southwest.com/about-southwest/terms-and-conditions/privacy-policy/index.html?clk=CB674705).", "text": "*Msg & data rates may apply. Texts may be automated; msg frequency varies. Consent to texts is not a requirement or condition of purchase. Text STOP to 70139 to opt-out (a confirmation message may be sent). Subject to [Terms & Conditions](https://www.southwest.com/about-southwest/terms-and-conditions/?clk=CB674705) and [Privacy Policy](https://www.southwest.com/about-southwest/terms-and-conditions/privacy-policy/index.html?clk=CB674705).", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 11, "styles": [] }, "type": "label" } }, { "identifier": "88c75d1b-8392-4ca7-88d0-289d7bd48aa3", "margin": { "bottom": 0, "end": 20, "start": 20, "top": 0 }, "size": { "height": 50, "width": "100%" }, "view": { "direction": "vertical", "items": [ { "margin": { "bottom": 8, "top": 4 }, "size": { "height": 50, "width": "100%" }, "view": { "background_color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 1 }, "content_description": "Phone number", "identifier": "88c75d1b-8392-4ca7-88d0-289d7bd48aa3", "input_type": "sms", "locales": [ { "country_code": "US", "prefix": "+1", "registration": { "sender_id": "70139", "type": "opt_in" } } ], "on_edit": { "state_actions": [ { "key": "88c75d1b-8392-4ca7-88d0-289d7bd48aa3_sms_is_valid", "type": "set" } ] }, "on_error": { "state_actions": [ { "key": "88c75d1b-8392-4ca7-88d0-289d7bd48aa3_sms_is_valid", "type": "set", "value": false } ] }, "on_valid": { "state_actions": [ { "key": "88c75d1b-8392-4ca7-88d0-289d7bd48aa3_sms_is_valid", "type": "set", "value": true } ] }, "place_holder": "* Phone number", "required": true, "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 16, "place_holder_color": { "default": { "alpha": 1, "hex": "#000000", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#000000", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] } }, "type": "text_input", "view_overrides": { "border": [ { "value": { "radius": 4, "stroke_color": { "default": { "alpha": 0.5, "hex": "#ff0000", "type": "hex" } }, "stroke_width": 1 }, "when_state_matches": { "scope": [ "88c75d1b-8392-4ca7-88d0-289d7bd48aa3_sms_is_valid" ], "value": { "equals": false } } }, { "value": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#cccccc", "type": "hex" } }, "stroke_width": 1 }, "when_state_matches": { "scope": [ "88c75d1b-8392-4ca7-88d0-289d7bd48aa3_sms_is_valid" ], "value": { "equals": true } } } ], "icon_end": [ { "value": { "icon": { "color": { "default": { "alpha": 0.5, "hex": "#ff0000", "type": "hex" } }, "icon": "exclamationmark_circle_fill", "scale": 1, "type": "icon" }, "type": "floating" }, "when_state_matches": { "scope": [ "88c75d1b-8392-4ca7-88d0-289d7bd48aa3_sms_is_valid" ], "value": { "equals": false } } }, { "value": { "icon": { "color": { "default": { "alpha": 0.5, "hex": "#00ff00", "type": "hex" } }, "icon": "checkmark", "scale": 1, "type": "icon" }, "type": "floating" }, "when_state_matches": { "scope": [ "88c75d1b-8392-4ca7-88d0-289d7bd48aa3_sms_is_valid" ], "value": { "equals": true } } } ] } } } ], "type": "linear_layout" } }, { "identifier": "2f86c07c-cb4d-4f21-a1fb-f237aa107a06", "margin": { "bottom": 20, "end": 20, "start": 20, "top": 8 }, "size": { "height": 48, "width": "100%" }, "view": { "actions": {}, "background_color": { "default": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "button_click": [ "form_submit", "pager_next" ], "content_description": "Sign up", "enabled": [ "form_validation" ], "event_handlers": [ { "state_actions": [ { "key": "submitted", "type": "set", "value": true } ], "type": "tap" } ], "identifier": "submit_feedback--Sign up", "label": { "content_description": "Sign up", "text": "Sign up", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Arial" ], "font_size": 20, "styles": [ "bold" ] }, "type": "label", "view_overrides": { "icon_start": [ { "value": { "icon": { "color": { "default": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "icon": "progress_spinner", "scale": 1, "type": "icon" }, "space": 8, "type": "floating" }, "when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "validating" } } } ], "text": [ { "value": "Sign up", "when_state_matches": { "scope": [ "$forms", "current", "status", "type" ], "value": { "equals": "validating" } } } ] } }, "reporting_metadata": { "button_action": "submit_feedback", "button_id": "2f86c07c-cb4d-4f21-a1fb-f237aa107a06", "trigger_link_id": "2f86c07c-cb4d-4f21-a1fb-f237aa107a06" }, "type": "label_button" } }, { "identifier": "8ce302ea-275d-4135-a676-21bae5aca6a5_wrapper", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "8ce302ea-275d-4135-a676-21bae5aca6a5_container", "position": { "horizontal": "center", "vertical": "bottom" }, "size": { "height": "auto", "width": "100%" }, "view": { "media_fit": "center_inside", "media_type": "image", "type": "media", "url": "https://c00316-dl.urbanairship.com/binary/public/QpqQ9YfNQWut6CYpm0sQbw/da77da59-5f26-4bc0-9078-4500956ec83a" } } ], "type": "container" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "container" } }, { "identifier": "5b8ead60-c42e-4944-8ad8-5e652b7713c6", "state_actions": [ { "key": "5b8ead60-c42e-4944-8ad8-5e652b7713c6_next", "type": "set" } ], "type": "pager_item", "view": { "background_color": { "default": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "items": [ { "identifier": "cfea8c28-624d-486f-b7fe-bfc921b914a2_background_image_container", "ignore_safe_area": false, "margin": { "end": 0, "start": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "media_fit": "fit_crop", "media_type": "image", "position": { "horizontal": "center", "vertical": "center" }, "type": "media", "url": "https://c00316-dl.urbanairship.com/binary/public/QpqQ9YfNQWut6CYpm0sQbw/51f2ae07-3bc0-4313-802f-9018b0bf17bf" } }, { "identifier": "99a24c81-46e4-4ff1-ae2a-a98f88c5baa7_main_view_container_item", "ignore_safe_area": false, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "items": [ { "identifier": "ab2d17a4-af95-42aa-a401-af8eb1ab6334_container_item", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "items": [ { "identifier": "scroll_container", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "vertical", "type": "scroll_layout", "view": { "direction": "vertical", "items": [ { "identifier": "be50dce5-422e-4824-9f51-96fe3ec93cd1", "margin": { "bottom": 8, "end": 20, "start": 20, "top": 80 }, "size": { "height": "auto", "width": "100%" }, "view": { "accessibility_hidden": false, "content_description": "You're now prepared for takeoff. Look for a confirmation text from 70139 soon. You will need to reply \"Y\" to confirm your subscription.", "text": "You're now prepared for takeoff. Look for a confirmation text from 70139 soon. You will need to reply \"Y\" to confirm your subscription.", "text_appearance": { "alignment": "start", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "sans-serif" ], "font_size": 23, "styles": [] }, "type": "label" } }, { "identifier": "bf5f0542-cfb8-4868-a6bd-13c1a0becb84", "margin": { "bottom": 8, "end": 16, "start": 16, "top": 8 }, "size": { "height": 48, "width": "100%" }, "view": { "actions": {}, "background_color": { "default": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 4, "stroke_color": { "default": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFBF27", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "stroke_width": 0 }, "button_click": [ "dismiss" ], "content_description": "Dismiss", "enabled": [], "identifier": "dismiss--Dismiss", "label": { "content_description": "Dismiss", "text": "Dismiss", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#111b40", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "font_families": [ "Arial" ], "font_size": 20, "styles": [ "bold" ] }, "type": "label" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "bf5f0542-cfb8-4868-a6bd-13c1a0becb84", "trigger_link_id": "bf5f0542-cfb8-4868-a6bd-13c1a0becb84" }, "type": "label_button" } }, { "identifier": "7fd4c4fc-7b29-410b-9f12-fedc1a6b1c19_linear_layout_item", "size": { "height": "100%", "width": "100%" }, "view": { "direction": "horizontal", "items": [], "type": "linear_layout" } } ], "type": "linear_layout" } } }, { "identifier": "059a1004-41ab-4c26-ba1f-3fea2b4951ea", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 28 }, "size": { "height": "auto", "width": "100%" }, "view": { "items": [ { "identifier": "059a1004-41ab-4c26-ba1f-3fea2b4951ea_linear_container", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "100%" }, "view": { "background_color": { "default": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 0, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "border": { "radius": 0 }, "direction": "horizontal", "items": [ { "identifier": "568ac3eb-d15e-44cd-a12f-d7d71c1d69cf", "margin": { "bottom": 0, "end": 0, "start": 0, "top": 0 }, "size": { "height": "auto", "width": "100%" }, "view": { "media_fit": "center_inside", "media_type": "image", "type": "media", "url": "https://c00316-dl.urbanairship.com/binary/public/QpqQ9YfNQWut6CYpm0sQbw/da77da59-5f26-4bc0-9078-4500956ec83a" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "linear_layout" } } ], "type": "container" } } ], "type": "container" } } ], "type": "pager" } }, { "position": { "horizontal": "end", "vertical": "top" }, "size": { "height": 48, "width": 48 }, "view": { "button_click": [ "dismiss" ], "identifier": "dismiss_button", "image": { "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "icon": "close", "scale": 0.4, "type": "icon" }, "localized_content_description": { "fallback": "Dismiss", "ref": "ua_dismiss" }, "reporting_metadata": { "button_action": "dismiss", "button_id": "dismiss_button" }, "type": "image_button" } }, { "margin": { "bottom": 4, "end": 0, "start": 0, "top": 4 }, "position": { "horizontal": "center", "vertical": "bottom" }, "size": { "height": 14, "width": "100%" }, "view": { "automated_accessibility_actions": [ { "type": "announce" } ], "bindings": { "selected": { "shapes": [ { "aspect_ratio": 2, "border": { "radius": 16 }, "color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "scale": 1, "type": "rectangle" } ] }, "unselected": { "shapes": [ { "aspect_ratio": 1, "color": { "default": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "selectors": [ { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "ios" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "android" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "android" }, { "color": { "alpha": 1, "hex": "#7B7C84", "type": "hex" }, "dark_mode": false, "platform": "web" }, { "color": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" }, "dark_mode": true, "platform": "web" } ] }, "scale": 0.5, "type": "ellipse" } ] } }, "spacing": 0, "type": "pager_indicator" } }, { "margin": { "bottom": 20, "end": 20, "start": 20, "top": 20 }, "position": { "horizontal": "center", "vertical": "bottom" }, "size": { "height": "auto", "width": "auto" }, "view": { "background_color": { "default": { "alpha": 0.8, "hex": "#3d4047", "type": "hex" } }, "border": { "radius": 20, "stroke_color": { "default": { "alpha": 0.8, "hex": "#3d4047", "type": "hex" } }, "stroke_width": 1 }, "items": [ { "margin": { "bottom": 10, "end": 20, "start": 20, "top": 10 }, "position": { "horizontal": "center", "vertical": "center" }, "size": { "height": "auto", "width": "auto" }, "view": { "content_description": "Error processing form. Please try again", "text": "Error processing form. Please try again", "text_appearance": { "alignment": "center", "color": { "default": { "alpha": 1, "hex": "#FFFFFF", "type": "hex" } }, "font_families": [ "sans-serif" ], "font_size": 16 }, "type": "label" } } ], "type": "container", "visibility": { "default": false, "invert_when_state_matches": { "scope": [ "ASYNC_VALIDATION_TOAST" ], "value": { "equals": true } } } } } ], "type": "container" } } } ], "type": "linear_layout" } } } } }, "display_type": "layout", "name": "TEST 2025-12-11 - Promotional - SMS Acquisition" }, "reporting_context": { "content_types": [ "scene", "survey" ], "experiment_id": "", "is_test": true }, "requires_eligibility": false, "scopes": [ "app" ], "triggers": [ { "goal": 1, "predicate": { "or": [ { "and": [ { "ignore_case": true, "key": "event_name", "value": { "equals": "swa:offers:southwest homepage" } }, { "and": [ { "key": "launchedFromDeepLink", "scope": [ "properties" ], "value": { "equals": "0" } } ] } ] } ] }, "type": "custom_event_count" } ] }, "notify": false } ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/mobile-5553-shape-corners.yml ================================================ --- # MOBILE-5553: Verify per-corner radii on rectangle shapes # Shapes render inside stack_image_button items. # Left column = uniform radius | Right column = per-corner equivalent # With the fix both columns should look identical (or correctly asymmetric in rows 2/4). # Without the fix the right column would render as plain rectangles. version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 95% height: 90% shade_color: default: hex: '#000000' alpha: 0.6 view: type: scroll_layout direction: vertical view: type: container background_color: default: hex: "#ffffff" alpha: 1 border: radius: 12 items: - position: horizontal: center vertical: top size: height: auto width: 100% view: type: linear_layout direction: vertical items: # Title - margin: top: 16 bottom: 4 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "MOBILE-5553: Shape Corner Radii" text_appearance: alignment: center font_size: 16 styles: [bold] color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 16 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "Left = uniform radius | Right = per-corner\nBoth columns should look identical per row" text_appearance: alignment: center font_size: 11 color: default: hex: "#666666" alpha: 1 # ── Row 1: fill-only, all corners equal ────────────────────── - margin: top: 0 bottom: 6 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "1. Fill only — all corners = 24" text_appearance: alignment: start font_size: 13 styles: [bold] color: default: hex: "#333333" alpha: 1 - margin: top: 0 bottom: 20 start: 16 end: 16 size: width: 100% height: 64 view: type: linear_layout direction: horizontal items: - size: width: 50% height: 100% margin: end: 6 view: type: stack_image_button identifier: row1_left items: - type: shape shape: type: rectangle color: default: hex: "#4A90D9" alpha: 1 border: radius: 24 - size: width: 50% height: 100% margin: start: 6 view: type: stack_image_button identifier: row1_right items: - type: shape shape: type: rectangle color: default: hex: "#E8734A" alpha: 1 border: corner_radius: top_left: 24 top_right: 24 bottom_left: 24 bottom_right: 24 # ── Row 2: fill-only, top corners only ─────────────────────── - margin: top: 0 bottom: 6 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "2. Fill only — top corners only (TL=24 TR=24 BL=0 BR=0)" text_appearance: alignment: start font_size: 13 styles: [bold] color: default: hex: "#333333" alpha: 1 - margin: top: 0 bottom: 4 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "Right: should be rounded top, square bottom" text_appearance: alignment: start font_size: 11 color: default: hex: "#888888" alpha: 1 - margin: top: 0 bottom: 20 start: 16 end: 16 size: width: 100% height: 64 view: type: linear_layout direction: horizontal items: - size: width: 50% height: 100% margin: end: 6 view: type: label text: "N/A\n(uniform can't\ndo this)" text_appearance: alignment: center font_size: 11 color: default: hex: "#999999" alpha: 1 - size: width: 50% height: 100% margin: start: 6 view: type: stack_image_button identifier: row2_right items: - type: shape shape: type: rectangle color: default: hex: "#E8734A" alpha: 1 border: corner_radius: top_left: 24 top_right: 24 bottom_left: 0 bottom_right: 0 # ── Row 3: stroke + all corners ────────────────────────────── - margin: top: 0 bottom: 6 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "3. Stroke (3dp) — all corners = 16" text_appearance: alignment: start font_size: 13 styles: [bold] color: default: hex: "#333333" alpha: 1 - margin: top: 0 bottom: 20 start: 16 end: 16 size: width: 100% height: 64 view: type: linear_layout direction: horizontal items: - size: width: 50% height: 100% margin: end: 6 view: type: stack_image_button identifier: row3_left items: - type: shape shape: type: rectangle border: radius: 16 stroke_width: 3 stroke_color: default: hex: "#4A90D9" alpha: 1 - size: width: 50% height: 100% margin: start: 6 view: type: stack_image_button identifier: row3_right items: - type: shape shape: type: rectangle border: corner_radius: top_left: 16 top_right: 16 bottom_left: 16 bottom_right: 16 stroke_width: 3 stroke_color: default: hex: "#E8734A" alpha: 1 # ── Row 4: stroke + diagonal corners (ticket use case) ─────── - margin: top: 0 bottom: 6 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "4. Stroke (3dp) — diagonal corners TL=24 BR=24" text_appearance: alignment: start font_size: 13 styles: [bold] color: default: hex: "#333333" alpha: 1 - margin: top: 0 bottom: 4 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "Right: matches ticket use case (outside-edge rounded buttons)" text_appearance: alignment: start font_size: 11 color: default: hex: "#888888" alpha: 1 - margin: top: 0 bottom: 24 start: 16 end: 16 size: width: 100% height: 64 view: type: linear_layout direction: horizontal items: - size: width: 50% height: 100% margin: end: 6 view: type: label text: "N/A\n(uniform can't\ndo this)" text_appearance: alignment: center font_size: 11 color: default: hex: "#999999" alpha: 1 - size: width: 50% height: 100% margin: start: 6 view: type: stack_image_button identifier: row4_right items: - type: shape shape: type: rectangle border: corner_radius: top_left: 24 top_right: 0 bottom_left: 0 bottom_right: 24 stroke_width: 3 stroke_color: default: hex: "#E8734A" alpha: 1 # Dismiss - margin: top: 0 bottom: 20 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: dismiss background_color: default: hex: "#333333" alpha: 1 border: radius: 8 label: type: label text: "Dismiss" text_appearance: alignment: center font_size: 14 color: default: hex: "#ffffff" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-buttons.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 95% height: 85% shade_color: default: hex: '#000000' alpha: 0.75 view: type: container border: stroke_color: default: hex: "#FF00FF" alpha: 1 stroke_width: 1 radius: 10 background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: # # Positions # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "Position" text_appearance: alignment: start color: default: hex: "#000000" alpha: 1 styles: - bold - underlined font_size: 16 # Button: start|top - size: width: 100% height: auto view: type: container position: horizontal: center vertical: center items: - position: horizontal: start vertical: top margin: top: 0 bottom: 0 start: 8 end: 8 size: width: auto height: auto view: type: label_button identifier: button1 background_color: default: hex: "#D32F2F" # red alpha: 1 label: type: label text: 'start|top' text_appearance: font_size: 10 alignment: center color: default: hex: "#000000" alpha: 1 # Button: center|center - size: width: 100% height: auto view: type: container position: horizontal: center vertical: center items: - position: horizontal: center vertical: center margin: top: 0 bottom: 0 start: 8 end: 8 size: width: auto height: auto view: type: label_button identifier: button2 background_color: default: hex: "#E65100" # orange alpha: 1 label: type: label text: 'center|center' text_appearance: font_size: 10 alignment: center color: default: hex: "#000000" alpha: 1 # Button: end|bottom - size: width: 100% height: auto view: type: container position: horizontal: center vertical: center items: - position: horizontal: end vertical: bottom margin: top: 0 bottom: 0 start: 8 end: 8 size: width: auto height: auto view: type: label_button identifier: button3 background_color: default: hex: "#FFD600" # yellow alpha: 1 label: type: label text: 'end|bottom' text_appearance: font_size: 10 alignment: center color: default: hex: "#000000" alpha: 1 # # Borders # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "Border" text_appearance: alignment: start styles: - bold - underlined font_size: 16 color: default: hex: "#000000" alpha: 1 # Button w/ 2dp border and no radius - margin: top: 0 bottom: 0 start: 8 end: 8 size: width: 100% height: auto view: type: label_button identifier: button4 background_color: default: hex: "#558B2F" # green alpha: 1 border: radius: 0 stroke_width: 3 stroke_color: default: hex: "#AED581" # light green alpha: 1 label: type: label text: '3dp stroke' text_appearance: font_size: 12 alignment: center color: default: hex: "#000000" alpha: 1 # Button w/ 5dp border and 10dp radius - margin: top: 4 bottom: 4 start: 8 end: 8 size: width: 100% height: auto view: type: label_button identifier: button5 background_color: default: hex: "#1976D2" # blue alpha: 1 border: radius: 15 stroke_width: 5 stroke_color: default: hex: "#64B5F6" # light blue alpha: 1 label: type: label text: '5dp stroke, 15dp radius' text_appearance: font_size: 12 alignment: center color: default: hex: "#000000" alpha: 1 # Button w/ no border and 20dp radius - margin: top: 0 bottom: 0 start: 8 end: 8 size: width: 100% height: auto view: type: label_button identifier: button6 background_color: default: hex: "#283593" # purple alpha: 1 border: radius: 25 stroke_width: 0 label: type: label text: 'no stroke, 20dp radius' text_appearance: font_size: 12 alignment: center color: default: hex: "#ffffff" alpha: 1 # # Sizes # - margin: top: 4 bottom: 4 start: 8 end: 8 size: width: 100% height: auto view: type: label text: "Size" text_appearance: alignment: start styles: - bold - underlined font_size: 16 color: default: hex: "#000000" alpha: 1 # Button: auto x auto - margin: top: 0 bottom: 0 start: 8 end: 8 size: width: auto height: auto view: type: label_button identifier: button7 background_color: default: hex: "#AED581" # light green alpha: 1 label: type: label text: 'auto x auto' text_appearance: font_size: 12 color: default: hex: "#333333" alpha: 1 alignment: center # auto x 56dp - margin: top: 0 bottom: 0 start: 8 end: 8 size: width: auto height: 64 view: type: label_button identifier: button9 background_color: default: hex: "#283593" # purple alpha: 1 label: type: label text: 'auto x 64dp' text_appearance: font_size: 12 alignment: center color: default: hex: "#ffffff" alpha: 1 # 66% x auto - margin: top: 0 bottom: 0 start: 8 end: 8 size: width: 66% height: auto view: type: label_button identifier: button8 background_color: default: hex: "#64B5F6" # light blue alpha: 1 label: type: label text: '66% x auto' text_appearance: font_size: 12 alignment: center color: default: hex: "#333333" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-checkboxes-radios.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: 100% shade_color: default: hex: "#000000" alpha: 0.75 view: type: state_controller view: type: form_controller identifier: neat_form submit: submit_event view: type: container background_color: default: hex: "#ffffff" alpha: 1 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 2 radius: 0 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: auto width: 100% view: type: linear_layout direction: vertical items: - size: width: auto height: auto view: type: toggle identifier: hide event_handlers: - type: form_input state_actions: - type: set_form_value key: hide style: type: switch toggle_colors: on: default: hex: "#00FF00" alpha: 1 off: default: hex: "#FF0000" alpha: 1 - size: width: 100% height: auto view: visibility: default: true invert_when_state_matches: key: hide value: equals: true type: linear_layout direction: vertical items: - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: label text: Check some boxes! text_appearance: color: default: hex: "#000000" alpha: 1 alignment: start font_size: 18 - size: width: 100% height: auto view: type: checkbox_controller identifier: box_types required: true min_selection: 1 max_selection: 2 view: type: linear_layout direction: vertical items: - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 100% height: 100% margin: start: 16 end: 16 view: type: label text: Moving boxes text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start - size: width: 48 height: 48 view: type: checkbox reporting_value: moving boxes style: type: checkbox bindings: selected: shapes: - type: rectangle scale: .5 aspect_ratio: 1 color: default: hex: "#66FF66" alpha: 1 border: stroke_width: 2 radius: 5 stroke_color: default: hex: "#333333" alpha: 1 icon: icon: checkmark color: default: hex: "#333333" alpha: 1 scale: .4 unselected: shapes: - type: rectangle scale: .5 aspect_ratio: 1 color: default: hex: "#FF6666" alpha: 1 border: stroke_width: 2 radius: 5 stroke_color: default: hex: "#333333" alpha: 1 icon: icon: close color: default: hex: "#333333" alpha: 1 scale: .4 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 100% height: 100% margin: start: 16 end: 16 view: type: label text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start text: Bread boxes - size: width: 48 height: 48 view: type: checkbox reporting_value: bread boxes style: type: checkbox bindings: selected: shapes: - type: rectangle scale: .5 aspect_ratio: 1 color: default: hex: "#66FF66" alpha: 1 border: stroke_width: 2 radius: 5 stroke_color: default: hex: "#333333" alpha: 1 icon: icon: checkmark color: default: hex: "#333333" alpha: 1 scale: .4 unselected: shapes: - type: rectangle scale: .5 aspect_ratio: 1 color: default: hex: "#FF6666" alpha: 1 border: stroke_width: 2 radius: 5 stroke_color: default: hex: "#333333" alpha: 1 icon: icon: close color: default: hex: "#333333" alpha: 1 scale: .4 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 100% height: 100% margin: start: 16 end: 16 view: type: label text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start text: Hat boxes - size: width: 48 height: 48 view: type: checkbox reporting_value: hat boxes style: type: checkbox bindings: selected: shapes: - type: rectangle scale: .5 aspect_ratio: 1 color: default: hex: "#66FF66" alpha: 1 border: stroke_width: 2 radius: 5 stroke_color: default: hex: "#333333" alpha: 1 icon: icon: checkmark color: default: hex: "#333333" alpha: 1 scale: .4 unselected: shapes: - type: rectangle scale: .5 aspect_ratio: 1 color: default: hex: "#FF6666" alpha: 1 border: stroke_width: 2 radius: 5 stroke_color: default: hex: "#333333" alpha: 1 icon: icon: close color: default: hex: "#333333" alpha: 1 scale: .4 - size: width: 100% height: auto view: type: radio_input_controller identifier: radio_types required: true attribute_name: channel: "radio-attribute-name-channel" view: type: linear_layout direction: vertical items: - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: label text: Choose a radio! text_appearance: color: default: hex: "#000000" alpha: 1 alignment: start font_size: 18 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 100% height: 100% margin: start: 16 end: 16 view: type: label text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start text: AM Radio - size: width: 48 height: 48 view: type: radio_input reporting_value: am style: type: checkbox bindings: selected: shapes: - type: ellipse scale: .5 aspect_ratio: 1 border: stroke_width: 2 stroke_color: default: hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: .3 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: .5 aspect_ratio: 1 border: stroke_width: 2 stroke_color: default: hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 100% height: 100% margin: start: 16 end: 16 view: type: label text: FM Radio text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start - size: width: 48 height: 48 view: type: radio_input reporting_value: fm style: type: checkbox bindings: selected: shapes: - type: ellipse scale: .5 aspect_ratio: 1 border: stroke_width: 2 stroke_color: default: hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: .3 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: .5 aspect_ratio: 1 border: stroke_width: 2 stroke_color: default: hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 100% height: 100% margin: start: 16 end: 16 view: type: label text: HAM Radio text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start - size: width: 48 height: 48 view: type: radio_input reporting_value: ham style: type: checkbox bindings: selected: shapes: - type: ellipse scale: .5 aspect_ratio: 1 border: stroke_width: 2 stroke_color: default: hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - type: ellipse scale: .3 aspect_ratio: 1 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse scale: .5 aspect_ratio: 1 border: stroke_width: 2 stroke_color: default: hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: linear_layout direction: horizontal items: - size: width: 100% height: 100% margin: end: 16 view: type: label text: Toggle the switch! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 18 alignment: start - size: width: auto height: auto view: type: toggle identifier: toggle-switch required: true attribute_name: contact: "toggle-attribute-name-contact" attribute_value: "attribute-value-toggle-switch" style: type: switch toggle_colors: on: default: hex: "#00FF00" alpha: 1 off: default: hex: "#FF0000" alpha: 1 # Nested NPS form - size: width: 100% height: auto view: type: nps_form_controller identifier: cool_nps_form nps_identifier: "nps_rating" view: type: linear_layout direction: vertical items: - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: label text: Choose a score! text_appearance: color: default: hex: "#000000" alpha: 1 alignment: start font_size: 18 - size: width: 100% height: auto margin: start: 16 end: 16 view: type: score identifier: "nps_rating" required: true attribute_name: channel: "nps-feedback-attribute-name-channel" style: type: number_range start: 0 end: 10 spacing: 4 bindings: selected: shapes: - type: rectangle aspect_ratio: 1 scale: 1 color: default: hex: "#000000" alpha: 1 - type: ellipse aspect_ratio: 1.5 scale: 1 border: stroke_width: 2 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#FFFFFF" alpha: 1 font_families: - permanent_marker unselected: shapes: - type: ellipse aspect_ratio: 1.5 scale: 1 border: stroke_width: 2 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#FFFFFF" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#333333" alpha: 1 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: label text: Type stuff! text_appearance: color: default: hex: "#000000" alpha: 1 alignment: start font_size: 18 - size: width: 100% height: 48 margin: start: 16 end: 16 view: type: text_input identifier: nps_feedback input_type: text_multiline place_holder: blah blah blah... required: true border: stroke_width: 2 stroke_color: default: hex: "#666666" alpha: 1 background_color: default: hex: "#ffffff" alpha: 1 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: start font_size: 12 place_holder_color: default: hex: "#ff0000" alpha: 1 # BOTTOM-PINNED BUTTON - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: label_button identifier: SUBMIT_BUTTON background_color: default: hex: "#000000" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] label: type: label text: 'SEND IT!' text_appearance: font_size: 14 alignment: center color: default: hex: "#ffffff" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-constrained.yml ================================================ --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false device: lock_orientation: portrait size: width: 100% height: 100% position: horizontal: center vertical: bottom margin: top: 99 bottom: 25 start: 25 end: 25 shade_color: default: type: hex hex: "#000000" alpha: 0.2 view: type: pager_controller identifier: 6ab1531a-fcb3-44b4-91d7-52db73ae7cd9 view: type: linear_layout direction: vertical border: stroke_color: default: hex: "#ff0000" alpha: 1.0 stroke_width: 2 radius: 0 items: - size: height: 100% width: 100% view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: c36a5103-0a8d-4e34-b7b7-331ec1cbc87e type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: This is test text_appearance: font_size: 30 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - serif - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-custom-biometric-login.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: "#000000" alpha: 0.6 view: type: container background_color: default: hex: "#000000" alpha: 1 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 radius: 0 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: # SCROLL LAYOUT - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: # SCROLL CONTENT (LINEAR LAYOUT) type: linear_layout direction: vertical size: width: 100% height: 100% items: # Camera View - size: width: 100% height: 100% view: type: custom_view name: biometric_login_custom_view properties: login_description: "I'm a biometric login view in a glassmorphic style" background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 # BODY - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 8 end: 8 view: type: label text: Camera permissions will be requested automatically if we're not already authorized. We inherit our authorization from the containing application. text_appearance: color: default: hex: "#FFFFFF" alpha: 1 alignment: start styles: [italic] font_families: [permanent_marker] font_size: 14 # TOP-RIGHT ICON BUTTON - position: horizontal: end vertical: top size: width: 24 height: 24 view: type: image_button identifier: close_button button_click: [dismiss] image: type: icon icon: close color: default: hex: "#FFFFFF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-custom-interstitial-banner-ad.yaml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: "#000000" alpha: 0.6 view: type: container background_color: default: hex: "#000000" alpha: 1 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 radius: 0 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: # SCROLL LAYOUT - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: # SCROLL CONTENT (LINEAR LAYOUT) type: linear_layout direction: vertical size: width: 100% height: 100% items: # Camera View - size: width: 100% height: 100% view: type: custom_view name: ad properties: login_description: "I'm a biometric login view in a glassmorphic style" background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 # BODY - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 8 end: 8 view: type: label text: Camera permissions will be requested automatically if we're not already authorized. We inherit our authorization from the containing application. text_appearance: color: default: hex: "#FFFFFF" alpha: 1 alignment: start styles: [italic] font_families: [permanent_marker] font_size: 14 # TOP-RIGHT ICON BUTTON - position: horizontal: end vertical: top size: width: 24 height: 24 view: type: image_button identifier: close_button button_click: [dismiss] image: type: icon icon: close color: default: hex: "#FFFFFF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-custom-weather-and-map.yaml ================================================ --- version: 1 presentation: android: disable_back_button: false default_placement: size: min_width: 100% min_height: 100% max_width: 100% width: 100% height: 100% max_height: 100% device: lock_orientation: portrait shade_color: default: type: hex hex: "#000000" alpha: 0.2 ignore_safe_area: false position: horizontal: center vertical: top type: modal dismiss_on_touch_outside: false view: type: pager_controller view: type: linear_layout direction: vertical items: - view: items: - size: width: 100% height: 100% view: type: pager items: - type: pager_item view: items: - position: horizontal: center vertical: center view: items: - size: width: 100% height: 100% view: items: - size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: items: - margin: bottom: 0 top: 0 end: 0 start: 0 size: width: 100% height: auto view: type: custom_view name: weather_custom_view properties: weather_type: "storm" background_color: default: hex: "#FF00FF" alpha: 1 - margin: bottom: 8 end: 16 top: 8 start: 16 size: width: 100% height: auto view: text_appearance: alignment: start styles: - bold font_size: 24 font_families: - sans-serif color: default: type: hex hex: "#487399" alpha: 1 selectors: - color: hex: "#487399" alpha: 1 type: hex dark_mode: true platform: ios - platform: android dark_mode: true color: alpha: 1 type: hex hex: "#487399" type: label text: Portland Metro Storm Advisory - margin: start: 16 end: 16 top: 8 bottom: 8 view: type: label text: "A Flood warning is in effect from \n4:45 PM PST" text_appearance: alignment: start styles: [] color: default: hex: "#000000" alpha: 1 type: hex selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 type: hex - color: type: hex hex: "#FFFFFF" alpha: 1 platform: android dark_mode: true font_families: - sans-serif font_size: 20 size: width: 100% height: auto - margin: start: 16 end: 16 top: 8 bottom: 8 view: text_appearance: alignment: start styles: - bold color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: alpha: 1 type: hex hex: "#FFFFFF" - platform: android dark_mode: true color: hex: "#FFFFFF" alpha: 1 type: hex font_families: - sans-serif font_size: 20 type: label text: Do not attempt to travel unless you are fleeing an area subject to flooding or under an evacuation order size: width: 100% height: auto - margin: start: 16 end: 16 top: 8 bottom: 0 size: width: 100% height: auto view: background_color: default: alpha: 1 type: hex hex: "#FFFFFF" selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android color: hex: "#000000" alpha: 1 type: hex dark_mode: true type: linear_layout items: - margin: start: 0 end: 0 top: 4 bottom: 16 view: border: stroke_width: 1 stroke_color: default: hex: "#63AFF1" type: hex alpha: 1 selectors: - color: hex: "#63AFF1" type: hex alpha: 1 platform: ios dark_mode: true - color: type: hex hex: "#63AFF1" alpha: 1 dark_mode: true platform: android radius: 0 button_click: - pager_next enabled: - pager_next reporting_metadata: trigger_link_id: e1842a1c-1183-45da-90fc-c3c343a4a28f identifier: next--Show My Flood Risk background_color: default: hex: "#8d5797" type: hex alpha: 1 selectors: - color: type: hex alpha: 1 hex: "#8d5797" platform: ios dark_mode: true - platform: android color: alpha: 1 type: hex hex: "#8d5797" dark_mode: true type: label_button label: type: label text: Show My Flood Risk text_appearance: alignment: center styles: [] font_size: 16 font_families: - sans-serif color: default: type: hex alpha: 1 hex: "#000000" selectors: - platform: ios color: type: hex alpha: 1 hex: "#FFFFFF" dark_mode: true - platform: android color: hex: "#FFFFFF" type: hex alpha: 1 dark_mode: true actions: {} size: width: 100% height: 48 direction: horizontal - view: type: linear_layout direction: horizontal items: [] size: width: 100% height: 100% direction: vertical type: linear_layout identifier: scroll_container background_color: default: hex: "#FFFFFF" alpha: 1 type: hex selectors: - platform: ios dark_mode: true color: type: hex alpha: 1 hex: "#000000" - color: hex: "#000000" type: hex alpha: 1 dark_mode: true platform: android type: linear_layout direction: vertical position: horizontal: center vertical: center margin: bottom: 16 type: container size: width: 100% height: 100% background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - color: hex: "#000000" type: hex alpha: 1 dark_mode: true platform: ios - color: type: hex alpha: 1 hex: "#000000" platform: android dark_mode: true type: container identifier: 2eb347b6-84d9-4761-9a27-1cdc6719a394 - type: pager_item view: background_color: default: hex: "#FFFFFF" type: hex alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex alpha: 1 hex: "#000000" - color: type: hex hex: "#000000" alpha: 1 dark_mode: true platform: android items: - size: width: 100% height: 100% position: horizontal: center vertical: center view: items: - size: width: 100% height: 100% view: items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout view: items: - margin: bottom: 0 top: 42 end: 0 start: 0 size: width: 100% height: 40 view: type: custom_view name: ad_custom_view properties: ad_type: "survival" background_color: default: hex: "#FF00FF" alpha: 1 - margin: start: 0 end: 0 top: 0 bottom: 0 size: width: 100% height: auto view: media_type: image media_fit: center_inside url: https://ehs.stanford.edu/wp-content/uploads/Risk-Assessment-Tool.png type: media - margin: start: 16 end: 16 top: 8 bottom: 8 view: text_appearance: alignment: center styles: - bold color: default: type: hex hex: "#000000" alpha: 1 selectors: - color: alpha: 1 type: hex hex: "#FFFFFF" platform: ios dark_mode: true - platform: android color: alpha: 1 hex: "#FFFFFF" type: hex dark_mode: true font_families: - sans-serif font_size: 20 type: label text: Your Location's Flood Risk is size: width: 100% height: auto - margin: bottom: 8 top: 8 end: 16 start: 16 view: type: label text: High text_appearance: alignment: center styles: - bold color: default: hex: "#FF0000" alpha: 1 type: hex font_families: - sans-serif font_size: 55 size: width: 100% height: auto - margin: bottom: 8 top: 8 end: 16 start: 16 view: text_appearance: alignment: center styles: - bold font_size: 24 font_families: - sans-serif color: default: type: hex alpha: 1 hex: "#000000" selectors: - platform: ios dark_mode: true color: type: hex alpha: 1 hex: "#FFFFFF" - color: hex: "#FFFFFF" type: hex alpha: 1 dark_mode: true platform: android text: Please proceed to your evacutation site immediately type: label size: width: 100% height: auto - margin: bottom: 0 end: 16 top: 8 start: 16 view: background_color: default: type: hex alpha: 1 hex: "#FFFFFF" selectors: - platform: ios dark_mode: true color: type: hex alpha: 1 hex: "#000000" - platform: android color: type: hex alpha: 1 hex: "#000000" dark_mode: true type: linear_layout items: - margin: bottom: 16 end: 0 top: 4 start: 0 size: width: 100% height: 48 view: button_click: - pager_next border: stroke_width: 1 stroke_color: default: hex: "#63AFF1" type: hex alpha: 1 selectors: - platform: ios dark_mode: true color: hex: "#63AFF1" type: hex alpha: 1 - color: alpha: 1 type: hex hex: "#63AFF1" dark_mode: true platform: android radius: 0 reporting_metadata: trigger_link_id: 3ea917f7-f52d-4a7f-bb42-71d1761e9165 enabled: - pager_next background_color: default: alpha: 1 type: hex hex: "#63AFF1" selectors: - platform: ios color: alpha: 1 hex: "#63AFF1" type: hex dark_mode: true - color: type: hex hex: "#63AFF1" alpha: 1 dark_mode: true platform: android identifier: next--Show Evacuation Route type: label_button label: text_appearance: alignment: center styles: [] font_size: 16 font_families: - sans-serif color: default: type: hex alpha: 1 hex: "#000000" selectors: - platform: ios color: alpha: 1 type: hex hex: "#FFFFFF" dark_mode: true - platform: android dark_mode: true color: hex: "#FFFFFF" alpha: 1 type: hex type: label text: Show Evacuation Route actions: {} direction: horizontal size: width: 100% height: auto - view: type: linear_layout direction: horizontal items: [] size: width: 100% height: 100% direction: vertical type: linear_layout direction: vertical type: linear_layout background_color: default: type: hex alpha: 1 hex: "#FFFFFF" selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: hex: "#000000" type: hex alpha: 1 direction: vertical position: horizontal: center vertical: center margin: bottom: 16 type: container type: container identifier: 8be0627e-274f-49d7-a116-f2e087c922b3 - type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center view: items: - size: width: 100% height: 100% view: background_color: default: hex: "#FFFFFF" type: hex alpha: 1 selectors: - platform: ios color: alpha: 1 type: hex hex: "#000000" dark_mode: true - platform: android dark_mode: true color: type: hex alpha: 1 hex: "#000000" type: linear_layout items: - size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: items: - margin: bottom: 0 top: 42 end: 0 start: 0 size: width: 100% height: 420 view: type: custom_view name: map_custom_view properties: map_type: "route" background_color: default: hex: "#FF00FF" alpha: 1 - margin: start: 16 end: 16 top: 8 bottom: 8 size: width: 100% height: auto view: text_appearance: alignment: start styles: - bold font_size: 18 font_families: - sans-serif color: default: type: hex alpha: 1 hex: "#000000" selectors: - color: hex: "#FFFFFF" type: hex alpha: 1 platform: ios dark_mode: true - platform: android dark_mode: true color: alpha: 1 type: hex hex: "#FFFFFF" text: Please proceed to your evacutation site type: label - margin: bottom: 0 top: 0 end: 16 start: 16 size: width: 100% height: auto view: type: linear_layout items: - margin: top: 8 bottom: 8 view: items: - margin: end: 16 bottom: 0 top: 0 view: type: media media_fit: center_inside url: https://cdn-icons-png.flaticon.com/512/9494/9494565.png media_type: image size: width: 60 height: 60 - view: text_appearance: alignment: start styles: [] color: default: alpha: 1 hex: "#000000" type: hex selectors: - color: type: hex alpha: 1 hex: "#FFFFFF" dark_mode: true platform: ios - platform: android dark_mode: true color: alpha: 1 type: hex hex: "#FFFFFF" font_families: - sans-serif font_size: 16 text: Follow your evacuation route to higher ground now type: label size: width: 100% height: 100% type: linear_layout direction: horizontal size: width: 100% height: auto - margin: top: 8 bottom: 8 view: items: - margin: end: 16 bottom: 0 top: 0 size: width: 60 height: 60 view: url: https://cdn-icons-png.flaticon.com/512/9494/9494599.png type: media media_fit: center_inside media_type: image - size: width: 100% height: 100% view: type: label text: 'Do not drive or walk into flooded areas: it only takes 6" of water to knock you off your feet' text_appearance: alignment: start styles: [] font_size: 16 font_families: - sans-serif color: default: hex: "#000000" alpha: 1 type: hex selectors: - color: type: hex hex: "#FFFFFF" alpha: 1 platform: ios dark_mode: true - platform: android color: type: hex hex: "#FFFFFF" alpha: 1 dark_mode: true direction: horizontal type: linear_layout size: width: 100% height: auto direction: vertical - margin: start: 16 top: 8 end: 16 bottom: 0 size: width: 100% height: auto view: items: - margin: bottom: 16 top: 4 end: 0 start: 0 view: button_click: - dismiss border: stroke_width: 1 stroke_color: default: hex: "#63AFF1" alpha: 1 type: hex selectors: - color: alpha: 1 type: hex hex: "#63AFF1" dark_mode: true platform: ios - color: type: hex alpha: 1 hex: "#63AFF1" platform: android dark_mode: true radius: 0 enabled: [] reporting_metadata: trigger_link_id: 13c8900d-3131-424a-9478-b756fa310f41 background_color: default: hex: "#63AFF1" type: hex alpha: 1 selectors: - color: hex: "#63AFF1" alpha: 1 type: hex platform: ios dark_mode: true - color: alpha: 1 hex: "#63AFF1" type: hex dark_mode: true platform: android label: type: label text: Close text_appearance: alignment: center styles: [] font_size: 16 font_families: - sans-serif color: default: alpha: 1 hex: "#000000" type: hex selectors: - color: type: hex hex: "#FFFFFF" alpha: 1 dark_mode: true platform: ios - platform: android dark_mode: true color: alpha: 1 hex: "#FFFFFF" type: hex identifier: dismiss--Close type: label_button actions: {} size: width: 100% height: 48 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: true color: hex: "#000000" type: hex alpha: 1 - platform: android dark_mode: true color: alpha: 1 hex: "#000000" type: hex type: linear_layout direction: horizontal - view: type: linear_layout items: [] direction: horizontal size: width: 100% height: 100% direction: vertical type: linear_layout identifier: scroll_container direction: vertical margin: bottom: 16 position: horizontal: center vertical: center type: container background_color: default: hex: "#FFFFFF" type: hex alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 identifier: 89095bb5-eeec-43ee-861d-6ca45f392540 disable_swipe: false ignore_safe_area: false position: horizontal: center vertical: center - position: horizontal: end vertical: top view: identifier: dismiss_button button_click: - dismiss image: scale: 0.4 icon: close type: icon color: default: alpha: 1 hex: "#63AFF1" type: hex selectors: - color: type: hex alpha: 1 hex: "#63AFF1" dark_mode: true platform: ios - platform: android dark_mode: true color: type: hex alpha: 1 hex: "#63AFF1" type: image_button size: width: 48 height: 48 - size: width: 100% height: 7 view: type: pager_indicator spacing: 6 bindings: selected: shapes: - scale: 1 aspect_ratio: 1 type: ellipse color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios color: type: hex alpha: 1 hex: "#63AFF1" dark_mode: true - platform: android dark_mode: true color: hex: "#63AFF1" alpha: 1 type: hex unselected: shapes: - color: default: hex: "#BCBDC2" alpha: 1 type: hex selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 0.5 - platform: android dark_mode: true color: hex: "#FFFFFF" type: hex alpha: 0.5 aspect_ratio: 1 type: ellipse scale: 1 margin: bottom: 8 top: 0 end: 0 start: 0 position: horizontal: center vertical: bottom type: container size: width: 100% height: 100% identifier: 3451d5ae-31ea-4bca-81f9-84846274b683 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-day-night-colors.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 80% height: 30% position: horizontal: center vertical: center shade_color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 0.5 - platform: ios dark_mode: false color: hex: "#000000" alpha: 0.5 default: hex: "#FF00FF" alpha: 1 view: type: container border: stroke_color: selectors: - platform: ios dark_mode: false color: hex: "#f5e253" alpha: 1 - platform: ios dark_mode: true color: hex: "#7329c2" alpha: 1 default: hex: "#FF00FF" alpha: 1 stroke_width: 2 radius: 15 background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 items: - position: horizontal: center vertical: top margin: top: 64 bottom: 16 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "The Earth orbits the sun once every 365 days and rotates about its axis once every 24 hours. Day and night are due to the Earth rotating on its axis, not its orbiting around the sun." text_appearance: font_size: 14 color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 - position: horizontal: center vertical: bottom margin: bottom: 16 size: width: auto height: auto view: type: label_button identifier: cool_button button_click: [ cancel ] border: stroke_width: 1 radius: 3 stroke_color: selectors: - platform: ios dark_mode: true color: hex: "#7329c2" alpha: 1 - platform: ios dark_mode: false color: hex: "#f5e253" alpha: 1 default: hex: "#FF00FF" alpha: 1 background_color: selectors: - platform: ios dark_mode: true color: hex: "#7329c2" alpha: 1 - platform: ios dark_mode: false color: hex: "#f5e253" alpha: 1 default: hex: "#FF00FF" alpha: 1 label: type: label text: Cool story, bro! text_appearance: font_size: 14 color: selectors: - platform: ios dark_mode: true color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 - position: horizontal: end vertical: top margin: top: 8 end: 8 size: width: 24 height: 24 view: type: image_button identifier: close_button button_click: [ cancel ] image: type: icon icon: close color: selectors: - platform: ios dark_mode: false color: hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: hex: "#ffffff" alpha: 1 default: hex: "#FF00FF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-disable-back-button.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true android: disable_back_button: true ignore_safe_area: true default_placement: size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: hex: "#000000" alpha: 1 view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: bottom: 16 size: width: 100% height: auto view: type: label text: "Playground App needs an update" text_appearance: font_size: 24 styles: - bold color: default: hex: "#000000" alpha: 1 - margin: bottom: 32 size: width: 100% height: auto view: type: label text: "To continue using this app, download the latest version." text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - position: horizontal: center vertical: bottom margin: bottom: 16 size: width: auto height: auto view: type: label_button identifier: update_button actions: deep_link_action: "uairship://app_store" background_color: default: hex: "#33dd33" alpha: 1 label: type: label text: UPDATE text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-labels.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 90% height: auto shade_color: default: hex: '#000000' alpha: 0.75 dismiss_on_touch_outside: true view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: vertical items: # # Size # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Size text_appearance: alignment: start color: default: hex: "#000000" alpha: 1 styles: - bold - underlined font_size: 16 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Tiny (8sp) text_appearance: alignment: start font_size: 8 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Small (10sp) text_appearance: alignment: start font_size: 10 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Normal (12sp) text_appearance: alignment: start font_size: 12 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Larger (16sp) text_appearance: alignment: start font_size: 16 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Huge (24sp) text_appearance: alignment: start font_size: 24 color: default: hex: "#000000" alpha: 1 # # Alignment # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Alignment text_appearance: alignment: start styles: - bold - underlined font_size: 16 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Start text_appearance: alignment: start font_size: 12 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Center text_appearance: alignment: center font_size: 12 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: End text_appearance: alignment: end font_size: 12 color: default: hex: "#000000" alpha: 1 # # Style # - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Style text_appearance: alignment: start styles: - bold - underlined font_size: 16 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Normal text_appearance: alignment: start font_size: 12 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Bold text_appearance: styles: - bold alignment: start font_size: 12 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Underline text_appearance: styles: - underlined alignment: start font_size: 12 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Italic text_appearance: styles: - italic alignment: start font_size: 12 color: default: hex: "#000000" alpha: 1 - margin: top: 0 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Bold + Italic + Underline text_appearance: styles: - bold - underlined - italic alignment: start font_size: 12 color: default: hex: "#000000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-linear-layout-horizontal.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 364 height: 30% shade_color: default: hex: '#000000' alpha: 0.7 view: type: container background_color: default: hex: "#ffffff" alpha: 1 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 3 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: horizontal background_color: default: hex: "#ffffff" alpha: 0.35 items: - position: horizontal: center vertical: center size: width: auto height: 100% view: type: label text: auto background_color: default: hex: "#FF0000" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 - position: horizontal: center vertical: center size: width: 75% height: 100% view: type: label text: 75% background_color: default: hex: "#00FF00" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 - position: horizontal: center vertical: center size: width: 20% height: 100% view: type: label text: 20% background_color: default: hex: "#0000FF" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 - position: horizontal: center vertical: center size: width: 40 height: 100% view: type: label text: 40dp background_color: default: hex: "#FF00FF" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-linear-layout-vertical.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 30% height: 364 shade_color: default: hex: '#000000' alpha: 0.7 view: type: container background_color: default: hex: "#ffffff" alpha: 1 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 3 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical background_color: default: hex: "#ffffff" alpha: 0.35 items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: label text: auto background_color: default: hex: "#FF0000" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 - position: horizontal: center vertical: center size: width: 100% height: 75% view: type: label text: 75% background_color: default: hex: "#00FF00" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 - position: horizontal: center vertical: center size: width: 100% height: 20% view: type: label text: 20% background_color: default: hex: "#0000FF" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 - position: horizontal: center vertical: center size: width: 100% height: 40 view: type: label text: 40dp background_color: default: hex: "#FF00FF" alpha: 0.35 text_appearance: color: default: hex: "#000000" alpha: 1 alignment: center font_size: 12 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-linear-layout-webview-emoji.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 95% height: 90% shade_color: default: hex: '#000000' alpha: 0.7 view: type: container background_color: default: hex: "#000000" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 24 bottom: 24 start: 16 end: 16 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: auto view: type: label text: "Wikipedia <i>Push technology</i>" text_appearance: color: default: hex: "#ffffff" alpha: 1 alignment: center font_size: 18 styles: - bold font_families: - monospace - position: horizontal: center vertical: center size: width: 100% height: 100% margin: top: 16 bottom: 16 start: 16 end: 16 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: web_view url: "https://en.m.wikipedia.org/wiki/Push_technology" - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 24 bottom: 24 start: 16 end: 16 view: type: container items: - position: horizontal: center vertical: center size: width: auto height: auto view: type: label text: "🆒 🆒 🆒" text_appearance: font_size: 24 color: default: hex: "#FFFFFF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-linear-layout.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 75% height: 60% shade_color: default: hex: "#000000" alpha: 0 view: type: container background_color: default: hex: "#ffffff" alpha: 1 border: stroke_color: default: hex: "#00FF00" alpha: 1 stroke_width: 5 radius: 15 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: # SCROLL CONTENT (CONTAINER) type: container items: - position: horizontal: center vertical: center margin: top: 16 bottom: 16 start: 16 end: 16 size: width: 100% height: 100% view: type: label text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In arcu cursus euismod quis viverra nibh. Lobortis feugiat vivamus at augue eget arcu dictum. Imperdiet dui accumsan sit amet nulla. Ultrices neque ornare aenean euismod elementum. Tincidunt id aliquet risus feugiat in ante metus dictum text_appearance: color: default: hex: "#333333" alpha: 1 alignment: start styles: - italic font_families: - permanent_marker - casual font_size: 14 # BOTTOM-PINNED BUTTON #1 - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: label_button identifier: BUTTON background_color: default: hex: "#FF0000" alpha: 1 button_click: - cancel label: type: label text: 'Push me!' text_appearance: font_size: 24 alignment: center color: default: hex: "#333333" alpha: 1 styles: - bold font_families: - casual ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-media-header-body-stacked-buttons.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 90% height: 75% shade_color: default: hex: '#000000' alpha: 0.6 view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: # SCROLL LAYOUT - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: # SCROLL CONTENT (LINEAR LAYOUT) type: linear_layout direction: vertical size: width: 100% height: 100% items: # MEDIA - size: width: 100% height: auto view: type: media url: https://media.giphy.com/media/6y70TnZ4ug9zetb5Oq/giphy.gif media_type: image media_fit: center_inside # HEADER - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 100% height: auto view: type: label text: Lorem ipsum dolor sit amet text_appearance: color: default: hex: "#FF00FF" alpha: 1 alignment: start styles: - bold - underlined - italic font_families: - permanent_marker - casual font_size: 24 # BODY - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 8 end: 8 view: type: label text: Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In arcu cursus euismod quis viverra nibh. Lobortis feugiat vivamus at augue eget arcu dictum. Imperdiet dui accumsan sit amet nulla. Ultrices neque ornare aenean euismod elementum. Tincidunt id aliquet risus feugiat in ante metus dictum. text_appearance: color: default: hex: "#333333" alpha: 1 alignment: start styles: [ italic ] font_families: [ permanent_marker ] font_size: 14 # BOTTOM-PINNED BUTTON #1 - position: horizontal: center vertical: center margin: top: 0 bottom: 0 start: 8 end: 8 size: width: 100% height: auto view: type: label_button identifier: BUTTON background_color: default: hex: "#FF0000" alpha: 1 label: type: label text_appearance: font_size: 24 alignment: center styles: - bold - italic - underlined font_families: - permanent_marker color: default: hex: "#00FF00" alpha: 1 text: 'NO' # BOTTOM-PINNED BUTTON #2 - position: horizontal: center vertical: center margin: top: 0 bottom: 0 start: 8 end: 8 size: width: 100% height: auto view: type: label_button identifier: BUTTON background_color: default: hex: "#00FF00" alpha: 1 label: type: label text_appearance: font_size: 24 alignment: center styles: - bold - italic - underlined font_families: - permanent_marker color: default: hex: "#FF0000" alpha: 1 text: 'YES' # TOP-LEFT IMAGE BUTTON - position: horizontal: start vertical: top size: width: 48 height: 48 view: type: image_button identifier: octopus_button button_click: [ cancel ] image: type: url url: https://testing-library.com/img/octopus-64x64.png # TOP-RIGHT ICON BUTTON - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: close_button button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#FF00FF" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-pager-fullsize.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 90% height: 80% shade_color: default: hex: '#000000' alpha: 0.6 view: type: pager_controller identifier: "pager-controller-id" view: type: container border: radius: 30 stroke_width: 8 stroke_color: default: hex: "#333333" alpha: 0.8 background_color: default: hex: "#ffffff" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: pager items: - identifier: "pager-page-1-id" display_actions: add_tags_action: 'pager-page-1x' view: type: container background_color: default: hex: "#FF0000" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: This is the first page about stuff. text_appearance: alignment: center color: default: hex: "#000000" alpha: 1 font_size: 14 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_1 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 - identifier: "pager-page-2-id" display_actions: add_tags_action: 'pager-page-2x' view: type: container background_color: default: hex: "#00FF00" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: More stuff is here on the second page. text_appearance: alignment: center color: default: hex: "#000000" alpha: 1 font_size: 14 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_2 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 - identifier: "pager-page-3-id" display_actions: add_tags_action: 'pager-page-3x' view: type: container background_color: default: hex: "#0000FF" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_3_nps_form nps_identifier: score_identifier submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier required: true style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-3-form-submit' label: type: label text: SuBmIt!1!1@ text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_3 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 - size: height: 16 width: auto position: vertical: bottom horizontal: center margin: bottom: 8 view: type: pager_indicator carousel_identifier: CAROUSEL_ID background_color: default: hex: "#333333" alpha: 0.7 border: radius: 8 spacing: 4 bindings: selected: shapes: - type: rectangle aspect_ratio: 2.25 scale: 0.9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle aspect_ratio: 2.25 scale: .9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#000000" alpha: 0 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-pager-with-title-and-button.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: 95% position: horizontal: center vertical: center shade_color: default: hex: "#444444" alpha: .3 view: type: pager_controller identifier: "pager-controller-id" view: type: container background_color: default: hex: "#FFFFFF" alpha: 1 items: - position: horizontal: center vertical: center size: height: auto width: 100% view: type: linear_layout direction: vertical items: - size: height: auto width: 100% margin: top: 16 start: 16 end: 16 view: type: label text: Take a spin text_appearance: alignment: center styles: - bold font_size: 18 color: default: hex: "#000000" - size: height: 250 width: 100% margin: start: 64 end: 64 top: 16 bottom: 16 view: type: pager disable_swipe: false items: - identifier: "page-1" view: type: container background_color: default: hex: "#88FF0000" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: This is the first page about stuff. text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" - identifier: "page-2" view: type: container background_color: default: hex: "#00FF00" items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: More stuff is here on the second page. text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - identifier: "page-3" view: type: container background_color: default: hex: "#0000FF" alpha: .8 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: That's not all! There's a third page, too! text_appearance: alignment: center color: default: hex: "#000000" font_size: 14 - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 90% view: type: custom_view name: scene_controller_test properties: cool: story border: stroke_color: default: hex: "#000000" stroke_width: 3 radius: 4 - size: height: 30 width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: pager_indicator spacing: 16 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 color: default: hex: "#0000FF" icon: icon: checkmark color: default: hex: "#ffffff" alpha: 1 scale: .8 unselected: shapes: - type: rectangle aspect_ratio: 1 border: stroke_color: default: hex: "#000000" stroke_width: 3 radius: 4 color: default: hex: "#FF0000" icon: icon: close color: default: hex: "#ffffff" alpha: 1 scale: .8 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-placement-selectors.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true placement_selectors: - orientation: portrait placement: size: width: 66% height: auto max_height: 10% position: horizontal: center vertical: center shade_color: default: hex: "#000000" alpha: 0.6 - orientation: landscape placement: size: width: 33% height: auto position: horizontal: center vertical: center shade_color: default: hex: "#000000" alpha: 0.6 default_placement: size: width: 100% height: auto position: horizontal: center vertical: center shade_color: default: hex: "#000000" alpha: 1 view: type: container border: stroke_color: default: hex: "#CED8F7" alpha: 1 stroke_width: 2 radius: 15 background_color: default: hex: "#002082" alpha: 1 items: - position: horizontal: center vertical: center margin: top: 16 bottom: 16 start: 16 end: 16 size: width: 100% height: auto view: type: label text: If the Placement Selectors for this modal are working correctly, the container should fill ~2/3 of the screen width in portrait orientation or ~1/3 of the width in landscape orientation.\n\nIf this modal is touching any edges of the screen, something ain't right and it was rendered with default placement!. text_appearance: color: default: hex: "#CED8F7" alpha: 1 alignment: center font_families: - walter_turncoat font_size: 14 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-responsive.yml ================================================ --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false placement_selectors: - orientation: landscape placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: bottom margin: top: 25 bottom: 25 start: 25 end: 25 shade_color: default: type: hex hex: "#000000" alpha: 0.2 background_color: default: type: hex hex: "#FF0000" alpha: 1 border: radius: 20 default_placement: ignore_safe_area: false size: width: 100% height: 100% shade_color: default: type: hex hex: "#000000" alpha: 0.2 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 view: type: pager_controller identifier: 6ab1531a-fcb3-44b4-91d7-52db73ae7cd9 view: type: linear_layout direction: vertical items: - size: height: 100% width: 100% view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: c36a5103-0a8d-4e34-b7b7-331ec1cbc87e type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: This is test text_appearance: font_size: 30 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - serif - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-score.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 90% height: auto shade_color: default: hex: "#000000" alpha: 0.75 view: type: form_controller identifier: parent_form submit: submit_event view: type: linear_layout direction: vertical background_color: default: hex: "#ffffff" alpha: 1 items: # Score 1 (0 - 10) - size: width: auto height: 40 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 2 start: 0 end: 10 bindings: selected: shapes: - type: rectangle color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 12 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 12 color: default: hex: "#666666" alpha: 1 # Score 2 (1 - 5) - size: width: auto height: 24 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 8 start: 1 end: 5 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFDD33" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#3333ff" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 # Score 3 (97 - 105) - size: width: auto height: 32 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 8 start: 97 end: 105 bindings: selected: shapes: - type: ellipse color: default: hex: "#FF0000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#0000FF" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 # BOTTOM-PINNED BUTTON - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: label_button identifier: SUBMIT_BUTTON background_color: default: hex: "#000000" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] label: type: label text: 'SEND IT!' text_appearance: font_size: 14 alignment: center color: default: hex: "#ffffff" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-transparancy.yml ================================================ #100% — FF #95% — F2 #90% — E6 #85% — D9 #80% — CC #75% — BF #70% — B3 #65% — A6 #60% — 99 #55% — 8C #50% — 80 #45% — 73 #40% — 66 #35% — 59 #30% — 4D #25% — 40 #20% — 33 #15% — 26 #10% — 1A #5% — 0D #0% — 00 --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 75% height: 50% shade_color: default: hex: '#000000' alpha: 0.75 view: type: container background_color: default: hex: "#FFFFFF" alpha: .25 border: stroke_color: default: hex: "#FF0000" alpha: 0.5 stroke_width: 2 radius: 0 items: - position: horizontal: center vertical: center size: height: 100% width: 100% margin: top: 16 bottom: 16 start: 16 end: 16 view: type: container background_color: default: hex: "#FFFFFF" alpha: 0.25 border: stroke_color: default: hex: "#0000FF" alpha: 0.5 stroke_width: 2 radius: 0 items: - position: horizontal: center vertical: center margin: top: 16 bottom: 16 start: 16 end: 16 size: width: auto height: auto view: type: label text: Lorem ipsum dolor sit amet text_appearance: color: default: hex: "#333333" alpha: 1 alignment: center font_size: 14 font_families: - permanent_marker ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-video-cropping.yml ================================================ --- presentation: dismiss_on_touch_outside: true default_placement: position: horizontal: center vertical: center shade_color: default: alpha: 0.5 hex: "#000000" type: hex size: height: 100% width: 100% type: modal version: 1 view: type: pager_controller identifier: "pager-controller-id" view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 margin: top: 36 view: type: pager items: - identifier: "page-1" view: type: container items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - margin: top: 8 size: width: auto height: auto view: type: label text: "Wide Image (100% x auto)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 24 end: 24 size: width: 100% height: auto view: media_fit: center_inside media_type: video type: media url: https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_10MB.mp4 video: aspect_ratio: 1.7777 show_controls: false autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Center (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 url: https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_10MB.mp4 media_type: video video: aspect_ratio: 1.7777 show_controls: false autoplay: true muted: true loop: true type: media position: horizontal: center vertical: center - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Top Start (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media position: horizontal: start vertical: top url: https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_10MB.mp4 video: aspect_ratio: 1.7777 show_controls: false autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Bottom End (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media position: horizontal: end vertical: bottom url: https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_10MB.mp4 video: aspect_ratio: 1.7777 show_controls: false autoplay: true muted: true loop: true - identifier: "page-2" view: type: container items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - margin: top: 8 size: width: auto height: auto view: type: label text: "Tall Image (100% x auto)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 48 end: 48 size: width: 100% height: auto view: media_fit: center_inside media_type: video type: media url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 video: aspect_ratio: 0.5625 show_controls: false autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Center (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop position: horizontal: center vertical: center border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 video: aspect_ratio: 0.5625 show_controls: false autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Top Start (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop position: horizontal: start vertical: top border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 video: aspect_ratio: 0.5625 show_controls: false autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Bottom End (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop position: horizontal: end vertical: bottom border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 video: aspect_ratio: 0.5625 show_controls: false autoplay: true muted: true loop: true - size: height: 16 width: auto position: vertical: top horizontal: center margin: top: 12 view: type: pager_indicator carousel_identifier: CAROUSEL_ID border: radius: 8 spacing: 4 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 scale: 0.75 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 0.75 border: stroke_width: 1 stroke_color: default: hex: "#333333" alpha: 1 color: default: hex: "#ffffff" alpha: 1 - position: vertical: top horizontal: end size: width: 36 height: 36 margin: top: 0 end: 0 view: type: image_button identifier: x_button button_click: [ dismiss ] image: type: icon icon: close scale: 0.5 color: default: hex: "#000000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-webview-full-size.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 95% height: 90% shade_color: default: hex: '#000000' alpha: 0.6 view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: web_view url: "https://docs.airship.com" background_color: default: hex: "#000000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/modal-webview-with-buttons.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 95% height: 90% shade_color: default: hex: '#000000' alpha: 0.6 view: type: container background_color: default: hex: "#000000" alpha: 1 items: - position: horizontal: center vertical: top size: height: 90% width: 100% view: type: web_view url: "https://docs.airship.com" background_color: default: hex: "#000000" alpha: 1 - position: horizontal: start vertical: bottom size: height: 10% width: 50% view: type: label_button identifier: button1 button_click: [ dismiss ] label: type: label text: cool text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 background_color: default: hex: "#AED581" # green alpha: 1 - position: horizontal: end vertical: bottom size: height: 10% width: 50% view: type: label_button button_click: [ cancel ] identifier: button2 background_color: default: hex: "#D32F2F" # red alpha: 1 label: type: label text: beans text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/model-custom-camera-view.yml ================================================ --- version: 1 presentation: dismiss_on_touch_outside: true type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: '#000000' alpha: 0.6 view: type: container background_color: default: hex: "#000000" alpha: 1 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 radius: 0 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical items: # SCROLL LAYOUT - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: # SCROLL CONTENT (LINEAR LAYOUT) type: linear_layout direction: vertical size: width: 100% height: 100% items: # Camera View - size: width: 100% height: 100% view: type: custom_view name: camera_custom_view properties: camera: "I'm a camera" background_color: selectors: - platform: ios dark_mode: false color: hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: hex: "#000000" alpha: 1 default: hex: "#FF00FF" alpha: 1 # BODY - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 8 end: 8 view: type: label text: Camera permissions will be requested automatically if we're not already authorized.\n\n We inherit our authorization from the containing application. text_appearance: color: default: hex: "#333333" alpha: 1 alignment: start styles: [ italic ] font_families: [ permanent_marker ] font_size: 14 # TOP-RIGHT ICON BUTTON - position: horizontal: end vertical: top size: width: 24 height: 24 view: type: image_button identifier: close_button button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/multi-video-players.yml ================================================ --- presentation: android: disable_back_button: false default_placement: device: lock_orientation: portrait ignore_safe_area: false 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: cd53eb72-6983-43b7-a7c6-084067b3e431 type: pager_controller view: direction: vertical items: - size: height: 100% width: 100% view: items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: disable_swipe: false items: - identifier: d3fc5b7e-1a6b-4dc9-aacc-75f1a4b0f8be type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex selectors: - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: ios - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: android items: - 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: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex selectors: - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: ios - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: android direction: vertical items: - identifier: scroll_container size: height: 100% width: 100% view: direction: vertical type: scroll_layout view: direction: vertical items: - size: height: auto width: 100% view: media_fit: center_inside media_type: youtube type: media url: https://www.youtube.com/embed/xUOQZeN8A7o video: aspect_ratio: 1.77777777777778 autoplay: true loop: true muted: true show_controls: true - size: height: auto width: 100% view: media_fit: center_inside media_type: vimeo type: media url: https://player.vimeo.com/video/714680147?autoplay=0&loop=1&controls=1&muted=1&unmute_button=0 video: aspect_ratio: 1.77777777777778 autoplay: false loop: true muted: true show_controls: true - size: height: auto 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 - size: height: 100% width: 100% view: direction: horizontal items: [] type: linear_layout type: linear_layout type: linear_layout type: container type: container - identifier: d3fc5b7e-1a6b-4dc9-aacc-75f1a4b0f8be type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex selectors: - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: ios - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: android items: - 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: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex selectors: - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: ios - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: android direction: vertical items: - identifier: scroll_container size: height: 100% width: 100% view: direction: vertical type: scroll_layout view: direction: vertical items: - size: height: auto width: 100% view: media_fit: center_inside media_type: video type: media url: https://dsqqu7oxq6o1v.cloudfront.net/preview-992269-Avy3e9Poxf-high.mp4 video: aspect_ratio: 1.77777777777778 autoplay: false loop: true muted: true show_controls: true - size: height: auto width: 100% view: media_fit: center_inside media_type: video type: media url: https://dsqqu7oxq6o1v.cloudfront.net/preview-992269-Avy3e9Poxf-high.mp4 video: aspect_ratio: 1.77777777777778 autoplay: true loop: true muted: true show_controls: true - size: height: 100% width: 100% view: direction: horizontal items: [] type: linear_layout type: linear_layout type: linear_layout type: container type: container - identifier: a2f25df7-fdc2-4cbd-8f7e-80f2eba335d3 type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex selectors: - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: ios - color: alpha: 1 hex: "#000000" type: hex dark_mode: true platform: android items: - margin: end: 0 start: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: media_fit: center_crop media_type: vimeo type: media url: https://player.vimeo.com/video/714680147?autoplay=1&loop=1&controls=1&muted=1&unmute_button=0 video: aspect_ratio: 1.7777777777777777 autoplay: true loop: true muted: true show_controls: true - 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: scroll_container size: height: 100% width: 100% view: direction: vertical type: scroll_layout view: direction: vertical items: - size: height: 100% width: 100% view: direction: horizontal items: [] type: linear_layout type: linear_layout type: linear_layout type: container type: container type: pager - position: horizontal: end vertical: top size: height: 48 width: 48 view: button_click: - dismiss identifier: dismiss_button image: 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 icon: close scale: 0.4 type: icon type: image_button - margin: bottom: 8 end: 0 start: 0 top: 0 position: horizontal: center vertical: bottom size: height: 12 width: 100% view: bindings: selected: shapes: - aspect_ratio: 1 color: default: alpha: 1 hex: "#AAAAAA" type: hex scale: 1 type: ellipse unselected: shapes: - aspect_ratio: 1 color: default: alpha: 1 hex: "#CCCCCC" type: hex scale: 1 type: ellipse spacing: 4 type: pager_indicator type: container type: linear_layout ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/nps.yml ================================================ version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#000000" alpha: 0.2 web: ignore_shade: true view: type: state_controller view: type: pager_controller identifier: 50674629-f8fe-4d98-8ff8-7c727db42d1c view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: ef6ed88c-2329-4fa4-a6ed-6141051febf0 nps_identifier: 221acead-707d-4927-90ae-cffc09636871 type: nps_form_controller submit: submit_event form_enabled: - form_submission response_type: nps view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: 760793cc-460f-4e68-a035-8db5b4bed25e type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 221acead-707d-4927-90ae-cffc09636871 size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: auto view: type: label text: "How likely is it that you would recommend [your company, product, etc.] to a friend or colleague?" labels: type: labels view_type: "score" view_id: "221acead-707d-4927-90ae-cffc09636871" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 0 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: auto margin: end: 4 bottom: 4 view: type: label text: Not Likely text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 50% height: auto margin: start: 4 bottom: 4 view: type: label text: Very Likely text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: end styles: [] font_families: - sans-serif - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: height: auto width: 100% view: type: score content_description: "0 means not likely and 10 means very likely" style: type: number_range start: 0 end: 10 spacing: 2 wrapping: line_spacing: 10 max_items_per_line: 6a bindings: selected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: alignment: center font_families: - sans-serif font_size: 24 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 text_appearance: font_size: 24 font_families: - sans-serif color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: 221acead-707d-4927-90ae-cffc09636871 required: true - identifier: f6987b3a-8531-4568-9dfd-aaf6a20fffb3 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: What is the primary reason for your score? text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: 75 view: background_color: default: type: hex hex: "#eae9e9" alpha: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#63656b" alpha: 1 type: text_input text_appearance: alignment: start font_size: 14 color: default: type: hex hex: "#000000" alpha: 1 identifier: f6987b3a-8531-4568-9dfd-aaf6a20fffb3 input_type: text_multiline required: false - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] - identifier: e8c5c4ae-c909-4cfd-b4a1-a9d37790b630 size: width: 100% height: auto view: type: container items: - margin: top: 8 bottom: 8 start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: auto view: type: linear_layout direction: vertical items: - identifier: fd5e54ff-5564-4cf4-9ff6-d44d52bfb09e margin: top: 8 end: 16 bottom: 0 start: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 16 start: 0 end: 0 size: width: 100% height: 48 view: type: label_button identifier: submit_feedback--Submit reporting_metadata: trigger_link_id: c26d8bb3-9989-4ea0-b02b-91a0585f8dae label: type: label text: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 border: radius: 0 stroke_width: 16 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true background_color: default: type: hex hex: "#FFFFFF" alpha: 0 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 0 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 0 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 0 background_color: default: type: hex hex: "#FFFFFF" alpha: 0 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 0 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 0 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 0 margin: top: 8 bottom: 8 start: 0 end: 0 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/pager-behaviors.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 90% height: 90% position: horizontal: center vertical: center shade_color: default: hex: "#444444" alpha: .3 view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 items: - size: height: auto width: auto margin: top: 16 start: 16 end: 16 bottom: 16 view: type: label text: Pager Behaviors text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" - size: width: 100% height: auto view: type: pager_controller identifier: pager_controller view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: container items: - position: vertical: center horizontal: center size: height: 75 width: 50% view: type: pager border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 items: - identifier: "page1" view: type: empty_view background_color: default: hex: "#00FF00" alpha: 0.5 - identifier: "page2" view: type: empty_view background_color: default: hex: "#FFFF00" alpha: 0.5 - identifier: "page3" view: type: empty_view background_color: default: hex: "#FF00FF" alpha: 0.5 - position: horizontal: center vertical: bottom size: height: 24 width: auto margin: bottom: 8 view: type: pager_indicator background_color: default: hex: "#333333" alpha: 0.7 border: radius: 4 spacing: 4 bindings: selected: shapes: - type: rectangle aspect_ratio: 2.25 scale: 0.9 border: radius: 4 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle aspect_ratio: 2.25 scale: .9 border: radius: 4 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#000000" alpha: 0 - size: height: auto width: auto view: type: linear_layout direction: horizontal items: - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Previous' button_click: [ "pager_previous" ] enabled: [ "pager_previous" ] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Next' button_click: [ "pager_next" ] enabled: [ "pager_next" ] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Next or dismiss' button_click: [ "pager_next_or_dismiss" ] - size: height: auto width: auto margin: start: 16 end: 16 top: 16 bottom: 16 view: type: label_button identifier: button1 background_color: default: hex: "#FFD600" label: type: label text_appearance: font_size: 10 color: default: hex: "#333333" alignment: center text: 'Next or first' button_click: [ "pager_next_or_first" ] ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/portrait-video.yml ================================================ --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: max_width: 100% max_height: 100% width: 100% min_width: 100% height: 100% min_height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0.2 view: type: pager_controller identifier: 80df39a5-774d-4bb5-9e35-c7a465189583 view: type: linear_layout direction: vertical items: - size: height: 100% width: 100% view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: false items: - identifier: d07e16f6-f4c4-4acd-ad54-86996e6cf29c type: pager_item view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - size: height: auto width: 100% view: type: media video: aspect_ratio: 0.56 show_controls: true autoplay: true muted: true loop: true media_fit: center_inside url: https://hangar-dl.urbanairship.com/binary/public/Hx7SIqHqQDmFj6aruaAFcQ/611c0ad9-73b6-4a66-8010-8d1ea1be961a #url: https://dsqqu7oxq6o1v.cloudfront.net/preview-992269-Avy3e9Poxf-high.mp4 #url: https:\/\/hangar-dl.urbanairship.com\/binary\/public\/ISex_TTJRuarzs9-o_Gkhg\/1dc4d48b-63ba-4dd4-8cdc-8ba63613780f media_type: video - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: test text_appearance: font_size: 16 color: default: type: hex hex: "#222222" alpha: 1 alignment: start styles: [ ] font_families: - sans-serif - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: test text_appearance: font_size: 16 color: default: type: hex hex: "#222222" alpha: 1 alignment: start styles: [ ] font_families: - sans-serif background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - identifier: 1cd9d07b-32ce-40bb-9526-412cce473b38 type: pager_item view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: test text_appearance: font_size: 16 color: default: type: hex hex: "#222222" alpha: 1 alignment: start styles: [] font_families: - sans-serif background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - identifier: 1c798221-acf9-4031-a633-22e9708990fb type: pager_item view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: blabla text_appearance: font_size: 20 color: default: type: hex hex: "#222222" alpha: 1 alignment: start styles: [] font_families: - sans-serif background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#222222" alpha: 1 identifier: dismiss_button button_click: - dismiss - margin: top: 0 bottom: 8 end: 0 start: 0 position: horizontal: center vertical: bottom size: height: 12 width: 100% view: type: pager_indicator spacing: 4 bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 color: default: type: hex hex: "#AAAAAA" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: type: hex hex: "#CCCCCC" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/qa-advseg-2998.yml ================================================ --- presentation: default_placement: ignore_safe_area: false 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% disable_back_button: false dismiss_on_touch_outside: false type: modal version: 1 view: identifier: 962920d0-ee64-48ad-baec-16777779f1a9 submit: submit_event type: form_controller view: items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: background_color: default: alpha: 1 hex: "#e8e4dc" type: hex direction: vertical type: scroll_layout view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: media_fit: center_crop media_type: image type: media url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/bc692c5f-09ce-4ea4-bb55-108b1b5d28a8 - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 size: height: 100% width: 100% view: text: How satisfied are you with our product? text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 100% width: 100% view: identifier: bdd3abc0-c6ae-40a9-8962-135ee9e822c0 type: radio_input_controller view: direction: vertical items: - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: 20 view: reporting_value: da0695bd-d2c9-412a-aeb7-9f8ac2ff820b style: bindings: selected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Very satisfied text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: 20 view: reporting_value: 941e41b1-aa1a-477d-abfd-3866e84c8732 style: bindings: selected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Dissatisfied text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: 20 view: reporting_value: 3365a895-22d5-4a6a-bd20-1b4374cf6146 style: bindings: selected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Satisfied text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: 20 view: reporting_value: aac3415a-43f7-44a8-9c1d-ac9d482dc72d style: bindings: selected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Very dissatisfied text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 100% width: 100% view: direction: horizontal items: - size: height: 20 width: 20 view: reporting_value: 30a71f98-d60d-4e38-bdbc-047d6ef1b9f2 style: bindings: selected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: ellipse unselected: shapes: - border: radius: 20 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: ellipse type: checkbox type: radio_input - size: height: 100% width: 100% view: text: Neither satisfied nor dissatisfied text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout type: linear_layout type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: text: What did you like most about your experience? text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 70 width: 100% view: background_color: default: alpha: 1 hex: "#eae9e9" type: hex border: radius: 2 stroke_color: default: alpha: 1 hex: "#63656b" type: hex stroke_width: 1 identifier: 7c7f0793-188f-4f60-aec2-0b35d9a4d005 input_type: text required: false text_appearance: alignment: start color: default: alpha: 1 hex: "#000000" type: hex font_size: 14 type: text_input type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: text: What areas do we need to improve? text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 70 width: 100% view: background_color: default: alpha: 1 hex: "#eae9e9" type: hex border: radius: 2 stroke_color: default: alpha: 1 hex: "#63656b" type: hex stroke_width: 1 identifier: 9fb8ef64-2fbc-4439-b450-87b20fda5c43 input_type: text required: false text_appearance: alignment: start color: default: alpha: 1 hex: "#000000" type: hex font_size: 14 type: text_input type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: text: Do you have any other feedback, concern or question? text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 70 width: 100% view: background_color: default: alpha: 1 hex: "#eae9e9" type: hex border: radius: 2 stroke_color: default: alpha: 1 hex: "#63656b" type: hex stroke_width: 1 identifier: 4a2e9284-9321-4176-9173-7f10b648e2e3 input_type: text required: false text_appearance: alignment: start color: default: alpha: 1 hex: "#000000" type: hex font_size: 14 type: text_input type: linear_layout - margin: bottom: 30 top: 10 size: height: 100% width: 100% view: direction: horizontal items: - margin: bottom: 30 end: 20 start: 20 top: 4 size: height: 40 width: 85% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - form_submit - dismiss enabled: - form_validation identifier: e49c1d9a-1118-4a7b-8ae8-2e1ce42b0f1a label: text: Submit text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 24 type: label type: label_button type: linear_layout type: linear_layout - margin: bottom: 0 end: 10 start: 0 top: 10 position: horizontal: end vertical: top size: height: 48 width: 48 view: type: image_button button_click: - dismiss identifier: dismiss_button image: color: default: alpha: 1 hex: "#000000" type: hex icon: close scale: 0.4 type: icon type: container ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/qa-advseg-2999.yml ================================================ --- presentation: default_placement: ignore_safe_area: false 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% disable_back_button: false dismiss_on_touch_outside: false type: modal version: 1 view: identifier: fa241604-ce7c-45a3-94a9-1951caec74a4 nps_identifier: 24574490-64c5-476d-8ecb-7390ea42fe02 submit: submit_event type: nps_form_controller view: items: - position: horizontal: center vertical: center size: height: 100% width: 100% view: background_color: default: alpha: 1 hex: "#FEF5E7" type: hex direction: vertical type: scroll_layout view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: media_fit: center_crop media_type: image type: media url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/bc692c5f-09ce-4ea4-bb55-108b1b5d28a8 - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - size: height: 140 width: 100% view: direction: vertical items: - margin: bottom: 4 end: 0 start: 0 top: 20 size: height: 120 width: 100% view: text: How likely is it that you would recommend 23 Grande to a friend or colleague? text_appearance: alignment: end color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label type: linear_layout - margin: bottom: 0 end: 10 start: 10 top: 0 size: height: 90 width: 100% view: direction: vertical items: - margin: bottom: 8 top: 10 size: height: 25 width: 100% view: direction: horizontal items: - size: height: 25 width: 50% view: text: Not Likely text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label - size: height: 25 width: 50% view: text: Very Likely text_appearance: alignment: end color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - size: height: 40 width: 100% view: direction: horizontal items: - margin: bottom: 0 end: 2 start: 2 top: 0 size: height: 40 width: 100% view: identifier: 24574490-64c5-476d-8ecb-7390ea42fe02 required: true style: bindings: selected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#DDDDDD" type: hex scale: 1 type: rectangle text_appearance: alignment: center color: default: alpha: 1 hex: "#000000" type: hex font_size: 12 unselected: shapes: - border: radius: 2 stroke_color: default: alpha: 1 hex: "#000000" type: hex stroke_width: 1 color: default: alpha: 1 hex: "#FFFFFF" type: hex scale: 1 type: rectangle text_appearance: color: default: alpha: 1 hex: "#000000" type: hex font_size: 12 end: 10 spacing: 2 start: 0 type: number_range type: score type: linear_layout type: linear_layout type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 0 size: height: auto width: 100% view: text: What is the primary reason for your score? text_appearance: alignment: end color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 18 styles: - bold type: label - size: height: 70 width: 100% view: background_color: default: alpha: 1 hex: "#eae9e9" type: hex border: radius: 2 stroke_color: default: alpha: 1 hex: "#63656b" type: hex stroke_width: 1 identifier: e7427c26-64c2-48c6-99b6-13de49a83c7f input_type: text required: false text_appearance: alignment: start color: default: alpha: 1 hex: "#000000" type: hex font_size: 14 type: text_input type: linear_layout - margin: bottom: 30 top: 10 size: height: 100% width: 100% view: direction: horizontal items: - margin: bottom: 30 end: 20 start: 20 top: 4 size: height: 40 width: 85% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - form_submit - dismiss enabled: - form_validation identifier: 9e1d0085-ed68-41f9-bd4e-2741d7d79b66 label: text: Submit text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 24 type: label type: label_button type: linear_layout type: linear_layout - margin: bottom: 0 end: 10 start: 0 top: 10 position: horizontal: end vertical: top size: height: 48 width: 48 view: button_click: - dismiss identifier: dismiss_button image: color: default: alpha: 1 hex: "#000000" type: hex icon: close scale: 0.4 type: icon type: image_button type: container ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/safe-areas-linear-layout.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: '#000000' alpha: 0 # no shade ignore_safe_area: true view: type: container background_color: default: hex: "#FF00FF" alpha: 1 items: # TOP-LEVEL LINEAR LAYOUT - position: horizontal: center vertical: center size: height: 100% width: 100% view: type: linear_layout direction: vertical border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 5 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: empty_view background_color: default: hex: "#FF6666" alpha: 1 - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: empty_view background_color: default: hex: "#FFFFFF" alpha: 1 - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: empty_view background_color: default: hex: "#FF6666" alpha: 1 - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: empty_view background_color: default: hex: "#FFFFFF" alpha: 1 - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: empty_view background_color: default: hex: "#FF6666" alpha: 1 - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: empty_view background_color: default: hex: "#FFFFFF" alpha: 1 - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: empty_view background_color: default: hex: "#FF6666" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/safe-areas-pager.yml ================================================ --- version: 1 presentation: type: modal default_placement: ignore_safe_area: true size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0 # no shade view: type: pager_controller identifier: pager-id view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: pager disable_swipe: false items: - identifier: "page-1" view: type: container background_color: default: hex: "#FF0000" alpha: 1 items: - size: width: 100% height: 100% position: horizontal: center vertical: center margin: top: 36 bottom: 36 start: 8 end: 8 view: type: empty_view border: stroke_width: 2 stroke_color: default: hex: "#FFFFFF" alpha: 1 radius: 5 - idenfier: "page-2" view: type: container background_color: default: hex: "#00FF00" alpha: 1 items: - size: width: 100% height: 100% position: horizontal: center vertical: center margin: top: 36 bottom: 36 start: 8 end: 8 view: type: empty_view border: stroke_width: 2 stroke_color: default: hex: "#FFFFFF" alpha: 1 radius: 5 - identifier: "page-3" view: type: container background_color: default: hex: "#0000FF" alpha: 1 items: - size: width: 100% height: 100% position: horizontal: center vertical: center margin: top: 36 bottom: 36 start: 8 end: 8 view: type: empty_view border: stroke_width: 2 stroke_color: default: hex: "#FFFFFF" alpha: 1 radius: 5 - position: horizontal: end vertical: top ignore_safe_area: false margin: top: 8 end: 8 size: width: 24 height: 24 view: type: image_button image: type: icon icon: close color: default: type: hex alpha: 1 hex: "#FFFFFF" identifier: dismiss_button button_click: - dismiss - margin: bottom: 8 position: horizontal: center vertical: bottom size: height: 20 width: 100% ignore_safe_area: false view: type: pager_indicator spacing: 4 bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: type: hex hex: "#FFFFFF" alpha: 0.5 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/scene-scroll.yml ================================================ --- presentation: android: disable_back_button: false default_placement: device: lock_orientation: portrait ignore_safe_area: false 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: d565411e-b6ac-46be-9b12-9160ca354cb2 type: pager_controller view: items: - ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: disable_swipe: false items: - identifier: d86b50db-5ef4-44dd-a0a8-fba5bee5a00f type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - margin: bottom: 28 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - margin: top: 0 size: height: auto width: 100% view: media_fit: center_crop media_type: image type: media url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/56956abe-2bf2-416c-8f7e-f25d65ab6fc0 - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: auto width: 90% view: text: Your free trial has ended text_appearance: alignment: center color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 20 styles: - underlined - bold - italic type: label - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 90% view: direction: vertical type: scroll_layout view: text: 'Are you enjoying your experience? To continue enjoying your benefits, select a plan to upgrade to today. ' text_appearance: alignment: center color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 34 styles: - underlined - bold - italic type: label - size: height: 0 width: 0 view: actions: {} identifier: home_page--footer_button label: text: '' text_appearance: alignment: center color: default: alpha: 1 hex: "#37FF00" type: hex font_families: - Test limit font_size: 34 styles: - bold - italic - underlined type: label type: label_button - margin: bottom: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 4 end: 0 start: 0 top: 4 size: height: 40 width: 100% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - pager_next enabled: - pager_next identifier: next--See All Plans label: text: See All Plans text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 14 styles: [] type: label type: label_button - margin: bottom: 20 end: 0 start: 0 top: 4 size: height: 40 width: 100% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - dismiss enabled: [] identifier: 'home_page--Go back to basic account ' label: text: 'Go back to basic account ' text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFF0" type: hex font_families: - SF Pro font_size: 14 styles: [] type: label type: label_button type: linear_layout type: linear_layout type: container - identifier: ed3ff5ff-f633-4dd0-869b-32b285c3e9d9 type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - margin: bottom: 28 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: auto width: 100% view: direction: vertical items: - margin: bottom: 10 end: 0 start: 0 top: 35 size: height: auto width: 90% view: text: Why upgrade? text_appearance: alignment: center color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 20 styles: - underlined - bold - italic type: label - size: height: 0 width: 0 view: text: '' text_appearance: alignment: center color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 34 styles: - underlined - bold - italic type: label - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 60 width: 100% view: direction: horizontal items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: 60 width: 60 view: media_fit: center_inside media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: text: 'Plan #1 - Value prop - Price' text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 34 styles: - underlined - bold - italic type: label type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 60 width: 100% view: direction: horizontal items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: 60 width: 60 view: media_fit: center_inside media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: text: 'Plan #2 - Value prop - Price' text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 34 styles: - underlined - bold - italic type: label type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 60 width: 100% view: direction: horizontal items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: 60 width: 60 view: media_fit: center_inside media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: text: 'Plan #3 - Value prop - Price' text_appearance: alignment: start color: default: alpha: 1 hex: "#111111" type: hex font_families: - sans-serif font_size: 34 styles: - underlined - bold - italic type: label type: linear_layout type: linear_layout - margin: bottom: 0 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: type: empty_view - margin: bottom: 0 top: 10 size: height: auto width: 92% view: direction: vertical items: - margin: bottom: 4 end: 0 start: 0 top: 4 size: height: 40 width: 100% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - pager_next enabled: - pager_next identifier: 'next--Plan #1' label: text: 'Plan #1' text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 14 styles: [] type: label type: label_button - margin: bottom: 4 end: 0 start: 0 top: 4 size: height: 40 width: 100% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - dismiss enabled: [] identifier: 'home_page--Plan #2' label: text: 'Plan #2' text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFF0" type: hex font_families: - SF Pro font_size: 14 styles: [] type: label type: label_button - margin: bottom: 20 end: 0 start: 0 top: 4 size: height: 40 width: 100% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - dismiss enabled: [] identifier: 'home_page--Plan #3' label: text: 'Plan #3' text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 14 styles: [] type: label type: label_button type: linear_layout type: linear_layout type: container type: pager - position: horizontal: end vertical: top size: height: 48 width: 48 view: button_click: - dismiss identifier: dismiss_button image: color: default: alpha: 1 hex: "#000000" type: hex icon: close scale: 0.4 type: icon type: image_button - margin: bottom: 4 end: 0 start: 0 top: 0 position: horizontal: center vertical: bottom size: height: 12 width: 100% view: bindings: selected: shapes: - aspect_ratio: 1 color: default: alpha: 1 hex: "#AAAAAA" type: hex scale: 1 type: ellipse unselected: shapes: - aspect_ratio: 1 color: default: alpha: 1 hex: "#CCCCCC" type: hex scale: 1 type: ellipse spacing: 4 type: pager_indicator type: container ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/story-one-screen.yml ================================================ --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false device: lock_orientation: portrait size: max_width: 100% max_height: 100% width: 100% min_width: 100% height: 100% min_height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0.2 view: type: pager_controller identifier: 63a41161-9322-4425-a940-fa928665459e view: type: linear_layout direction: vertical items: - size: height: 100% width: 100% view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true gestures: - identifier: 63a41161-9322-4425-a940-fa928665459e_tap_start type: tap location: start behavior: behaviors: - pager_previous - identifier: 63a41161-9322-4425-a940-fa928665459e_tap_end type: tap location: end behavior: behaviors: - pager_next - identifier: 63a41161-9322-4425-a940-fa928665459e_swipe_up type: swipe direction: up behavior: behaviors: - dismiss - identifier: 63a41161-9322-4425-a940-fa928665459e_swipe_down type: swipe direction: down behavior: behaviors: - dismiss - identifier: 63a41161-9322-4425-a940-fa928665459e_hold type: hold press_behavior: behaviors: - pager_pause release_behavior: behaviors: - pager_resume items: - identifier: a648eca0-6f68-49fc-971e-6de4cfdf5af3 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: layout_container size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: It’s time to update text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: - bold - italic - underlined font_families: - serif - size: height: auto width: 100% view: type: media media_fit: center_inside url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg media_type: image - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: The newest version of our app is now available. We’re excited to tell you what’s new! text_appearance: font_size: 14 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: - italic font_families: - fancy fonts - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 automated_actions: - identifier: pager_next_or_first_a648eca0-6f68-49fc-971e-6de4cfdf5af3 delay: 4 behaviors: - pager_next_or_first - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 identifier: dismiss_button button_click: - dismiss - margin: top: 8 bottom: 0 end: 16 start: 16 position: horizontal: center vertical: top size: height: 1.5 width: 100% view: type: story_indicator source: type: pager style: type: linear_progress direction: horizontal sizing: equal spacing: 4 progress_color: default: type: hex hex: "#AAAAAA" alpha: 1 track_color: default: type: hex hex: "#AAAAAA" alpha: 0.5 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/story-video-and-gif.yml ================================================ --- 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 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/story.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: '#000000' alpha: 0.6 ignore_safe_area: true view: type: pager_controller identifier: pager-controller-id view: type: container border: radius: 30 stroke_width: 2 stroke_color: default: hex: '#333333' alpha: 0.8 background_color: default: hex: '#ffffff' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% margin: top: 0 bottom: 0 start: 0 end: 0 view: border: stroke_width: 1 media_fit: center_crop media_type: image type: media url: >- https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/bc692c5f-09ce-4ea4-bb55-108b1b5d28a8 - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 view: type: pager gestures: - behavior: behaviors: - dismiss direction: up identifier: 7f67898a-5576-4f9d-af73-ff4d9568117a_swipe_up type: swipe - type: tap identifier: tap-gesture-start-id location: start behavior: behaviors: - pager_previous - type: tap identifier: tap-gesture-end-id location: end behavior: behaviors: - pager_next - type: hold identifier: hold-gesture-any-id press_behavior: behaviors: - pager_pause release_behavior: behaviors: - pager_resume items: - identifier: pager-page-1-id automated_actions: - delay: 0 identifier: automated-action-1-delay0-id actions: - add_tags_action: pager-page-1x-automated - add_tags_action: pager-page-1y-automated - delay: 4 identifier: automated-action-1-delay4-id reporting_metadata: key1: value1 key2: value2 behaviors: - pager_next display_actions: add_tags_action: pager-page-1x view: type: container background_color: default: hex: '#FF0000' alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: This is the first page about stuff. text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 font_size: 14 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_1 button_click: - dismiss image: type: icon icon: close color: default: hex: '#000000' alpha: 1 - identifier: pager-page-2-id automated_actions: - delay: 1.03 identifier: automated-action-2-delay1-id actions: - add_tags_action: pager-page-2x-automated - delay: 6 identifier: automated-action-2-delay6-id behaviors: - pager_next display_actions: add_tags_action: pager-page-2x view: type: container background_color: default: hex: '#00FF00' alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: label text: More stuff is here on the second page. text_appearance: alignment: center color: default: hex: '#000000' alpha: 1 font_size: 14 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_2 button_click: - dismiss image: type: icon icon: close color: default: hex: '#000000' alpha: 1 - identifier: pager-page-3-id automated_actions: - delay: 4.01 identifier: automated-action-3-delay4-id actions: - add_tags_action: pager-page-3x-automated - delay: 8 identifier: automated-action-3-delay8-id behaviors: - pager_next_or_first - form_submit display_actions: add_tags_action: pager-page-3x view: type: container background_color: default: hex: '#0000FF' alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_3_nps_form nps_identifier: score_identifier submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier required: true style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: ellipse color: default: hex: '#FFFFFF' alpha: 0 text_appearance: font_size: 14 color: default: hex: '#000000' alpha: 1 unselected: shapes: - type: ellipse color: default: hex: '#000000' alpha: 1 text_appearance: font_size: 14 color: default: hex: '#ffffff' alpha: 1 - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: '#ffffff' alpha: 1 button_click: - form_submit - cancel enabled: - form_validation display_actions: add_tags_action: pager-page-3-form-submit label: type: label text: SuBmIt!1!1@ text_appearance: font_size: 14 alignment: center color: default: hex: '#000000' alpha: 1 - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_3 button_click: - dismiss image: type: icon icon: close color: default: hex: '#000000' alpha: 1 - size: height: 5 width: 80% position: vertical: top horizontal: center margin: bottom: 8 view: type: story_indicator source: type: pager style: type: linear_progress direction: horizontal sizing: equal spacing: 4 progress_color: default: hex: '#E6E6FA' alpha: 1 track_color: default: hex: '#E6E6FA' alpha: 0.7 - ignore_safe_area: false position: horizontal: start vertical: center size: height: 48 width: 48 margin: bottom: 0 end: 4 start: 4 top: 0 view: type: stack_image_button identifier: back button button_click: - pager_previous enabled: - pager_previous items: - type: shape shape: type: ellipse aspect_ratio: 1 color: default: alpha: 1 type: hex hex: '#FFFFFF' selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: true scale: 0.7 - type: icon icon: type: icon color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true icon: chevron_backward scale: 0.35 - ignore_safe_area: false position: horizontal: end vertical: center size: height: 48 width: 48 margin: bottom: 0 end: 4 start: 4 top: 0 view: type: stack_image_button identifier: forward button button_click: - pager_next enabled: - pager_next items: - type: shape shape: type: ellipse aspect_ratio: 1 color: default: alpha: 1 type: hex hex: '#FFFFFF' selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: true scale: 0.7 - type: icon icon: type: icon color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true icon: chevron_forward scale: 0.35 - ignore_safe_area: false position: horizontal: center vertical: bottom size: height: 48 width: 48 margin: bottom: 0 end: 4 start: 4 top: 0 view: type: stack_image_button identifier: play/pause button button_click: - pager_toggle_pause items: - type: shape shape: type: ellipse aspect_ratio: 1 color: default: alpha: 1 type: hex hex: '#FFFFFF' selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: true scale: 0.7 - type: icon icon: type: icon color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true icon: pause scale: 0.35 view_overrides: items: - value: - type: shape shape: type: ellipse aspect_ratio: 1 color: default: alpha: 1 type: hex hex: '#FFFFFF' selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: true scale: 0.7 - type: icon icon: type: icon color: default: hex: '#000000' alpha: 1 type: hex selectors: - color: hex: '#000000' alpha: 1 type: hex dark_mode: false - color: hex: '#FFFFFF' alpha: 1 type: hex dark_mode: true icon: play scale: 0.35 when_state_matches: scope: - $pagers - current - paused value: equals: true ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/tap-handler-visibility.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 90% height: auto shade_color: default: hex: '#000000' alpha: 0.75 dismiss_on_touch_outside: false view: type: state_controller view: type: form_controller identifier: a_form submit: submit_event view: type: scroll_layout direction: vertical view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 32 bottom: 32 start: 16 end: 16 view: type: linear_layout direction: vertical items: # # Label # - size: width: 100% height: auto margin: top: 0 bottom: 12 view: type: label text: Label text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: label text: Tap me text_appearance: alignment: start font_size: 16 color: default: hex: "#000000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: label_tapped value: true visibility: default: true invert_when_state_matches: key: label_tapped value: equals: true - size: width: 100% height: auto view: type: label text: Tap me again? text_appearance: alignment: end font_size: 16 color: default: hex: "#FF00FF" alpha: 1 visibility: default: false invert_when_state_matches: key: label_tapped value: equals: true event_handlers: - type: tap state_actions: - type: set key: label_tapped value: false # # Label Button # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Label Button text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: auto height: auto view: type: label_button identifier: boop_button background_color: default: hex: "#000000" alpha: 1 label: type: label text: Click to close text_appearance: alignment: start font_size: 12 color: default: hex: "#FFFFFF" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: button_tapped value: true visibility: default: true invert_when_state_matches: key: button_tapped value: equals: true - size: width: auto height: auto view: type: label_button background_color: default: hex: "#000000" alpha: 1 identifier: close_confirm_button button_click: - dismiss label: type: label text: Are you sure? text_appearance: alignment: end font_size: 12 color: default: hex: "#FF0000" alpha: 1 visibility: default: false invert_when_state_matches: key: button_tapped value: equals: true # # Image Buttons (url & icon) # - size: width: 100% height: auto margin: top: 24 bottom: 4 view: type: label text: Image Button text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: start: 12 end: 12 view: type: linear_layout direction: horizontal items: - size: width: 50% height: 100 view: type: image_button identifier: image_button_airship image: type: url url: https://upload.wikimedia.org/wikipedia/en/thumb/8/8b/Airship_2019_logo.png/220px-Airship_2019_logo.png event_handlers: - type: tap state_actions: - type: set key: airship_tapped value: true visibility: default: true invert_when_state_matches: key: airship_tapped value: equals: true - size: width: 50% height: 100 view: type: image_button identifier: image_button_airship_old image: type: url url: https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/Urban_Airship_Logo.jpg/640px-Urban_Airship_Logo.jpg event_handlers: - type: tap state_actions: - type: set key: airship_tapped value: false visibility: default: false invert_when_state_matches: key: airship_tapped value: equals: true - size: width: 50% height: 100 view: type: image_button identifier: image_button_forward image: type: icon icon: forward_arrow color: default: hex: "#00ff00" alpha: 1 scale: 0.5 event_handlers: - type: tap state_actions: - type: set key: icon_tapped value: true visibility: default: true invert_when_state_matches: key: icon_tapped value: equals: true - size: width: 50% height: 100 view: type: image_button identifier: image_button_back image: type: icon icon: back_arrow color: default: hex: "#ff0000" alpha: 1 scale: 0.5 event_handlers: - type: tap state_actions: - type: set key: icon_tapped value: false visibility: default: false invert_when_state_matches: key: icon_tapped value: equals: true # # Toggle # - size: width: 100% height: auto margin: top: 4 view: type: label text: Toggle text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 16 start: 24 view: type: linear_layout direction: horizontal items: - size: width: auto height: auto view: type: toggle identifier: toggle style: type: switch toggle_colors: on: default: hex: "#00FF00" alpha: 1 off: default: hex: "#FF0000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: toggled value: true - size: width: auto height: auto view: type: label text: "<-- You tapped!" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: toggled value: equals: true - size: width: auto height: 24 view: type: label text: "<-- Tap there" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: true invert_when_state_matches: key: toggled value: equals: true # # Radios # - size: width: 100% height: auto margin: top: 24 view: type: label text: Radio Inputs text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 start: 24 view: type: radio_input_controller identifier: radios view: type: linear_layout direction: horizontal items: - size: width: auto height: auto view: type: linear_layout direction: vertical items: - size: width: auto height: auto margin: top: 4 view: type: radio_input reporting_value: radio_red event_handlers: - type: tap state_actions: - type: set key: color value: red style: type: checkbox bindings: selected: shapes: - border: radius: 2 stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 2 color: default: hex: "#ff0000" alpha: 1 scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: hex: "#000000" alpha: 0.5 stroke_width: 1 color: default: hex: "#ff0000" alpha: 0.5 scale: 1 type: ellipse - size: width: auto height: auto margin: top: 4 view: type: radio_input reporting_value: radio_yellow event_handlers: - type: tap state_actions: - type: set key: color value: yellow style: type: checkbox bindings: selected: shapes: - border: radius: 2 stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 2 color: default: hex: "#ffff00" alpha: 1 scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: hex: "#000000" alpha: 0.5 stroke_width: 1 color: default: hex: "#ffff00" alpha: 0.5 scale: 1 type: ellipse - size: width: auto height: auto margin: top: 4 view: type: radio_input reporting_value: radio_green event_handlers: - type: tap state_actions: - type: set key: color value: green style: type: checkbox bindings: selected: shapes: - border: radius: 2 stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 2 color: default: hex: "#00ff00" alpha: 1 scale: 1 type: ellipse unselected: shapes: - border: radius: 2 stroke_color: default: hex: "#000000" alpha: 0.5 stroke_width: 1 color: default: hex: "#00ff00" alpha: 0.5 scale: 1 type: ellipse - size: width: auto height: auto margin: top: 4 start: 8 view: type: label text: "<-- Pick a color" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: true invert_when_state_matches: key: color value: is_present: true - size: width: auto height: auto margin: top: 4 start: 8 view: type: label text: "<-- You picked RED" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: color value: equals: red - size: width: auto height: auto margin: top: 4 start: 8 view: type: label text: "<-- You picked YELLOW" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: color value: equals: yellow - size: width: auto height: auto margin: top: 4 start: 8 view: type: label text: "<-- You picked GREEN" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: color value: equals: green # # Checkboxes # - size: width: 100% height: auto margin: top: 24 view: type: label text: Checkboxes text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: top: 12 start: 24 view: type: checkbox_controller identifier: checkboxes view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: auto height: auto margin: top: 0 view: type: checkbox reporting_value: check_cyan event_handlers: - type: tap state_actions: - type: set key: last_check value: cyan style: type: checkbox bindings: selected: icon: icon: checkmark color: default: hex: "#000000" alpha: 1 scale: 0.5 shapes: - border: radius: 5 stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 2 color: default: hex: "#00ffff" alpha: 1 scale: 1 type: rectangle unselected: shapes: - border: radius: 5 stroke_color: default: hex: "#000000" alpha: 0.5 stroke_width: 1 color: default: hex: "#00ffff" alpha: 0.5 scale: 1 type: rectangle - size: width: auto height: auto margin: start: 8 view: type: label text: "<-- Check it" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: true invert_when_state_matches: key: last_check value: is_present: true - size: width: auto height: auto margin: start: 8 view: type: label text: "<-- Tapped last" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: last_check value: equals: cyan - size: width: 100% height: auto margin: top: 4 view: type: linear_layout direction: horizontal items: - size: width: auto height: auto view: type: checkbox reporting_value: check_magenta event_handlers: - type: tap state_actions: - type: set key: last_check value: magenta style: type: checkbox bindings: selected: icon: icon: checkmark color: default: hex: "#000000" alpha: 1 scale: 0.5 shapes: - border: radius: 5 stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 2 color: default: hex: "#ff00ff" alpha: 1 scale: 1 type: rectangle unselected: shapes: - border: radius: 5 stroke_color: default: hex: "#000000" alpha: 0.5 stroke_width: 1 color: default: hex: "#ff00ff" alpha: 0.5 scale: 1 type: rectangle - size: width: auto height: auto margin: start: 8 view: type: label text: "<-- Check it" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: true invert_when_state_matches: key: last_check value: is_present: true - size: width: auto height: auto margin: start: 8 view: type: label text: "<-- Tapped last" text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: last_check value: equals: magenta # # Text Input # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Text Input text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 125 height: auto margin: start: 24 view: type: text_input place_holder: Tap in here identifier: text_input border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 text_appearance: alignment: start font_size: 14 color: default: type: hex hex: "#000000" alpha: 1 input_type: text required: false event_handlers: - type: tap state_actions: - type: set key: text_input_tapped value: true - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: text_input_tapped value: equals: true # # Score # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Score text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 125 height: 32 margin: start: 24 view: type: score identifier: score_input border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 required: false style: type: number_range start: 1 end: 5 spacing: 1 bindings: selected: shapes: - type: rectangle scale: 0.75 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#000000" alpha: .5 text_appearance: alignment: center font_size: 12 color: default: type: hex hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle scale: 0.75 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#ffffff" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: score_tapped value: true - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: score_tapped value: equals: true # # Media Image # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Media Image text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 150 height: 100 view: type: media media_fit: center_inside url: https://upload.wikimedia.org/wikipedia/en/thumb/8/8b/Airship_2019_logo.png/220px-Airship_2019_logo.png media_type: image event_handlers: - type: tap state_actions: - type: set key: media_image_tapped value: true visibility: default: true invert_when_state_matches: key: media_image_tapped value: equals: true - size: width: 150 height: 100 view: type: media media_fit: center_inside url: https://upload.wikimedia.org/wikipedia/commons/thumb/2/2b/Urban_Airship_Logo.jpg/640px-Urban_Airship_Logo.jpg media_type: image event_handlers: - type: tap state_actions: - type: set key: media_image_tapped value: false visibility: default: false invert_when_state_matches: key: media_image_tapped value: equals: true - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: media_image_tapped value: is_present: true # # Media Video # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Media Video text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 175 height: auto view: type: media media_fit: center_inside url: https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 media_type: video event_handlers: - type: tap state_actions: - type: set key: media_video_tapped value: true - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: media_video_tapped value: equals: true # # Media SVG # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Media SVG text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto margin: start: 16 view: type: linear_layout direction: horizontal items: - size: width: 75 height: 75 view: type: media media_fit: center_inside url: https://www.airship.com/wp-content/themes/airship/images/logo-mark.svg media_type: image event_handlers: - type: tap state_actions: - type: set key: media_svg_tapped value: true visibility: default: true invert_when_state_matches: key: media_svg_tapped value: equals: true - size: width: 75 height: 75 view: type: media media_fit: center_inside url: https://upload.wikimedia.org/wikipedia/commons/thumb/0/04/Zeppelin_%28example%29.svg/512px-Zeppelin_%28example%29.svg.png?20180723125412 media_type: image event_handlers: - type: tap state_actions: - type: set key: media_svg_tapped value: false visibility: default: false invert_when_state_matches: key: media_svg_tapped value: equals: true - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: media_svg_tapped value: is_present: true # # WebView # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: WebView text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: 150 view: type: web_view url: "https://example.com" event_handlers: - type: tap state_actions: - type: set key: web_view_tapped value: true - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: web_view_tapped value: equals: true # # Pager Indicator # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Pager Indicator text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: 75 view: type: pager_controller identifier: pager_controller view: type: container border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: pager items: - identifier: "page1" view: type: empty_view background_color: default: hex: "#00FF00" alpha: 0.5 - identifier: "page2" view: type: empty_view background_color: default: hex: "#FFFF00" alpha: 0.5 - identifier: "page2" view: type: empty_view background_color: default: hex: "#FF00FF" alpha: 0.5 - position: horizontal: center vertical: bottom size: height: 24 width: auto margin: bottom: 8 view: type: pager_indicator background_color: default: hex: "#333333" alpha: 0.7 border: radius: 4 spacing: 4 event_handlers: - type: tap state_actions: - type: set key: pager_indicator_tapped value: true bindings: selected: shapes: - type: rectangle aspect_ratio: 2.25 scale: 0.9 border: radius: 4 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle aspect_ratio: 2.25 scale: .9 border: radius: 4 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#000000" alpha: 0 - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: pager_indicator_tapped value: equals: true # # Empty View # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Empty View text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: 75 view: type: empty_view border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: empty_tapped value: true - size: width: auto height: auto margin: start: 8 view: type: label text: <-- you tapped! text_appearance: color: default: hex: "#000000" alpha: 1 font_size: 14 alignment: start visibility: default: false invert_when_state_matches: key: empty_tapped value: equals: true ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/textInput ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 100% height: 70% shade_color: default: hex: '#000000' alpha: 0.75 view: type: state_controller view: type: form_controller identifier: a_form submit: submit_event view: type: scroll_layout direction: vertical view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: auto margin: top: 32 bottom: 32 start: 16 end: 16 view: type: linear_layout direction: vertical items: # # Text Input type: text # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Text Input type text text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 200 height: auto margin: start: 24 view: type: text_input place_holder: Tap in here identifier: text_input_text border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 text_appearance: alignment: start font_size: 15 color: default: type: hex hex: "#325ca8" alpha: 1 input_type: text required: false # # Text Input type: email # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Text Input type email text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 200 height: auto margin: start: 24 view: type: text_input place_holder: Tap in here identifier: text_input_email border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 text_appearance: alignment: start font_size: 20 color: default: type: hex hex: "#a8323a" alpha: 1 input_type: email required: false # # Text Input type: number # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Text Input type number text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 200 height: auto margin: start: 24 view: type: text_input place_holder: Tap in here identifier: text_input_number border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 text_appearance: alignment: start font_size: 25 color: default: type: hex hex: "#339c3f" alpha: 1 styles: - italic - underlined input_type: number required: false # # Text Input type: text_multiline # - size: width: 100% height: auto margin: top: 24 bottom: 12 view: type: label text: Text Input type text_multiline text_appearance: alignment: center font_size: 14 color: default: hex: "#000000" alpha: 1 - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 300 height: auto margin: start: 24 view: type: text_input place_holder: Tap in here identifier: text_input_multiline border: radius: 5 stroke_width: 1 stroke_color: default: type: hex hex: "#cccccc" alpha: 1 text_appearance: alignment: start font_size: 30 color: default: type: hex hex: "#000000" alpha: 1 styles: - bold - italic - underlined input_type: text_multiline required: false ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/toggle-branching-simple-quiz.yml ================================================ version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: '#000000' alpha: 0.75 web: {} view: type: pager_controller identifier: 5ff76966-4e7e-4a5f-b3b4-3928cf7f4076 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: fa59a9dc-4f78-407e-bc89-4bb20c03c310 type: form_controller validation_mode: type: on_demand form_enabled: - form_submission submit: submit_event response_type: user_feedback view: type: container background_color: default: hex: '#004bff' alpha: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: false items: - identifier: b5628f32-3da1-42ae-b76e-c20800922d47 type: pager_item branching: next_page: selectors: - page_id: 24e09863-1dcf-40ea-8b81-b398d286d449 when_state_matches: or: - scope: - $forms - current - data - children - edf9064c-54d5-4b29-9ac9-ad0997a62696 value: equals: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 key: value - page_id: d495142a-38d3-482c-9f14-203501b0518a when_state_matches: and: - scope: - $forms - current - data - children - edf9064c-54d5-4b29-9ac9-ad0997a62696 value: equals: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d key: value view: type: container background_color: default: hex: '#004bff' alpha: 1 items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 size: width: 100% height: auto margin: top: 40 bottom: 8 start: 24 end: 24 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 24 size: width: 100% height: auto view: type: label text: "\U0001F43E Favorite Animal \U0001F43E" content_description: Favorite Animal text_appearance: font_size: 30 color: default: hex: '#FFFFFF' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - margin: top: 0 bottom: 32 size: width: 100% height: auto view: type: label text: Please select your favorite animal text_appearance: alignment: center font_size: 18 color: default: hex: '#FFFFFF' alpha: 0.9 - margin: top: 0 bottom: 24 size: width: 100% height: auto view: type: radio_input_controller identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 required: true on_error: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: error on_valid: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: valid on_edit: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: editing view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: 4eb714c3-c19f-467b-8daa-0a27b5b4d9f7 content_description: Cat style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: "\U0001F431 Cat" content_description: Cat text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: 33c7f7cb-d703-4685-a0c8-0a6591cbb67d content_description: Dog style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: "\U0001F436 Dog" content_description: Dog text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif randomize_children: false attribute_name: {} - size: width: 100% height: 50 margin: top: 24 bottom: 32 start: 24 end: 24 view: type: label_button identifier: next_button_animal background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - pager_next enabled: - form_validation label: type: label text: Next text_appearance: font_size: 18 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - identifier: d495142a-38d3-482c-9f14-203501b0518a type: pager_item branching: next_page: selectors: - page_id: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 view: type: container background_color: default: hex: '#004bff' alpha: 1 items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 7beb5f48-a5fe-4c56-ae31-994263ffcdb2 size: width: 100% height: auto margin: top: 40 bottom: 8 start: 24 end: 24 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 24 size: width: 100% height: auto view: type: label text: "\U0001F436 Favorite Dog Collar \U0001F436" content_description: Favorite Dog Collar Color text_appearance: font_size: 30 color: default: hex: '#FFFFFF' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - margin: top: 0 bottom: 32 size: width: 100% height: auto view: type: label text: What color collar would you choose for your dog? text_appearance: alignment: center font_size: 18 color: default: hex: '#FFFFFF' alpha: 0.9 - margin: top: 0 bottom: 24 size: width: 100% height: auto view: type: radio_input_controller identifier: 7beb5f48-a5fe-4c56-ae31-994263ffcdb2 required: false view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: d0596a26-f197-48d7-96b3-c61ace2fb16b content_description: Blue style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: "\U0001F535 Blue Collar" content_description: Blue text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif - size: width: 24 height: 24 margin: end: 8 view: type: empty_view background_color: default: hex: '#001f9e' alpha: 1 border: radius: 12 stroke_width: 0 - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: radio_input reporting_value: 55ffe2a4-d851-40a8-8046-28c8349e9ace content_description: Red style: type: checkbox bindings: selected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 1 - type: ellipse scale: 0.6 aspect_ratio: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 aspect_ratio: 1 border: radius: 20 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: "\U0001F534 Red Collar" content_description: Red text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif - size: width: 24 height: 24 margin: end: 8 view: type: empty_view background_color: default: hex: '#f1084f' alpha: 1 border: radius: 12 stroke_width: 0 randomize_children: false attribute_name: {} - size: width: 100% height: auto margin: top: 24 bottom: 32 start: 0 end: 0 view: type: linear_layout direction: horizontal items: - size: width: 48% height: 50 margin: end: 8 start: 24 view: type: label_button identifier: prev_button_color background_color: default: hex: '#001f9e' alpha: 1 border: radius: 25 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.3 button_click: - pager_previous label: type: label text: Previous text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - size: width: 48% height: 50 margin: start: 8 end: 24 view: type: label_button identifier: next_button_color background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - pager_next enabled: - form_validation label: type: label text: Next text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - identifier: 24e09863-1dcf-40ea-8b81-b398d286d449 type: pager_item branching: next_page: selectors: - page_id: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 view: type: container background_color: default: hex: '#004bff' alpha: 1 items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f size: width: 100% height: auto margin: top: 40 bottom: 8 start: 24 end: 24 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 24 size: width: 100% height: auto view: type: label text: "\U0001F63A Favorite Cat Shows \U0001F63A" content_description: What are your favorite cat shows? (select at least 2) text_appearance: font_size: 30 color: default: hex: '#FFFFFF' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - margin: top: 0 bottom: 32 size: width: 100% height: auto view: type: label text: Select your favorite cat TV shows (at least 2) text_appearance: alignment: center font_size: 18 color: default: hex: '#FFFFFF' alpha: 0.9 - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: checkbox_controller identifier: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f on_error: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: error on_valid: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: valid on_edit: state_actions: - type: set key: 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: editing required: true min_selection: 2 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: checkbox reporting_value: 646d668e-5d50-44cf-8ddf-7fe4e2a404ab content_description: My Cat From Hell style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#f1084f' alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: My Cat From Hell content_description: My Cat From Hell text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: checkbox reporting_value: 3a6f2143-968c-484e-ac2f-69efdbfaaabd content_description: Cat People style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#f1084f' alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: Cat People content_description: Cat People text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: checkbox reporting_value: 86eaa518-2a24-4018-a303-4c9eb05ec2a3 content_description: Cats 101 style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#f1084f' alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: Cats 101 content_description: Cats 101 text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: checkbox reporting_value: bbd19354-9728-4be6-baab-7d3779841eb2 content_description: The Lion in Your Living Room style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#f1084f' alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: The Lion in Your Living Room content_description: The Lion in Your Living Room text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif - size: width: 100% height: 100% margin: top: 0 bottom: 16 view: type: linear_layout direction: horizontal items: - margin: end: 16 size: width: 24 height: 24 view: type: checkbox reporting_value: d27cc3e2-e1a9-4ed8-b013-a3b64017ee91 content_description: Big Cat Diary style: type: checkbox bindings: selected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#f1084f' alpha: 1 icon: type: icon icon: checkmark scale: 0.8 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: rectangle scale: 1 aspect_ratio: 1 border: radius: 4 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 1 color: default: hex: '#004bff' alpha: 0 - margin: end: 16 size: height: auto width: 100% view: type: label text: Big Cat Diary content_description: Big Cat Diary text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start font_families: - sans-serif randomize_children: true - size: width: 100% height: auto margin: top: 24 bottom: 32 start: 0 end: 0 view: type: linear_layout direction: horizontal items: - size: width: 48% height: 50 margin: end: 8 start: 24 view: type: label_button identifier: prev_button_shows background_color: default: hex: '#001f9e' alpha: 1 border: radius: 25 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.3 button_click: - pager_previous label: type: label text: Previous text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - size: width: 48% height: 50 margin: start: 8 end: 24 view: type: label_button identifier: next_button_shows background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - pager_next enabled: - form_validation label: type: label text: Next text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - identifier: 3cbc6d3e-94f2-4803-90ed-d8978b6a5573 type: pager_item view: type: container background_color: default: hex: '#004bff' alpha: 1 items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: rating_title_label size: width: 100% height: auto margin: top: 40 bottom: 24 start: 24 end: 24 view: type: label text: "\u2764\uFE0F Your Information \u2764\uFE0F" content_description: Your Information text_appearance: font_size: 30 color: default: hex: '#FFFFFF' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - identifier: fe561774-93f5-4b09-bd0c-831b955042e6 size: width: 100% height: auto margin: top: 16 bottom: 8 start: 24 end: 24 view: type: label text: Email Address text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: 2cb5fa0f-1c25-4929-b951-66ce56910901 size: width: 100% height: 50 margin: top: 8 bottom: 16 start: 24 end: 24 view: type: text_input place_holder: email@email.com identifier: 2cb5fa0f-1c25-4929-b951-66ce56910901 border: radius: 8 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.5 text_appearance: alignment: start font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 input_type: email required: true view_overrides: icon_end: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: error value: type: floating icon: type: icon icon: exclamationmark_circle_fill scale: 1 color: default: hex: '#f1084f' alpha: 1 - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: valid value: type: floating icon: type: icon icon: checkmark scale: 1 color: default: hex: '#6ca15f' alpha: 1 border: - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: error value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#f1084f' alpha: 1 - when_state_matches: scope: - 2cb5fa0f-1c25-4929-b951-66ce56910901 value: equals: valid value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#6ca15f' alpha: 1 on_error: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: error on_edit: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: editing on_valid: state_actions: - type: set key: 2cb5fa0f-1c25-4929-b951-66ce56910901 value: valid - identifier: 26b7ddd7-f995-4a27-8fcb-e1b86d00d210 size: width: 100% height: auto margin: top: 16 bottom: 8 start: 24 end: 24 view: type: label text: Additional Email (Optional) text_appearance: font_size: 18 color: default: hex: '#FFFFFF' alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5 size: width: 100% height: 50 margin: top: 8 bottom: 24 start: 24 end: 24 view: type: text_input place_holder: Optional identifier: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5 border: radius: 8 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.5 text_appearance: alignment: start font_size: 16 color: default: hex: '#FFFFFF' alpha: 1 input_type: text required: false view_overrides: icon_end: - when_state_matches: scope: - c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: equals: true value: type: floating icon: type: icon icon: exclamationmark_circle_fill scale: 1 color: default: hex: '#f1084f' alpha: 1 border: - when_state_matches: scope: - c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: equals: true value: radius: 8 stroke_width: 2 stroke_color: default: hex: '#f1084f' alpha: 1 on_error: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error value: true on_edit: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error on_valid: state_actions: - type: set key: c69a7f9f-1c7c-4e87-bee1-0301d9b7b3c5_error - size: width: 100% height: auto margin: top: 24 bottom: 32 start: 0 end: 0 view: type: linear_layout direction: horizontal items: - size: width: 48% height: 50 margin: end: 8 start: 24 view: type: label_button identifier: prev_button_email background_color: default: hex: '#001f9e' alpha: 1 border: radius: 25 stroke_width: 2 stroke_color: default: hex: '#FFFFFF' alpha: 0.3 button_click: - pager_previous label: type: label text: Previous text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 - size: width: 48% height: 50 margin: start: 8 end: 24 view: type: label_button identifier: submit_feedback--Submit background_color: default: hex: '#f1084f' alpha: 1 border: radius: 25 stroke_width: 0 button_click: - form_submit - dismiss enabled: - form_validation label: type: label text: Submit text_appearance: font_size: 16 alignment: center styles: - bold color: default: hex: '#FFFFFF' alpha: 1 view_overrides: icon_start: - value: type: floating space: 8 icon: type: icon icon: progress_spinner color: default: hex: '#FFFFFF' alpha: 1.0 scale: 1 when_state_matches: scope: - $forms - current - status - type value: equals: validating text: - value: Processing ... when_state_matches: scope: - $forms - current - status - type value: equals: validating event_handlers: - type: tap state_actions: - type: set key: submitted value: true - position: horizontal: end vertical: top margin: top: 16 end: 16 size: width: 32 height: 32 view: type: image_button identifier: dismiss_button button_click: - dismiss image: type: icon icon: close color: default: hex: '#FFFFFF' alpha: 0.8 scale: 0.7 - margin: top: 4 bottom: 16 end: 0 start: 0 position: horizontal: center vertical: bottom size: height: 8 width: 100% view: type: pager_indicator spacing: 8 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 1 color: default: hex: '#FFFFFF' alpha: 0.3 branching: pager_completions: [] ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/toggle-layout-types.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: false default_placement: ignore_safe_area: true position: horizontal: center vertical: center size: width: 100% height: 100% background_color: default: type: hex hex: "#FFFFFF" alpha: 1 view: type: form_controller identifier: toggle_layout_showcase response_type: user_feedback validation_mode: type: on_demand view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: # Header - identifier: header size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: label text: Toggle Layout Test text_appearance: font_size: 24 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: - bold font_families: - sans-serif # SECTION 1: Basic Toggle Layout (standalone) - identifier: basic_toggle_section size: width: 100% height: auto margin: top: 24 bottom: 8 start: 16 end: 16 view: type: label text: "1. Basic Toggle Layout" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: - bold font_families: - sans-serif # Basic Toggle Example - identifier: basic_toggle_item size: width: 100% height: auto margin: top: 8 bottom: 24 start: 16 end: 16 view: type: basic_toggle_layout identifier: basic_toggle_1 content_description: "Basic toggle example" on_toggle_on: state_actions: - type: set key: basic_toggle_1 value: true on_toggle_off: state_actions: - type: set key: basic_toggle_1 value: false view: type: container border: radius: 8 stroke_width: 1 stroke_color: default: type: hex hex: "#666666" alpha: 1 view_overrides: background_color: - when_state_matches: key: basic_toggle_1 value: equals: true value: default: type: hex hex: "#4285F4" alpha: 0.3 items: - size: width: 100% height: auto position: horizontal: center vertical: center margin: top: 12 bottom: 12 start: 16 end: 16 view: type: label text: "Basic Toggle Layout" text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center font_families: - sans-serif # SECTION 2: Radio Input Toggle Layout (within controller) - identifier: radio_toggle_section size: width: 100% height: auto margin: top: 16 bottom: 8 start: 16 end: 16 view: type: label text: "2. Radio Input Toggle Layouts" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: - bold font_families: - sans-serif # Radio Controller - identifier: radio_controller_item size: width: 100% height: auto margin: top: 8 bottom: 24 start: 16 end: 16 view: type: radio_input_controller identifier: radio_controller_1 required: true view: type: linear_layout direction: vertical items: # Radio Option 1 - identifier: radio_option_1 size: width: 100% height: auto margin: top: 8 bottom: 8 view: type: radio_input_toggle_layout identifier: radio_toggle_1 content_description: "Radio Option One" # Using "reporting_value:" to match Swift implementation reporting_value: "option_one" on_toggle_on: state_actions: - type: set key: selected_radio value: "option_one" on_toggle_off: state_actions: [] view: type: container border: radius: 8 stroke_width: 1 stroke_color: default: type: hex hex: "#666666" alpha: 1 view_overrides: background_color: - when_state_matches: key: selected_radio value: equals: "option_one" value: default: type: hex hex: "#FFC107" alpha: 0.3 items: - size: width: 100% height: auto position: horizontal: center vertical: center margin: top: 12 bottom: 12 start: 16 end: 16 view: type: label text: "Radio Option One" text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center font_families: - sans-serif # Radio Option 2 - identifier: radio_option_2 size: width: 100% height: auto margin: top: 8 bottom: 8 view: type: radio_input_toggle_layout identifier: radio_toggle_2 content_description: "Radio Option Two" # Using "reporting_value:" to match Swift implementation reporting_value: "option_two" on_toggle_on: state_actions: - type: set key: selected_radio value: "option_two" on_toggle_off: state_actions: [] view: type: container border: radius: 8 stroke_width: 1 stroke_color: default: type: hex hex: "#666666" alpha: 1 view_overrides: background_color: - when_state_matches: key: selected_radio value: equals: "option_two" value: default: type: hex hex: "#FFC107" alpha: 0.3 items: - size: width: 100% height: auto position: horizontal: center vertical: center margin: top: 12 bottom: 12 start: 16 end: 16 view: type: label text: "Radio Option Two" text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center font_families: - sans-serif # SECTION 3: Checkbox Toggle Layout - identifier: checkbox_toggle_section size: width: 100% height: auto margin: top: 16 bottom: 8 start: 16 end: 16 view: type: label text: "3. Checkbox Toggle Layouts" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: - bold font_families: - sans-serif # Checkbox Controller - identifier: checkbox_controller_item size: width: 100% height: auto margin: top: 8 bottom: 24 start: 16 end: 16 view: type: checkbox_controller identifier: checkbox_controller_1 required: true view: type: linear_layout direction: vertical items: # Checkbox Option 1 - identifier: checkbox_option_1 size: width: 100% height: auto margin: top: 8 bottom: 8 view: type: checkbox_toggle_layout identifier: checkbox_toggle_1 content_description: "Checkbox Option One" # Using "reporting_value:" to match Swift implementation reporting_value: "checkbox_one" on_toggle_on: state_actions: - type: set key: checkbox_one value: true on_toggle_off: state_actions: - type: set key: checkbox_one value: false view: type: container border: radius: 8 stroke_width: 1 stroke_color: default: type: hex hex: "#666666" alpha: 1 view_overrides: background_color: - when_state_matches: key: checkbox_one value: equals: true value: default: type: hex hex: "#8BC34A" alpha: 0.3 items: - size: width: 100% height: auto position: horizontal: center vertical: center margin: top: 12 bottom: 12 start: 16 end: 16 view: type: label text: "Checkbox Option One" text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center font_families: - sans-serif # Checkbox Option 2 - identifier: checkbox_option_2 size: width: 100% height: auto margin: top: 8 bottom: 8 view: type: checkbox_toggle_layout identifier: checkbox_toggle_2 content_description: "Checkbox Option Two" # Using "reporting_value:" to match Swift implementation reporting_value: "checkbox_two" on_toggle_on: state_actions: - type: set key: checkbox_two value: true on_toggle_off: state_actions: - type: set key: checkbox_two value: false view: type: container border: radius: 8 stroke_width: 1 stroke_color: default: type: hex hex: "#666666" alpha: 1 view_overrides: background_color: - when_state_matches: key: checkbox_two value: equals: true value: default: type: hex hex: "#8BC34A" alpha: 0.3 items: - size: width: 100% height: auto position: horizontal: center vertical: center margin: top: 12 bottom: 12 start: 16 end: 16 view: type: label text: "Checkbox Option Two" text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center font_families: - sans-serif # Submit Button - identifier: submit_button size: width: 100% height: auto margin: top: 24 bottom: 32 start: 16 end: 16 view: type: label_button identifier: submit_button border: radius: 8 background_color: default: type: hex hex: "#4285F4" alpha: 1 button_click: - form_submit - dismiss enabled: - form_validation label: type: label text: "Submit" content_description: "Submit" text_appearance: font_size: 16 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: - bold font_families: - sans-serif # Close button - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: close_button button_click: - dismiss image: type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 scale: 0.4 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/toggle-rating-numbers.yaml ================================================ version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: '#ffffff' alpha: 0.2 view: type: state_controller background_color: default: type: hex hex: '#FFFFFF' alpha: 1 view: type: pager_controller identifier: 6b72219d-15ef-4bcf-832b-70850b419687 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: 4a560c36-28fa-452d-a2e0-f07a9e3d7521 type: form_controller validation_mode: type: on_demand submit: submit_event form_enabled: - form_submission view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: d2432b09-48d6-40a9-8753-72e372319121 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: rating_title_label size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: Rating content_description: Rating Section Title text_appearance: font_size: 20 color: default: type: hex hex: '#000000' alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: rating_input_section size: width: 100% height: auto margin: top: 8 bottom: 16 start: 16 end: 16 view: type: score_controller identifier: score_radio_controller required: true view: type: linear_layout direction: horizontal padding: top: 8 bottom: 8 items: - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_1 content_description: Rating 1 reporting_value: 1 on_toggle_on: state_actions: - type: set key: selected_score value: 1 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 1 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '1' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 1 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_2 content_description: Rating 2 reporting_value: 2 on_toggle_on: state_actions: - type: set key: selected_score value: 2 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 2 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '2' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 2 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_3 content_description: Rating 3 reporting_value: 3 on_toggle_on: state_actions: - type: set key: selected_score value: 3 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 3 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '3' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 3 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_4 content_description: Rating 4 reporting_value: 4 on_toggle_on: state_actions: - type: set key: selected_score value: 4 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 4 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '4' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 4 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_5 content_description: Rating 5 reporting_value: 5 on_toggle_on: state_actions: - type: set key: selected_score value: 5 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 5 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '5' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 5 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_6 content_description: Rating 6 reporting_value: 6 on_toggle_on: state_actions: - type: set key: selected_score value: 6 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 6 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '6' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 6 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_7 content_description: Rating 7 reporting_value: 7 on_toggle_on: state_actions: - type: set key: selected_score value: 7 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 7 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '7' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 7 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_8 content_description: Rating 8 reporting_value: 8 on_toggle_on: state_actions: - type: set key: selected_score value: 8 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 8 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '8' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 8 - size: width: auto height: 40 margin: end: 4 view: type: score_toggle_layout identifier: score_toggle_9 content_description: Rating 9 reporting_value: 9 on_toggle_on: state_actions: - type: set key: selected_score value: 9 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 9 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '9' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 9 - size: width: auto height: 40 view: type: score_toggle_layout identifier: score_toggle_10 content_description: Rating 10 reporting_value: 10 on_toggle_on: state_actions: - type: set key: selected_score value: 10 on_toggle_off: state_actions: [] view: type: container border: radius: 20 stroke_width: 1 stroke_color: default: type: hex hex: '#CCCCCC' alpha: 1 background_color: default: type: hex hex: '#DDDDDD' alpha: 1 view_overrides: background_color: - value: default: type: hex hex: '#FFA500' alpha: 1 when_state_matches: key: selected_score value: at_least: 10 items: - position: horizontal: center vertical: center size: width: 24 height: 24 view: type: label text: '10' text_appearance: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#333333' alpha: 1 view_overrides: text_appearance: - value: font_size: 14 alignment: center styles: - bold color: default: type: hex hex: '#FFFFFF' alpha: 1 when_state_matches: key: selected_score value: at_least: 10 - identifier: rating_display_label size: width: 100% height: auto margin: top: 16 bottom: 32 start: 16 end: 16 view: type: label text: 'Your rating: Not selected' view_overrides: text: - when_state_matches: key: selected_score value: equals: 1 value: 'Your rating: 1 - Poor' - when_state_matches: key: selected_score value: equals: 2 value: 'Your rating: 2 - Below Average' - when_state_matches: key: selected_score value: equals: 3 value: 'Your rating: 3 - Below Average' - when_state_matches: key: selected_score value: equals: 4 value: 'Your rating: 4 - Average' - when_state_matches: key: selected_score value: equals: 5 value: 'Your rating: 5 - Average' - when_state_matches: key: selected_score value: equals: 6 value: 'Your rating: 6 - Average' - when_state_matches: key: selected_score value: equals: 7 value: 'Your rating: 7 - Good' - when_state_matches: key: selected_score value: equals: 8 value: 'Your rating: 8 - Very Good' - when_state_matches: key: selected_score value: equals: 9 value: 'Your rating: 9 - Excellent' - when_state_matches: key: selected_score value: equals: 10 value: 'Your rating: 10 - Perfect' text_appearance: font_size: 18 color: default: type: hex hex: '#000000' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - identifier: submit_button size: width: 100% height: auto margin: top: 8 bottom: 32 start: 16 end: 16 view: type: label_button identifier: submit_button border: radius: 8 background_color: default: type: hex hex: '#4285F4' alpha: 1 button_click: - form_submit - dismiss enabled: - form_validation label: type: label text: Submit Rating content_description: Submit Rating text_appearance: font_size: 16 color: default: type: hex hex: '#FFFFFF' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss image: scale: 0.4 type: icon icon: close color: default: type: hex hex: '#000000' alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/toggle-rating-stars-small.yaml ================================================ version: 1 presentation: type: modal dismiss_on_touch_outside: false default_placement: ignore_safe_area: false position: horizontal: center vertical: top size: width: 100% height: 100% shade_color: default: type: hex hex: '#ffffff' alpha: 0.2 view: type: state_controller background_color: default: type: hex hex: '#FFFFFF' alpha: 1 view: type: pager_controller identifier: rating-pager-controller view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: rating-form-controller type: form_controller validation_mode: type: on_demand submit: submit_event form_enabled: - form_submission view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: rating-page type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: rating_title_label size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: Star Rating Example content_description: Rating Section Title text_appearance: font_size: 20 color: default: type: hex hex: '#000000' alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: rating_description size: width: 100% height: auto margin: top: 8 bottom: 16 start: 16 end: 16 view: type: label text: 'Please rate your experience:' labels: type: "labels" view_id: score_radio_controller view_type: score_controller text_appearance: font_size: 16 color: default: type: hex hex: '#000000' alpha: 1 alignment: start font_families: - sans-serif - identifier: rating_input_section size: width: 100% height: auto margin: top: 8 bottom: 16 start: 16 end: 16 view: type: score_controller identifier: score_radio_controller content_description: "0 means a bad experience and 5 means a great experience" required: true view: type: linear_layout direction: horizontal main_axis_alignment: space_evenly padding: top: 8 bottom: 8 items: - size: width: auto height: 40 margin: end: 2 view: type: score_toggle_layout identifier: score_toggle_1 content_description: Rating 1 star reporting_value: '1' on_toggle_on: state_actions: - type: set key: selected_score value: 1 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: star scale: 1.0 color: default: type: hex hex: '#808080' alpha: 0.5 view_overrides: icon: - when_state_matches: key: selected_score value: at_least: 1 value: icon: star_fill scale: 1.0 color: default: type: hex hex: '#FFD700' alpha: 1.0 - size: width: auto height: 40 margin: end: 2 view: type: score_toggle_layout identifier: score_toggle_2 content_description: Rating 2 stars reporting_value: '2' on_toggle_on: state_actions: - type: set key: selected_score value: 2 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: star scale: 1.0 color: default: type: hex hex: '#808080' alpha: 0.5 view_overrides: icon: - when_state_matches: key: selected_score value: at_least: 2 value: icon: star_fill scale: 1.0 color: default: type: hex hex: '#FFD700' alpha: 1.0 - size: width: auto height: 40 margin: end: 2 view: type: score_toggle_layout identifier: score_toggle_3 content_description: Rating 3 stars reporting_value: '3' on_toggle_on: state_actions: - type: set key: selected_score value: 3 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: star scale: 1.0 color: default: type: hex hex: '#808080' alpha: 0.5 view_overrides: icon: - when_state_matches: key: selected_score value: at_least: 3 value: icon: star_fill scale: 1.0 color: default: type: hex hex: '#FFD700' alpha: 1.0 - size: width: auto height: 40 margin: end: 2 view: type: score_toggle_layout identifier: score_toggle_4 content_description: Rating 4 stars reporting_value: '4' on_toggle_on: state_actions: - type: set key: selected_score value: 4 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: star scale: 1.0 color: default: type: hex hex: '#808080' alpha: 0.5 view_overrides: icon: - when_state_matches: key: selected_score value: at_least: 4 value: icon: star_fill scale: 1.0 color: default: type: hex hex: '#FFD700' alpha: 1.0 - size: width: auto height: 40 view: type: score_toggle_layout identifier: score_toggle_5 content_description: Rating 5 stars reporting_value: '5' on_toggle_on: state_actions: - type: set key: selected_score value: 5 on_toggle_off: state_actions: [] view: type: icon_view icon: icon: star scale: 1.0 color: default: type: hex hex: '#808080' alpha: 0.5 view_overrides: icon: - when_state_matches: key: selected_score value: at_least: 5 value: icon: star_fill scale: 1.0 color: default: type: hex hex: '#FFD700' alpha: 1.0 - identifier: rating_display_label size: width: 100% height: auto margin: top: 16 bottom: 32 start: 16 end: 16 view: type: label text: 'Your rating: Not selected' view_overrides: text: - when_state_matches: key: selected_score value: equals: 1 value: 'Your rating: 1 - Poor' - when_state_matches: key: selected_score value: equals: 2 value: 'Your rating: 2 - Fair' - when_state_matches: key: selected_score value: equals: 3 value: 'Your rating: 3 - Good' - when_state_matches: key: selected_score value: equals: 4 value: 'Your rating: 4 - Very Good' - when_state_matches: key: selected_score value: equals: 5 value: 'Your rating: 5 - Excellent' text_appearance: font_size: 18 color: default: type: hex hex: '#000000' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - identifier: submit_button size: width: 100% height: auto margin: top: 8 bottom: 32 start: 16 end: 16 view: type: label_button identifier: submit_button border: radius: 8 background_color: default: type: hex hex: '#4285F4' alpha: 1 button_click: - form_submit - dismiss enabled: - form_validation label: type: label text: Submit Rating content_description: Submit Rating text_appearance: font_size: 16 color: default: type: hex hex: '#FFFFFF' alpha: 1 alignment: center styles: - bold font_families: - sans-serif - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss image: scale: 0.4 type: icon icon: close color: default: type: hex hex: '#000000' alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/toggleLayout.yml ================================================ --- version: 1 presentation: type: modal placement_selectors: [] android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 100% height: 100% position: horizontal: center vertical: top shade_color: default: type: hex hex: "#ffffff" alpha: 0.2 view: type: state_controller background_color: default: type: hex hex: "#FFFFFF" alpha: 1 view: type: pager_controller identifier: 6b72219d-15ef-4bcf-832b-70850b419687 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: 4a560c36-28fa-452d-a2e0-f07a9e3d7521 type: form_controller validation_mode: type: on_demand submit: submit_event form_enabled: - form_submission view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: d2432b09-48d6-40a9-8753-72e372319121 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: 8ce7c2d5-23f6-4eda-b840-c76ba483fb68 size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: label text: Single Choice content_description: Single Choice text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: edf9064c-54d5-4b29-9ac9-0000000 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "Favorite Animal" content_description: "Favorite Animal" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: radio_input_controller identifier: edf9064c-54d5-4b29-9ac9-ad0997a62696 required: true on_error: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "error" on_valid: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "valid" on_edit: state_actions: - type: set key: edf9064c-54d5-4b29-9ac9-ad0997a62696 value: "editing" view: type: linear_layout direction: horizontal items: - margin: end: 8 size: width: 100% height: 100% view: type: radio_input_toggle_layout identifier: cat_toggle_id content_description: Cat reporting_value: cat_toggle on_toggle_on: state_actions: - type: set key: animal value: cat on_toggle_off: state_actions: [] view: type: container border: radius: 15 stroke_width: 1 stroke_color: default: type: hex hex: "#231ebd" alpha: 1 view_overrides: background_color: - value: default: hex: "#231ebd" alpha: .6 when_state_matches: key: animal value: equals: cat items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Cat content_description: Cat text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - margin: end: 8 size: width: 100% height: 100% view: type: radio_input_toggle_layout identifier: dog_toggle_id content_description: Dog reporting_value: dog_toggle on_toggle_on: state_actions: - type: set key: animal value: dog on_toggle_off: state_actions: [] view: type: container border: radius: 15 stroke_width: 1 stroke_color: default: type: hex hex: "#231ebd" alpha: 1 view_overrides: background_color: - value: default: hex: "#231ebd" alpha: .6 when_state_matches: key: animal value: equals: dog items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Dog content_description: Dog text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - margin: end: 8 size: width: 100% height: 100% view: type: radio_input_toggle_layout identifier: dragon_toggle_id content_description: Dragon reporting_value: dragon_toggle view_overrides: background_color: - value: default: hex: "#ffbbbb" alpha: 1 when_state_matches: key: animal value: equals: Dragon on_toggle_on: state_actions: - type: set key: animal value: dragon on_toggle_off: state_actions: [] view: type: container border: radius: 15 stroke_width: 1 stroke_color: default: type: hex hex: "#231ebd" alpha: 1 view_overrides: background_color: - value: default: hex: "#231ebd" alpha: .6 when_state_matches: key: animal value: equals: dragon items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Dragon content_description: Dragon text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - identifier: 7beb5f48-a5fe-4c56-ae31-000000 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: Favorite Color content_description: Favorite Color text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: radio_input_controller identifier: 7beb5f48-a5fe-4c56-ae31-994263ffcdb2 required: false view: type: linear_layout direction: vertical items: - margin: end: 8 start: 8 top: 8 bottom: 8 size: width: 100% height: 100% view: type: radio_input_toggle_layout identifier: blue_toggle_id content_description: Blue reporting_value: blue_toggle on_toggle_on: state_actions: - type: set key: color value: blue on_toggle_off: state_actions: [] view: type: container border: radius: 8 stroke_width: 4 stroke_color: default: type: hex hex: "#231ebd" alpha: 1 view_overrides: background_color: - value: default: hex: "#231ebd" alpha: .6 when_state_matches: key: color value: equals: blue items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Blue view_overrides: icon_start: - value: space: 8 type: floating icon: type: icon icon: checkmark color: default: hex: "#000000" alpha: 1.0 scale: 1 when_state_matches: key: color value: equals: blue content_description: Blue text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: end: 8 start: 8 bottom: 8 view: type: radio_input_toggle_layout identifier: red_toggle_id content_description: Red reporting_value: red_toggle on_toggle_on: state_actions: - type: set key: color value: red on_toggle_off: state_actions: [] view: type: container border: radius: 8 stroke_width: 4 stroke_color: default: type: hex hex: "#bd1e1e" alpha: 1 view_overrides: background_color: - value: default: hex: "#bd1e1e" alpha: .6 when_state_matches: key: color value: equals: red items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 position: horizontal: center vertical: center view: type: label text: Red view_overrides: icon_start: - value: space: 8 type: floating icon: type: icon icon: checkmark color: default: hex: "#000000" alpha: 1.0 scale: 1 when_state_matches: key: color value: equals: red content_description: Red text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - identifier: 413f50b9-0b78-4a42-9ca7-cf64e7125253 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: Multi Choice content_description: Multi Choice text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: 4e3f3d9e-a4a2-4a01-a7e4-000000000 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "What are your favorite Guy Fieri shows? (select at least 2)" content_description: "What are your favorite Guy Fieri shows? (select at least 2)" text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: checkbox_controller identifier: 4e3f3d9e-a4a2-4a01-a7e4-76654455 required: true view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 8 bottom: 8 view: type: checkbox_toggle_layout identifier: grocery_games_toggle_id content_description: Guy's Grocery Games reporting_value: grocery_games_toggle on_toggle_on: state_actions: - type: set key: grocery value: true on_toggle_off: state_actions: - type: set key: grocery value: false view: type: label text: Guy's Grocery Games content_description: Guy's Grocery Games view_overrides: icon_start: - when_state_matches: key: grocery value: equals: true value: space: 16 type: floating icon: type: icon icon: checkmark color: default: hex: "#0bb31b" alpha: 1.0 scale: .8 text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: auto margin: top: 8 bottom: 8 view: type: checkbox_toggle_layout identifier: big_bite_toggle_id content_description: Guy's Big Bite reporting_value: big_bite_toggle on_toggle_on: state_actions: - type: set key: guy value: true on_toggle_off: state_actions: - type: set key: guy value: false view: type: label text: Guy's Big Bite content_description: Guy's Big Bite view_overrides: icon_start: - when_state_matches: key: guy value: equals: true value: space: 16 type: floating icon: type: icon icon: checkmark color: default: hex: "#0bb31b" alpha: 1.0 scale: .8 text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: auto margin: top: 8 bottom: 8 view: type: checkbox_toggle_layout identifier: family_road_trip_toggle_id content_description: Guy's Family Road Trip reporting_value: family_road_trip_toggle on_toggle_on: state_actions: - type: set key: family_road_trip value: true on_toggle_off: state_actions: - type: set key: family_road_trip value: false view: type: label text: Guy's Family Road Trip content_description: Guy's Family Road Trip view_overrides: icon_start: - when_state_matches: key: family_road_trip value: equals: true value: space: 16 type: floating icon: type: icon icon: checkmark color: default: hex: "#0bb31b" alpha: 1.0 scale: .8 text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 100% height: 100% margin: top: 8 bottom: 8 view: type: checkbox_toggle_layout identifier: minute_to_win_it_toggle_id content_description: Minute to Win It reporting_value: minute_to_win_it_toggle on_toggle_on: state_actions: - type: set key: minute_to_win_it value: true on_toggle_off: state_actions: - type: set key: minute_to_win_it value: false view: type: label text: Minute to Win It content_description: Minute to Win It view_overrides: icon_start: - when_state_matches: key: minute_to_win_it value: equals: true value: space: 16 type: floating icon: type: icon icon: checkmark color: default: hex: "#0bb31b" alpha: 1.0 scale: .8 text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - identifier: 413f50b9-0b78-4a42-9ca7-cf64e7125253 size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: Standalone content_description: Standalone text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: - bold font_families: - sans-serif - identifier: 4e3f3d9e-a4a2-4a01-a7e4-09925d5bb30F size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: label text: "What are your hobbies?" content_description: "What are your hobbies?" icon_start: space: 8 type: floating icon: type: icon icon: asterisk color: default: hex: "#000000" alpha: 1.0 scale: .75 view_overrides: icon_start: - when_state_matches: scope: - 4e3f3d9e-a4a2-4a01-a7e4-09925d5aa30f value: equals: "error" value: space: 8 type: floating icon: type: icon icon: asterisk_circle_fill color: default: hex: "#ff0000" alpha: 1.0 scale: .75 text_appearance: font_size: 20 color: default: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - margin: top: 0 bottom: 8 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: end: 8 bottom: 8 top: 8 start: 8 size: width: 100% height: auto view: type: basic_toggle_layout identifier: reading_toggle_id content_description: Reading reporting_value: reading_toggle on_toggle_on: state_actions: - type: set key: reading value: true on_toggle_off: state_actions: - type: set key: reading value: false view: type: container border: radius: 2 stroke_width: 4 stroke_color: default: type: hex hex: "#bd1e90" alpha: 1 view_overrides: background_color: - value: default: hex: "#bd1e90" alpha: .6 when_state_matches: key: reading value: equals: true items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Reading content_description: Reading text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - size: width: 100% height: auto margin: end: 8 bottom: 8 top: 8 start: 8 view: type: basic_toggle_layout identifier: travelling_toggle_id content_description: Travelling reporting_value: travelling_toggle on_toggle_on: state_actions: - type: set key: travelling value: true on_toggle_off: state_actions: - type: set key: travelling value: false view: type: container border: radius: 2 stroke_width: 4 stroke_color: default: type: hex hex: "#bd1e90" alpha: 1 view_overrides: background_color: - value: default: hex: "#bd1e90" alpha: .6 when_state_matches: key: travelling value: equals: true items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Travelling content_description: Travelling text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - size: width: 100% height: auto margin: end: 8 bottom: 8 top: 8 start: 8 view: type: basic_toggle_layout identifier: music_toggle_id content_description: Music reporting_value: music_toggle on_toggle_on: state_actions: - type: set key: music value: true on_toggle_off: state_actions: - type: set key: music value: false view: type: container border: radius: 2 stroke_width: 4 stroke_color: default: type: hex hex: "#bd1e90" alpha: 1 view_overrides: background_color: - value: default: hex: "#bd1e90" alpha: .6 when_state_matches: key: music value: equals: true items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Music content_description: Music text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - size: width: 100% height: auto margin: end: 8 bottom: 8 top: 8 start: 8 view: type: basic_toggle_layout identifier: news_toggle_id content_description: News reporting_value: news_toggle on_toggle_on: state_actions: - type: set key: news value: true on_toggle_off: state_actions: - type: set key: news value: false view: type: container border: radius: 2 stroke_width: 4 stroke_color: default: type: hex hex: "#bd1e90" alpha: 1 view_overrides: background_color: - value: default: hex: "#bd1e90" alpha: .6 when_state_matches: key: news value: equals: true items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: News content_description: News text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - size: width: 100% height: auto margin: end: 8 bottom: 8 top: 8 start: 8 view: type: basic_toggle_layout identifier: sport_toggle_id content_description: Sport reporting_value: sport_toggle on_toggle_on: state_actions: - type: set key: sport value: true on_toggle_off: state_actions: - type: set key: sport value: false view: type: container border: radius: 2 stroke_width: 4 stroke_color: default: type: hex hex: "#bd1e90" alpha: 1 view_overrides: background_color: - value: default: hex: "#bd1e90" alpha: .6 when_state_matches: key: sport value: equals: true items: - size: width: 100% height: 100% margin: top: 8 bottom: 8 start: 8 end: 8 position: horizontal: center vertical: center view: type: label text: Sport content_description: Sport text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif - identifier: f1047936-d73a-403f-9ac8-68bf6bacb802 margin: top: 8 bottom: 8 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: submit_feedback--Submit reporting_metadata: trigger_link_id: f1047936-d73a-403f-9ac8-68bf6bacb802 label: view_overrides: icon_start: - value: type: "floating" space: 8 icon: type: icon icon: progress_spinner color: default: hex: "#FFFFFF" alpha: 1.0 scale: 1 when_state_matches: scope: - $forms - current - status - type value: equals: "validating" text: - value: "Processing ..." when_state_matches: scope: - $forms - current - status - type value: equals: "validating" type: label text: Submit content_description: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#63AFF1" alpha: 1 border: radius: 0 stroke_width: 16 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/tour-example.yml ================================================ --- presentation: default_placement: ignore_safe_area: false 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% disable_back_button: false dismiss_on_touch_outside: false type: modal version: 1 view: identifier: bafbfd98-3bd1-479a-a667-e3de75a018bb type: pager_controller view: items: - ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: disable_swipe: false items: - identifier: 8f219146-8624-49b4-9de9-a05dd939e7d9 type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - margin: bottom: 28 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - margin: top: 25 size: height: auto width: 100% view: media_fit: center_crop media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 10 size: height: 80 width: 90% view: text: Welcome to the app text_appearance: alignment: center color: default: alpha: 1 hex: "#020202" type: hex font_families: - sans-serif font_size: 24 styles: - bold type: label - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 90% view: direction: vertical type: scroll_layout view: text: This is the app text_appearance: alignment: center color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label - size: height: 0 width: 0 view: text: '' text_appearance: alignment: center color: default: alpha: 1 hex: "#333333" type: hex font_families: - serif font_size: 14 styles: - underlined type: label - margin: bottom: 0 top: 10 size: height: 100 width: 100% view: direction: horizontal items: - margin: bottom: 30 end: 20 start: 20 top: 4 size: height: 40 width: 85% view: actions: {} background_color: default: alpha: 1 hex: "#222222" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#222222" type: hex stroke_width: 1 button_click: - pager_next enabled: - pager_next identifier: a9b96212-98ab-4695-ad90-1e37cbacc3e3 label: text: Coool text_appearance: alignment: center color: default: alpha: 1 hex: "#FFFFFF" type: hex font_families: - sans-serif font_size: 24 type: label type: label_button type: linear_layout type: linear_layout type: container - identifier: b7a62f09-218b-4fc5-8672-fedd0eb68223 type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - margin: bottom: 28 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - margin: top: 25 size: height: auto width: 100% view: media_fit: center_crop media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 10 size: height: 80 width: 90% view: text: 'Highlight of feature #1' text_appearance: alignment: center color: default: alpha: 1 hex: "#020202" type: hex font_families: - sans-serif font_size: 24 styles: - bold type: label - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 90% view: direction: vertical type: scroll_layout view: text: 'Description about feature #1' text_appearance: alignment: center color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label - size: height: 0 width: 0 view: text: '' text_appearance: alignment: center color: default: alpha: 1 hex: "#333333" type: hex font_families: - serif font_size: 14 styles: - underlined type: label type: linear_layout type: container - identifier: '0235975e-a30c-4e0b-91ef-284ace180968' type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - margin: bottom: 28 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - margin: top: 25 size: height: auto width: 100% view: media_fit: center_crop media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 10 size: height: 80 width: 90% view: text: 'Highlight of feature #2' text_appearance: alignment: center color: default: alpha: 1 hex: "#020202" type: hex font_families: - sans-serif font_size: 24 styles: - bold type: label - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 90% view: direction: vertical type: scroll_layout view: text: 'Description about feature #2' text_appearance: alignment: center color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label - size: height: 0 width: 0 view: text: '' text_appearance: alignment: center color: default: alpha: 1 hex: "#333333" type: hex font_families: - serif font_size: 14 styles: - underlined type: label type: linear_layout type: container - identifier: d780b32a-434a-4f77-87e7-15b46af39373 type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - margin: bottom: 28 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: 100% width: 100% view: direction: vertical items: - margin: bottom: 0 end: 0 start: 0 top: 25 size: height: 80 width: 90% view: text: Do you mind if we stay in touch? text_appearance: alignment: center color: default: alpha: 1 hex: "#020202" type: hex font_families: - sans-serif font_size: 24 styles: - bold type: label - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 90% view: text: 'Enable push notifications so you can:' text_appearance: alignment: start color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 100% view: direction: horizontal items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: 60 width: 60 view: media_fit: center_inside media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: text: 'Value proposition #1' text_appearance: alignment: start color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 100% view: direction: horizontal items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: 60 width: 60 view: media_fit: center_inside media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: text: 'Value proposition #2' text_appearance: alignment: start color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 100% view: direction: horizontal items: - margin: bottom: 0 end: 20 start: 20 top: 0 size: height: 60 width: 60 view: media_fit: center_inside media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 0 end: 0 start: 0 top: 0 size: height: 100% width: 100% view: text: 'Value proposition #3' text_appearance: alignment: start color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label type: linear_layout type: linear_layout - margin: bottom: 0 top: 10 size: height: 100 width: 100% view: direction: vertical items: - margin: bottom: 4 end: 2 start: 2 top: 4 size: height: 40 width: 85% view: actions: enable_feature: user_notifications background_color: default: alpha: 1 hex: "#e2e2e2" type: hex border: radius: 3 stroke_color: default: alpha: 1 hex: "#e2e2e2" type: hex stroke_width: 1 button_click: [] enabled: [] identifier: 0b0c6e37-dfaf-4021-b345-79fe46d48865 label: text: Yes please text_appearance: alignment: center color: default: alpha: 1 hex: "#666666" type: hex font_families: - sans-serif font_size: 18 type: label type: label_button - margin: bottom: 30 end: 2 start: 2 top: 4 size: height: 40 width: 85% view: actions: {} background_color: default: alpha: 1 hex: "#ffffff" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#ffffff" type: hex stroke_width: 1 button_click: [] enabled: [] identifier: ea7010f3-a471-4335-86a9-a61a7066de12 label: text: Maybe later text_appearance: alignment: center color: default: alpha: 1 hex: "#666666" type: hex font_families: - sans-serif font_size: 18 type: label type: label_button type: linear_layout type: linear_layout type: container - identifier: 5a5b6768-e9f2-4453-aa78-5893b75a9948 type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFF" type: hex items: - margin: bottom: 28 end: 0 start: 0 top: 0 position: horizontal: center vertical: center size: height: 100% width: 100% view: direction: vertical items: - margin: bottom: 0 end: 0 start: 0 top: 25 size: height: 80 width: 90% view: text: Thank you text_appearance: alignment: center color: default: alpha: 1 hex: "#020202" type: hex font_families: - sans-serif font_size: 24 styles: - bold type: label - margin: top: 10 size: height: auto width: 100% view: media_fit: center_crop media_type: image type: media url: https://unroll-images-production.s3.amazonaws.com/projects/2487/1565801064410-banner-500x500-no-words.jpg - margin: bottom: 10 end: 0 start: 0 top: 10 size: height: 100% width: 90% view: direction: vertical type: scroll_layout view: text: "Weâ\x80\x99re so glad youâ\x80\x99re here. Customize your experience by setting your notification preferences today!" text_appearance: alignment: center color: default: alpha: 1 hex: "#222222" type: hex font_families: - sans-serif font_size: 18 styles: [] type: label - size: height: 0 width: 0 view: text: '' text_appearance: alignment: center color: default: alpha: 1 hex: "#333333" type: hex font_families: - serif font_size: 14 styles: - underlined type: label - margin: bottom: 0 top: 10 size: height: 100 width: 100% view: direction: vertical items: - margin: bottom: 4 end: 2 start: 2 top: 4 size: height: 40 width: 85% view: actions: deep_link_action: '' background_color: default: alpha: 1 hex: "#e2e2e2" type: hex border: radius: 4 stroke_color: default: alpha: 1 hex: "#e2e2e2" type: hex stroke_width: 1 button_click: [] enabled: [] identifier: 6205af07-1c6b-4668-9f2e-01418006d09a label: text: Set my preferences text_appearance: alignment: center color: default: alpha: 1 hex: "#666666" type: hex font_families: - sans-serif font_size: 18 type: label type: label_button - margin: bottom: 30 end: 2 start: 2 top: 4 size: height: 40 width: 85% view: actions: {} background_color: default: alpha: 1 hex: "#ffffff" type: hex border: radius: 0 stroke_color: default: alpha: 1 hex: "#ffffff" type: hex stroke_width: 1 button_click: [] enabled: [] identifier: c19e0cba-bb93-4fd5-97cf-85800bfdaf05 label: text: Maybe later text_appearance: alignment: center color: default: alpha: 1 hex: "#666666" type: hex font_families: - sans-serif font_size: 18 type: label type: label_button type: linear_layout type: linear_layout type: container type: pager - margin: bottom: 0 end: 10 start: 0 top: 10 position: horizontal: end vertical: top size: height: 48 width: 48 view: button_click: - dismiss identifier: dismiss_button image: color: default: alpha: 1 hex: "#000000" type: hex icon: close scale: 0.4 type: icon type: image_button - margin: bottom: 4 end: 0 start: 0 top: 0 position: horizontal: center vertical: bottom size: height: 20 width: 100% view: bindings: selected: shapes: - aspect_ratio: 1 color: default: alpha: 1 hex: "#AAAAAA" type: hex scale: 1 type: ellipse unselected: shapes: - aspect_ratio: 1 color: default: alpha: 1 hex: "#CCCCCC" type: hex scale: 1 type: ellipse spacing: 4 type: pager_indicator type: container ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/tour-no-safe-areas.yml ================================================ --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false placement_selectors: - window_size: small placement: size: width: 100% height: 100% position: horizontal: center vertical: center device: lock_orientation: portrait shade_color: default: hex: "#FF0000" alpha: 0.6 - window_size: medium placement: size: width: 80% height: 450 position: horizontal: center vertical: center device: lock_orientation: portrait shade_color: default: hex: "#00FF00" alpha: 0.6 - window_size: large placement: size: width: 60% height: 450 position: horizontal: center vertical: center device: lock_orientation: portrait shade_color: default: hex: "#0000FF" alpha: 0.6 default_placement: ignore_safe_area: false device: lock_orientation: portrait size: width: 100% height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0.2 view: type: pager_controller identifier: f3500f42-5926-49a9-be43-ac09991a8432 view: identifier: 38b583bf-f6f5-446d-a118-ebbece4e747c nps_identifier: 562bb2a0-991e-449c-a750-a4045d590a48 type: nps_form_controller submit: submit_event response_type: nps view: type: container background_color: default: type: hex hex: "#FFFFFF" alpha: 1 items: - ignore_safe_area: false position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: 6f2151e2-a685-473c-a0d9-1a12d49eb891 type: pager_item view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: # Scroll content type: linear_layout direction: vertical items: - margin: top: 72 bottom: 32 start: 16 end: 16 size: width: 100% height: auto view: type: label text: How likely is it that you would recommend Airship to a friend or colleague? text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center font_families: - sans-serif - size: # "Not Likely", "Very Likely" width: 100% height: auto margin: bottom: 8 start: 24 end: 24 view: type: linear_layout direction: horizontal items: - size: width: 50% height: auto view: type: label text: Not Likely text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: start font_families: - sans-serif - size: width: 50% height: auto view: type: label text: Very Likely text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: end font_families: - sans-serif - size: height: auto width: 100% margin: top: 8 start: 16 end: 16 bottom: 16 view: type: score style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#DDDDDD" alpha: 1 text_appearance: alignment: center font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 identifier: 562bb2a0-991e-449c-a750-a4045d590a48 required: true - margin: # "What is the primary reason...", text input bottom: 16 start: 16 end: 16 size: width: 100% height: auto view: type: label text: What is the primary reason for your score? text_appearance: font_size: 16 color: default: type: hex hex: "#000000" alpha: 1 alignment: center font_families: - sans-serif - size: width: 100% height: 72 margin: start: 16 end: 16 view: background_color: default: type: hex hex: "#ffffff" alpha: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#63656b" alpha: 1 type: text_input text_appearance: alignment: start font_size: 14 color: default: type: hex hex: "#000000" alpha: 1 identifier: 81727468-1ebe-48cd-a538-562f98ab9739 input_type: text_multiline required: false - size: width: 100% height: 200 margin: top: 16 bottom: 16 start: 16 end: 16 view: type: empty_view background_color: default: hex: "#ff00ff" alpha: 1 - size: # Linear layout for button height: auto width: 100% view: type: linear_layout direction: horizontal background_color: default: hex: "#FFFFFF" alpha: 1 items: - margin: top: 16 bottom: 16 start: 16 end: 16 size: width: 100% height: auto view: type: label_button identifier: submit_feedback--Submit label: type: label text: Submit text_appearance: font_size: 16 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center font_families: - SF Pro - sans-serif enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#123456" alpha: 1 border: radius: 0 stroke_width: 1 stroke_color: default: type: hex hex: "#123456" alpha: 1 - position: horizontal: center vertical: top size: width: 100% height: 48 view: type: container background_color: default: type: hex hex: "#FFFFFF" alpha: 1 items: - position: # X button horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/tour-safe-areas.yml ================================================ --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false device: lock_orientation: portrait size: max_width: 100% max_height: 100% width: 100% min_width: 100% height: 100% min_height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0.2 view: type: pager_controller identifier: f3500f42-5926-49a9-be43-ac09991a8432 view: identifier: e42d88f2-c83d-437b-823b-85266ca31d34 nps_identifier: ea8eed71-7bc8-4e62-874e-7054425c5863 type: nps_form_controller submit: submit_event response_type: nps view: type: container items: - ignore_safe_area: false position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: 6f2151e2-a685-473c-a0d9-1a12d49eb891 type: pager_item view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - margin: top: 45 bottom: 10 start: 0 end: 0 size: width: 92% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: top: 20 bottom: 4 start: 0 end: 0 size: width: 100% height: auto view: type: label text: How likely is it that you would recommend [your company, product, etc.] to a friend or colleague? text_appearance: font_size: 20 color: default: type: hex hex: "#111111" alpha: 1 alignment: center styles: - underlined - bold - italic font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 0 start: 10 end: 10 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: bottom: 8 top: 10 view: type: linear_layout direction: horizontal items: - size: width: 50% height: auto view: type: label text: Not Likely text_appearance: font_size: 34 color: default: type: hex hex: "#111111" alpha: 1 alignment: start styles: - underlined - bold - italic font_families: - sans-serif - size: width: 50% height: auto view: type: label text: Very Likely text_appearance: font_size: 34 color: default: type: hex hex: "#111111" alpha: 1 alignment: end styles: - underlined - bold - italic font_families: - sans-serif - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: height: 40 width: 100% margin: top: 0 bottom: 0 start: 2 end: 2 view: type: score style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#DDDDDD" alpha: 1 text_appearance: alignment: center font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 identifier: ea8eed71-7bc8-4e62-874e-7054425c5863 required: true - margin: top: 10 bottom: 10 start: 0 end: 0 size: width: 92% height: auto view: type: linear_layout direction: vertical items: - margin: top: 0 bottom: 10 end: 0 start: 0 size: width: 100% height: auto view: type: label text: What is the primary reason for your score? text_appearance: font_size: 20 color: default: type: hex hex: "#111111" alpha: 1 alignment: center styles: - underlined - bold - italic font_families: - sans-serif - size: width: 100% height: 70 view: background_color: default: type: hex hex: "#eae9e9" alpha: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#63656b" alpha: 1 type: text_input text_appearance: alignment: start font_size: 14 color: default: type: hex hex: "#000000" alpha: 1 identifier: 4611e2b3-33de-4fef-908e-74a4a9ed6ee8 input_type: text required: false - margin: top: 0 start: 0 end: 0 bottom: 0 size: height: 100% width: 100% view: type: empty_view - margin: top: 10 bottom: 0 size: height: auto width: 92% view: type: linear_layout direction: horizontal items: - margin: top: 4 bottom: 20 start: 0 end: 0 size: width: 100% height: 40 view: type: label_button identifier: submit_feedback--fSubmit label: type: label text: fSubmit text_appearance: font_size: 14 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [] font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#222222" alpha: 1 border: radius: 0 stroke_width: 1 stroke_color: default: type: hex hex: "#222222" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#000000" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/unsafe_areas.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% shade_color: default: hex: '#000000' alpha: 0.5 # no shade ignore_safe_area: true view: # Top-level container (yellow bkg, ignores safe area) type: container background_color: default: hex: "#FFFF00" alpha: 1 items: # TOP|END linear_layout (ignores safe area) - position: horizontal: end vertical: top size: height: 50% width: 50% ignore_safe_area: true view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: auto view: type: label text: Linear Layout background_color: default: hex: "#FF7586" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - size: width: 100% height: auto view: type: label text: "ignore_safe_area: true" background_color: default: hex: "#7385FF" alpha: 1 text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center # TOP|START linear_layout (respects safe area) - position: horizontal: start vertical: top size: height: 50% width: 50% ignore_safe_area: true # originally false view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: auto view: type: label text: Linear Layout background_color: default: hex: "#FF7586" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - size: width: 100% height: auto view: type: label text: "ignore_safe_area: true" # originally false background_color: default: hex: "#7385FF" alpha: 1 text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center # BOTTOM|END linear_layout (ignores safe area, 4 nested children) - position: horizontal: end vertical: bottom size: height: 50% width: 50% ignore_safe_area: true view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: 100% view: type: container background_color: default: hex: "#FFFF00" alpha: 1 items: - position: horizontal: end vertical: top size: height: 50% width: 50% ignore_safe_area: true view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: auto view: type: label text: Linear Layout background_color: default: hex: "#FF7586" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - size: width: 100% height: auto view: type: label text: "ignore_safe_area: true" background_color: default: hex: "#7385FF" alpha: 1 text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - position: horizontal: start vertical: top size: height: 50% width: 50% ignore_safe_area: false view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: auto view: type: label text: Linear Layout background_color: default: hex: "#FF7586" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - size: width: 100% height: auto view: type: label text: "ignore_safe_area: false" background_color: default: hex: "#7385FF" alpha: 1 text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - position: horizontal: end vertical: bottom size: height: 50% width: 50% ignore_safe_area: true view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: auto view: type: label text: Linear Layout background_color: default: hex: "#FF7586" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - size: width: 100% height: auto view: type: label text: "ignore_safe_area: true" background_color: default: hex: "#7385FF" alpha: 1 text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - position: horizontal: start vertical: bottom size: height: 50% width: 50% ignore_safe_area: false view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: auto view: type: label text: Linear Layout background_color: default: hex: "#FF7586" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - size: width: 100% height: auto view: type: label text: "ignore_safe_area: false" background_color: default: hex: "#7385FF" alpha: 1 text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - position: horizontal: start vertical: bottom size: height: 50% width: 50% ignore_safe_area: false view: type: linear_layout direction: vertical background_color: default: hex: "#FFFFFF" alpha: 1 border: stroke_color: default: hex: "#0000FF" alpha: 1 stroke_width: 2 items: - size: width: 100% height: auto view: type: label text: Linear Layout background_color: default: hex: "#FF7586" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 alignment: center - size: width: 100% height: auto view: type: label text: "ignore_safe_area: false" background_color: default: hex: "#7385FF" alpha: 1 text_appearance: font_size: 10 color: default: type: hex hex: "#000000" alpha: 1 alignment: center ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/video-controls.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% ignore_safe_area: true shade_color: default: hex: '#000000' alpha: 0.2 dismiss_on_touch_outside: false view: type: video_controller identifier: vc-parent mute_group: identifier: shared-mute view: type: pager_controller identifier: pager-controller view: type: container background_color: default: type: hex hex: '#000000' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: pager gestures: - type: swipe identifier: swipe-up-id direction: up behavior: behaviors: - dismiss items: # PAGE 1 - identifier: page-1 type: pager_item view: type: video_controller identifier: vc-page-1 mute_group: identifier: shared-mute view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-1 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 1 — Big Buck Bunny" text_appearance: font_size: 24 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Muting here mutes all pages (shared mute group)." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 56 height: 56 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn_1 button_click: - video_toggle_play localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_pause fallback: Pause when_state_matches: scope: - $video - current - playing value: equals: true items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - playing value: equals: true - size: width: 56 height: 56 view: type: stack_image_button identifier: mute_btn_1 button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_unmute fallback: Unmute when_state_matches: scope: - $video - current - muted value: equals: true items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true # PAGE 2 - identifier: page-2 type: pager_item view: type: video_controller identifier: vc-page-2 mute_group: identifier: shared-mute view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-2 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 2 — Elephants Dream" text_appearance: font_size: 24 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Muting here mutes all pages (shared mute group)." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 56 height: 56 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn_2 button_click: - video_toggle_play localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_pause fallback: Pause when_state_matches: scope: - $video - current - playing value: equals: true items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - playing value: equals: true - size: width: 56 height: 56 view: type: stack_image_button identifier: mute_btn_2 button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_unmute fallback: Unmute when_state_matches: scope: - $video - current - muted value: equals: true items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true # PAGE 3 - identifier: page-3 type: pager_item view: type: video_controller identifier: vc-page-3 mute_group: identifier: shared-mute view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-3 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "Page 3 — For Bigger Blazes" text_appearance: font_size: 24 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Muting here mutes all pages (shared mute group)." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 56 height: 56 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn_3 button_click: - video_toggle_play localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_pause fallback: Pause when_state_matches: scope: - $video - current - playing value: equals: true items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - playing value: equals: true - size: width: 56 height: 56 view: type: stack_image_button identifier: mute_btn_3 button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_unmute fallback: Unmute when_state_matches: scope: - $video - current - muted value: equals: true items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true # Dismiss button (overlay on top of pager) - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss localized_content_description: refs: - ua_dismiss fallback: Dismiss image: type: icon icon: close scale: 0.4 color: default: type: hex hex: '#FFFFFF' alpha: 1 # Pager indicator - position: horizontal: center vertical: bottom margin: bottom: 16 size: width: auto height: 8 view: type: pager_indicator spacing: 8 bindings: selected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 0.4 display_type: layout name: Video Controls ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/video-qa.yaml ================================================ --- presentation: android: disable_back_button: false default_placement: device: lock_orientation: portrait ignore_safe_area: false position: horizontal: center vertical: center shade_color: default: alpha: 0.2 hex: "#000000" type: hex size: height: 100% width: 100% web: ignore_shade: true dismiss_on_touch_outside: false placement_selectors: - orientation: landscape placement: border: radius: 10 stroke_color: default: alpha: 1 hex: "#000000" type: hex device: lock_orientation: portrait ignore_safe_area: false position: horizontal: center vertical: center shade_color: default: alpha: 0.2 hex: "#000000" type: hex selectors: - color: alpha: 0.2 hex: "#FFFFFF" type: hex dark_mode: true size: height: 100% width: 100% web: ignore_shade: false window_size: large type: modal version: 1 view: type: state_controller view: identifier: 56c21953-ce73-4d79-ba9e-d590bb465b50 type: pager_controller view: direction: vertical items: - size: height: 100% width: 100% view: items: - identifier: 3b61f71f-328f-48dc-a8b3-c8e1bc1a206f_pager_container_item ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: disable_swipe: true gestures: - behavior: behaviors: - pager_previous identifier: 56c21953-ce73-4d79-ba9e-d590bb465b50_tap_start location: start type: tap - behavior: behaviors: - pager_next_or_dismiss identifier: 56c21953-ce73-4d79-ba9e-d590bb465b50_tap_end location: end type: tap - behavior: behaviors: - dismiss direction: up identifier: 56c21953-ce73-4d79-ba9e-d590bb465b50_swipe_up type: swipe - behavior: behaviors: - dismiss direction: down identifier: 56c21953-ce73-4d79-ba9e-d590bb465b50_swipe_down type: swipe - identifier: 56c21953-ce73-4d79-ba9e-d590bb465b50_hold press_behavior: behaviors: - pager_pause release_behavior: behaviors: - pager_resume type: hold items: - automated_actions: - behaviors: - pager_next delay: 5 identifier: "[pager_next]_3867ec4b-dea8-4682-98f6-697a14c9fcc8" identifier: 3867ec4b-dea8-4682-98f6-697a14c9fcc8 state_actions: - key: 3867ec4b-dea8-4682-98f6-697a14c9fcc8_next type: set type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFA" type: hex selectors: - color: alpha: 1 hex: "#111111" type: hex dark_mode: true items: - identifier: cc467337-1b3b-44f2-94fd-8abf6ce94b24_background_image_container ignore_safe_area: false 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/7sxVHYZ_PnA/?autoplay=1&controls=0&loop=1&mute=1 video: aspect_ratio: 0.5625 autoplay: true loop: true muted: true show_controls: false - identifier: 4c7af3ba-2396-459e-87c7-58abeb565fdf_main_view_container_item ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: items: - identifier: 0f664c4a-8969-4a62-bb07-d162c069e169_container_item margin: bottom: 0 end: 0 start: 0 top: 0 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: - identifier: 0e88653c-4ee2-4f15-94a5-e71e3256e043 margin: bottom: 8 end: 16 start: 16 top: 48 size: height: auto width: 100% view: accessibility_hidden: false accessibility_role: level: 1 type: heading content_description: QA VIDEO text: QA VIDEO text_appearance: alignment: start color: default: alpha: 1 hex: "#000000" type: hex selectors: - color: alpha: 1 hex: "#FFFFFF" type: hex dark_mode: true font_families: - sans-serif font_size: 29 styles: - bold type: label - identifier: 4b00b79c-d7f1-497b-b608-81a0b407588e margin: bottom: 0 end: 0 start: 0 top: 0 size: height: auto width: 50% view: media_fit: center_inside media_type: youtube type: media url: https://www.youtube.com/embed/pJtu4nBhkHo/?autoplay=0&controls=1&loop=0&mute=0 video: aspect_ratio: 1.3333333333333333 autoplay: false loop: false muted: false show_controls: true type: linear_layout type: linear_layout type: container type: container - automated_actions: - behaviors: - pager_next delay: 5 identifier: "[pager_next]_08b41094-e0e4-4c8f-9827-883bba4b28e6" identifier: '08b41094-e0e4-4c8f-9827-883bba4b28e6' state_actions: - key: '08b41094-e0e4-4c8f-9827-883bba4b28e6_next' type: set type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFA" type: hex selectors: - color: alpha: 1 hex: "#111111" type: hex dark_mode: true items: - identifier: cd028436-5d8c-4b8e-98f9-74ad9fe81e11_main_view_container_item ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: items: - identifier: 8e703435-11e0-4420-b0dc-6b428b500ad3_container_item margin: bottom: 0 end: 0 start: 0 top: 0 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: - identifier: 03221167-63d1-4fa2-bd5d-fdc0d6ed425b margin: bottom: 0 end: 0 start: 0 top: 0 size: height: auto width: 100% view: media_fit: center_inside media_type: video type: media url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/b63811eb-17dc-4ba3-a4f0-092c073d7081 video: aspect_ratio: 1.3333333333333333 autoplay: false loop: false muted: true show_controls: true type: linear_layout type: linear_layout type: container type: container - automated_actions: - behaviors: - pager_next_or_dismiss delay: 5 identifier: pager_next_or_dismiss_aa8dd148-663d-455c-8df4-4e0e1f50d676 identifier: aa8dd148-663d-455c-8df4-4e0e1f50d676 state_actions: - key: aa8dd148-663d-455c-8df4-4e0e1f50d676_next type: set type: pager_item view: background_color: default: alpha: 1 hex: "#FFFFFA" type: hex selectors: - color: alpha: 1 hex: "#111111" type: hex dark_mode: true items: - identifier: 26436888-0192-4e3e-9c20-91d85c067ec5_main_view_container_item ignore_safe_area: false position: horizontal: center vertical: center size: height: 100% width: 100% view: items: - identifier: aaf45ec1-c0de-45a3-b84f-1681f759d980_container_item margin: bottom: 0 end: 0 start: 0 top: 0 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: - identifier: 06c3d487-8f24-44b0-a500-17b23ce73c83 margin: bottom: 0 end: 0 start: 0 top: 0 size: height: auto width: 100% view: media_fit: center_inside media_type: youtube type: media url: https://www.youtube.com/embed/pJtu4nBhkHo/?autoplay=1&controls=1&loop=0&mute=0 video: aspect_ratio: 1.7777777777777777 autoplay: true loop: false muted: false show_controls: true type: linear_layout type: linear_layout type: container type: container type: pager - margin: top: 8 position: horizontal: end vertical: top size: height: 48 width: 48 view: button_click: - dismiss identifier: dismiss_button image: color: default: alpha: 1 hex: "#000000" type: hex selectors: - color: alpha: 1 hex: "#FFFFFF" type: hex dark_mode: true icon: close scale: 0.4 type: icon localized_content_description: fallback: Dismiss ref: ua_dismiss refs: - ua_close - ua_dismiss reporting_metadata: button_action: dismiss button_id: dismiss_button type: image_button - margin: bottom: 0 end: 16 start: 16 top: 8 position: horizontal: center vertical: top size: height: 4 width: 100% view: automated_accessibility_actions: - type: announce source: type: pager style: direction: horizontal progress_color: default: alpha: 1 hex: "#000000" type: hex selectors: - color: alpha: 1 hex: "#FFFFFF" type: hex dark_mode: true sizing: equal spacing: 4 track_color: default: alpha: 1 hex: "#BFBFB0" type: hex type: linear_progress type: story_indicator type: container type: linear_layout ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/video-story-multipage.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% ignore_safe_area: true shade_color: default: hex: '#000000' alpha: 0.6 dismiss_on_touch_outside: false view: type: video_controller identifier: vc-story-multi view: type: pager_controller identifier: pager-controller-multi view: type: container background_color: default: type: hex hex: '#000000' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: pager gestures: - type: hold identifier: hold-id press_behavior: behaviors: - pager_pause release_behavior: behaviors: - pager_resume - type: swipe identifier: swipe-up-id direction: up behavior: behaviors: - dismiss items: # PAGE 1 — tests: first-load autoplay, navigate-away and return (should restart) - identifier: story-page-1 type: pager_item automated_actions: - delay: 15 identifier: auto-next-1 behaviors: - pager_next_or_dismiss view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: story-video-1 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: false loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 28 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "1 / 3" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Swipe → next. Come back — video should restart." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 # PAGE 2 — tests: autoplay on new page, navigate back restarts page 1 - identifier: story-page-2 type: pager_item automated_actions: - delay: 15 identifier: auto-next-2 behaviors: - pager_next_or_dismiss view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: story-video-2 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: false loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 28 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "2 / 3" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Swipe ← back — page 1 should autoplay from start." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 # PAGE 3 — tests: user pause intent preserved across navigation - identifier: story-page-3 type: pager_item automated_actions: - delay: 15 identifier: auto-next-3 behaviors: - pager_next_or_dismiss view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: story-video-3 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: false loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 28 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: "3 / 3" text_appearance: font_size: 22 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 6 view: type: label text: "Pause ↓ then swipe ← back — page 1 should stay paused." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 - size: height: 5 width: 80% position: vertical: top horizontal: center margin: top: 8 view: type: story_indicator source: type: pager style: type: linear_progress direction: horizontal sizing: equal spacing: 4 progress_color: default: type: hex hex: '#FFFFFF' alpha: 1 track_color: default: type: hex hex: '#FFFFFF' alpha: 0.4 - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss localized_content_description: refs: - ua_dismiss fallback: Dismiss image: type: icon icon: close scale: 0.4 color: default: type: hex hex: '#FFFFFF' alpha: 1 - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 64 height: 64 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn button_click: - pager_toggle_pause localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_pause fallback: Pause when_state_matches: scope: - $pagers - current - paused value: equals: false items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $pagers - current - paused value: equals: false - size: width: 64 height: 64 view: type: stack_image_button identifier: mute_btn button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_unmute fallback: Unmute when_state_matches: scope: - $video - current - muted value: equals: true items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true display_type: layout name: Video Story (Multi-Page) ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/video-story.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% ignore_safe_area: true shade_color: default: hex: '#000000' alpha: 0.6 dismiss_on_touch_outside: false view: type: video_controller identifier: vc-story view: type: pager_controller identifier: pager-controller-story view: type: container background_color: default: type: hex hex: '#000000' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: pager disable_swipe: true gestures: - type: tap identifier: tap-start-id location: start behavior: behaviors: - pager_previous - type: tap identifier: tap-end-id location: end behavior: behaviors: - pager_next - type: hold identifier: hold-id press_behavior: behaviors: - pager_pause release_behavior: behaviors: - pager_resume - type: swipe identifier: swipe-up-id direction: up behavior: behaviors: - dismiss items: - identifier: story-page-1 type: pager_item automated_actions: - delay: 7 identifier: auto-next-id behaviors: - pager_next_or_dismiss view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: story-video media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: false loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 28 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: Big Buck Bunny text_appearance: font_size: 28 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 8 view: type: label text: Hold to pause. Tap the button to toggle play and audio. text_appearance: font_size: 16 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 - size: height: 5 width: 80% position: vertical: top horizontal: center margin: top: 8 view: type: story_indicator source: type: pager style: type: linear_progress direction: horizontal sizing: equal spacing: 4 progress_color: default: type: hex hex: '#FFFFFF' alpha: 1 track_color: default: type: hex hex: '#FFFFFF' alpha: 0.4 - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss localized_content_description: refs: - ua_dismiss fallback: Dismiss image: type: icon icon: close scale: 0.4 color: default: type: hex hex: '#FFFFFF' alpha: 1 - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 64 height: 64 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn button_click: - pager_toggle_pause - video_toggle_play localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_pause fallback: Pause when_state_matches: scope: - $video - current - playing value: equals: true items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - playing value: equals: true - size: width: 64 height: 64 view: type: stack_image_button identifier: mute_btn button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_unmute fallback: Unmute when_state_matches: scope: - $video - current - muted value: equals: true items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true display_type: layout name: Video Story ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/video-test.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% ignore_safe_area: true shade_color: default: hex: '#000000' alpha: 0.2 dismiss_on_touch_outside: false view: type: video_controller identifier: vc-parent mute_group: identifier: shared-mute view: type: pager_controller identifier: pager-controller view: type: container background_color: default: type: hex hex: '#000000' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: pager items: # PAGE 1: YouTube - Autoplay + No Controls (auto_reset_position defaults true) - identifier: page-1 type: pager_item view: type: video_controller identifier: vc-page-1 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-1 media_fit: center_crop url: "https://www.youtube.com/embed/7sxVHYZ_PnA/?autoplay=1&controls=0&loop=1&mute=1" media_type: youtube video: aspect_ratio: 0.5625 autoplay: true muted: true loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "1. YouTube — Autoplay + No Controls\nauto_reset_position: true (default)" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 2: YouTube - Autoplay + Controls + auto_reset_position: true (override) - identifier: page-2 type: pager_item view: type: video_controller identifier: vc-page-2 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-2 media_fit: center_inside url: "https://www.youtube.com/embed/M7lc1UVf-VE/?autoplay=1&controls=1&loop=1&mute=1" media_type: youtube video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: true auto_reset_position: true - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "2. YouTube — Autoplay + Controls\nauto_reset_position: true (override)" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 3: Native - Autoplay + Controls (auto_reset_position defaults false) - identifier: page-3 type: pager_item view: type: video_controller identifier: vc-page-3 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-3 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: true - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "3. Native — Autoplay + Controls\nauto_reset_position: false (default)" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 4: Native - Non-autoplay + Controls - identifier: page-4 type: pager_item view: type: video_controller identifier: vc-page-4 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-4 media_type: video media_fit: center_inside url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4" video: aspect_ratio: 1.77777777777778 autoplay: false muted: false loop: false show_controls: true - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "4. Native — Non-autoplay + Controls\nShould not play until user taps play" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 5: Vimeo - Autoplay + Controls - identifier: page-5 type: pager_item view: type: video_controller identifier: vc-page-5 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-5 media_fit: center_inside url: "https://player.vimeo.com/video/76979871?background=1" media_type: vimeo video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: true - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "5. Vimeo — Autoplay + Controls\nPause should survive swipe" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 6: Vimeo - Non-autoplay + Controls - identifier: page-6 type: pager_item view: type: video_controller identifier: vc-page-6 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-6 media_fit: center_inside url: "https://player.vimeo.com/video/76979871?background=0" media_type: vimeo video: aspect_ratio: 1.77777777777778 autoplay: false muted: false loop: false show_controls: true - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "6. Vimeo — Non-autoplay + Controls\nShould not play until user taps play" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 7: Native - Autoplay + No Controls + auto_reset_position - identifier: page-7 type: pager_item view: type: video_controller identifier: vc-page-7 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-7 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "7. Native — Autoplay + No Controls\nauto_reset_position: true (default)" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 8: YouTube - Non-autoplay + Controls - identifier: page-8 type: pager_item view: type: video_controller identifier: vc-page-8 view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-8 media_fit: center_inside url: "https://www.youtube.com/embed/jNQXAC9IVRw/?autoplay=0&controls=1&loop=0&mute=0" media_type: youtube video: aspect_ratio: 1.77777777777778 autoplay: false muted: false loop: false show_controls: true - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "8. YouTube — Non-autoplay + Controls\nShould not play until user taps play" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 9: Native + Controller (shared mute with page 10) - identifier: page-9 type: pager_item view: type: video_controller identifier: vc-page-9 mute_group: identifier: shared-mute view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-9 media_type: video media_fit: center_crop url: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: false - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "9. Native — Controller\nShared mute with page 10" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 56 height: 56 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn_9 button_click: - video_toggle_play localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - playing value: equals: true - size: width: 56 height: 56 view: type: stack_image_button identifier: mute_btn_9 button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true # PAGE 10: YouTube + Controller (shared mute with page 9) - identifier: page-10 type: pager_item view: type: video_controller identifier: vc-page-10 mute_group: identifier: shared-mute view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: video-10 media_fit: center_inside url: "https://www.youtube.com/embed/dQw4w9WgXcQ/?autoplay=1&controls=1&loop=1&mute=1" media_type: youtube video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: true show_controls: true - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: label text: "10. YouTube — Controller\nShared mute with page 9" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 56 height: 56 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn_10 button_click: - video_toggle_play localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - playing value: equals: true - size: width: 56 height: 56 view: type: stack_image_button identifier: mute_btn_10 button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true # Dismiss button - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss localized_content_description: refs: - ua_dismiss fallback: Dismiss image: type: icon icon: close scale: 0.4 color: default: type: hex hex: '#FFFFFF' alpha: 1 # Pager indicator - position: horizontal: center vertical: bottom margin: bottom: 16 size: width: auto height: 8 view: type: pager_indicator spacing: 8 bindings: selected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 0.4 display_type: layout name: Video Test Matrix ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/video-youtube.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% ignore_safe_area: true shade_color: default: hex: '#000000' alpha: 0.2 dismiss_on_touch_outside: false view: type: video_controller identifier: vc-yt view: type: container background_color: default: type: hex hex: '#000000' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% ignore_safe_area: true view: type: media identifier: yt-video media_type: youtube media_fit: center_crop url: "https://www.youtube.com/embed/YE7VzlLtp-4" video: aspect_ratio: 1.77777777777778 autoplay: true muted: true loop: false show_controls: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button identifier: dismiss_button button_click: - dismiss localized_content_description: refs: - ua_dismiss fallback: Dismiss image: type: icon icon: close scale: 0.4 color: default: type: hex hex: '#FFFFFF' alpha: 1 - position: horizontal: center vertical: top margin: top: 64 start: 16 end: 16 size: width: 100% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: label text: Big Buck Bunny text_appearance: font_size: 28 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: top: 8 view: type: label text: YouTube background video — tests play/pause and mute state sync via onStateChange. text_appearance: font_size: 16 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.85 - position: horizontal: center vertical: bottom margin: bottom: 40 size: width: auto height: auto view: type: linear_layout direction: horizontal items: - size: width: 56 height: 56 margin: end: 16 view: type: stack_image_button identifier: play_pause_btn button_click: - video_toggle_play localized_content_description: refs: - ua_play fallback: Play items: - type: icon icon: type: icon icon: play color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_pause fallback: Pause when_state_matches: scope: - $video - current - playing value: equals: true items: - value: - type: icon icon: type: icon icon: pause color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - playing value: equals: true - size: width: 56 height: 56 view: type: stack_image_button identifier: mute_btn button_click: - video_toggle_mute localized_content_description: refs: - ua_mute fallback: Mute items: - type: icon icon: type: icon icon: unmute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 view_overrides: localized_content_description: - value: refs: - ua_unmute fallback: Unmute when_state_matches: scope: - $video - current - muted value: equals: true items: - value: - type: icon icon: type: icon icon: mute color: default: type: hex hex: '#FFFFFF' alpha: 1 scale: 0.5 when_state_matches: scope: - $video - current - muted value: equals: true display_type: layout name: Video YouTube ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/video_cropping.yml ================================================ --- presentation: dismiss_on_touch_outside: true default_placement: position: horizontal: center vertical: center shade_color: default: alpha: 0.5 hex: "#000000" type: hex size: height: 100% width: 100% type: modal version: 1 view: type: pager_controller identifier: "pager-controller-id" view: type: container background_color: default: hex: "#ffffff" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 margin: top: 36 view: type: pager items: - identifier: "page-1" view: type: container items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - margin: top: 8 size: width: auto height: auto view: type: label text: "Wide Image (100% x auto)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 24 end: 24 size: width: 100% height: auto view: media_fit: center_inside media_type: video type: media url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 video: aspect_ratio: 0.5625 show_controls: true autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Center (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 media_type: video video: aspect_ratio: 0.5625 show_controls: true autoplay: true muted: true loop: true type: media position: horizontal: center vertical: center video: aspect_ratio: 0.5625 show_controls: true autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Top Start (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media position: horizontal: start vertical: top url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 video: aspect_ratio: 0.5625 show_controls: true autoplay: true muted: true loop: true - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Bottom End (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media position: horizontal: end vertical: bottom url: https://storage.googleapis.com/airship-media-url/ProductTeam/Maxime/PaddleInMP4.mp4 video: aspect_ratio: 0.5625 show_controls: true autoplay: true muted: true loop: true - identifier: "page-2" view: type: container items: - position: vertical: center horizontal: center size: height: 100% width: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - margin: top: 8 size: width: auto height: auto view: type: label text: "Tall Image (100% x auto)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 48 end: 48 size: width: 100% height: auto view: media_fit: center_inside media_type: video type: media url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Center (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media position: horizontal: center vertical: center url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Top Start (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media position: horizontal: start vertical: top url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - margin: top: 8 size: width: auto height: auto view: type: label text: "Crop Bottom End (150 x 150)" text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 - margin: top: 8 bottom: 8 start: 8 end: 8 size: width: 150 height: 150 view: media_fit: fit_crop border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 1 media_type: video type: media position: horizontal: end vertical: bottom url: https://upload.wikimedia.org/wikipedia/commons/thumb/6/61/Multnomah_Falls_and_Bridge.jpg/768px-Multnomah_Falls_and_Bridge.jpg - size: height: 16 width: auto position: vertical: top horizontal: center margin: top: 12 view: type: pager_indicator carousel_identifier: CAROUSEL_ID border: radius: 8 spacing: 4 bindings: selected: shapes: - type: ellipse aspect_ratio: 1 scale: 0.75 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse aspect_ratio: 1 scale: 0.75 border: stroke_width: 1 stroke_color: default: hex: "#333333" alpha: 1 color: default: hex: "#ffffff" alpha: 1 - position: vertical: top horizontal: end size: width: 36 height: 36 margin: top: 0 end: 0 view: type: image_button identifier: x_button button_click: [ dismiss ] image: type: icon icon: close scale: 0.5 color: default: hex: "#000000" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/view_overrides.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: margin: start: 16 end: 16 size: width: 100% height: auto shade_color: default: hex: '#000000' alpha: 0.75 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 3 radius: 10 background_color: default: hex: '#ffffff' alpha: 1 view: type: container items: - size: height: auto width: 100% position: vertical: center horizontal: center margin: start: 32 end: 32 top: 32 bottom: 32 view: type: linear_layout direction: vertical background_color: default: hex: "#ffffff" alpha: 1 border: stroke_color: default: hex: "#000000" alpha: 1 stroke_width: 3 view_overrides: background_color: - value: default: hex: "#ffbbbb" alpha: 1 when_state_matches: key: favorite_color value: equals: red - value: default: hex: "#bbffbb" alpha: 1 when_state_matches: key: favorite_color value: equals: green - value: default: hex: "#bbbbff" alpha: 1 when_state_matches: key: favorite_color value: equals: blue border: - value: stroke_color: default: hex: "#ff0000" alpha: 1 stroke_width: 3 when_state_matches: key: favorite_color value: equals: red - value: stroke_color: default: hex: "#00ff00" alpha: 1 stroke_width: 3 when_state_matches: key: favorite_color value: equals: green - value: stroke_color: default: hex: "#0000ff" alpha: 1 stroke_width: 3 when_state_matches: key: favorite_color value: equals: blue items: - size: height: auto width: 100% view: type: linear_layout direction: horizontal items: - size: height: auto width: auto margin: start: 16 view: type: label text: 'Favorite Color:' text_appearance: alignment: start styles: - bold - underlined font_size: 16 color: default: hex: "#000000" alpha: 1 - size: height: auto width: 100% margin: start: 16 view: type: label text: "" text_appearance: alignment: start styles: - bold font_size: 16 color: default: hex: "#000000" alpha: 1 view_overrides: text: - value: Red when_state_matches: key: favorite_color value: equals: red - value: Green when_state_matches: key: favorite_color value: equals: green - value: Blue when_state_matches: key: favorite_color value: equals: blue - size: height: auto width: 300 margin: start: 16 end: 16 top: 16 bottom: 8 view: type: label_button identifier: red background_color: default: hex: "#FF3333" alpha: 1 label: type: label text: Red text_appearance: font_size: 10 alignment: center color: default: hex: "#000000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: favorite_color value: red - size: height: auto width: 300 margin: start: 16 end: 16 bottom: 8 top: 8 view: type: label_button identifier: green background_color: default: hex: "#33FF33" alpha: 1 label: type: label text: Green text_appearance: font_size: 10 alignment: center color: default: hex: "#000000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: favorite_color value: green - size: height: auto width: 300 margin: start: 16 end: 16 top: 8 bottom: 16 view: type: label_button identifier: blue background_color: default: hex: "#3333FF" alpha: 1 label: type: label text: Blue text_appearance: font_size: 10 alignment: center color: default: hex: "#000000" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: favorite_color value: blue ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wide-image-pager-test.yml ================================================ version: 1 presentation: type: modal default_placement: size: width: 100% height: 100% ignore_safe_area: true shade_color: default: hex: '#000000' alpha: 0.4 dismiss_on_touch_outside: false view: type: pager_controller identifier: wide-image-pager-controller view: type: container background_color: default: type: hex hex: '#1A1A2E' alpha: 1 items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager items: # PAGE 1 - Super wide aspect ratio image, center_crop overflows - identifier: page-wide-1 type: pager_item view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: media identifier: wide-image-1 media_type: image media_fit: fit_crop position: horizontal: center vertical: center url: "https://picsum.photos/id/10/5000/400" - position: horizontal: center vertical: bottom margin: bottom: 100 size: width: 100% height: auto view: type: label text: "Page 1 — Wide Aspect Ratio (15:1) center_crop" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # PAGE 2 - Button page (test if touches work) - identifier: page-button type: pager_item view: type: container background_color: default: type: hex hex: '#2D2D44' alpha: 1 items: - position: horizontal: center vertical: center size: width: 80% height: auto view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: bottom: 16 view: type: label text: "Page 2 — Button Test" text_appearance: font_size: 24 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold - size: width: 100% height: auto margin: bottom: 24 view: type: label text: "If the wide images from pages 1 & 3 block touches, this button won't work." text_appearance: font_size: 14 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 0.7 - size: width: 100% height: 50 view: type: label_button identifier: test-button label: type: label text: "TAP ME — Dismiss" text_appearance: font_size: 18 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold background_color: default: type: hex hex: '#4CAF50' alpha: 1 border: radius: 12 button_click: - dismiss # PAGE 3 - Another super wide aspect ratio image, center_crop overflows - identifier: page-wide-3 type: pager_item view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 300 view: type: media identifier: wide-image-3 media_type: image media_fit: fit_crop position: horizontal: center vertical: center url: "https://picsum.photos/id/47/5000/400" - position: horizontal: center vertical: bottom margin: bottom: 100 size: width: 100% height: auto view: type: label text: "Page 3 — Wide Aspect Ratio (15:1) center_crop" text_appearance: font_size: 20 alignment: center color: default: type: hex hex: '#FFFFFF' alpha: 1 styles: - bold # Dismiss button - position: horizontal: end vertical: top size: width: 48 height: 48 margin: top: 8 end: 8 view: type: image_button identifier: dismiss_button button_click: - dismiss localized_content_description: refs: - ua_dismiss fallback: Dismiss image: type: icon icon: close scale: 0.4 color: default: type: hex hex: '#FFFFFF' alpha: 1 # Pager indicator - position: horizontal: center vertical: bottom margin: bottom: 24 size: width: auto height: 8 view: type: pager_indicator spacing: 8 bindings: selected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 1 unselected: shapes: - type: ellipse scale: 1 color: default: type: hex hex: '#FFFFFF' alpha: 0.4 display_type: layout name: Wide Image Pager Test ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wrapping-modal-score.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 100% height: auto shade_color: default: hex: "#000000" alpha: 0.75 view: type: form_controller identifier: parent_form submit: submit_event view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical background_color: default: hex: "#ffffff" alpha: 1 items: - size: width: 100% height: auto margin: top: 16 bottom: 8 start: 16 end: 16 view: type: label text: "size: 100% x auto\nmaxItemsPerLine: 11" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 1 (0 - 10) - size: width: 100% height: auto margin: start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 24 start: 0 end: 10 wrapping: max_items_per_line: 11 line_spacing: 24 bindings: selected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: ellipse border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "size: auto x auto\nmaxItemsPerLine: 6" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 2 (0 - 10) - size: width: auto height: auto margin: start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 0 start: 0 end: 10 wrapping: max_items_per_line: 6 line_spacing: 0 bindings: selected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: ellipse border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "size: 100% x auto\nmaxItemsPerLine: 11" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 3 (0 - 10) - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 0 start: 0 end: 10 wrapping: max_items_per_line: 11 line_spacing: 0 bindings: selected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "size: 100% x auto\nmaxItemsPerLine: 6" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 4 (0 - 10) - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 0 start: 0 end: 10 wrapping: max_items_per_line: 6 line_spacing: 0 bindings: selected: shapes: - type: rectangle color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 # BOTTOM-PINNED BUTTON - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: label_button identifier: SUBMIT_BUTTON background_color: default: hex: "#000000" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] label: type: label text: 'SEND IT!' text_appearance: font_size: 14 alignment: center color: default: hex: "#ffffff" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wrapping-multi-page.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 90% height: 80% shade_color: default: hex: '#000000' alpha: 0.6 view: type: pager_controller identifier: "pager-controller-id" view: type: container border: radius: 30 stroke_width: 2 stroke_color: default: hex: "#333333" alpha: 0.8 background_color: default: hex: "#ffffff" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 view: type: pager items: - identifier: "pager-page-1-id" accessibility_actions: - type: default reporting_metadata: key: "page_1_next" localized_content_description: fallback: "Next Page" ref: "ua_next" actions: - add_tags_action: "page_1_next_action" behaviors: - pager_next - type: escape reporting_metadata: key: "page_1_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_1_dismiss_action" behaviors: - dismiss automated_actions: - identifier: "auto_announce_page_1" delay: 0.5 behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#FF0000" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_1_nps_form nps_identifier: score_identifier_1 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: auto height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_1 required: true style: type: number_range start: 989 end: 1000 spacing: 42 wrapping: line_spacing: 10 max_items_per_line: 11 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 42 color: default: hex: "#0F0FF0" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 42 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-1-form-submit' label: type: label text: width:auto height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_1 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 content_description: "Close button" - identifier: "pager-page-2-id" display_actions: add_tags_action: 'pager-page-2x' accessibility_actions: - type: default reporting_metadata: key: "page_2_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_2_previous_action" behaviors: - pager_previous - type: escape reporting_metadata: key: "page_2_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_2_dismiss_action" behaviors: - dismiss automated_actions: - identifier: "auto_announce_page_2" delay: 0.5 behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#00FF00" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_2_nps_form nps_identifier: score_identifier_2 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 200 height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_2 required: true style: type: number_range start: 0 end: 10 spacing: 0 wrapping: line_spacing: 0 max_items_per_line: 11 spacing: 8 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-2-form-submit' label: type: label text: width:200 height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - identifier: "pager-page-3-id" display_actions: add_tags_action: 'pager-page-3x' accessibility_actions: - type: default reporting_metadata: key: "page_3_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_3_previous_action" behaviors: - pager_previous - type: escape reporting_metadata: key: "page_3_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_3_dismiss_action" behaviors: - dismiss automated_actions: - identifier: "auto_announce_page_3" delay: 0.5 behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#00FFF0" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_2_nps_form nps_identifier: score_identifier_2 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 50 height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_2 required: true style: type: number_range start: 0 end: 10 spacing: 0 wrapping: line_spacing: 0 max_items_per_line: 11 spacing: 2 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 14 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] display_actions: add_tags_action: 'pager-page-2-form-submit' label: type: label text: width:50 height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_2 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 content_description: "Close button" gestures: - type: swipe identifier: "swipe_next" direction: up behavior: behaviors: ["pager_next"] - type: tap identifier: "tap_next" location: end behavior: behaviors: ["pager_next"] - size: height: 16 width: auto position: vertical: bottom horizontal: center margin: bottom: 8 view: type: pager_indicator spacing: 4 bindings: selected: shapes: - type: rectangle aspect_ratio: 2.25 scale: 0.9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle aspect_ratio: 2.25 scale: .9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#000000" alpha: 0 automated_accessibility_actions: - type: announce background_color: default: hex: "#333333" alpha: 0.7 border: radius: 8 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wrapping-nps-test-2.yml ================================================ --- version: 1 presentation: type: modal dismiss_on_touch_outside: true default_placement: size: width: 90% height: auto shade_color: default: hex: "#000000" alpha: 0.75 view: type: form_controller identifier: parent_form submit: submit_event view: type: linear_layout direction: vertical background_color: default: hex: "#ffffff" alpha: 1 items: # Score 1 (0 - 10) - size: width: auto height: 40 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 2 start: 0 end: 10 bindings: selected: shapes: - type: rectangle color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 24 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 24 color: default: hex: "#666666" alpha: 1 # Score 2 (1 - 5) - size: width: auto height: 24 margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 8 start: 1 end: 5 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFDD33" alpha: 1 text_appearance: font_size: 24 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#3333ff" alpha: 1 text_appearance: font_size: 24 color: default: hex: "#ffffff" alpha: 1 # Score 3 (97 - 105) - size: width: 90% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 8 wrapping: line_spacing: 10 max_items_per_line: 11 start: 994 end: 1000 bindings: selected: shapes: - type: ellipse color: default: hex: "#FF0000" alpha: 1 text_appearance: font_size: 24 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#0000FF" alpha: 1 text_appearance: font_size: 24 color: default: hex: "#ffffff" alpha: 1 # BOTTOM-PINNED BUTTON - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: label_button identifier: SUBMIT_BUTTON background_color: default: hex: "#000000" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] label: type: label text: 'SEND IT!' text_appearance: font_size: 14 alignment: center color: default: hex: "#ffffff" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wrapping-nps-test-3.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 100% height: auto shade_color: default: hex: "#000000" alpha: 0.75 view: type: form_controller identifier: parent_form submit: submit_event view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical background_color: default: hex: "#ffffff" alpha: 1 items: - size: width: 100% height: auto margin: top: 16 bottom: 8 start: 16 end: 16 view: type: label text: "size: 100% x auto\nmaxItemsPerLine: 11" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 1 (0 - 10) - size: width: 100% height: auto margin: start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 24 start: 0 end: 10 wrapping: max_items_per_line: 11 item_padding: 24 bindings: selected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: ellipse border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "size: auto x auto\nmaxItemsPerLine: 6" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 2 (0 - 10) - size: width: auto height: auto margin: start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 0 start: 0 end: 10 wrapping: max_items_per_line: 6 item_padding: 0 bindings: selected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: ellipse border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "size: 100% x auto\nmaxItemsPerLine: 11" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 3 (0 - 10) - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 0 start: 0 end: 10 wrapping: max_items_per_line: 11 item_padding: 0 bindings: selected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: label text: "size: 100% x auto\nmaxItemsPerLine: 6" text_appearance: font_size: 12 alignment: start color: default: hex: "#000000" alpha: 1 # Score 4 (0 - 10) - size: width: 100% height: auto margin: top: 8 bottom: 8 start: 16 end: 16 view: type: nps_form_controller identifier: nps_zero_to_ten_form nps_identifier: nps_zero_to_ten view: type: score identifier: "nps_zero_to_ten" required: true style: type: number_range spacing: 0 start: 0 end: 10 wrapping: max_items_per_line: 6 item_padding: 0 bindings: selected: shapes: - type: rectangle color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 14 styles: - bold color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle border: stroke_width: 1 stroke_color: default: hex: "#999999" alpha: 1 color: default: hex: "#dedede" alpha: 1 text_appearance: font_size: 14 color: default: hex: "#666666" alpha: 1 # BOTTOM-PINNED BUTTON - size: width: 100% height: auto margin: top: 16 bottom: 16 start: 16 end: 16 view: type: label_button identifier: SUBMIT_BUTTON background_color: default: hex: "#000000" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] label: type: label text: 'SEND IT!' text_appearance: font_size: 14 alignment: center color: default: hex: "#ffffff" alpha: 1 ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wrapping-nps-test-4.yml ================================================ # MOBILE-3069 --- version: 1 presentation: type: modal android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false device: lock_orientation: portrait size: max_width: 100% max_height: 100% width: 100% min_width: 100% height: 100% min_height: 100% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#000000" alpha: 0.2 view: type: pager_controller identifier: ea47eaac-4865-428a-bdfc-b512b899cc51 view: identifier: 6d4062aa-16ff-4234-aa70-014dc0e1de88 nps_identifier: 58c4f1c2-99f5-4a9a-998c-e5561abf056a type: nps_form_controller submit: submit_event response_type: nps view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: '068715c7-6b86-4530-a930-8546607e3776' type: pager_item view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: top: 20 bottom: 8 size: width: 100% height: auto view: type: label text: How likely is it that you would recommend [your company, product, etc.] to a friend or colleague? text_appearance: font_size: 20 color: default: type: hex hex: "#111111" alpha: 1 alignment: center styles: - underlined - bold - italic font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 0 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: auto margin: bottom: 4 view: type: label text: Not Likely text_appearance: font_size: 16 color: default: type: hex hex: "#111111" alpha: 1 alignment: start styles: - underlined - bold - italic font_families: - sans-serif - size: width: 50% height: auto margin: bottom: 4 view: type: label text: Very Likely text_appearance: font_size: 16 color: default: type: hex hex: "#111111" alpha: 1 alignment: end styles: - underlined - bold - italic font_families: - sans-serif - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: height: 50 width: 100% view: type: score style: type: number_range start: 0 end: 10 spacing: 2 bindings: selected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#DDDDDD" alpha: 1 text_appearance: alignment: center font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 unselected: shapes: - type: rectangle scale: 1 border: radius: 2 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: font_size: 12 color: default: type: hex hex: "#000000" alpha: 1 identifier: 58c4f1c2-99f5-4a9a-998c-e5561abf056a required: true - size: height: 100% width: 100% view: type: media media_fit: center_inside url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/9e13236f-de64-49ef-b77b-3140fa7a1f03 media_type: image margin: top: 0 bottom: 8 start: 0 end: 0 - margin: top: 8 bottom: 0 start: 16 end: 16 size: height: auto width: 100% view: type: linear_layout direction: horizontal items: - margin: top: 4 bottom: 16 start: 0 end: 0 size: width: 100% height: 48 view: type: label_button identifier: submit_feedback--Submit label: type: label text: Submit text_appearance: font_size: 14 color: default: type: hex hex: "#FFFFFF" alpha: 1 alignment: center styles: [ ] font_families: - sans-serif actions: { } enabled: - form_validation button_click: - form_submit - pager_next background_color: default: type: hex hex: "#222222" alpha: 1 border: radius: 0 stroke_width: 1 stroke_color: default: type: hex hex: "#222222" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - identifier: a232866d-dcc1-45f9-83e1-a6b5df90f40a type: pager_item view: type: container items: - margin: start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [ ] background_color: default: type: hex hex: "#FFFFFF" alpha: 1 - margin: start: 0 end: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: media media_fit: center_crop url: https://dl-staging.urbanairship.com/binary/public/Vl0wyG8kSyCyOUW98Wj4xg/84bdd2d5-f345-431a-aacc-dc246072ddb5 media_type: image - margin: top: 0 bottom: 0 start: 16 end: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - size: height: 100% width: 100% view: type: container items: - margin: bottom: 16 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: [ ] - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#F07ADC" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wrapping-nps-test-5.yml ================================================ version: 1 presentation: type: modal placement_selectors: - placement: ignore_safe_area: true size: width: 50% height: 60% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 0.2 selectors: - platform: ios dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 0.2 web: ignore_shade: false border: radius: 7 window_size: large orientation: landscape android: disable_back_button: false dismiss_on_touch_outside: false default_placement: ignore_safe_area: false size: width: 90% height: 55% position: horizontal: center vertical: center shade_color: default: type: hex hex: "#868686" alpha: 0.2 selectors: - platform: ios dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: ios dark_mode: true color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: android dark_mode: true color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: false color: type: hex hex: "#868686" alpha: 0.2 - platform: web dark_mode: true color: type: hex hex: "#868686" alpha: 0.2 web: ignore_shade: true border: radius: 0 view: type: state_controller view: type: pager_controller identifier: acfa4641-a21b-4887-8ec9-ec3e23c73470 view: type: linear_layout direction: vertical items: - size: width: 100% height: 100% view: identifier: 6f9f0cd7-5f05-4fbd-b8b2-0947f36a00ea nps_identifier: df83673d-a0a0-4b8c-b307-6d5c676e020e type: nps_form_controller content_description: "How likely?" submit: submit_event form_enabled: - form_submission response_type: nps view: type: container items: - position: horizontal: center vertical: center size: width: 100% height: 100% view: type: pager disable_swipe: true items: - identifier: 52bbe423-f050-46f8-8d34-aa9d15fd07a3 type: pager_item view: type: container items: - size: width: 100% height: 100% position: horizontal: center vertical: center ignore_safe_area: false view: type: container items: - margin: bottom: 0 top: 0 end: 0 start: 0 position: horizontal: center vertical: center size: width: 100% height: 100% view: type: linear_layout direction: vertical items: - identifier: scroll_container size: width: 100% height: 100% view: type: scroll_layout direction: vertical view: type: linear_layout direction: vertical items: - identifier: df83673d-a0a0-4b8c-b307-6d5c676e020e size: width: 100% height: auto margin: top: 48 bottom: 8 start: 16 end: 16 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: vertical items: - margin: top: 4 bottom: 8 size: width: 100% height: auto view: type: label text: How likely are you to recommend Airship to a friend or colleague? content_description: How likely are you to recommend Airship to a friend or colleague? labels: type: labels view_id: 2d6a001d-254d-4211-8dd3-074a2d43c7e5 view_type: score text_appearance: font_size: 20 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 alignment: center styles: - bold font_families: - sans-serif - size: width: 100% height: auto margin: top: 0 bottom: 0 view: type: linear_layout direction: vertical items: - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: width: 50% height: auto margin: end: 4 bottom: 4 view: type: label text: "Not Likely" accessibility: accessibility_hidden: true text_appearance: font_size: 18 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 alignment: start styles: [] font_families: - sans-serif - size: width: 50% height: auto margin: start: 4 bottom: 4 view: type: label text: "Extremly Likely" accessibility: accessibility_hidden: true text_appearance: font_size: 18 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 alignment: end styles: [] font_families: - sans-serif - size: width: 100% height: auto view: type: linear_layout direction: horizontal items: - size: height: auto width: 100% view: content_description: "0 is not at all likely, 10 means extremely likely" type: score style: type: number_range start: 0 end: 10 spacing: 2 wrapping: line_spacing: 16 max_items_per_line: 6 bindings: selected: shapes: - type: rectangle scale: 1 border: radius: 45 stroke_width: 1 stroke_color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 color: default: type: hex hex: "#000000" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: alignment: center font_families: - sans-serif font_size: 18 color: default: type: hex hex: "#004BFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 unselected: shapes: - type: rectangle scale: 1 border: radius: 45 stroke_width: 1 stroke_color: default: type: hex hex: "#004BFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#FFFFFF" alpha: 1 text_appearance: font_size: 18 font_families: - sans-serif color: default: type: hex hex: "#004BFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 identifier: df83673d-a0a0-4b8c-b307-6d5c676e020e required: false - identifier: 2d6a001d-254d-4211-8dd3-074a2d43c7e5 margin: top: 30 bottom: 8 start: 16 end: 16 size: width: 80% height: auto view: type: label_button identifier: submit_feedback--Submit Rating reporting_metadata: trigger_link_id: 2d6a001d-254d-4211-8dd3-074a2d43c7e5 label: type: label text: Submit Rating content_description: Submit Rating text_appearance: font_size: 16 color: default: type: hex hex: "#004BFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 alignment: center styles: - bold font_families: - sans-serif actions: {} enabled: - form_validation button_click: - form_submit - dismiss background_color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 border: radius: 3 stroke_width: 0 stroke_color: default: type: hex hex: "#63AFF1" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#63AFF1" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#63AFF1" alpha: 1 event_handlers: - type: tap state_actions: - type: set key: submitted value: true - size: width: 100% height: 100% view: type: linear_layout direction: horizontal items: [] background_color: default: type: hex hex: "#004BFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#004BFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#004BFF" alpha: 1 ignore_safe_area: false - position: horizontal: end vertical: top size: width: 48 height: 48 view: type: image_button image: scale: 0.4 type: icon icon: close color: default: type: hex hex: "#FFFFFF" alpha: 1 selectors: - platform: ios dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: ios dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: android dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: android dark_mode: true color: type: hex hex: "#000000" alpha: 1 - platform: web dark_mode: false color: type: hex hex: "#FFFFFF" alpha: 1 - platform: web dark_mode: true color: type: hex hex: "#000000" alpha: 1 identifier: dismiss_button button_click: - dismiss ================================================ FILE: DevApp/Dev App/Thomas/Resources/Scenes/Modal/wrapping-nps-test.yml ================================================ --- version: 1 presentation: type: modal default_placement: size: width: 90% height: 80% shade_color: default: hex: '#000000' alpha: 0.6 view: type: pager_controller identifier: "pager-controller-id" view: type: container border: radius: 30 stroke_width: 2 stroke_color: default: hex: "#333333" alpha: 0.8 background_color: default: hex: "#ffffff" alpha: 1 items: - position: vertical: center horizontal: center size: height: 100% width: 100% border: radius: 25 view: type: pager items: - identifier: "pager-page-1-id" # display_actions: # add_tags_action: 'pager-page-1x' accessibility_actions: - type: default reporting_metadata: key: "page_1_next" localized_content_description: fallback: "Next Page" ref: "ua_next" actions: - add_tags_action: "page_1_next_action" behaviors: - pager_next - type: escape reporting_metadata: key: "page_1_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_1_dismiss_action" behaviors: - dismiss # automated_actions: # - identifier: "auto_announce_page_1" # delay: 0.5 # behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#FF0000" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_1_nps_form nps_identifier: score_identifier_1 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: auto height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_1 required: true style: type: number_range start: 990 end: 1000 spacing: 120 wrapping: line_spacing: 10 max_items_per_line: 11 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 10 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 10 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] # display_actions: # add_tags_action: 'pager-page-1-form-submit' label: type: label text: width:auto height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_1 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 content_description: "Close button" - identifier: "pager-page-2-id" # display_actions: # add_tags_action: 'pager-page-2x' accessibility_actions: - type: default reporting_metadata: key: "page_2_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_2_previous_action" behaviors: - pager_previous - type: escape reporting_metadata: key: "page_2_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_2_dismiss_action" behaviors: - dismiss # automated_actions: # - identifier: "auto_announce_page_2" # delay: 0.5 # behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#00FF00" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_2_nps_form nps_identifier: score_identifier_2 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 200 height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_2 required: true style: type: number_range start: 0 end: 10 spacing: 0 wrapping: line_spacing: 0 max_items_per_line: 11 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 88 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 88 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] # display_actions: # add_tags_action: 'pager-page-2-form-submit' label: type: label text: width:200 height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - identifier: "pager-page-3-id" # display_actions: # add_tags_action: 'pager-page-3x' accessibility_actions: - type: default reporting_metadata: key: "page_3_previous" localized_content_description: fallback: "Previous Page" ref: "ua_previous" actions: - add_tags_action: "page_3_previous_action" behaviors: - pager_previous - type: escape reporting_metadata: key: "page_3_escape" localized_content_description: fallback: "Dismiss" ref: "ua_escape" actions: - add_tags_action: "page_3_dismiss_action" behaviors: - dismiss # automated_actions: # - identifier: "auto_announce_page_3" # delay: 0.5 # behaviors: ["pager_pause"] view: type: container background_color: default: hex: "#00FFF0" alpha: 0.5 items: - position: vertical: center horizontal: center size: height: auto width: auto view: type: nps_form_controller identifier: page_2_nps_form nps_identifier: score_identifier_2 submit: submit_event view: type: linear_layout direction: vertical items: - size: width: 50 height: auto margin: start: 8 end: 8 view: type: score identifier: score_identifier_2 required: true style: type: number_range start: 0 end: 10 spacing: 0 wrapping: line_spacing: 0 max_items_per_line: 11 bindings: selected: shapes: - type: ellipse color: default: hex: "#FFFFFF" alpha: 0 text_appearance: font_size: 42 color: default: hex: "#000000" alpha: 1 unselected: shapes: - type: ellipse color: default: hex: "#000000" alpha: 1 text_appearance: font_size: 42 color: default: hex: "#ffffff" alpha: 1 content_description: "Rate your experience from 0 to 10" - size: width: auto height: auto margin: start: 16 end: 16 view: type: label_button identifier: submit_button background_color: default: hex: "#ffffff" alpha: 1 button_click: ["form_submit", "cancel"] enabled: ["form_validation"] # display_actions: # add_tags_action: 'pager-page-2-form-submit' label: type: label text: width:50 height:auto text_appearance: font_size: 14 alignment: center color: default: hex: "#000000" alpha: 1 content_description: "Submit button" - position: horizontal: end vertical: top size: height: 24 width: 24 margin: top: 8 end: 8 view: type: image_button identifier: close_button_2 button_click: [ dismiss ] image: type: icon icon: close color: default: hex: "#000000" alpha: 1 content_description: "Close button" gestures: - type: swipe identifier: "swipe_next" direction: up behavior: behaviors: ["pager_next"] - type: tap identifier: "tap_next" location: end behavior: behaviors: ["pager_next"] - size: height: 16 width: auto position: vertical: bottom horizontal: center margin: bottom: 8 view: type: pager_indicator spacing: 4 bindings: selected: shapes: - type: rectangle aspect_ratio: 2.25 scale: 0.9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#ffffff" alpha: 1 unselected: shapes: - type: rectangle aspect_ratio: 2.25 scale: .9 border: radius: 3 stroke_width: 1 stroke_color: default: hex: "#ffffff" alpha: 0.7 color: default: hex: "#000000" alpha: 0 automated_accessibility_actions: - type: announce background_color: default: hex: "#333333" alpha: 0.7 border: radius: 8 ================================================ FILE: DevApp/Dev App/Thomas/Resources/SharedAssets.xcassets/23GrandeAccentColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "0x49", "green" : "0x0D", "red" : "0xFF" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/SharedAssets.xcassets/23GrandeAllFashion.imageset/Contents.json ================================================ { "images" : [ { "filename" : "all_fashion.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/SharedAssets.xcassets/23GrandeAppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "40.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "filename" : "60.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "filename" : "29.png", "idiom" : "iphone", "scale" : "1x", "size" : "29x29" }, { "filename" : "58.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "filename" : "87.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "filename" : "80.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "filename" : "57.png", "idiom" : "iphone", "scale" : "1x", "size" : "57x57" }, { "filename" : "114.png", "idiom" : "iphone", "scale" : "2x", "size" : "57x57" }, { "filename" : "120.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "filename" : "180.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "filename" : "20.png", "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "filename" : "40.png", "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "filename" : "29.png", "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "filename" : "58.png", "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "filename" : "40.png", "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "filename" : "80.png", "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "filename" : "50.png", "idiom" : "ipad", "scale" : "1x", "size" : "50x50" }, { "filename" : "100.png", "idiom" : "ipad", "scale" : "2x", "size" : "50x50" }, { "filename" : "72.png", "idiom" : "ipad", "scale" : "1x", "size" : "72x72" }, { "filename" : "144.png", "idiom" : "ipad", "scale" : "2x", "size" : "72x72" }, { "filename" : "76.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "filename" : "152.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "filename" : "167.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "filename" : "1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }, { "filename" : "16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { "filename" : "32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { "filename" : "64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { "filename" : "128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { "filename" : "256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { "filename" : "512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { "filename" : "1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" }, { "filename" : "48.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "24x24", "subtype" : "38mm" }, { "filename" : "55.png", "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "27.5x27.5", "subtype" : "42mm" }, { "filename" : "58.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "2x", "size" : "29x29" }, { "filename" : "87.png", "idiom" : "watch", "role" : "companionSettings", "scale" : "3x", "size" : "29x29" }, { "idiom" : "watch", "role" : "notificationCenter", "scale" : "2x", "size" : "33x33", "subtype" : "45mm" }, { "filename" : "80.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "40x40", "subtype" : "38mm" }, { "filename" : "88.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "44x44", "subtype" : "40mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "46x46", "subtype" : "41mm" }, { "filename" : "100.png", "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "50x50", "subtype" : "44mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "51x51", "subtype" : "45mm" }, { "idiom" : "watch", "role" : "appLauncher", "scale" : "2x", "size" : "54x54", "subtype" : "49mm" }, { "filename" : "172.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "86x86", "subtype" : "38mm" }, { "filename" : "196.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "98x98", "subtype" : "42mm" }, { "filename" : "216.png", "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "108x108", "subtype" : "44mm" }, { "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "117x117", "subtype" : "45mm" }, { "idiom" : "watch", "role" : "quickLook", "scale" : "2x", "size" : "129x129", "subtype" : "49mm" }, { "filename" : "1024.png", "idiom" : "watch-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/SharedAssets.xcassets/23GrandeArrival1.imageset/Contents.json ================================================ { "images" : [ { "filename" : "new_arrival_1.jpg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/SharedAssets.xcassets/23GrandeArrival2.imageset/Contents.json ================================================ { "images" : [ { "filename" : "new_arrival_2.jpg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/SharedAssets.xcassets/23GrandeHomeBanner.imageset/Contents.json ================================================ { "images" : [ { "filename" : "homeBanner.svg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Thomas/Resources/SharedAssets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/Dev App/Thomas/ThomasLayoutListView.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import SwiftUI // MARK: - AppView struct ThomasLayoutListView: View { @StateObject private var viewModel = ThomasLayoutViewModel() private enum SceneRoutes: Hashable, CaseIterable { case embedded case modal case banner } private enum InAppAutomationRoutes: Hashable, CaseIterable { case modal case banner case fullscreen case html } @State var errorMessage: String? @State var showError: Bool = false func open(_ layout: LayoutFile, addToRecents: Bool = true) { do { try viewModel.openLayout(layout, addToRecents: addToRecents) } catch { self.showError = true self.errorMessage = "Failed to open layout \(error)" } } private var layoutsView: some View { Form { Section("Recent") { ForEach(viewModel.recentLayouts) { layout in Button(layout.fileName) { open(layout, addToRecents: false) } } } Section("Scenes") { ForEach(SceneRoutes.allCases, id: \.self) { route in switch route { case .embedded: NavigationLink(value: AppRouter.HomeRoute.thomas(.layoutList(.sceneEmbedded))) { Label("Embedded", systemImage: "rectangle.portrait.topleft.inset.filled") } case .modal: makeDestination(type: .sceneModal) case .banner: makeDestination(type: .sceneBanner) } } } Section("In-App Automations") { ForEach(InAppAutomationRoutes.allCases, id: \.self) { route in let type: LayoutType = switch route { case .modal: .messageModal case .banner: .messageBanner case .fullscreen: .messageFullscreen case .html: .messageHTML } makeDestination(type: type) } } } .navigationTitle("Layout Viewer") .alert(isPresented: $showError) { Alert( title: Text("Error"), message: Text(self.errorMessage ?? "error"), dismissButton: .default(Text("OK")) ) } .onAppear { self.viewModel.refreshRecent() } } var body: some View { layoutsView } @ViewBuilder private func makeDestination(type: LayoutType) -> some View { let (label, icon) = switch(type) { case .sceneEmbedded: ("", "") case .sceneModal: ("Modal", "rectangle.portrait.center.inset.filled") case .sceneBanner: ("Banner", "rectangle.portrait.topthird.inset.filled") case .messageModal: ("Modal", "rectangle.portrait.center.inset.filled") case .messageBanner: ("Banner", "rectangle.portrait.topthird.inset.filled") case .messageFullscreen: ("Fullscreen", "rectangle.portrait.inset.filled") case .messageHTML: ("HTML", "safari.fill") } NavigationLink(value: AppRouter.HomeRoute.thomas(.layoutList(type))) { Label(label, systemImage: icon) } } } #Preview { ThomasLayoutListView() } ================================================ FILE: DevApp/Dev App/Thomas/ThomasLayoutViewModel.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation internal import Yams @MainActor final class ThomasLayoutViewModel: ObservableObject { private let layoutLoader: LayoutLoader = .init() @Published public private(set) var recentLayouts: [LayoutFile] = [] init() { self.recentLayouts = ThomasUserDefaults.shared.recentLayouts } func openLayout(_ layout: LayoutFile, addToRecents: Bool = true) throws { try layout.open() if addToRecents { Self.saveToRecent(layout) self.recentLayouts = ThomasUserDefaults.shared.recentLayouts } } func layouts(type: LayoutType) -> [LayoutFile] { return layoutLoader.load(type: type) } func refreshRecent() { self.recentLayouts = ThomasUserDefaults.shared.recentLayouts } static func saveToRecent(_ layout: LayoutFile) { ThomasUserDefaults.shared.addRecentLayout(layout) } } @MainActor fileprivate final class ThomasUserDefaults { static let shared: ThomasUserDefaults = ThomasUserDefaults() var recentLayouts: [LayoutFile] { get { readCodable("recentLayouts") ?? [] } set { write("recentLayouts", codable: newValue) } } let defaults: UserDefaults private init() { self.defaults = UserDefaults(suiteName: "airship.layout")! } private func write<T>(_ key: String, codable: T) where T: Codable { let data = try? JSONEncoder().encode(codable) write(key, value: data) } private func readCodable<T>(_ key: String) -> T? where T: Codable { guard let value: Data = read(key) else { return nil } return try? JSONDecoder().decode(T.self, from: value) } private func write(_ key: String, value: Any?) { if let value = value { defaults.set(value, forKey: key) } else { defaults.removeObject(forKey: key) } } private func read<T>(_ key: String) -> T? { return defaults.value(forKey: key) as? T } private func readString(_ key: String, trimmingCharacters: CharacterSet? = nil) -> String? { guard let value: String = read(key) else { return nil } return if let trimmingCharacters { value.trimmingCharacters(in: trimmingCharacters) } else { value } } } extension ThomasUserDefaults { func addRecentLayout(_ layout: LayoutFile) { var current = recentLayouts // Remove duplicate if exists current.removeAll(where: { $0 == layout }) // Insert new layout at the beginning current.insert(layout, at: 0) // Keep only the last 5 items if current.count > 5 { current = Array(current.prefix(5)) } self.recentLayouts = current } } ================================================ FILE: DevApp/Dev App/Toast.swift ================================================ /* Copyright Airship and Contributors */ import Combine import Foundation @MainActor final class Toast: ObservableObject { struct Message: Equatable, Sendable, Hashable { let id: String = UUID().uuidString let text: String let duration: TimeInterval } @Published var message: Message? } ================================================ FILE: DevApp/Dev App/View/AppView.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import AirshipMessageCenter import AirshipPreferenceCenter import SwiftUI struct AppView: View { @EnvironmentObject private var toast: Toast @EnvironmentObject private var router: AppRouter @StateObject private var viewModel: ViewModel = ViewModel() var body: some View { TabView(selection: $router.selectedTab) { HomeView() .tabItem { Label( "Home", systemImage: "house.fill" ) } .onAppear { Airship.analytics.trackScreen("home") } .tag(AppRouter.Tabs.home) MessageCenterView( controller: router.messageCenterController ) .tabItem { Label( "Message Center", systemImage: "tray.fill" ) } #if !os(tvOS) .badge(self.viewModel.messageCenterUnreadcount) #endif .onAppear { Airship.analytics.trackScreen("message_center") } .tag(AppRouter.Tabs.messageCenter) PreferenceCenterView( preferenceCenterID: router.preferenceCenterID ) #if !os(macOS) .navigationViewStyle(.stack) #endif .tabItem { Label( "Preferences", systemImage: "person.fill" ) } .onAppear { Airship.analytics.trackScreen("preference_center") } .tag(AppRouter.Tabs.preferenceCenter) } .overlay { ToastView(toast: toast).padding() } } @MainActor final class ViewModel: ObservableObject { @Published var messageCenterUnreadcount: Int private var task: Task<Void, Never>? = nil @MainActor init() { self.messageCenterUnreadcount = 0 self.task = Task { [weak self] in await Airship.waitForReady() for await unreadCount in Airship.messageCenter.inbox.unreadCountUpdates { self?.messageCenterUnreadcount = unreadCount } } } deinit { task?.cancel() } } } #Preview { AppView() .environmentObject(AppRouter()) .environmentObject(Toast()) } ================================================ FILE: DevApp/Dev App/View/HomeView.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Combine import SwiftUI #if canImport(ActivityKit) import ActivityKit #endif struct HomeView: View { @StateObject private var viewModel: ViewModel = ViewModel() @Environment(\.verticalSizeClass) private var verticalSizeClass @EnvironmentObject private var toast: Toast @EnvironmentObject private var appRouter: AppRouter @ViewBuilder private func makeQuickSettingItem( title: String, value: String? = nil ) -> some View { VStack(alignment: .leading) { Text(title) .foregroundColor(.accentColor) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) if let value { Text(value) .foregroundColor(Color.secondary) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) } } } @ViewBuilder private var quickSettings: some View { ScrollView { VStack { Button( action: { #if !os(tvOS) self.viewModel.copyChannel() self.toast.message = Toast.Message( text: "Channel copied to pasteboard", duration: 2.0 ) #endif } ) { makeQuickSettingItem( title: "Channel ID", value: self.viewModel.channelID ?? "Unavailable" ) } Divider() NavigationLink( value: AppRouter.HomeRoute.namedUser ) { makeQuickSettingItem( title: "Named User", value: self.viewModel.namedUserID ?? "Not Set" ) } #if canImport(ActivityKit) && !os(macOS) Divider() Button(action: { do { try viewModel.startLiveActivity() toast.message = Toast.Message( text: "Live Activity started!", duration: 2.0 ) } catch { toast.message = Toast.Message( text: "Failed to start live activity \(error)", duration: 2.0 ) } }) { makeQuickSettingItem( title: "Start Live Activity", value: "Tap to create delivery" ) } #endif Divider() NavigationLink(value: AppRouter.HomeRoute.thomas(.home)) { makeQuickSettingItem( title: "Thomas Layouts", value: "Tap to preview layouts" ) } } } } @ViewBuilder private var hero: some View { AirshipEmbeddedView(embeddedID: "test") { Image("HomeHeroImage") .resizable() .scaledToFit() } } @ViewBuilder private var pushButton: some View { Button(action: { viewModel.toggleNotifications() }) { if let optedIn = viewModel.notificationStatus?.isUserOptedIn { Text(optedIn ? "Disable Notifications" : "Enable Notifications") .fontWeight(.semibold) .frame(maxWidth: .infinity) .padding(.vertical, 12) } else { ProgressView() .frame(maxWidth: .infinity) .padding(.vertical, 12) } } .controlSize(.large) .buttonStyle(.bordered) .buttonBorderShape(.capsule) } @ViewBuilder private var content: some View { if self.verticalSizeClass == .compact { HStack(spacing: 16) { VStack(spacing: 16) { hero.frame(maxHeight: .infinity) pushButton } .frame(maxHeight: .infinity) quickSettings.frame(maxHeight: .infinity) } } else { VStack(spacing: 16) { VStack(spacing: 16) { hero.frame(maxHeight: .infinity) pushButton } .frame(maxHeight: .infinity) quickSettings.frame(maxHeight: .infinity) } } } var body: some View { NavigationStack(path: self.$appRouter.homePath) { content .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) #if !os(macOS) .navigationBarTitle("") .navigationBarHidden(true) #endif .navigationDestination(for: AppRouter.HomeRoute.self) { $0.destination() } } } @MainActor class ViewModel: ObservableObject { @Published var notificationStatus: AirshipNotificationStatus? @Published var channelID: String? @Published var namedUserID: String? private var task: Task<Void, Never>? = nil @MainActor init() { self.task = Task { [weak self] in await Airship.waitForReady() // Get initial values self?.namedUserID = await Airship.contact.namedUserID self?.notificationStatus = await Airship.push.notificationStatus self?.channelID = Airship.channel.identifier // Listen for changes self?.listenForChanges() } } private func listenForChanges() { // Named User changes Task { @MainActor [weak self] in for await update in Airship.contact.namedUserIDPublisher.values { self?.namedUserID = update } } // Push notification changes Task { @MainActor [weak self] in for await update in await Airship.push.notificationStatusUpdates { self?.notificationStatus = update } } // Channel ID changes Task { @MainActor [weak self] in for await update in Airship.channel.identifierUpdates { self?.channelID = update } } } deinit { task?.cancel() } #if !os(tvOS) func copyChannel() { guard let channelID = Airship.channel.identifier else { return } #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.declareTypes([.string], owner: nil) pasteboard.setString(channelID, forType: .string) #else UIPasteboard.general.string = channelID #endif } #endif #if canImport(ActivityKit) && !os(macOS) func startLiveActivity() throws { let state = DeliveryAttributes.ContentState( stopsAway: 10 ) let attributes = DeliveryAttributes( orderNumber: generateOrderNumber() ) let activity = try Activity.request( attributes: attributes, content: .init(state: state, staleDate: nil), pushType: .token ) Airship.channel.trackLiveActivity( activity, name: attributes.orderNumber ) } #endif @MainActor func toggleNotifications() { if self.notificationStatus?.isUserOptedIn != true { Task { Airship.privacyManager.enableFeatures(.push) await Airship.push.enableUserPushNotifications(fallback: .systemSettings) } } else { Airship.push.userPushNotificationsEnabled = false } } private func generateOrderNumber() -> String { var number = "#" for _ in 1...6 { number += "\(Int.random(in: 1...9))" } return number } } } ================================================ FILE: DevApp/Dev App/View/NamedUserView.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Foundation import SwiftUI @MainActor struct NamedUserView: View { @StateObject private var viewModel: ViewModel = ViewModel() @ViewBuilder private func makeTextInput() -> some View { TextField("Named User", text: self.$viewModel.namedUserID) .onSubmit { viewModel.apply() } #if !os(macOS) .textInputAutocapitalization(.never) #endif .disableAutocorrection(true) } var body: some View { VStack(alignment: .leading, spacing: 24) { Text( "A named user is an identifier that maps multiple devices and channels to a specific individual." ) .multilineTextAlignment(.leading) makeTextInput() .padding() .overlay( RoundedRectangle(cornerRadius: 4) .strokeBorder(Color.secondary, lineWidth: 1) ) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle("Named User") .padding() } @MainActor private class ViewModel: ObservableObject { @Published public var namedUserID: String = "" init() { Airship.onReady { Task { @MainActor [weak self] in self?.namedUserID = await Airship.contact.namedUserID ?? "" } } } func apply() { let normalized = self.namedUserID.trimmingCharacters( in: .whitespacesAndNewlines ) if !normalized.isEmpty { Airship.contact.identify(normalized) } else { Airship.contact.reset() } } } } ================================================ FILE: DevApp/Dev App/View/ToastView.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI struct ToastView: View { @ObservedObject private var toast: Toast init(toast: Toast) { self.toast = toast } @State private var toastTask: Task<(), Never>? = nil @State private var toastVisible: Bool = false @ViewBuilder private func makeView() -> some View { Text(toast.message?.text ?? "") .padding() .background( .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous) ) } @ViewBuilder var body: some View { makeView() .airshipOnChangeOf(self.toast.message) { incoming in if incoming != nil { showToast() } } .hideOpt(self.toastVisible == false || self.toast.message == nil) } private func showToast() { self.toastTask?.cancel() guard let message = toast.message else { return } let waitTask = Task { try? await Task.sleep( nanoseconds: UInt64(message.duration * 1_000_000_000) ) return } Task { let _ = await waitTask.result await MainActor.run { if !waitTask.isCancelled { withAnimation { self.toastVisible = false self.toast.message = nil } } } } self.toastTask = waitTask withAnimation { self.toastVisible = true } } } extension View { @ViewBuilder func hideOpt(_ shouldHide: Bool) -> some View { if shouldHide { self.hidden() } else { self } } } ================================================ FILE: DevApp/Dev-App-Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>GADApplicationIdentifier</key> <string>ca-app-pub-9716462883842968~5417675763</string> <key>SKAdNetworkItems</key> <array> <dict> <key>SKAdNetworkIdentifier</key> <string>cstr6suwn9.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>4fzdc2evr5.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>4pfyvq9l8r.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>2fnua5tdw4.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>ydx93a7ass.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>5a6flpkh64.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>p78axxw29g.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>v72qych5uu.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>ludvb6z3bs.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>cp8zw746q7.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>3sh42y64q3.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>c6k4g5qg8m.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>s39g8k73mm.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>3qy4746246.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>f38h382jlk.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>hs6bdukanm.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>v4nxqhlyqp.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>wzmmz9fp6w.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>yclnxrl5pm.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>t38b2kh725.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>7ug5zh24hu.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>gta9lk7p23.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>vutu7akeur.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>y5ghdn5j9k.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>n6fk4nfna4.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>v9wttpbfk9.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>n38lu8286q.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>47vhws6wlr.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>kbd757ywx3.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>9t245vhmpl.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>eh6m2bh4zr.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>a2p9lx4jpn.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>22mmun2rn5.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>4468km3ulz.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>2u9pt9hc89.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>8s468mfl3y.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>klf5c3l5u5.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>ppxm28t8ap.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>ecpz2srf59.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>uw77j35x4d.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>pwa73g5rt2.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>mlmmfzh3r3.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>578prtvx9j.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>4dzt52r2t5.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>e5fvkxwrpn.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>8c4e2ghe7u.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>zq492l623r.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>3rd42ekr43.skadnetwork</string> </dict> <dict> <key>SKAdNetworkIdentifier</key> <string>3qcr597p9d.skadnetwork</string> </dict> </array> <key>UIAppFonts</key> <array> <string>JurassicPark.otf</string> </array> <key>UIBackgroundModes</key> <array> <string>fetch</string> <string>remote-notification</string> </array> </dict> </plist> ================================================ FILE: DevApp/DevApp App Clip/Airship_Sample_App_Clip.entitlements ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.developer.parent-application-identifiers</key> <array> <string>$(AppIdentifierPrefix)com.urbanairship.richpush</string> </array> </dict> </plist> ================================================ FILE: DevApp/DevApp App Clip/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSAppClip</key> <dict> <key>NSAppClipRequestEphemeralUserNotification</key> <true/> <key>NSAppClipRequestLocationConfirmation</key> <false/> </dict> </dict> </plist> ================================================ FILE: DevApp/DevApp Service Extension/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSExtension</key> <dict> <key>NSExtensionPointIdentifier</key> <string>com.apple.usernotifications.service</string> <key>NSExtensionPrincipalClass</key> <string>$(PRODUCT_MODULE_NAME).NotificationService</string> </dict> </dict> </plist> ================================================ FILE: DevApp/DevApp Service Extension/NotificationService.swift ================================================ /* Copyright Airship and Contributors */ import AirshipNotificationServiceExtension class NotificationService: UANotificationServiceExtension { /// Overrides config to log everyting publically override var airshipConfig: AirshipExtensionConfig { AirshipExtensionConfig( logLevel: .verbose, logHandler: .publicLogger ) } } ================================================ FILE: DevApp/DevApp.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 55; objects = { /* Begin PBXBuildFile section */ 279DCED42E85A544008B5542 /* BiometricLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECE2E85A544008B5542 /* BiometricLoginView.swift */; }; 279DCED52E85A544008B5542 /* WeatherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECF2E85A544008B5542 /* WeatherView.swift */; }; 279DCED62E85A544008B5542 /* MapRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECB2E85A544008B5542 /* MapRouteView.swift */; }; 279DCED72E85A544008B5542 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECC2E85A544008B5542 /* CameraView.swift */; }; 279DCED82E85A544008B5542 /* AdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECD2E85A544008B5542 /* AdView.swift */; }; 279DCED92E85A544008B5542 /* WeatherViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCED02E85A544008B5542 /* WeatherViewModel.swift */; }; 279DCEDA2E85A544008B5542 /* BiometricLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECE2E85A544008B5542 /* BiometricLoginView.swift */; }; 279DCEDB2E85A544008B5542 /* WeatherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECF2E85A544008B5542 /* WeatherView.swift */; }; 279DCEDC2E85A544008B5542 /* MapRouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECB2E85A544008B5542 /* MapRouteView.swift */; }; 279DCEDD2E85A544008B5542 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECC2E85A544008B5542 /* CameraView.swift */; }; 279DCEDE2E85A544008B5542 /* AdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCECD2E85A544008B5542 /* AdView.swift */; }; 279DCEDF2E85A544008B5542 /* WeatherViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCED02E85A544008B5542 /* WeatherViewModel.swift */; }; 279DCEE12E85A558008B5542 /* ThomasLayoutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEE02E85A558008B5542 /* ThomasLayoutViewModel.swift */; }; 279DCEE22E85A558008B5542 /* ThomasLayoutViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEE02E85A558008B5542 /* ThomasLayoutViewModel.swift */; }; 279DCEE62E85A5BD008B5542 /* Scenes in Resources */ = {isa = PBXBuildFile; fileRef = 279DCEE42E85A5BD008B5542 /* Scenes */; }; 279DCEE72E85A5BD008B5542 /* Messages in Resources */ = {isa = PBXBuildFile; fileRef = 279DCEE32E85A5BD008B5542 /* Messages */; }; 279DCEE82E85A5BD008B5542 /* Scenes in Resources */ = {isa = PBXBuildFile; fileRef = 279DCEE42E85A5BD008B5542 /* Scenes */; }; 279DCEE92E85A5BD008B5542 /* Messages in Resources */ = {isa = PBXBuildFile; fileRef = 279DCEE32E85A5BD008B5542 /* Messages */; }; 279DCEF92E85A6A7008B5542 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 279DCEF82E85A6A7008B5542 /* Yams */; }; 279DCEFB2E85A6C6008B5542 /* JurassicPark.otf in Resources */ = {isa = PBXBuildFile; fileRef = 279DCEFA2E85A6C6008B5542 /* JurassicPark.otf */; }; 279DCEFC2E85A6C6008B5542 /* JurassicPark.otf in Resources */ = {isa = PBXBuildFile; fileRef = 279DCEFA2E85A6C6008B5542 /* JurassicPark.otf */; }; 279DCF032E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEFE2E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift */; }; 279DCF042E85A75C008B5542 /* PlaceholderToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF012E85A75C008B5542 /* PlaceholderToggleView.swift */; }; 279DCF052E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEFD2E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift */; }; 279DCF062E85A75C008B5542 /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF002E85A75C008B5542 /* KeyView.swift */; }; 279DCF072E85A75C008B5542 /* EmbeddedPlaygroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEFF2E85A75C008B5542 /* EmbeddedPlaygroundView.swift */; }; 279DCF082E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEFE2E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift */; }; 279DCF092E85A75C008B5542 /* PlaceholderToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF012E85A75C008B5542 /* PlaceholderToggleView.swift */; }; 279DCF0A2E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEFD2E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift */; }; 279DCF0B2E85A75C008B5542 /* KeyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF002E85A75C008B5542 /* KeyView.swift */; }; 279DCF0C2E85A75C008B5542 /* EmbeddedPlaygroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCEFF2E85A75C008B5542 /* EmbeddedPlaygroundView.swift */; }; 279DCF0E2E85A766008B5542 /* Layouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF0D2E85A766008B5542 /* Layouts.swift */; }; 279DCF0F2E85A766008B5542 /* Layouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF0D2E85A766008B5542 /* Layouts.swift */; }; 279DCF112E85A826008B5542 /* ThomasLayoutListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF102E85A826008B5542 /* ThomasLayoutListView.swift */; }; 279DCF122E85A826008B5542 /* ThomasLayoutListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF102E85A826008B5542 /* ThomasLayoutListView.swift */; }; 279DCF142E85A9DE008B5542 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 279DCF132E85A9DE008B5542 /* Yams */; }; 279DCF162E85AA02008B5542 /* LayoutsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF152E85AA02008B5542 /* LayoutsList.swift */; }; 279DCF172E85AA02008B5542 /* LayoutsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279DCF152E85AA02008B5542 /* LayoutsList.swift */; }; 32B5BE4B28F8BFBA00F2254B /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE4A28F8BFBA00F2254B /* Toast.swift */; }; 32B5BE4C28F8BFBA00F2254B /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE4A28F8BFBA00F2254B /* Toast.swift */; }; 32B5BE4E28F94FD200F2254B /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE4D28F94FD200F2254B /* ToastView.swift */; }; 32B5BE4F28F94FD200F2254B /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5BE4D28F94FD200F2254B /* ToastView.swift */; }; 45AA81872F4A697800B81FBC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = 45AA81862F4A697800B81FBC /* AirshipConfig.plist */; }; 45AA81882F4A697800B81FBC /* AirshipConfig.plist in Resources */ = {isa = PBXBuildFile; fileRef = 45AA81862F4A697800B81FBC /* AirshipConfig.plist */; }; 6E7DB38F28ED0AF3002725F6 /* DeliveryAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7DB37B28ECA830002725F6 /* DeliveryAttributes.swift */; }; 6E7DB39028ED0AF4002725F6 /* DeliveryAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7DB37B28ECA830002725F6 /* DeliveryAttributes.swift */; }; 6E7DB39128ED0AF5002725F6 /* DeliveryAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E7DB37B28ECA830002725F6 /* DeliveryAttributes.swift */; }; 6E938DAC2AC35F2600F691D9 /* AirshipFeatureFlags.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E2F5AA82A67330900CABD3D /* AirshipFeatureFlags.framework */; }; 6E938DAD2AC35F2600F691D9 /* AirshipFeatureFlags.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6E2F5AA82A67330900CABD3D /* AirshipFeatureFlags.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6EAAE97F28C7E18C003CAE53 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6EAAE97E28C7E18C003CAE53 /* Assets.xcassets */; }; 6EAAE98728C7E1A1003CAE53 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8B828C7C9A3003CAE53 /* HomeView.swift */; }; 6EAAE98828C7E1A1003CAE53 /* NamedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8BB28C7C9A3003CAE53 /* NamedUserView.swift */; }; 6EAAE98A28C7E1A1003CAE53 /* MainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8BC28C7C9A3003CAE53 /* MainApp.swift */; }; 6EAAE98C28C7E1A1003CAE53 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8C028C7C9A3003CAE53 /* AppView.swift */; }; 6EAAE9AB28C7E309003CAE53 /* DevApp App Clip.app in Embed App Clips */ = {isa = PBXBuildFile; fileRef = 6EAAE99C28C7E308003CAE53 /* DevApp App Clip.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6EAAE9B128C7E31E003CAE53 /* MainApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8BC28C7C9A3003CAE53 /* MainApp.swift */; }; 6EAAE9B228C7E31E003CAE53 /* NamedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8BB28C7C9A3003CAE53 /* NamedUserView.swift */; }; 6EAAE9B928C7E31E003CAE53 /* AppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8C028C7C9A3003CAE53 /* AppView.swift */; }; 6EAAE9BD28C7E31E003CAE53 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE8B828C7C9A3003CAE53 /* HomeView.swift */; }; 6EAAE9BE28C7E31E003CAE53 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6EAAE97E28C7E18C003CAE53 /* Assets.xcassets */; }; 6EAAE9F228C7E417003CAE53 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EAAE9F128C7E417003CAE53 /* NotificationService.swift */; }; 6EAAE9F628C7E417003CAE53 /* DevApp Service Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6EAAE9EF28C7E417003CAE53 /* DevApp Service Extension.appex */; platformFilters = (ios, xros, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6EAAE9FA28C7E438003CAE53 /* AirshipNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D5912396E9A300AF2D1A /* AirshipNotificationServiceExtension.framework */; }; 6EAAE9FB28C7E438003CAE53 /* AirshipNotificationServiceExtension.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D5912396E9A300AF2D1A /* AirshipNotificationServiceExtension.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6EAAE9FF28C7E4B4003CAE53 /* AirshipBasement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EB1B41326EAA531000421B9 /* AirshipBasement.framework */; }; 6EAAEA0028C7E4B4003CAE53 /* AirshipBasement.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6EB1B41326EAA531000421B9 /* AirshipBasement.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6EAAEA0128C7E4B4003CAE53 /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D57C2396E99D00AF2D1A /* AirshipCore.framework */; }; 6EAAEA0228C7E4B4003CAE53 /* AirshipCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D57C2396E99D00AF2D1A /* AirshipCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6EAAEA0328C7E4B4003CAE53 /* AirshipDebug.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D5842396E99D00AF2D1A /* AirshipDebug.framework */; }; 6EAAEA0428C7E4B4003CAE53 /* AirshipDebug.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D5842396E99D00AF2D1A /* AirshipDebug.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6EAAEA0528C7E4B4003CAE53 /* AirshipMessageCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D5882396E99D00AF2D1A /* AirshipMessageCenter.framework */; }; 6EAAEA0628C7E4B4003CAE53 /* AirshipMessageCenter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6E65D5882396E99D00AF2D1A /* AirshipMessageCenter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6EAAEA0728C7E4B4003CAE53 /* AirshipPreferenceCenter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EAD7CDF26B2003700B88EA7 /* AirshipPreferenceCenter.framework */; }; 6EAAEA0828C7E4B4003CAE53 /* AirshipPreferenceCenter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6EAD7CDF26B2003700B88EA7 /* AirshipPreferenceCenter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 6EB21AA02E82008A001A5660 /* AppRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A962E820088001A5660 /* AppRouter.swift */; }; 6EB21AA12E82008A001A5660 /* AppRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21A962E820088001A5660 /* AppRouter.swift */; }; 6EB21AA32E820C5A001A5660 /* AirshipInitializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21AA22E820C55001A5660 /* AirshipInitializer.swift */; }; 6EB21AA42E820C5A001A5660 /* AirshipInitializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21AA22E820C55001A5660 /* AirshipInitializer.swift */; }; 6EB21AFE2E821E01001A5660 /* LiveActivityHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21AFD2E821DFD001A5660 /* LiveActivityHandler.swift */; }; 6EB21AFF2E821E01001A5660 /* LiveActivityHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21AFD2E821DFD001A5660 /* LiveActivityHandler.swift */; }; 6EB21B022E821E34001A5660 /* DeepLinkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21B012E821E30001A5660 /* DeepLinkHandler.swift */; }; 6EB21B032E821E34001A5660 /* DeepLinkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21B012E821E30001A5660 /* DeepLinkHandler.swift */; }; 6EB21B052E821E50001A5660 /* PushNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21B042E821E4C001A5660 /* PushNotificationHandler.swift */; }; 6EB21B062E821E50001A5660 /* PushNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EB21B042E821E4C001A5660 /* PushNotificationHandler.swift */; }; 6EDB2B0628D4E24F00A01377 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EDB2B0528D4E24F00A01377 /* WidgetKit.framework */; }; 6EDB2B0828D4E24F00A01377 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EDB2B0728D4E24F00A01377 /* SwiftUI.framework */; }; 6EDB2B0B28D4E24F00A01377 /* DeliveryActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDB2B0A28D4E24F00A01377 /* DeliveryActivityWidget.swift */; }; 6EDB2B0E28D4E25100A01377 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6EDB2B0D28D4E25100A01377 /* Assets.xcassets */; }; 6EDB2B1028D4E25100A01377 /* LiveActivity.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 6EDB2B0C28D4E24F00A01377 /* LiveActivity.intentdefinition */; }; 6EDB2B1128D4E25100A01377 /* LiveActivity.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 6EDB2B0C28D4E24F00A01377 /* LiveActivity.intentdefinition */; }; 6EDB2B1428D4E25100A01377 /* LiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6EDB2B0428D4E24F00A01377 /* LiveActivityExtension.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6EDB2B2E28D4F97F00A01377 /* Widgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDB2B2A28D4F27800A01377 /* Widgets.swift */; }; 6EDFBAF42F5660C40043D9EF /* AirshipAutomation.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 84A565712A3D01A100F3A345 /* AirshipAutomation.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 99E8D8072B57A5BA0099B6F3 /* AirshipAutomation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 84A565712A3D01A100F3A345 /* AirshipAutomation.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 1BEF603A24CB3DB60039091F /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D58B2396E9A300AF2D1A /* AirshipExtensions.xcodeproj */; proxyType = 2; remoteGlobalIDString = DF0C1B14244E483C0011ACCA; remoteInfo = AirshipNotificationServiceExtensionTests; }; 32F68D0528F07C7300F7F52A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 6E4A466E28EF44F600A25617; remoteInfo = AirshipMessageCenterTests; }; 6E1B7AD92B6DBE3A00695561 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 6E0B8729294A9C120064B7BD; remoteInfo = AirshipAutomation; }; 6E1B7AEA2B6DBE4A00695561 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 6E0B8729294A9C120064B7BD; remoteInfo = AirshipAutomation; }; 6E2F5A982A67330900CABD3D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; 6E2F5AA72A67330900CABD3D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = A62058692A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; 6E2F5AA92A67330900CABD3D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = A62058702A5841330041FBF9; remoteInfo = AirshipFeatureFlagsTests; }; 6E2F5AAE2A67331500CABD3D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = A62058682A5841330041FBF9; remoteInfo = AirshipFeatureFlags; }; 6E65D57B2396E99D00AF2D1A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 494DD9571B0EB677009C134E; remoteInfo = AirshipCore; }; 6E65D57F2396E99D00AF2D1A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = CC64F0541D8B77E3009CEF27; remoteInfo = AirshipTests; }; 6E65D5832396E99D00AF2D1A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 3CA0E2AC237CCE2600EE76CF; remoteInfo = AirshipDebug; }; 6E65D5872396E99D00AF2D1A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 3CA0E423237E4A7B00EE76CF; remoteInfo = AirshipMessageCenter; }; 6E65D5902396E9A300AF2D1A /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D58B2396E9A300AF2D1A /* AirshipExtensions.xcodeproj */; proxyType = 2; remoteGlobalIDString = 49AA00FC1D65158C0081989A; remoteInfo = AirshipNotificationServiceExtension; }; 6EAAE9A928C7E309003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CCDA359B1B8D233A00950AD5 /* Project object */; proxyType = 1; remoteGlobalIDString = 6EAAE99B28C7E308003CAE53; remoteInfo = "Airship Sample App Clip"; }; 6EAAE9F428C7E417003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CCDA359B1B8D233A00950AD5 /* Project object */; proxyType = 1; remoteGlobalIDString = 6EAAE9EE28C7E417003CAE53; remoteInfo = "Airship Sample Service Extension"; }; 6EAAEA0C28C7E4DB003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 6E431F6B26EA814F009228AB; remoteInfo = AirshipBasement; }; 6EAAEA0E28C7E4DB003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 6EAAEA1028C7E4DB003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 3CA0E298237CCE2600EE76CF; remoteInfo = AirshipDebug; }; 6EAAEA1228C7E4DB003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 3CA0E346237E4A7B00EE76CF; remoteInfo = AirshipMessageCenter; }; 6EAAEA1428C7E4DB003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 847BFFF3267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; 6EAAEA1628C7E4E3003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D58B2396E9A300AF2D1A /* AirshipExtensions.xcodeproj */; proxyType = 1; remoteGlobalIDString = 49AA00FB1D65158C0081989A; remoteInfo = AirshipNotificationServiceExtension; }; 6EAAEA1A28C7E4ED003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 6E431F6B26EA814F009228AB; remoteInfo = AirshipBasement; }; 6EAAEA1C28C7E4ED003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 494DD9561B0EB677009C134E; remoteInfo = AirshipCore; }; 6EAAEA1E28C7E4ED003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 3CA0E298237CCE2600EE76CF; remoteInfo = AirshipDebug; }; 6EAAEA2028C7E4ED003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 3CA0E346237E4A7B00EE76CF; remoteInfo = AirshipMessageCenter; }; 6EAAEA2228C7E4ED003CAE53 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 1; remoteGlobalIDString = 847BFFF3267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; 6EAD7CDE26B2003700B88EA7 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 847BFFF4267CD739007CD249; remoteInfo = AirshipPreferenceCenter; }; 6EAD7CE026B2003700B88EA7 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 847BFFFC267CD73A007CD249; remoteInfo = AirshipPreferenceCenterTests; }; 6EAF57662D35E2B700DF01BB /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = A641E1472BDBBDB400DE6FAA; remoteInfo = AirshipObjectiveC; }; 6EB1B41226EAA531000421B9 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 6E43204826EA814F009228AB; remoteInfo = AirshipBasement; }; 6EDB2B1228D4E25100A01377 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = CCDA359B1B8D233A00950AD5 /* Project object */; proxyType = 1; remoteGlobalIDString = 6EDB2B0328D4E24F00A01377; remoteInfo = LiveActivityExtension; }; 84A565702A3D01A100F3A345 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 6E0B872A294A9C120064B7BD; remoteInfo = AirshipAutomationSwift; }; 84A565722A3D01A100F3A345 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; proxyType = 2; remoteGlobalIDString = 6E0B8731294A9C130064B7BD; remoteInfo = AirshipAutomationSwiftTests; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 6EAAE9AF28C7E309003CAE53 /* Embed App Clips */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = "$(CONTENTS_FOLDER_PATH)/AppClips"; dstSubfolderSpec = 16; files = ( 6EAAE9AB28C7E309003CAE53 /* DevApp App Clip.app in Embed App Clips */, ); name = "Embed App Clips"; runOnlyForDeploymentPostprocessing = 0; }; 6EAAE9D128C7E393003CAE53 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( 6EAAE9F628C7E417003CAE53 /* DevApp Service Extension.appex in Embed Foundation Extensions */, 6EDB2B1428D4E25100A01377 /* LiveActivityExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; 6EAAE9FC28C7E438003CAE53 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( 6EAAE9FB28C7E438003CAE53 /* AirshipNotificationServiceExtension.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; 6EAAEA0928C7E4B4003CAE53 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( 6EDFBAF42F5660C40043D9EF /* AirshipAutomation.framework in Embed Frameworks */, 6EAAEA0028C7E4B4003CAE53 /* AirshipBasement.framework in Embed Frameworks */, 6EAAEA0428C7E4B4003CAE53 /* AirshipDebug.framework in Embed Frameworks */, 6EAAEA0628C7E4B4003CAE53 /* AirshipMessageCenter.framework in Embed Frameworks */, 6EAAEA0828C7E4B4003CAE53 /* AirshipPreferenceCenter.framework in Embed Frameworks */, 6E938DAD2AC35F2600F691D9 /* AirshipFeatureFlags.framework in Embed Frameworks */, 6EAAEA0228C7E4B4003CAE53 /* AirshipCore.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 1BEF603F24CB3FF10039091F /* AppClip.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppClip.framework; path = System/Library/Frameworks/AppClip.framework; sourceTree = SDKROOT; }; 1BFB70562388302000BF7F07 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; 1BFB70582388302000BF7F07 /* UserNotificationsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotificationsUI.framework; path = System/Library/Frameworks/UserNotificationsUI.framework; sourceTree = SDKROOT; }; 279DCECB2E85A544008B5542 /* MapRouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapRouteView.swift; sourceTree = "<group>"; }; 279DCECC2E85A544008B5542 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = "<group>"; }; 279DCECD2E85A544008B5542 /* AdView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdView.swift; sourceTree = "<group>"; }; 279DCECE2E85A544008B5542 /* BiometricLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BiometricLoginView.swift; sourceTree = "<group>"; }; 279DCECF2E85A544008B5542 /* WeatherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherView.swift; sourceTree = "<group>"; }; 279DCED02E85A544008B5542 /* WeatherViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherViewModel.swift; sourceTree = "<group>"; }; 279DCEE02E85A558008B5542 /* ThomasLayoutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutViewModel.swift; sourceTree = "<group>"; }; 279DCEE32E85A5BD008B5542 /* Messages */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Messages; sourceTree = "<group>"; }; 279DCEE42E85A5BD008B5542 /* Scenes */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Scenes; sourceTree = "<group>"; }; 279DCEFA2E85A6C6008B5542 /* JurassicPark.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = JurassicPark.otf; sourceTree = "<group>"; }; 279DCEFD2E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPlaygroundMenuView.swift; sourceTree = "<group>"; }; 279DCEFE2E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPlaygroundPicker.swift; sourceTree = "<group>"; }; 279DCEFF2E85A75C008B5542 /* EmbeddedPlaygroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedPlaygroundView.swift; sourceTree = "<group>"; }; 279DCF002E85A75C008B5542 /* KeyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyView.swift; sourceTree = "<group>"; }; 279DCF012E85A75C008B5542 /* PlaceholderToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderToggleView.swift; sourceTree = "<group>"; }; 279DCF0D2E85A766008B5542 /* Layouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Layouts.swift; sourceTree = "<group>"; }; 279DCF102E85A826008B5542 /* ThomasLayoutListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThomasLayoutListView.swift; sourceTree = "<group>"; }; 279DCF152E85AA02008B5542 /* LayoutsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutsList.swift; sourceTree = "<group>"; }; 32B5BE4A28F8BFBA00F2254B /* Toast.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Toast.swift; sourceTree = "<group>"; }; 32B5BE4D28F94FD200F2254B /* ToastView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; }; 45AA81862F4A697800B81FBC /* AirshipConfig.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = AirshipConfig.plist; sourceTree = "<group>"; }; 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Airship.xcodeproj; path = ../Airship/Airship.xcodeproj; sourceTree = "<group>"; }; 6E65D58B2396E9A300AF2D1A /* AirshipExtensions.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = AirshipExtensions.xcodeproj; path = ../AirshipExtensions/AirshipExtensions.xcodeproj; sourceTree = "<group>"; }; 6E7DB37B28ECA830002725F6 /* DeliveryAttributes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeliveryAttributes.swift; sourceTree = "<group>"; }; 6EAAE8B828C7C9A3003CAE53 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; }; 6EAAE8BB28C7C9A3003CAE53 /* NamedUserView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NamedUserView.swift; sourceTree = "<group>"; }; 6EAAE8BC28C7C9A3003CAE53 /* MainApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainApp.swift; sourceTree = "<group>"; }; 6EAAE8C028C7C9A3003CAE53 /* AppView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppView.swift; sourceTree = "<group>"; }; 6EAAE97828C7E18B003CAE53 /* DevApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DevApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6EAAE97E28C7E18C003CAE53 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 6EAAE99528C7E254003CAE53 /* DevApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DevApp.entitlements; sourceTree = "<group>"; }; 6EAAE99628C7E257003CAE53 /* Dev-App-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Dev-App-Info.plist"; sourceTree = SOURCE_ROOT; }; 6EAAE99C28C7E308003CAE53 /* DevApp App Clip.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DevApp App Clip.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 6EAAE9A728C7E309003CAE53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 6EAAE9A828C7E309003CAE53 /* Airship_Sample_App_Clip.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Airship_Sample_App_Clip.entitlements; sourceTree = "<group>"; }; 6EAAE9EF28C7E417003CAE53 /* DevApp Service Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "DevApp Service Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 6EAAE9F128C7E417003CAE53 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 6EAAE9F328C7E417003CAE53 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 6EB21A962E820088001A5660 /* AppRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouter.swift; sourceTree = "<group>"; }; 6EB21AA22E820C55001A5660 /* AirshipInitializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AirshipInitializer.swift; sourceTree = "<group>"; }; 6EB21AFD2E821DFD001A5660 /* LiveActivityHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityHandler.swift; sourceTree = "<group>"; }; 6EB21B012E821E30001A5660 /* DeepLinkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkHandler.swift; sourceTree = "<group>"; }; 6EB21B042E821E4C001A5660 /* PushNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationHandler.swift; sourceTree = "<group>"; }; 6EDB2B0428D4E24F00A01377 /* LiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6EDB2B0528D4E24F00A01377 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 6EDB2B0728D4E24F00A01377 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 6EDB2B0A28D4E24F00A01377 /* DeliveryActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryActivityWidget.swift; sourceTree = "<group>"; }; 6EDB2B0C28D4E24F00A01377 /* LiveActivity.intentdefinition */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; path = LiveActivity.intentdefinition; sourceTree = "<group>"; }; 6EDB2B0D28D4E25100A01377 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 6EDB2B0F28D4E25100A01377 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 6EDB2B2A28D4F27800A01377 /* Widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widgets.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ 6EAAE97528C7E18B003CAE53 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 99E8D8072B57A5BA0099B6F3 /* AirshipAutomation.framework in Frameworks */, 6EAAE9FF28C7E4B4003CAE53 /* AirshipBasement.framework in Frameworks */, 6EAAEA0328C7E4B4003CAE53 /* AirshipDebug.framework in Frameworks */, 6EAAEA0528C7E4B4003CAE53 /* AirshipMessageCenter.framework in Frameworks */, 6EAAEA0728C7E4B4003CAE53 /* AirshipPreferenceCenter.framework in Frameworks */, 6E938DAC2AC35F2600F691D9 /* AirshipFeatureFlags.framework in Frameworks */, 279DCEF92E85A6A7008B5542 /* Yams in Frameworks */, 6EAAEA0128C7E4B4003CAE53 /* AirshipCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EAAE99928C7E308003CAE53 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 279DCF142E85A9DE008B5542 /* Yams in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EAAE9EC28C7E417003CAE53 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6EAAE9FA28C7E438003CAE53 /* AirshipNotificationServiceExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EDB2B0128D4E24F00A01377 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 6EDB2B0828D4E24F00A01377 /* SwiftUI.framework in Frameworks */, 6EDB2B0628D4E24F00A01377 /* WidgetKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 279DCECA2E85A530008B5542 /* Thomas */ = { isa = PBXGroup; children = ( 279DCEFA2E85A6C6008B5542 /* JurassicPark.otf */, 279DCED32E85A544008B5542 /* CustomView */, 279DCEE02E85A558008B5542 /* ThomasLayoutViewModel.swift */, 279DCF022E85A75C008B5542 /* Embedded Playground View */, 279DCF0D2E85A766008B5542 /* Layouts.swift */, 279DCEE52E85A5BD008B5542 /* Resources */, 279DCF102E85A826008B5542 /* ThomasLayoutListView.swift */, 279DCF152E85AA02008B5542 /* LayoutsList.swift */, ); path = Thomas; sourceTree = "<group>"; }; 279DCED12E85A544008B5542 /* Weather */ = { isa = PBXGroup; children = ( 279DCECF2E85A544008B5542 /* WeatherView.swift */, 279DCED02E85A544008B5542 /* WeatherViewModel.swift */, ); path = Weather; sourceTree = "<group>"; }; 279DCED22E85A544008B5542 /* Examples */ = { isa = PBXGroup; children = ( 279DCECB2E85A544008B5542 /* MapRouteView.swift */, 279DCECC2E85A544008B5542 /* CameraView.swift */, 279DCECD2E85A544008B5542 /* AdView.swift */, 279DCECE2E85A544008B5542 /* BiometricLoginView.swift */, 279DCED12E85A544008B5542 /* Weather */, ); path = Examples; sourceTree = "<group>"; }; 279DCED32E85A544008B5542 /* CustomView */ = { isa = PBXGroup; children = ( 279DCED22E85A544008B5542 /* Examples */, ); path = CustomView; sourceTree = "<group>"; }; 279DCEE52E85A5BD008B5542 /* Resources */ = { isa = PBXGroup; children = ( 279DCEE32E85A5BD008B5542 /* Messages */, 279DCEE42E85A5BD008B5542 /* Scenes */, ); path = Resources; sourceTree = "<group>"; }; 279DCF022E85A75C008B5542 /* Embedded Playground View */ = { isa = PBXGroup; children = ( 279DCEFD2E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift */, 279DCEFE2E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift */, 279DCEFF2E85A75C008B5542 /* EmbeddedPlaygroundView.swift */, 279DCF002E85A75C008B5542 /* KeyView.swift */, 279DCF012E85A75C008B5542 /* PlaceholderToggleView.swift */, ); path = "Embedded Playground View"; sourceTree = "<group>"; }; 6E65D56B2396E99D00AF2D1A /* Products */ = { isa = PBXGroup; children = ( 6EB1B41326EAA531000421B9 /* AirshipBasement.framework */, 6E65D57C2396E99D00AF2D1A /* AirshipCore.framework */, 84A565712A3D01A100F3A345 /* AirshipAutomation.framework */, 6E2F5AA82A67330900CABD3D /* AirshipFeatureFlags.framework */, 6E65D5882396E99D00AF2D1A /* AirshipMessageCenter.framework */, 6EAD7CDF26B2003700B88EA7 /* AirshipPreferenceCenter.framework */, 6EAF57672D35E2B700DF01BB /* AirshipObjectiveC.framework */, 6E65D5842396E99D00AF2D1A /* AirshipDebug.framework */, 6E65D5802396E99D00AF2D1A /* AirshipTests.xctest */, 84A565732A3D01A100F3A345 /* AirshipAutomationTests.xctest */, 6E2F5AAA2A67330900CABD3D /* AirshipFeatureFlagsTests.xctest */, 32F68D0628F07C7300F7F52A /* AirshipMessageCenterTests.xctest */, 6EAD7CE126B2003700B88EA7 /* AirshipPreferenceCenterTests.xctest */, ); name = Products; sourceTree = "<group>"; }; 6E65D58C2396E9A300AF2D1A /* Products */ = { isa = PBXGroup; children = ( 6E65D5912396E9A300AF2D1A /* AirshipNotificationServiceExtension.framework */, 1BEF603B24CB3DB60039091F /* AirshipNotificationServiceExtensionTests.xctest */, ); name = Products; sourceTree = "<group>"; }; 6EAAE97928C7E18B003CAE53 /* Dev App */ = { isa = PBXGroup; children = ( 279DCECA2E85A530008B5542 /* Thomas */, 6EB21B002E821E21001A5660 /* Setup */, 32B5BE4A28F8BFBA00F2254B /* Toast.swift */, 6EB21A962E820088001A5660 /* AppRouter.swift */, 6EAAE8BC28C7C9A3003CAE53 /* MainApp.swift */, 6EB21AA52E821509001A5660 /* View */, 6EAAE99628C7E257003CAE53 /* Dev-App-Info.plist */, 6EAAE99528C7E254003CAE53 /* DevApp.entitlements */, 6EAAE97E28C7E18C003CAE53 /* Assets.xcassets */, ); path = "Dev App"; sourceTree = "<group>"; }; 6EAAE99D28C7E308003CAE53 /* DevApp App Clip */ = { isa = PBXGroup; children = ( 6EAAE9A728C7E309003CAE53 /* Info.plist */, 6EAAE9A828C7E309003CAE53 /* Airship_Sample_App_Clip.entitlements */, ); path = "DevApp App Clip"; sourceTree = "<group>"; }; 6EAAE9F028C7E417003CAE53 /* DevApp Service Extension */ = { isa = PBXGroup; children = ( 6EAAE9F128C7E417003CAE53 /* NotificationService.swift */, 6EAAE9F328C7E417003CAE53 /* Info.plist */, ); path = "DevApp Service Extension"; sourceTree = "<group>"; }; 6EB21AA52E821509001A5660 /* View */ = { isa = PBXGroup; children = ( 32B5BE4D28F94FD200F2254B /* ToastView.swift */, 6EAAE8C028C7C9A3003CAE53 /* AppView.swift */, 6EAAE8B828C7C9A3003CAE53 /* HomeView.swift */, 6EAAE8BB28C7C9A3003CAE53 /* NamedUserView.swift */, ); path = View; sourceTree = "<group>"; }; 6EB21B002E821E21001A5660 /* Setup */ = { isa = PBXGroup; children = ( 6EB21AA22E820C55001A5660 /* AirshipInitializer.swift */, 6EB21B042E821E4C001A5660 /* PushNotificationHandler.swift */, 6EB21B012E821E30001A5660 /* DeepLinkHandler.swift */, 6EB21AFD2E821DFD001A5660 /* LiveActivityHandler.swift */, ); path = Setup; sourceTree = "<group>"; }; 6EDB2B0928D4E24F00A01377 /* LiveActivity */ = { isa = PBXGroup; children = ( 6E7DB37B28ECA830002725F6 /* DeliveryAttributes.swift */, 6EDB2B0A28D4E24F00A01377 /* DeliveryActivityWidget.swift */, 6EDB2B0C28D4E24F00A01377 /* LiveActivity.intentdefinition */, 6EDB2B0D28D4E25100A01377 /* Assets.xcassets */, 6EDB2B0F28D4E25100A01377 /* Info.plist */, 6EDB2B2A28D4F27800A01377 /* Widgets.swift */, ); path = LiveActivity; sourceTree = "<group>"; }; CCDA359A1B8D233A00950AD5 = { isa = PBXGroup; children = ( 45AA81862F4A697800B81FBC /* AirshipConfig.plist */, 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */, 6E65D58B2396E9A300AF2D1A /* AirshipExtensions.xcodeproj */, 6EAAE97928C7E18B003CAE53 /* Dev App */, 6EAAE99D28C7E308003CAE53 /* DevApp App Clip */, 6EAAE9F028C7E417003CAE53 /* DevApp Service Extension */, 6EDB2B0928D4E24F00A01377 /* LiveActivity */, CCDA35A41B8D233A00950AD5 /* Products */, DF4E494E221D96CB00F306A5 /* Frameworks */, ); sourceTree = "<group>"; }; CCDA35A41B8D233A00950AD5 /* Products */ = { isa = PBXGroup; children = ( 6EAAE97828C7E18B003CAE53 /* DevApp.app */, 6EAAE99C28C7E308003CAE53 /* DevApp App Clip.app */, 6EAAE9EF28C7E417003CAE53 /* DevApp Service Extension.appex */, 6EDB2B0428D4E24F00A01377 /* LiveActivityExtension.appex */, ); name = Products; sourceTree = "<group>"; }; DF4E494E221D96CB00F306A5 /* Frameworks */ = { isa = PBXGroup; children = ( 1BEF603F24CB3FF10039091F /* AppClip.framework */, 1BFB70562388302000BF7F07 /* UserNotifications.framework */, 1BFB70582388302000BF7F07 /* UserNotificationsUI.framework */, 6EDB2B0528D4E24F00A01377 /* WidgetKit.framework */, 6EDB2B0728D4E24F00A01377 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = "<group>"; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 6EAAE97728C7E18B003CAE53 /* DevApp */ = { isa = PBXNativeTarget; buildConfigurationList = 6EAAE98328C7E18C003CAE53 /* Build configuration list for PBXNativeTarget "DevApp" */; buildPhases = ( 6EAAE97428C7E18B003CAE53 /* Sources */, 6EAAE97528C7E18B003CAE53 /* Frameworks */, 6EAAE97628C7E18B003CAE53 /* Resources */, 6EAAE9AF28C7E309003CAE53 /* Embed App Clips */, 6EAAE9D128C7E393003CAE53 /* Embed Foundation Extensions */, 6EAAEA0928C7E4B4003CAE53 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( 6E1B7ADA2B6DBE3A00695561 /* PBXTargetDependency */, 6E2F5A992A67330900CABD3D /* PBXTargetDependency */, 6EAAEA0D28C7E4DB003CAE53 /* PBXTargetDependency */, 6EAAEA0F28C7E4DB003CAE53 /* PBXTargetDependency */, 6EAAEA1128C7E4DB003CAE53 /* PBXTargetDependency */, 6EAAEA1328C7E4DB003CAE53 /* PBXTargetDependency */, 6EAAEA1528C7E4DB003CAE53 /* PBXTargetDependency */, 6EAAE9AA28C7E309003CAE53 /* PBXTargetDependency */, 6EAAE9F528C7E417003CAE53 /* PBXTargetDependency */, 6EDB2B1328D4E25100A01377 /* PBXTargetDependency */, ); name = DevApp; productName = "Airship Sample"; productReference = 6EAAE97828C7E18B003CAE53 /* DevApp.app */; productType = "com.apple.product-type.application"; }; 6EAAE99B28C7E308003CAE53 /* DevApp App Clip */ = { isa = PBXNativeTarget; buildConfigurationList = 6EAAE9AC28C7E309003CAE53 /* Build configuration list for PBXNativeTarget "DevApp App Clip" */; buildPhases = ( 6EAAE99828C7E308003CAE53 /* Sources */, 6EAAE99928C7E308003CAE53 /* Frameworks */, 6EAAE99A28C7E308003CAE53 /* Resources */, ); buildRules = ( ); dependencies = ( 6E1B7AEB2B6DBE4A00695561 /* PBXTargetDependency */, 6E2F5AAF2A67331500CABD3D /* PBXTargetDependency */, 6EAAEA1B28C7E4ED003CAE53 /* PBXTargetDependency */, 6EAAEA1D28C7E4ED003CAE53 /* PBXTargetDependency */, 6EAAEA1F28C7E4ED003CAE53 /* PBXTargetDependency */, 6EAAEA2128C7E4ED003CAE53 /* PBXTargetDependency */, 6EAAEA2328C7E4ED003CAE53 /* PBXTargetDependency */, ); name = "DevApp App Clip"; productName = "Airship Sample App Clip"; productReference = 6EAAE99C28C7E308003CAE53 /* DevApp App Clip.app */; productType = "com.apple.product-type.application.on-demand-install-capable"; }; 6EAAE9EE28C7E417003CAE53 /* DevApp Service Extension */ = { isa = PBXNativeTarget; buildConfigurationList = 6EAAE9F728C7E417003CAE53 /* Build configuration list for PBXNativeTarget "DevApp Service Extension" */; buildPhases = ( 6EAAE9EB28C7E417003CAE53 /* Sources */, 6EAAE9EC28C7E417003CAE53 /* Frameworks */, 6EAAE9ED28C7E417003CAE53 /* Resources */, 6EAAE9FC28C7E438003CAE53 /* Embed Frameworks */, ); buildRules = ( ); dependencies = ( 6EAAEA1728C7E4E3003CAE53 /* PBXTargetDependency */, ); name = "DevApp Service Extension"; productName = "Airship Sample Service Extension"; productReference = 6EAAE9EF28C7E417003CAE53 /* DevApp Service Extension.appex */; productType = "com.apple.product-type.app-extension"; }; 6EDB2B0328D4E24F00A01377 /* LiveActivityExtension */ = { isa = PBXNativeTarget; buildConfigurationList = 6EDB2B2228D4E25100A01377 /* Build configuration list for PBXNativeTarget "LiveActivityExtension" */; buildPhases = ( 6EDB2B0028D4E24F00A01377 /* Sources */, 6EDB2B0128D4E24F00A01377 /* Frameworks */, 6EDB2B0228D4E24F00A01377 /* Resources */, ); buildRules = ( ); dependencies = ( ); name = LiveActivityExtension; productName = LiveActivityExtension; productReference = 6EDB2B0428D4E24F00A01377 /* LiveActivityExtension.appex */; productType = "com.apple.product-type.app-extension"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ CCDA359B1B8D233A00950AD5 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1410; LastUpgradeCheck = 1600; ORGANIZATIONNAME = "Urban Airship"; TargetAttributes = { 6EAAE97728C7E18B003CAE53 = { CreatedOnToolsVersion = 13.4; }; 6EAAE99B28C7E308003CAE53 = { CreatedOnToolsVersion = 13.4; DevelopmentTeam = PGJV57GD94; ProvisioningStyle = Automatic; }; 6EAAE9EE28C7E417003CAE53 = { CreatedOnToolsVersion = 13.4; DevelopmentTeam = PGJV57GD94; ProvisioningStyle = Automatic; }; 6EDB2B0328D4E24F00A01377 = { CreatedOnToolsVersion = 14.1; DevelopmentTeam = PGJV57GD94; ProvisioningStyle = Automatic; }; }; }; buildConfigurationList = CCDA359E1B8D233A00950AD5 /* Build configuration list for PBXProject "DevApp" */; compatibilityVersion = "Xcode 3.2"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, de, es, fr, it, ja, pt, "zh-Hans", "zh-Hant", ); mainGroup = CCDA359A1B8D233A00950AD5; packageReferences = ( 279DCEF72E85A6A7008B5542 /* XCRemoteSwiftPackageReference "Yams" */, ); productRefGroup = CCDA35A41B8D233A00950AD5 /* Products */; projectDirPath = ""; projectReferences = ( { ProductGroup = 6E65D56B2396E99D00AF2D1A /* Products */; ProjectRef = 6E65D56A2396E99D00AF2D1A /* Airship.xcodeproj */; }, { ProductGroup = 6E65D58C2396E9A300AF2D1A /* Products */; ProjectRef = 6E65D58B2396E9A300AF2D1A /* AirshipExtensions.xcodeproj */; }, ); projectRoot = ""; targets = ( 6EAAE97728C7E18B003CAE53 /* DevApp */, 6EAAE99B28C7E308003CAE53 /* DevApp App Clip */, 6EAAE9EE28C7E417003CAE53 /* DevApp Service Extension */, 6EDB2B0328D4E24F00A01377 /* LiveActivityExtension */, ); }; /* End PBXProject section */ /* Begin PBXReferenceProxy section */ 1BEF603B24CB3DB60039091F /* AirshipNotificationServiceExtensionTests.xctest */ = { isa = PBXReferenceProxy; fileType = wrapper.cfbundle; path = AirshipNotificationServiceExtensionTests.xctest; remoteRef = 1BEF603A24CB3DB60039091F /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 32F68D0628F07C7300F7F52A /* AirshipMessageCenterTests.xctest */ = { isa = PBXReferenceProxy; fileType = wrapper.cfbundle; path = AirshipMessageCenterTests.xctest; remoteRef = 32F68D0528F07C7300F7F52A /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6E2F5AA82A67330900CABD3D /* AirshipFeatureFlags.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipFeatureFlags.framework; remoteRef = 6E2F5AA72A67330900CABD3D /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6E2F5AAA2A67330900CABD3D /* AirshipFeatureFlagsTests.xctest */ = { isa = PBXReferenceProxy; fileType = wrapper.cfbundle; path = AirshipFeatureFlagsTests.xctest; remoteRef = 6E2F5AA92A67330900CABD3D /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6E65D57C2396E99D00AF2D1A /* AirshipCore.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipCore.framework; remoteRef = 6E65D57B2396E99D00AF2D1A /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6E65D5802396E99D00AF2D1A /* AirshipTests.xctest */ = { isa = PBXReferenceProxy; fileType = wrapper.cfbundle; path = AirshipTests.xctest; remoteRef = 6E65D57F2396E99D00AF2D1A /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6E65D5842396E99D00AF2D1A /* AirshipDebug.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipDebug.framework; remoteRef = 6E65D5832396E99D00AF2D1A /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6E65D5882396E99D00AF2D1A /* AirshipMessageCenter.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipMessageCenter.framework; remoteRef = 6E65D5872396E99D00AF2D1A /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6E65D5912396E9A300AF2D1A /* AirshipNotificationServiceExtension.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipNotificationServiceExtension.framework; remoteRef = 6E65D5902396E9A300AF2D1A /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6EAD7CDF26B2003700B88EA7 /* AirshipPreferenceCenter.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipPreferenceCenter.framework; remoteRef = 6EAD7CDE26B2003700B88EA7 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6EAD7CE126B2003700B88EA7 /* AirshipPreferenceCenterTests.xctest */ = { isa = PBXReferenceProxy; fileType = wrapper.cfbundle; path = AirshipPreferenceCenterTests.xctest; remoteRef = 6EAD7CE026B2003700B88EA7 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6EAF57672D35E2B700DF01BB /* AirshipObjectiveC.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipObjectiveC.framework; remoteRef = 6EAF57662D35E2B700DF01BB /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 6EB1B41326EAA531000421B9 /* AirshipBasement.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipBasement.framework; remoteRef = 6EB1B41226EAA531000421B9 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 84A565712A3D01A100F3A345 /* AirshipAutomation.framework */ = { isa = PBXReferenceProxy; fileType = wrapper.framework; path = AirshipAutomation.framework; remoteRef = 84A565702A3D01A100F3A345 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; 84A565732A3D01A100F3A345 /* AirshipAutomationTests.xctest */ = { isa = PBXReferenceProxy; fileType = wrapper.cfbundle; path = AirshipAutomationTests.xctest; remoteRef = 84A565722A3D01A100F3A345 /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXReferenceProxy section */ /* Begin PBXResourcesBuildPhase section */ 6EAAE97628C7E18B003CAE53 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 279DCEE82E85A5BD008B5542 /* Scenes in Resources */, 279DCEE92E85A5BD008B5542 /* Messages in Resources */, 279DCEFB2E85A6C6008B5542 /* JurassicPark.otf in Resources */, 45AA81882F4A697800B81FBC /* AirshipConfig.plist in Resources */, 6EAAE97F28C7E18C003CAE53 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EAAE99A28C7E308003CAE53 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 279DCEE62E85A5BD008B5542 /* Scenes in Resources */, 279DCEE72E85A5BD008B5542 /* Messages in Resources */, 279DCEFC2E85A6C6008B5542 /* JurassicPark.otf in Resources */, 45AA81872F4A697800B81FBC /* AirshipConfig.plist in Resources */, 6EAAE9BE28C7E31E003CAE53 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EAAE9ED28C7E417003CAE53 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 6EDB2B0228D4E24F00A01377 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EDB2B0E28D4E25100A01377 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 6EAAE97428C7E18B003CAE53 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EDB2B1128D4E25100A01377 /* LiveActivity.intentdefinition in Sources */, 6EB21AA32E820C5A001A5660 /* AirshipInitializer.swift in Sources */, 279DCEE12E85A558008B5542 /* ThomasLayoutViewModel.swift in Sources */, 32B5BE4B28F8BFBA00F2254B /* Toast.swift in Sources */, 6EAAE98C28C7E1A1003CAE53 /* AppView.swift in Sources */, 6EB21AFF2E821E01001A5660 /* LiveActivityHandler.swift in Sources */, 279DCF032E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift in Sources */, 279DCF042E85A75C008B5542 /* PlaceholderToggleView.swift in Sources */, 279DCF052E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift in Sources */, 279DCF062E85A75C008B5542 /* KeyView.swift in Sources */, 279DCF072E85A75C008B5542 /* EmbeddedPlaygroundView.swift in Sources */, 6EB21B062E821E50001A5660 /* PushNotificationHandler.swift in Sources */, 279DCEDA2E85A544008B5542 /* BiometricLoginView.swift in Sources */, 279DCEDB2E85A544008B5542 /* WeatherView.swift in Sources */, 279DCEDC2E85A544008B5542 /* MapRouteView.swift in Sources */, 279DCEDD2E85A544008B5542 /* CameraView.swift in Sources */, 279DCEDE2E85A544008B5542 /* AdView.swift in Sources */, 279DCF162E85AA02008B5542 /* LayoutsList.swift in Sources */, 279DCEDF2E85A544008B5542 /* WeatherViewModel.swift in Sources */, 6EAAE98A28C7E1A1003CAE53 /* MainApp.swift in Sources */, 6EB21B022E821E34001A5660 /* DeepLinkHandler.swift in Sources */, 279DCF112E85A826008B5542 /* ThomasLayoutListView.swift in Sources */, 6EAAE98828C7E1A1003CAE53 /* NamedUserView.swift in Sources */, 32B5BE4E28F94FD200F2254B /* ToastView.swift in Sources */, 6EB21AA12E82008A001A5660 /* AppRouter.swift in Sources */, 6EAAE98728C7E1A1003CAE53 /* HomeView.swift in Sources */, 6E7DB39128ED0AF5002725F6 /* DeliveryAttributes.swift in Sources */, 279DCF0E2E85A766008B5542 /* Layouts.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EAAE99828C7E308003CAE53 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 279DCF122E85A826008B5542 /* ThomasLayoutListView.swift in Sources */, 6EB21B032E821E34001A5660 /* DeepLinkHandler.swift in Sources */, 6EAAE9B128C7E31E003CAE53 /* MainApp.swift in Sources */, 279DCF0F2E85A766008B5542 /* Layouts.swift in Sources */, 6EAAE9BD28C7E31E003CAE53 /* HomeView.swift in Sources */, 32B5BE4C28F8BFBA00F2254B /* Toast.swift in Sources */, 279DCED42E85A544008B5542 /* BiometricLoginView.swift in Sources */, 279DCED52E85A544008B5542 /* WeatherView.swift in Sources */, 279DCED62E85A544008B5542 /* MapRouteView.swift in Sources */, 279DCED72E85A544008B5542 /* CameraView.swift in Sources */, 279DCED82E85A544008B5542 /* AdView.swift in Sources */, 279DCED92E85A544008B5542 /* WeatherViewModel.swift in Sources */, 6EB21AFE2E821E01001A5660 /* LiveActivityHandler.swift in Sources */, 6EAAE9B928C7E31E003CAE53 /* AppView.swift in Sources */, 6EB21AA02E82008A001A5660 /* AppRouter.swift in Sources */, 279DCF082E85A75C008B5542 /* EmbeddedPlaygroundPicker.swift in Sources */, 279DCF092E85A75C008B5542 /* PlaceholderToggleView.swift in Sources */, 279DCF0A2E85A75C008B5542 /* EmbeddedPlaygroundMenuView.swift in Sources */, 279DCF0B2E85A75C008B5542 /* KeyView.swift in Sources */, 279DCF0C2E85A75C008B5542 /* EmbeddedPlaygroundView.swift in Sources */, 6E7DB39028ED0AF4002725F6 /* DeliveryAttributes.swift in Sources */, 32B5BE4F28F94FD200F2254B /* ToastView.swift in Sources */, 6EB21B052E821E50001A5660 /* PushNotificationHandler.swift in Sources */, 279DCF172E85AA02008B5542 /* LayoutsList.swift in Sources */, 279DCEE22E85A558008B5542 /* ThomasLayoutViewModel.swift in Sources */, 6EAAE9B228C7E31E003CAE53 /* NamedUserView.swift in Sources */, 6EB21AA42E820C5A001A5660 /* AirshipInitializer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EAAE9EB28C7E417003CAE53 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EAAE9F228C7E417003CAE53 /* NotificationService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; 6EDB2B0028D4E24F00A01377 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 6EDB2B0B28D4E24F00A01377 /* DeliveryActivityWidget.swift in Sources */, 6EDB2B1028D4E25100A01377 /* LiveActivity.intentdefinition in Sources */, 6E7DB38F28ED0AF3002725F6 /* DeliveryAttributes.swift in Sources */, 6EDB2B2E28D4F97F00A01377 /* Widgets.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 6E1B7ADA2B6DBE3A00695561 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipAutomation; targetProxy = 6E1B7AD92B6DBE3A00695561 /* PBXContainerItemProxy */; }; 6E1B7AEB2B6DBE4A00695561 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipAutomation; targetProxy = 6E1B7AEA2B6DBE4A00695561 /* PBXContainerItemProxy */; }; 6E2F5A992A67330900CABD3D /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipFeatureFlags; targetProxy = 6E2F5A982A67330900CABD3D /* PBXContainerItemProxy */; }; 6E2F5AAF2A67331500CABD3D /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipFeatureFlags; targetProxy = 6E2F5AAE2A67331500CABD3D /* PBXContainerItemProxy */; }; 6EAAE9AA28C7E309003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; target = 6EAAE99B28C7E308003CAE53 /* DevApp App Clip */; targetProxy = 6EAAE9A928C7E309003CAE53 /* PBXContainerItemProxy */; }; 6EAAE9F528C7E417003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilters = ( ios, xros, ); target = 6EAAE9EE28C7E417003CAE53 /* DevApp Service Extension */; targetProxy = 6EAAE9F428C7E417003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA0D28C7E4DB003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipBasement; targetProxy = 6EAAEA0C28C7E4DB003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA0F28C7E4DB003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipCore; targetProxy = 6EAAEA0E28C7E4DB003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA1128C7E4DB003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipDebug; platformFilters = ( ios, tvos, xros, ); targetProxy = 6EAAEA1028C7E4DB003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA1328C7E4DB003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipMessageCenter; targetProxy = 6EAAEA1228C7E4DB003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA1528C7E4DB003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipPreferenceCenter; targetProxy = 6EAAEA1428C7E4DB003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA1728C7E4E3003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipNotificationServiceExtension; platformFilter = ios; targetProxy = 6EAAEA1628C7E4E3003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA1B28C7E4ED003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipBasement; targetProxy = 6EAAEA1A28C7E4ED003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA1D28C7E4ED003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipCore; targetProxy = 6EAAEA1C28C7E4ED003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA1F28C7E4ED003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipDebug; targetProxy = 6EAAEA1E28C7E4ED003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA2128C7E4ED003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipMessageCenter; targetProxy = 6EAAEA2028C7E4ED003CAE53 /* PBXContainerItemProxy */; }; 6EAAEA2328C7E4ED003CAE53 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = AirshipPreferenceCenter; targetProxy = 6EAAEA2228C7E4ED003CAE53 /* PBXContainerItemProxy */; }; 6EDB2B1328D4E25100A01377 /* PBXTargetDependency */ = { isa = PBXTargetDependency; platformFilter = ios; target = 6EDB2B0328D4E24F00A01377 /* LiveActivityExtension */; targetProxy = 6EDB2B1228D4E25100A01377 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 6EAAE98428C7E18C003CAE53 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; AUTOMATION_APPLE_EVENTS = NO; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "Dev App/DevApp.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = YES; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = YES; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Dev-App-Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "Camera App needs to use your camera to submit your ID Photo"; INFOPLIST_KEY_NSFaceIDUsageDescription = "Thomas app needs to use Face ID to log you in"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Weather app needs to use your location to route you to your nearest NETs station"; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,3,7"; }; name = Debug; }; 6EAAE98528C7E18C003CAE53 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; AUTOMATION_APPLE_EVENTS = NO; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "Dev App/DevApp.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; ENABLE_RESOURCE_ACCESS_CALENDARS = NO; ENABLE_RESOURCE_ACCESS_CAMERA = YES; ENABLE_RESOURCE_ACCESS_CONTACTS = NO; ENABLE_RESOURCE_ACCESS_LOCATION = YES; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Dev-App-Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "Camera App needs to use your camera to submit your ID Photo"; INFOPLIST_KEY_NSFaceIDUsageDescription = "Thomas app needs to use Face ID to log you in"; INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Weather app needs to use your location to route you to your nearest NETs station"; INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.6; MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; RUNTIME_EXCEPTION_DEBUGGING_TOOL = NO; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = NO; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,3,7"; }; name = Release; }; 6EAAE9AD28C7E309003CAE53 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "DevApp App Clip/Airship_Sample_App_Clip.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "DevApp App Clip/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = DevApp; INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.Clip; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 6EAAE9AE28C7E309003CAE53 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "DevApp App Clip/Airship_Sample_App_Clip.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; ENABLE_PREVIEWS = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "DevApp App Clip/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = DevApp; INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.Clip; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; 6EAAE9F828C7E417003CAE53 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "DevApp Service Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "DevApp Service Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Urban Airship. All rights reserved."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.ServiceExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Debug; }; 6EAAE9F928C7E417003CAE53 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "DevApp Service Extension/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = "DevApp Service Extension"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Urban Airship. All rights reserved."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.ServiceExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2,7"; }; name = Release; }; 6EDB2B1528D4E25100A01377 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = LiveActivity/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = LiveActivity; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Urban Airship. All rights reserved."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.LiveActivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 6EDB2B1628D4E25100A01377 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = LiveActivity/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = LiveActivity; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Urban Airship. All rights reserved."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush.LiveActivity; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; CCDA35B51B8D233A00950AD5 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = 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_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_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 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 = gnu99; 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; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.6; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VALID_ARCHS = "$(inherited)"; }; name = Debug; }; CCDA35B61B8D233A00950AD5 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = 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_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_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 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 = gnu99; 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; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.6; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 6.0; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; VALID_ARCHS = "$(inherited)"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 6EAAE98328C7E18C003CAE53 /* Build configuration list for PBXNativeTarget "DevApp" */ = { isa = XCConfigurationList; buildConfigurations = ( 6EAAE98428C7E18C003CAE53 /* Debug */, 6EAAE98528C7E18C003CAE53 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6EAAE9AC28C7E309003CAE53 /* Build configuration list for PBXNativeTarget "DevApp App Clip" */ = { isa = XCConfigurationList; buildConfigurations = ( 6EAAE9AD28C7E309003CAE53 /* Debug */, 6EAAE9AE28C7E309003CAE53 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6EAAE9F728C7E417003CAE53 /* Build configuration list for PBXNativeTarget "DevApp Service Extension" */ = { isa = XCConfigurationList; buildConfigurations = ( 6EAAE9F828C7E417003CAE53 /* Debug */, 6EAAE9F928C7E417003CAE53 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 6EDB2B2228D4E25100A01377 /* Build configuration list for PBXNativeTarget "LiveActivityExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( 6EDB2B1528D4E25100A01377 /* Debug */, 6EDB2B1628D4E25100A01377 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; CCDA359E1B8D233A00950AD5 /* Build configuration list for PBXProject "DevApp" */ = { isa = XCConfigurationList; buildConfigurations = ( CCDA35B51B8D233A00950AD5 /* Debug */, CCDA35B61B8D233A00950AD5 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ 279DCEF72E85A6A7008B5542 /* XCRemoteSwiftPackageReference "Yams" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/jpsim/Yams.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 6.1.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ 279DCEF82E85A6A7008B5542 /* Yams */ = { isa = XCSwiftPackageProductDependency; package = 279DCEF72E85A6A7008B5542 /* XCRemoteSwiftPackageReference "Yams" */; productName = Yams; }; 279DCF132E85A9DE008B5542 /* Yams */ = { isa = XCSwiftPackageProductDependency; package = 279DCEF72E85A6A7008B5542 /* XCRemoteSwiftPackageReference "Yams" */; productName = Yams; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = CCDA359B1B8D233A00950AD5 /* Project object */; } ================================================ FILE: DevApp/DevApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ <?xml version="1.0" encoding="UTF-8"?> <Workspace version = "1.0"> <FileRef location = "self:"> </FileRef> </Workspace> ================================================ FILE: DevApp/DevApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>IDEDidComputeMac32BitWarning</key> <true/> </dict> </plist> ================================================ FILE: DevApp/DevApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved ================================================ { "originHash" : "00fc82b0de716b0b6fae8646fae1c289e5b4679f93a4b0a9aca7e47cd71f9315", "pins" : [ { "identity" : "yams", "kind" : "remoteSourceControl", "location" : "https://github.com/jpsim/Yams.git", "state" : { "revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28", "version" : "6.2.1" } } ], "version" : 3 } ================================================ FILE: DevApp/LiveActivity/Assets.xcassets/23Grande.imageset/Contents.json ================================================ { "images" : [ { "filename" : "1024.png", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/LiveActivity/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/LiveActivity/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/LiveActivity/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/LiveActivity/Assets.xcassets/WidgetBackground.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp/LiveActivity/DeliveryActivityWidget.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(ActivityKit) import Foundation import ActivityKit import SwiftUI import WidgetKit struct DeliveryActivityWidget: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: DeliveryAttributes.self) { context in LockScreenLiveActivityView(context: context) .widgetURL(URL(string: "cool")) } dynamicIsland: { context in DynamicIsland { DynamicIslandExpandedRegion(.leading) { Image(systemName: "box.truck.fill") .resizable() .scaledToFit() .frame(width: 30, height: 30) } DynamicIslandExpandedRegion(.trailing) { Label { Text("\(context.attributes.orderNumber)") } icon: { Image(systemName: "shippingbox.fill") .foregroundColor(.primary) } } DynamicIslandExpandedRegion(.center) { if context.state.stopsAway > 0 { Text("\(context.state.stopsAway) stops away") } else { Text("Delivered") } } DynamicIslandExpandedRegion(.bottom) { DeliveryStatusView(stopsAway: context.state.stopsAway) } } compactLeading: { Image(systemName: "box.truck.fill") .resizable() .scaledToFit() .padding(2) } compactTrailing: { if context.state.stopsAway > 0 { Text("En Route") } else { Text("Delivered") } } minimal: { Image(systemName: "box.truck.fill") .resizable() .scaledToFit() .padding(2) } .keylineTint(.red) } } } struct LockScreenLiveActivityView: View { let context: ActivityViewContext<DeliveryAttributes> var body: some View { VStack(alignment: .leading) { HStack(alignment: .top) { VStack(alignment: .leading) { Image(systemName: "box.truck.fill") .resizable() .scaledToFit() .frame(width: 48, height: 48) Text("Shipping Status") if context.state.stopsAway <= 0 { Text("Delivered!") .font(.footnote) } else if context.state.stopsAway <= 10 { Text("\(context.state.stopsAway) stops away") .font(.footnote) } else { Text("En Route...") .font(.footnote) } } Spacer() VStack { Image(systemName: "shippingbox.fill") .resizable() .scaledToFit() .foregroundColor(.primary) .frame(width: 48, height: 48) Text("Order: \(context.attributes.orderNumber)") } } DeliveryStatusView(stopsAway: self.context.state.stopsAway) } .padding(16) } } struct DeliveryStatusView: View { let stopsAway: Int @ViewBuilder var body: some View { VStack { HStack(spacing: 0) { Circle() .foregroundColor(.red) .frame(width: 16, height: 16) .offset(x: 1) Rectangle() .foregroundColor(.red) .frame(height: 6) Circle() .foregroundColor(.red) .frame(width: 16, height: 16) .offset(x: -1) if self.stopsAway > 0 { ForEach(1..<10, id: \.self) { index in Rectangle() .foregroundColor(.primary) .frame(height: 6) .padding(.horizontal, 2) } Circle() .strokeBorder(.primary, lineWidth: 2) .frame(width: 16, height: 16) } } } } } struct Line: Shape { func path(in rect: CGRect) -> Path { var path = Path() path.move(to: CGPoint(x: 0, y: 0)) path.addLine(to: CGPoint(x: rect.width, y: 0)) return path } } #endif ================================================ FILE: DevApp/LiveActivity/DeliveryAttributes.swift ================================================ /* Copyright Airship and Contributors */ #if canImport(ActivityKit) && !os(macOS) import ActivityKit import Foundation struct DeliveryAttributes: ActivityAttributes { public typealias PizzaDeliveryStatus = ContentState public struct ContentState: Codable, Hashable { var stopsAway: Int } var orderNumber: String } #endif ================================================ FILE: DevApp/LiveActivity/Info.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>NSExtension</key> <dict> <key>NSExtensionPointIdentifier</key> <string>com.apple.widgetkit-extension</string> </dict> </dict> </plist> ================================================ FILE: DevApp/LiveActivity/LiveActivity.intentdefinition ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>INEnums</key> <array/> <key>INIntentDefinitionModelVersion</key> <string>1.2</string> <key>INIntentDefinitionNamespace</key> <string>88xZPY</string> <key>INIntentDefinitionSystemVersion</key> <string>20A294</string> <key>INIntentDefinitionToolsBuildVersion</key> <string>12A6144</string> <key>INIntentDefinitionToolsVersion</key> <string>12.0</string> <key>INIntents</key> <array> <dict> <key>INIntentCategory</key> <string>information</string> <key>INIntentDescriptionID</key> <string>tVvJ9c</string> <key>INIntentEligibleForWidgets</key> <true/> <key>INIntentIneligibleForSuggestions</key> <true/> <key>INIntentName</key> <string>Configuration</string> <key>INIntentResponse</key> <dict> <key>INIntentResponseCodes</key> <array> <dict> <key>INIntentResponseCodeName</key> <string>success</string> <key>INIntentResponseCodeSuccess</key> <true/> </dict> <dict> <key>INIntentResponseCodeName</key> <string>failure</string> </dict> </array> </dict> <key>INIntentTitle</key> <string>Configuration</string> <key>INIntentTitleID</key> <string>gpCwrM</string> <key>INIntentType</key> <string>Custom</string> <key>INIntentVerb</key> <string>View</string> </dict> </array> <key>INTypes</key> <array/> </dict> </plist> ================================================ FILE: DevApp/LiveActivity/Widgets.swift ================================================ import Foundation import SwiftUI import WidgetKit #if canImport(ActivityKit) import ActivityKit #endif @main struct Widgets: WidgetBundle { var body: some Widget { #if canImport(ActivityKit) DeliveryActivityWidget() #endif } } ================================================ FILE: DevApp/Sample Plist/AirshipConfig.plist.sample ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>detectProvisioningMode</key> <true/> <key>developmentAppKey</key> <string>Your Development App Key</string> <key>developmentAppSecret</key> <string>Your Development App Secret</string> <key>productionAppKey</key> <string>Your Production App Key</string> <key>productionAppSecret</key> <string>Your Production App Secret</string> </dict> </plist> ================================================ FILE: DevApp watchOS/DevApp watchOS/AppDelegate.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import WatchKit class ExtensionDelegate: NSObject, WKExtensionDelegate, DeepLinkDelegate { func applicationDidFinishLaunching() { // Populate AirshipConfig.plist with your app's info from https://go.urbanairship.com // or set runtime properties here. var config = try! AirshipConfig.default() config.productionLogLevel = .verbose config.developmentLogLevel = .verbose // Set log level for debugging config loading (optional) // It will be set to the value in the loaded config upon takeOff config.productionLogLevel = .verbose config.developmentLogLevel = .verbose // Print out the application configuration for debugging (optional) print("Config:\n \(config)") // You can then programmatically override the plist values: // config.developmentAppKey = "YourKey" // etc. // Call takeOff (which creates the UAirship singleton) try? Airship.takeOff(config) Airship.channel.editTags { $0.add(["watchOs"]) } Airship.deepLinkDelegate = self // User notifications will not be enabled until userPushNotificationsEnabled is // enabled on UAPush. Once enabled, the setting will be persisted and the user // will be prompted to allow notifications. You should wait for a more appropriate // time to enable push to increase the likelihood that the user will accept // notifications. Airship.push.notificationOptions = [.alert, .sound, .badge] } func applicationDidBecomeActive() { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. } func applicationWillResignActive() { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, etc. } func receivedDeepLink(_ deepLink: URL) async { // Handle deeplink navigation print("deeplink received: \(deepLink)") } } ================================================ FILE: DevApp watchOS/DevApp watchOS/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "1.000", "green" : "0.294", "red" : "0.000" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp watchOS/DevApp watchOS/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "watchOSicon@2x.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp watchOS/DevApp watchOS/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp watchOS/DevApp watchOS/HomeView.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Combine import SwiftUI struct HomeView: View { enum Path: Sendable, Hashable, Equatable, CaseIterable { case namedUser } @State private var path: [Path] = [] @StateObject private var viewModel: ViewModel = ViewModel() @ViewBuilder private func makeQuickSettingItem(title: String, value: String) -> some View { VStack(alignment: .leading) { Text(title) .foregroundColor(.accentColor) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) Text(value) .foregroundColor(Color.secondary) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) } } @ViewBuilder private var channelIDView: some View { Group { if let channelID = viewModel.channelID { Text("Channel ID:") Text(channelID) .font(.system(size: 12)) .fixedSize(horizontal: false, vertical: true) } else { Text("Channel ID: Unavailable") } } } @ViewBuilder private var enablePushButton: some View { Toggle("Push Notifications", isOn: self.$viewModel.pushEnabled) } @ViewBuilder private var namedUserButton: some View { Button(action: { self.path.append(.namedUser) }) { makeQuickSettingItem( title: "Named User", value: self.viewModel.namedUserID ?? "Not Set" ) } } @ViewBuilder private var content: some View { VStack(alignment: .leading) { channelIDView enablePushButton namedUserButton } .padding(.bottom) } var body: some View { NavigationStack(path: $path) { content.padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationBarTitle("Home") .navigationDestination(for: Path.self) { selection in switch(selection) { case .namedUser: NamedUserView() } } } } @MainActor class ViewModel: ObservableObject { @Published var pushEnabled: Bool = true { didSet { if (pushEnabled) { Task { await Airship.push.enableUserPushNotifications(fallback: .systemSettings) } } else { Airship.push.userPushNotificationsEnabled = false } } } @Published var channelID: String? = Airship.channel.identifier @Published var namedUserID: String? = "" private var subscriptions = Set<AnyCancellable>() @MainActor init() { NotificationCenter.default .publisher(for: AirshipNotifications.ChannelCreated.name) .receive(on: RunLoop.main) .sink { _ in self.channelID = Airship.channel.identifier } .store(in: &self.subscriptions) Airship.contact.namedUserIDPublisher .receive(on: RunLoop.main) .sink { namedUserID in self.namedUserID = namedUserID } .store(in: &self.subscriptions) Airship.push.notificationStatusPublisher .map { status in status.isUserOptedIn } .receive(on: RunLoop.main) .sink { optedIn in if (self.pushEnabled != optedIn) { self.pushEnabled = optedIn } } .store(in: &self.subscriptions) } } } #Preview { HomeView() } ================================================ FILE: DevApp watchOS/DevApp watchOS/MainApp.swift ================================================ /* Copyright Airship and Contributors */ import SwiftUI @main struct MainApp: App { @WKExtensionDelegateAdaptor(ExtensionDelegate.self) var appDelegate var body: some Scene { WindowGroup { HomeView() } } } ================================================ FILE: DevApp watchOS/DevApp watchOS/NamedUserView.swift ================================================ /* Copyright Airship and Contributors */ import AirshipCore import Foundation import SwiftUI struct NamedUserView: View { @StateObject private var viewModel: ViewModel = ViewModel() private func updateNamedUser() { let normalized = self.viewModel.namedUserID.trimmingCharacters( in: .whitespacesAndNewlines ) if !normalized.isEmpty { Airship.contact.identify(normalized) } else { Airship.contact.reset() } } @ViewBuilder private func makeTextInput() -> some View { TextField("Named User", text:self.$viewModel.namedUserID) { updateNamedUser() } } var body: some View { VStack() { Text("Named User") .padding(.bottom) makeTextInput() } } @MainActor private class ViewModel: ObservableObject { @Published public var namedUserID: String = "" init() { Task { @MainActor in self.namedUserID = await Airship.contact.namedUserID ?? "" } } } } ================================================ FILE: DevApp watchOS/DevApp watchOS/Preview Content/Preview Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "color" : { "color-space" : "srgb", "components" : { "alpha" : "1.000", "blue" : "1.000", "green" : "0.294", "red" : "0.000" } }, "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp watchOS/DevApp watchOS/Preview Content/Preview Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "filename" : "watchOSicon@2x.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp watchOS/DevApp watchOS/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: DevApp watchOS/DevApp watchOS/PushNotificationPayload.apns ================================================ { "aps": { "alert": { "body": "Test message", "title": "Optional title", "subtitle": "Optional subtitle" }, "category": "myCategory", "thread-id": "5280" }, "Simulator Target Bundle": "com.urbanairship.richpush", "WatchKit Simulator Actions": [ { "title": "First Button", "identifier": "firstButtonAction" } ], "customKey": "Use this file to define a testing payload for your notifications. The aps dictionary specifies the category, alert text and title. The WatchKit Simulator Actions array can provide info for one or more action buttons in addition to the standard Dismiss button. Any other top level keys are custom payload. If you have multiple such JSON files in your project, you'll be able to select them when choosing to debug the notification interface of your Watch App." } ================================================ FILE: DevApp watchOS/DevApp watchOS.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 77; objects = { /* Begin PBXBuildFile section */ 327BFC3F2D53C042009A73C6 /* DevApp watchOS Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 327BFC3E2D53C042009A73C6 /* DevApp watchOS Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 32BC89B52D54CE4F001EAF3D /* AirshipBasement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 32BC89AC2D54CE4F001EAF3D /* AirshipBasement.framework */; }; 32BC89B62D54CE4F001EAF3D /* AirshipBasement.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 32BC89AC2D54CE4F001EAF3D /* AirshipBasement.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 32BC89B72D54CE4F001EAF3D /* AirshipCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 32BC89AD2D54CE4F001EAF3D /* AirshipCore.framework */; }; 32BC89B82D54CE4F001EAF3D /* AirshipCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 32BC89AD2D54CE4F001EAF3D /* AirshipCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ 327BFC402D53C042009A73C6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 327BFC322D53C042009A73C6 /* Project object */; proxyType = 1; remoteGlobalIDString = 327BFC3D2D53C042009A73C6; remoteInfo = "AirshipWatchOS Sample Watch App"; }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ 327BFC672D53C044009A73C6 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; dstSubfolderSpec = 16; files = ( 327BFC3F2D53C042009A73C6 /* DevApp watchOS Watch App.app in Embed Watch Content */, ); name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; }; 32BC89C32D54CE4F001EAF3D /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( 32BC89B62D54CE4F001EAF3D /* AirshipBasement.framework in Embed Frameworks */, 32BC89B82D54CE4F001EAF3D /* AirshipCore.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; 32BC89F92D55FEB1001EAF3D /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 327BFC382D53C042009A73C6 /* DevApp watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DevApp watchOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 327BFC3E2D53C042009A73C6 /* DevApp watchOS Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DevApp watchOS Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89922D54CAD5001EAF3D /* Airship.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Airship.xcodeproj; path = ../Airship/Airship.xcodeproj; sourceTree = SOURCE_ROOT; }; 32BC89AB2D54CE4F001EAF3D /* AirshipAutomation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipAutomation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89AC2D54CE4F001EAF3D /* AirshipBasement.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipBasement.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89AD2D54CE4F001EAF3D /* AirshipCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89AE2D54CE4F001EAF3D /* AirshipDebug.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipDebug.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89AF2D54CE4F001EAF3D /* AirshipFeatureFlags.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipFeatureFlags.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89B02D54CE4F001EAF3D /* AirshipMessageCenter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipMessageCenter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89B12D54CE4F001EAF3D /* AirshipObjectiveC.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipObjectiveC.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC89B22D54CE4F001EAF3D /* AirshipPreferenceCenter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipPreferenceCenter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC8A0A2D5604A7001EAF3D /* AirshipNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 32BC8A0E2D560CAB001EAF3D /* AirshipCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = AirshipCore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 32BC89A82D54CDDF001EAF3D /* Exceptions for "DevApp watchOS" folder in "DevApp watchOS" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( PushNotificationPayload.apns, ); target = 327BFC372D53C042009A73C6 /* DevApp watchOS */; }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 327BFC422D53C042009A73C6 /* DevApp watchOS */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( 32BC89A82D54CDDF001EAF3D /* Exceptions for "DevApp watchOS" folder in "DevApp watchOS" target */, ); path = "DevApp watchOS"; sourceTree = "<group>"; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ 327BFC3B2D53C042009A73C6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 32BC89B52D54CE4F001EAF3D /* AirshipBasement.framework in Frameworks */, 32BC89B72D54CE4F001EAF3D /* AirshipCore.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 327BFC312D53C042009A73C6 = { isa = PBXGroup; children = ( 32BC89922D54CAD5001EAF3D /* Airship.xcodeproj */, 327BFC422D53C042009A73C6 /* DevApp watchOS */, 32BC89AA2D54CE4F001EAF3D /* Frameworks */, 327BFC392D53C042009A73C6 /* Products */, ); sourceTree = "<group>"; }; 327BFC392D53C042009A73C6 /* Products */ = { isa = PBXGroup; children = ( 327BFC382D53C042009A73C6 /* DevApp watchOS.app */, 327BFC3E2D53C042009A73C6 /* DevApp watchOS Watch App.app */, ); name = Products; sourceTree = "<group>"; }; 32BC89AA2D54CE4F001EAF3D /* Frameworks */ = { isa = PBXGroup; children = ( 32BC8A0E2D560CAB001EAF3D /* AirshipCore.framework */, 32BC8A0A2D5604A7001EAF3D /* AirshipNotificationServiceExtension.framework */, 32BC89AB2D54CE4F001EAF3D /* AirshipAutomation.framework */, 32BC89AC2D54CE4F001EAF3D /* AirshipBasement.framework */, 32BC89AD2D54CE4F001EAF3D /* AirshipCore.framework */, 32BC89AE2D54CE4F001EAF3D /* AirshipDebug.framework */, 32BC89AF2D54CE4F001EAF3D /* AirshipFeatureFlags.framework */, 32BC89B02D54CE4F001EAF3D /* AirshipMessageCenter.framework */, 32BC89B12D54CE4F001EAF3D /* AirshipObjectiveC.framework */, 32BC89B22D54CE4F001EAF3D /* AirshipPreferenceCenter.framework */, ); name = Frameworks; sourceTree = "<group>"; }; 604C4A692D66505500E58688 /* Products */ = { isa = PBXGroup; children = ( ); name = Products; sourceTree = "<group>"; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ 327BFC372D53C042009A73C6 /* DevApp watchOS */ = { isa = PBXNativeTarget; buildConfigurationList = 327BFC682D53C044009A73C6 /* Build configuration list for PBXNativeTarget "DevApp watchOS" */; buildPhases = ( 327BFC362D53C042009A73C6 /* Resources */, 327BFC672D53C044009A73C6 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 327BFC412D53C042009A73C6 /* PBXTargetDependency */, ); name = "DevApp watchOS"; packageProductDependencies = ( ); productName = "AirshipWatchOS Sample"; productReference = 327BFC382D53C042009A73C6 /* DevApp watchOS.app */; productType = "com.apple.product-type.application.watchapp2-container"; }; 327BFC3D2D53C042009A73C6 /* DevApp watchOS Watch App */ = { isa = PBXNativeTarget; buildConfigurationList = 327BFC642D53C044009A73C6 /* Build configuration list for PBXNativeTarget "DevApp watchOS Watch App" */; buildPhases = ( 327BFC3A2D53C042009A73C6 /* Sources */, 327BFC3B2D53C042009A73C6 /* Frameworks */, 327BFC3C2D53C042009A73C6 /* Resources */, 32BC89C32D54CE4F001EAF3D /* Embed Frameworks */, 32BC89F92D55FEB1001EAF3D /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( ); fileSystemSynchronizedGroups = ( 327BFC422D53C042009A73C6 /* DevApp watchOS */, ); name = "DevApp watchOS Watch App"; packageProductDependencies = ( ); productName = "AirshipWatchOS Sample Watch App"; productReference = 327BFC3E2D53C042009A73C6 /* DevApp watchOS Watch App.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 327BFC322D53C042009A73C6 /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 1620; TargetAttributes = { 327BFC372D53C042009A73C6 = { CreatedOnToolsVersion = 16.2; LastSwiftMigration = 1620; }; 327BFC3D2D53C042009A73C6 = { CreatedOnToolsVersion = 16.2; }; }; }; buildConfigurationList = 327BFC352D53C042009A73C6 /* Build configuration list for PBXProject "DevApp watchOS" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = 327BFC312D53C042009A73C6; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; productRefGroup = 327BFC392D53C042009A73C6 /* Products */; projectDirPath = ""; projectReferences = ( { ProductGroup = 604C4A692D66505500E58688 /* Products */; ProjectRef = 32BC89922D54CAD5001EAF3D /* Airship.xcodeproj */; }, ); projectRoot = ""; targets = ( 327BFC372D53C042009A73C6 /* DevApp watchOS */, 327BFC3D2D53C042009A73C6 /* DevApp watchOS Watch App */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ 327BFC362D53C042009A73C6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; 327BFC3C2D53C042009A73C6 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 327BFC3A2D53C042009A73C6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ 327BFC412D53C042009A73C6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 327BFC3D2D53C042009A73C6 /* DevApp watchOS Watch App */; targetProxy = 327BFC402D53C042009A73C6 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 327BFC622D53C044009A73C6 /* 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; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; 327BFC632D53C044009A73C6 /* 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; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; 327BFC652D53C044009A73C6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"AirshipWatchOS Sample Watch App/Preview Content\""; DEVELOPMENT_TEAM = PGJV57GD94; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "AirshipWatchOS Sample"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKWatchOnly = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 11.6; }; name = Debug; }; 327BFC662D53C044009A73C6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"AirshipWatchOS Sample Watch App/Preview Content\""; DEVELOPMENT_TEAM = PGJV57GD94; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "AirshipWatchOS Sample"; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKWatchOnly = YES; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VALIDATE_PRODUCT = YES; WATCHOS_DEPLOYMENT_TARGET = 11.6; }; name = Release; }; 327BFC692D53C044009A73C6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; INFOPLIST_KEY_CFBundleDisplayName = "AirshipWatchOS Sample"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_OBJC_BRIDGING_HEADER = "AirshipWatchOS Sample Watch App/AirshipWatchOS Sample-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; }; name = Debug; }; 327BFC6A2D53C044009A73C6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PGJV57GD94; INFOPLIST_KEY_CFBundleDisplayName = "AirshipWatchOS Sample"; IPHONEOS_DEPLOYMENT_TARGET = 16.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.urbanairship.richpush; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SWIFT_OBJC_BRIDGING_HEADER = "AirshipWatchOS Sample Watch App/AirshipWatchOS Sample-Bridging-Header.h"; SWIFT_VERSION = 5.0; VALIDATE_PRODUCT = YES; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ 327BFC352D53C042009A73C6 /* Build configuration list for PBXProject "DevApp watchOS" */ = { isa = XCConfigurationList; buildConfigurations = ( 327BFC622D53C044009A73C6 /* Debug */, 327BFC632D53C044009A73C6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 327BFC642D53C044009A73C6 /* Build configuration list for PBXNativeTarget "DevApp watchOS Watch App" */ = { isa = XCConfigurationList; buildConfigurations = ( 327BFC652D53C044009A73C6 /* Debug */, 327BFC662D53C044009A73C6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; 327BFC682D53C044009A73C6 /* Build configuration list for PBXNativeTarget "DevApp watchOS" */ = { isa = XCConfigurationList; buildConfigurations = ( 327BFC692D53C044009A73C6 /* Debug */, 327BFC6A2D53C044009A73C6 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ }; rootObject = 327BFC322D53C042009A73C6 /* Project object */; } ================================================ FILE: DevApp watchOS/DevApp watchOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata ================================================ <?xml version="1.0" encoding="UTF-8"?> <Workspace version = "1.0"> <FileRef location = "self:"> </FileRef> </Workspace> ================================================ FILE: Documentation/.jazzy.json ================================================ { "author":"Urban Airship", "author_url":"https://urbanairship.com", "github_url":"https://github.com/urbanairship/ios-library", "readme":"readme-for-jazzy.md", "documentation":"Documentation/Migration/M*md", "abstract":"Documentation/abstracts/*md" } ================================================ FILE: Documentation/Migration/README.md ================================================ # Airship iOS SDK Migration Guides Comprehensive migration guides with code examples, troubleshooting, and clear migration paths. ## Migration Guides - **[SDK 19.x → 20.0](migration-guide-19-20.md)** - Major architectural changes, block-based callbacks, and UI refactors - **[SDK 18.x → 19.x](migration-guide-18-19.md)** - SwiftUI improvements and modern APIs - **[SDK 17.x → 18.x](migration-guide-17-18.md)** - Enhanced messaging and automation features - **[SDK 16.x → 17.x](migration-guide-16-17.md)** - Core platform updates and new capabilities ## Older Migration Guides Migration guides for SDK versions 9-15 are available in the [16.0.0 release](https://github.com/urbanairship/ios-library/tree/16.0.0/Documentation/Migration). ## Support - [Full Documentation](https://docs.airship.com/) - [Report Issues](https://github.com/urbanairship/ios-library/issues) ================================================ FILE: Documentation/Migration/migration-guide-16-17.md ================================================ # Airship iOS SDK 16.x to 17.0 Migration Guide ## Xcode requirements SDK 17.x now requires Xcode 14.3 or newer. ## Minimum deployment version SDK 17.x is compatible with iOS 14+. Apps using Airship will need to update the minimum deployment version. ## Removed modules The following modules are no longer supported and have been removed from the SDK: ### `AirshipAccengage` Users of Accengage should remove the `AirshipAccengage` module from their project after completing the migration process. For further information about migration and removal, see the [Accengage Migration guide](https://docs.airship.com/platform/mobile/accengage-migration/migration/ios/index.html#remove-airship-accengage-module). ### `AirshipChat` The Airship Chat module is no longer supported and has been removed from the SDK. ### `AirshipLocation` The Airship Location module is no longer supported and has been removed from the SDK. If you want to continue prompting users for location permissions, you must update your integration to set a location permission delegate on the `PermissionsManager`: ``` import Foundation import CoreLocation import AirshipCore import Combine class LocationPermissionDelegate: AirshipPermissionDelegate { let locationManager = CLLocationManager() @MainActor func checkPermissionStatus() async -> AirshipCore.AirshipPermissionStatus { return self.status } @MainActor func requestPermission() async -> AirshipCore.AirshipPermissionStatus { guard (self.status == .notDetermined) else { return self.status } guard (AppStateTracker.shared.state == .active) else { return .notDetermined } locationManager.requestAlwaysAuthorization() await waitActive() return self.status } var status: AirshipPermissionStatus { switch(locationManager.authorizationStatus) { case .notDetermined: return .notDetermined case .restricted: return .denied case .denied: return .denied case .authorizedAlways: return .granted case .authorizedWhenInUse: return .granted @unknown default: return .notDetermined } } } @MainActor private func waitActive() async { var subscription: AnyCancellable? await withCheckedContinuation { continuation in subscription = NotificationCenter.default.publisher(for: AppStateTracker.didBecomeActiveNotification) .first() .sink { _ in continuation.resume() } } subscription?.cancel() } ``` Then after takeOff, register the permission delegate for the location permission: ``` Airship.shared.permissionsManager.setDelegate( LocationPermissionDelegate(), permission: .location ) ``` ### `AirshipExtendedActions` The Airship Extended Actions only contained the RateAppAction which is now available in the core module. ## Allowed URLs The URL allow list configuration has been changed to an opt-out process, rather than an opt-in process like previous SDK versions. By default, all URLs are allowed by SDK 17, unless explicitly disallowed by the app via the `urlAllowList` or `urlAllowListScopeOpen` config options. Allow list behavior changes: - If neither `urlAllowList` or `urlAllowListScopeOpenURL` are set in your Airship config, the SDK will default to allowing all URLs and an error message will be logged. - To suppress the error message, set `urlAllowList` or `urlAllowListScopeOpenURL` to `[*]` to your config to adopt the new allow-all behavior, or customize the allowed URLs as needed. - URLs for media displayed within in-app messages will no longer be checked against the URL allow lists. - YouTube has been removed from the default allow list. If your application makes use of opening links to YouTube from Airship messaging, you will need to update your allow list to explicitly allow `youtube.com`, or allow all URLs with `[*]`. ## Renamed classes Some common class names have been renamed to prevent collisions with other libraries/apps: | Legacy class name | New class name | | -------------------| ----------------| | Config | AirshipConfig | | Channel | AirshipChannel | | Contact | AirshipContact | | Push | AirshipPush | | PrivacyManager | AirshipPrivacyManager| | Analytics | AirshipAnalytics| | Action | AirshipAction | | Situation | ActionSituation | | Features | AirshipFeature | | Event | AirshipEvent | ## In-App Automation ### Updated the default display interval for In-App Messages The new default display interval for in-app messages is now set to 0 seconds. Apps that wish to maintain the previous default display interval of 30 seconds should set the display interval manually, after takeOff: ``` InAppAutomation.shared.inAppMessageManager.displayInterval = 30.0 ``` ### Deep link delegate Deep link delegate is now async. SDK 16: ``` deepLinkDelegate.receivedDeepLink(deepLink) { completionHandler(true) } ``` SDK 17: ``` await deepLinkDelegate.receivedDeepLink(deepLink) { completionHandler(true) } ``` ## Contacts ### Contact conflict listener interface updated Contact conflict event is now available as a NSNotification or using `Airship.contact.conflictEventPublisher` to listen for events: SDK 16: ``` conflictDelegate?.onConflict(anonymousContactData: anonData, namedUserID: namedUserID) ``` SDK 17: ``` Airship.contact.conflictEventPublisher.sink { event in // ... } NotificationCenter.default.addObserver( self, selector: #selector(conflictEventReceived), name: AirshipContact.contactConflictEvent ) ``` ### Async Named User ID access Named User ID access is now an async property: SDK 16: ``` let namedUserID = Airship.contact.namedUserID ``` SDK 17: ``` let namedUserID = await Airship.contact.namedUserID ``` ### Async Subscription lists access Subscription list is now an async method: SDK 16: ``` Airship.contact.fetchSubscriptionLists { contactSubscriptionLists, error in // Use the contactSubscriptionLists } ``` SDK 17: ``` let contactSubscriptions = try await Airship.contact.fetchSubscriptionLists ``` ## Channels ### Async Subscription lists access Subscription list is now an async method: SDK 16: ``` Airship.channel.fetchSubscriptionLists { channelSubscriptionLists, error in // Use the channelSubscriptionLists } ``` SDK 17: ``` let channelSubscriptions = try await Airship.channel.fetchSubscriptionLists ``` ### Live Activities The API's to track and restore live activity tracking have been updated to no longer require a Task: SDK 16: ``` Task { await Airship.channel.trackLiveActivity( activity, name: "order-1234" ) Task { await Airship.channel.restoreLiveActivityTracking { restorer in await restorer.restore( forType: Activity<DeliveryAttributes>.self ) await restorer.restore( forType: Activity<SomeOtherAttributes>.self ) } } ``` SDK 17: ``` Airship.channel.trackLiveActivity( activity, name: "order-1234" ) Airship.channel.restoreLiveActivityTracking { restorer in await restorer.restore( forType: Activity<DeliveryAttributes>.self ) await restorer.restore( forType: Activity<SomeOtherAttributes>.self ) } ``` ## Message Center The MessageCenter module has been rewritten in Swift and the OOTB UI in SwiftUI. With the rewrite, we are providing a new set of APIs that take advantage of Swift's structured concurrency. ### Message listing API Changes All methods on the Message Center for listing are now async, and the listing is no longer stored in memory. #### Accessing message list SDK 16: ``` let messages = MessageCenter.shared.messageList.messages ``` SDK 17: ``` let messages = await MessageCenter.shared.inbox.messages ``` #### Deleting a message SDK 16: ``` MessageCenter.shared.messageList.markMessagesDeleted([message]) { // completed } ``` SDK 17: ``` // by message ID await MessageCenter.shared.inbox.delete(messageIDs: ["messageID"]) // by message await MessageCenter.shared.inbox.delete(messages: [message]) ``` #### Marking a message as read SDK 16: ``` MessageCenter.shared.messageList.markMessagesRead([message]) { // completed } ``` SDK 17: ``` // by message ID await MessageCenter.shared.inbox.markRead(messageIDs: ["messageID"]) // by message await MessageCenter.shared.inbox.markRead(messages: [message]) ``` #### Refreshing the message listing SDK 16: ``` MessageCenter.shared.messageList.retrieveMessageList(successBlock: { // handle success }, withFailureBlock: { // handle failure }) ``` SDK 17: ``` await MessageCenter.shared.inbox.refreshMessages() ``` #### Listening to message listing updates SDK 16: ``` NotificationCenter.default.addObserver(self, selector: #selector(messageListWillUpdate), name: UAInboxMessageListWillUpdateNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(messageListUpdated), name: UAInboxMessageListUpdatedNotification, object: nil) ``` SDK 17: ``` MessageCenter.shared.inbox.messagePublisher .receive(on: RunLoop.main) .sink(receiveValue: { messages in // Latest messages }) .store(in: &self.subscriptions) ``` ### Message Center UI Now our Message Center View has been rewritten using SwiftUI. The UIKit based views have been removed. #### Embedding Message Center in SwiftUI ``` struct CustomMessageCenter: View { let controller = MessageCenterController() var body: some View { MessageCenterView(controller: controller) } } ``` #### Embedding Message Center in UIKit SDK 16: ``` import AirshipKit class MessageCenterViewController : DefaultMessageCenterSplitViewController { } ``` SDK 17: ``` // 1 let messageCenterviewController = MessageCenterViewControllerFactory.make( controller: controller ) if let messageCenterView = messageCenterviewController.view { // 2 // Add the message center view controller to the destination view controller. addChild(messageCenterviewController) view.addSubview(messageCenterView) // 3 // Create and activate the constraints. messageCenterView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ messageCenterView.topAnchor.constraint(equalTo: view.topAnchor), messageCenterView.bottomAnchor.constraint(equalTo: view.bottomAnchor), messageCenterView.leftAnchor.constraint(equalTo: view.leftAnchor), messageCenterView.rightAnchor.constraint(equalTo: view.rightAnchor), ]) } ``` #### Theming `MessageCenterStyle` has been renamed to `MessageCenterTheme` to avoid confusing with SwiftUI style patterns. SDK 16: ``` let style = MessageCenterStyle() MessageCenter.shared.defaultUI.style = style ``` SDK 17: ``` var messageCenterTheme = MessageCenterTheme() MessageCenter.shared.theme = messageCenterTheme ``` You can also set the theme on the MessageCenterView directly: Example: ``` MessageCenterView(controller: controller) .messageCenterTheme(CustomMessageCenter.messageCenterTheme) ``` ### Load custom message center message view #### Fetch user credentials: SDK 16: ``` MessageCenter.shared.user.getData { user in // ... } ``` SDK 17: ``` let user = await MessageCenter.shared.inbox.user ``` #### Load webView: The example below shows how to fetch the credentials, set auth on the request, and load a message into the webview. This code assumes a custom view controller with an embedded WKWebView, as well as a `MessageCenterMessage` ready to be loaded. SDK 16: ``` let requestObj = NSMutableURLRequest(url:message.messageURL) MessageCenter.shared.user.getData { data in // set the auth let auth = Utils.authHeaderString(withName: data.username, password: data.password) requestObj.setValue(auth, forHTTPHeaderField:"Authorization") // load the request self.webView.load(requestObj) }, queue: DispatchQueue.main) ``` SDK 17: ``` var request = URLRequest(url: message.bodyURL) let user = await MessageCenter.shared.inbox.user // set the auth request.setValue(user.basicAuthString, forHTTPHeaderField: "Authorization") // load the request self.webView.load(request) ``` ## Preference Center ### Preference Center UI The Preference Center UI has been rewritten in SwiftUI. #### Embedding Preference Center in SwiftUI ``` PreferenceCenterView(preferenceCenterID: "preferenceCenter-ID") ``` #### Embedding Preference Center in UIKit ``` // 1 let preferenceCenterviewController = PreferenceCenterViewControllerFactory.makeViewController(preferenceCenterID: "neat") if let preferenceCenterView = preferenceCenterviewController.view { // 2 // Add the prefrence center view controller to the destination view controller. addChild(preferenceCenterviewController) view.addSubview(preferenceCenterView) // 3 // Create and activate the constraints. preferenceCenterView.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ preferenceCenterView.topAnchor.constraint(equalTo: view.topAnchor), preferenceCenterView.bottomAnchor.constraint(equalTo: view.bottomAnchor), preferenceCenterView.leftAnchor.constraint(equalTo: view.leftAnchor), preferenceCenterView.rightAnchor.constraint(equalTo: view.rightAnchor), ]) } ``` ### Theme `PreferenceCenterStyle` has been replaced by `PreferenceCenterTheme`. SDK 16: ``` let style = PreferenceCenterStyle() PreferenceCenter.shared.style = style ``` SDK 17: ``` var theme = PreferenceCenterTheme() PreferenceCenter.shared.theme = theme ``` A theme can also be set directly on the PreferenceCenterView: ``` PreferenceCenterView(preferenceCenterID: "preferenceCenterID") .preferenceCenterTheme(theme) ``` ## Actions Actions have been rewritten to use async/await and be Sendable. ### Registering actions SDK 16: ``` Airship.shared.actionRegistry.register(action, names: ["action_name", "action_alias"]) ``` SDK 17: ``` Airship.shared.actionRegistry.registerEntry(names: ["action_name", "action_alias"]) { return ActionEntry(action: action) } ``` ### Defining actions SDK 16: ``` let customAction = BlockAction { args, completionHandler in print("Action is performing with args: \(args)") completionHandler(ActionResult.empty()) } ``` SDK 17: ``` let customAction = BlockAction { args in print("Action is performing with args: \(args)") return nil } ``` ### Running actions SDK 16: ``` // Run an action by name ActionRunner.run("action_name", value: "action_value", situation: .manualInvocation) { result in print("Action finished!") } // Run an action directly ActionRunner.run(action, value: "action_value", situation: .manualInvocation) { result in print("Action finished!") } ``` SDK 17: ``` // Run an action by name let result = await ActionRunner.run( actionName: "action_name", arguments: ActionArguments( string: "action_value", situation: .manualInvocation ) ) // Run an action directly let result = await ActionRunner.run( action: action, arguments:ActionArguments( string: "action_value", situation: .manualInvocation ) ) ``` ================================================ FILE: Documentation/Migration/migration-guide-17-18.md ================================================ # Airship iOS SDK 17.x to 18.0 Migration Guide ## Xcode requirements SDK 18.x now requires Xcode 15.2 or newer. ## Airship Components Instead of a mix of class vars and instance vars to access various components on the `Airship` instance, they have been normalized to just class vars. | SDK 17.x | SDK 18.x | | -----------------------------------------|------------------------------------| | Airship.shared.config | Airship.config | | Airship.shared.actionRegistry | Airship.actionRegistry | | Airship.shared.permissionsManager | Airship.permissionsManager | | Airship.shared.javaScriptCommandDelegate | Airship.javaScriptCommandDelegate | | Airship.shared.channelCapture | Airship.channelCapture | | Airship.shared.deepLinkDelegate | Airship.deepLinkDelegate | | Airship.shared.urlAllowList | Airship.urlAllowList | | Airship.shared.localeManager | Airship.localeManager | | Airship.shared.privacyManager | Airship.privacyManager | | Airship.shared.applicationMetrics | Removed, this is internal only now | Protocols are exposed instead of concrete classes on Airship to better hide implementation details. | SDK 17.x | SDK 18.x | |------------------------------------------|------------------------------------| | URLAllowList | URLAllowListProtocol | | AirshipLocaleManager | AirshipLocaleManagerProtocol | | AirshipPush | AirshipPushProtocol | | AirshipContact | AirshipContactProtocol | | AirshipAnalytics | AirshipAnalyticsProtocol | | AirshipChannel | AirshipChannelProtocol | `AirshipPush`, `AirshipContact`, `AirshipAnalytics`, and `AirshipChannel` are all internal classes now, the shared methods on those classes have been removed. Instead, use the `Airship.push`, `Airship.contact`, `Airship.analytics`, and `Airship.channel` class vars instead. ## NotificationCenter (NSNotificationCenter) Notification Center events emitted by the Airship SDK have been updated. Most the notifications are still available, except channel updated. The constants for the rest have been moved. #### Airship Ready Event 17.x: ``` NotificationCenter.default.addObserver( forName: Airship.airshipReadyNotification, object: nil, queue: nil ) { notification in /// Following values are only available if `extendedBroadcastEnabled` is true in config. let appKey = notification.userInfo?[Airship.airshipReadyAppKey] as? String let payloadVersion = notification.userInfo?[Airship.airshipReadyPayloadVersion] as? Int let channelID = notification.userInfo?[Airship.airshipReadyChannelIdentifier] as? String } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.AirshipReady.name, object: nil, queue: nil ) { notification in /// Following values are only available if `extendedBroadcastEnabled` is true in config. let appKey = notification.userInfo?[AirshipNotifications.AirshipReady.appKey] as? String let payloadVersion = notification.userInfo?[AirshipNotifications.AirshipReady.payloadVersionKey] as? Int let channelID = notification.userInfo?[AirshipNotifications.AirshipReady.channelIDKey] as? String } ``` #### Channel Created 17.x: ``` NotificationCenter.default.addObserver( forName: AirshipChannel.channelCreatedEvent, object: nil, queue: nil ) { notification in let channelID = notification.userInfo?[AirshipChannel.channelIdentifierKey] as? String let isExisting = notification.userInfo?[AirshipChannel.channelExistingKey] as? Bool ?? false } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.ChannelCreated.name, object: nil, queue: nil ) { notification in let channelID = notification.userInfo?[AirshipNotifications.ChannelCreated.channelIDKey] as? String let isExisting = notification.userInfo?[AirshipNotifications.ChannelCreated.isExistingChannelKey] as? Bool ?? false } ``` #### Channel Updated Channel updated has been removed. For the most part apps should not need that and most likely are trying to listen for opt-in status. For that, see `notificationStatus` on the `PushProtocol`. #### Received Notifications The foreground and background notifications have been collapsed into a single event with a userInfo key indicating foreground vs background. 17.x: ``` NotificationCenter.default.addObserver( forName: AirshipPush.receivedForegroundNotificationEvent, object: nil, queue: nil ) { notification in let isForeground = true let receivedNotification = notification.userInfo } NotificationCenter.default.addObserver( forName: AirshipPush.receivedBackgrounddNotificationEvent, object: nil, queue: nil ) { notification in let isForeground = false let receivedNotification = notification.userInfo } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.RecievedNotification.name, object: nil, queue: nil ) { notification in let isForeground = notification.userInfo?[AirshipNotifications.RecievedNotification.isForegroundKey] as? Bool let receivedNotification = notification.userInfo?[AirshipNotifications.RecievedNotification.notificationKey] as? [AnyHashable: Any] } ``` #### Received Notification Response 17.x: ``` NotificationCenter.default.addObserver( forName: AirshipPush.receivedNotificationResponseEvent, object: nil, queue: nil ) { notification in let response = notification.userInfo?[AirshipPush.receivedNotificationResponseEventResponseKey] } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.ReceivedNotificationResponse.name, object: nil, queue: nil ) { notification in let response = notification.userInfo?[AirshipNotifications.ReceivedNotificationResponse.responseKey] } ``` #### Locale Updated 17.x: ``` NotificationCenter.default.addObserver( forName: AirshipLocaleManager.localeUpdatedEvent, object: nil, queue: nil ) { notification in let locale = notification.userInfo?[AirshipLocaleManager.localeEventKey] as? Locale } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.LocaleUpdated.name, object: nil, queue: nil ) { notification in let response = notification.userInfo?[AirshipNotifications.LocaleUpdated.localeKey] as? Locale } ``` #### Privacy Manager Updated 17.x: ``` NotificationCenter.default.addObserver( forName: AirshipPrivacyManager.localechangeEventUpdatedEvent, object: nil, queue: nil ) { _ in } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.PrivacyManagerUpdated.name, object: nil, queue: nil ) { _ in } ``` #### Contact Conflict Event 17.x: ``` NotificationCenter.default.addObserver( forName: AirshipContact.contactConflictEvent, object: nil, queue: nil ) { notification in let conflictEvent = notification.userInfo?[AirshipContact.contactConflictEventKey] as? ContactConflictEvent } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.ContactConflict.name, object: nil, queue: nil ) { notification in let conflictEvent = notification.userInfo?[AirshipNotifications.ContactConflict.eventKey] as? ContactConflictEvent } ``` #### Message Center Updated 17.x: ``` NotificationCenter.default.addObserver( forName: MessageCenterInbox.messageListUpdatedEvent, object: nil, queue: nil ) { _ in } ``` 18.x: ``` NotificationCenter.default.addObserver( forName: AirshipNotifications.MessageCenterListUpdated.name, object: nil, queue: nil ) { _ in } ``` ## Adding Events The Analytics method `addEvent(_)` has been removed and replaced with `recordRegionEvent(_)` and `recordCustomEvent(_)` | SDK 17.x | SDK 18.x | ----------------------------------------|---------------------------------------------------| | Airship.analytics.addEvent(customEvent) | Airship.analytics.recordCustomEvent(customEvent) | | Airship.analytics.addEvent(regionEvent) | Airship.analytics.recordRegionEvent(regionEvent) | ## AirshipAutomation The `AirshipAutomation` module has been rewritten in swift and no longer supports obj-c bindings. For most apps, this will be a trivial update, but if you are using custom display adapters the update will be more extensive. See below for more on custom display adapters. ### Accessors The accessors for `InAppMessaging` and `LegacyInAppMessaging` have moved. | SDK 17.x | SDK 18.x | |---------------------------------------------|---------------------------------------------| | InAppAutomation.shared.inAppMessageManager | InAppAutomation.shared.inAppMessaging | | LegacyInAppMessaging.shared | InAppAutomation.shared.legacyInAppMessaging | ### Cache Management `InAppMessagePrepareAssetsDelegate`, `InAppMessageCachePolicyDelegate`, `InAppMessageAssetManager` have been removed and is no longer available to extend. These APIs were difficult to use and often times lead to unintended consequences. The Airship SDK will now manage its own assets. External assets required by the App that need to be fetched before hand should happen outside of Airship. If assets are needed and can be fetched at display time, use the `CustomDisplayAdapter.waitForReady()` method as a hook to fetch those assets. ### Display Coordinators Display coordinators was another difficult to use API that has been removed. Instead, use the `InAppMessageDisplayDelegate.isMessageReadyToDisplay(_:scheduleID:)` method to prevent messages from displaying, and `InAppAutomation.shared.inAppMessaging.notifyDisplayConditionsChanged()` to notify when the message should be tried again. If a use case is not able to be solved with the replacement methods, please file a Github issue with your use case. ### Extending messages InAppMessages are no longer extendable when displaying. If this is needed in your application, please file a Github issue with your use case. ### Custom Display Adapter `InAppMessageAdapterProtocol` has been replaced with `CustomDisplayAdapter`. The new protocol has changed, but it roughly provides the same functionality as before just with a different interface. | SDK 17.x `InAppMessageAdapterProtocol` | SDK 18.x `CustomDisplayAdapter` | | -----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------| | adapter(for:) | No mapping, no required factory method | | display() async -> InAppMessageResolution | display(scene: UIWindowScene) async -> CustomDisplayResolution | | func prepare(with assets: InAppMessageAssets) async -> InAppMessagePrepareResult | use isReady and func waitForReady() async. Asset are available in the factory callback | | isReadyToDisplay | isReady | Example: ``` final class MyCustomDisplayAdapter : CustomDisplayAdapter { @MainActor static func register() { InAppAutomation.shared.inAppMessaging.setAdapterFactoryBlock(forType: .banner) { message, assets in return MyCustomDisplayAdapter(message: message, assets: assets) } } let message: InAppMessage let assets: AirshipCachedAssetsProtocol init(message: InAppMessage, assets: AirshipCachedAssetsProtocol) { self.message = message self.assets = assets } @MainActor var isReady: Bool { // This is called before the message is displayed. If `false`, `waitForReady()` will // be called before this is checked again. If `true`, `display` will be called // on the same run loop return true } @MainActor func waitForReady() async { /// If `isReady` is false, this method should wait for whatever conditions are required to make `isReady` true. } @MainActor func display(scene: UIWindowScene) async -> CustomDisplayResolution { /// Most apps will probably need a continuation return await withCheckedContinuation { continuation in /// Display the message /// Resume with the results after its been displayed. Failing to resume will block other messages /// from displaying continuation.resume(returning: CustomDisplayResolution.userDismissed) } } } ``` Then, after takeOff: ``` func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil ) -> Bool { ... Airship.takeOff(config, launchOptions: launchOptions) MyCustomDisplayAdapter.register() ... } ``` ================================================ FILE: Documentation/Migration/migration-guide-18-19.md ================================================ # Airship iOS SDK 18.x to 19.0 Migration Guide The Airship SDK 19.0 introduces significant updates to improve Swift API support and adopts Swift 6. Major changes include transitioning Objective-C support to a separate framework, converting many classes to structs, and making most public APIs `Sendable`. This guide outlines the major updates and non-obvious changes for migrating from SDK 18.x to SDK 19.0. --- ## SDK 19 requirements - **Xcode 16.2 or newer** - iOS 15+ - tvOS 18+ - visionOS 1+ --- ## Objective-C Support Objective-C support has been removed from the core Airship frameworks to leverage Swift APIs fully. A new framework, `AirshipObjectiveC`, provides bindings for apps still using Objective-C. For this first release only the most common APIs will have bindings. - **Missing Bindings?** Open a GitHub issue to request additional bindings - Rewrite parts of your application in Swift - Provide your own Objective-C bindings --- ## Airship Config The `AirshipConfig` class has been converted to a struct. Key updates include: 1. **Property Changes**: - Properties like `appKey` and `appSecret` are now determined during `takeOff` based on the `inProduction` flag. - `inProduction` is now an optional Bool. If set the value will be used. If not, it will be inferred by inspecting the APNS environment. 2. **API Updates**: - APIs that could fail now throw errors instead of silently failing | SDK 18.x AirshipConfig API | SDK 19.x AirshipConfig API | | ----------------------------------------------------------- | ----------------------------------------------------------- | | class func AirshipConfig.default() -> AirshipConfig | static func AirshipConfig.default() throws -> AirshipConfig | | class func AirshipConfig.config() -> AirshipConfig | init() | | class func config(contentsOfFile: String?) -> AirshipConfig | init(fromPlist: String) throws | | init(contentsOfFile: String?) -> AirshipConfig | init(fromPlist: String) throws | | init(contentsOfFile: String?) -> AirshipConfig | init(fromPlist: String) throws | | var inProduction: Bool { get set } | var inProduction: Bool? { get set } | | var detectProvisioningMode: Bool { get set } | _REMOVED_ | | var appKey: String { get } | _REMOVED: determined during takeOff based on inProduction_ | | var appSecret: String { get } | _REMOVED: determined during takeOff based on inProduction_ | | var logLevel: AirshipLogLevel { get } | _REMOVED: determined during takeOff based on inProduction_ | | var logPrivacyLevel: AirshipLogPrivacyLevel { get } | _REMOVED: determined during takeOff based on inProduction_ | | func validate() -> Bool | func validateCredentials(inProduction: Bool) throws | | func validate(logIssues: Bool) -> Bool | func validateCredentials(inProduction: Bool) throws | --- ## Changes to `Airship.takeOff` The `takeOff` methods now throw errors for better error handling. `takeOff` will throw in the following conditions: - `takeOff` already successfully called. - `takeOff` was called without an `AirshipConfig` instance and it fails to parse `AirshipConfig.plist` - `takeOff` was called without an `AirshipConfig` instance and the parsed `AirshipConfig.plist` is invalid (missing credentials) - `takeOff` was called with an `AirshipConfig` instance with invalid config (missing credentials) No error will be thrown if the config is properly setup and Airship is only called once during `application(_:didFinishLaunchingWithOptions:)`. Example error handling: **Crash on Startup**: ```swift let config = try! AirshipConfig.default() try! Airship.takeOff(config, launchOptions: launchOptions) ``` **Log Misconfiguration (SDK 18.x behavior)**: ```swift do { let config = try AirshipConfig.default() try Airship.takeOff(config, launchOptions: launchOptions) } catch { print("Airship.takeOff failed: \(error)") } ``` The absence of an error does not guarantee that the provided app credentials are valid. Airship only verifies that the credentials exist for the specified production mode and conform to the expected length and character set. For new integrations, review the logs for any warnings or errors to ensure a proper setup. --- ## Logging Logger configuration before `takeOff` is no longer needed. All logging config has moved to `AirshipConfig`. Example: ```swift var config = AirshipConfig() // Log everything publicly to the console for development config.developmentLogLevel = .verbose config.developmentLogPrivacyLevel = .public // Custom log handler config.logHandler = MyCustomLogHandler() ``` --- ## Module component accessors Accessors for module components have been standardized: | SDK 18.x Accessors | SDK 19.x Accessors | | ------------------------- | -------------------------- | | MessageCenter.shared | Airship.messageCenter | | PreferenceCenter.shared | Airship.preferenceCenter | | FeatureFlagManager.shared | Airship.featureFlagManager | | InAppAutomation.shared | Airship.inAppAutomation | --- ## Push options `UANotificationOptions` and `UAAuthorizationStatus` have been removed. Use Apple's equivalents instead: | SDK 18.x Type | SDK 19.x Replacement | | --------------------- | ---------------------- | | UANotificationOptions | UNAuthorizationOptions | | UAAuthorizationStatus | UNAuthorizationStatus | `UAAuthorizedNotificationSettings` has been ported to Swift and is now named `AirshipAuthorizedNotificationSettings`: | SDK 18.x Type | SDK 19.x Replacement | | -------------------------------- | ------------------------------------- | | UAAuthorizedNotificationSettings | AirshipAuthorizedNotificationSettings | --- ## Changes to `PushNotificationDelegate` The `PushNotificationDelegate` methods are now asynchronous. Update your implementations to match the new async methods. --- ## Changes to `AppIntegration` For apps disabling automatic integration, methods in `AppIntegration` are now async and decorated with `@MainActor`. Update your implementation to use the async equivalent methods: ```swift func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { AppIntegration.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { AppIntegration.application(application, didFailToRegisterForRemoteNotificationsWithError: error) } func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult { return await AppIntegration.application(application, didReceiveRemoteNotification: userInfo) } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { await AppIntegration.userNotificationCenter(center, didReceive: response) } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { return await AppIntegration.userNotificationCenter(center, willPresent: notification) } ``` If you are running into sendable issues with any of the above methods, you should decorate the AppDelegate class with `@MainActor`. --- ## Changes to `AirshipJSON` `AirshipJSON.wrap` will no longer special case a `Date` by formatting it as an ISO date string. Instead it will use the date formatting strategy defined in the encoder/decoder. `Airship.defaultEncoder` and `Airship.defaultDecoder` now use the `.iso8601` date strategy. If you are using `Airship.defaultEncoder` or `Airship.defaultDecoder`, you may want to use a default instance instead. --- ## Changes to `CustomEvent` Custom event property is now a `Decimal` instead of an `NSNumber`. The init methods for the value now only accepts `Double` and `Decimal`. String values are no longer accepted in the init methods so the app can detect parse failures instead of it silently failing. The property `eventValue` is no longer optional and defaults to 1.0. The default value did not change, just the interface. Property values are now able to be set with mutating functions on the custom event. These functions will wrap the value as an `AirshipJSON` to make the event `Sendable`. The `JSONEncoder` can now be specified in the function, the static mutable property `CustomEvent.defaultEncoder` has been replaced by a factory method `CustomEvent.defaultEncoder()` that provides the default encoder for the property mutators if one is not provided. **API Changes** | SDK 18.x CustomEvent API | SDK 19.x CustomEvent API | | --------------------------------------------------------------- | ------------------------------------------------------------------------------ | | var eventValue: NSNumber? { get set } | var eventValue: Decimal { get set } | | var properties: [String: Any] { get set } | var properties: [String: AirshipJSON] { get } | | var properties: [String: Any] { get set } | var properties: [String: AirshipJSON] { get } | | init(name: String, value: NSNumber?) | init(name: String, value: Double) or init(name: String, decimalValue: Decimal) | | init(name: String, stringValue: String?) | _REMOVED: parse the value as a Double first_ | | class func event(name: String) -> CustomEvent | init(name: String) | | class func event(name: String, string: String?) -> CustomEvent | _REMOVED: parse the value as a Double first, then use init(name:value:)_ | | class func event(name: String, value: NSNumber?) -> CustomEvent | init(name: String, value: Double) or init(name: String, decimalValue: Decimal) | **Property Mutators** | SDK 19.x Custom Event property functions | Description | | -------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | mutating func setProperty(string: String, forKey: String) | Sets a string value in the property map | | mutating func setProperty(bool: Bool, forKey: String) | Sets a bool value in the property map | | mutating func setProperty(double: Double, forKey: String) | Sets a double value in the property map | | mutating func setProperty(value: Any?, forKey: String, encoder: JSONEncoder) -> throws | Sets a value (wrapped by AirshipJSON) in the property map | | mutating func removeProperty(forKey: String) | Removes a property in the property map | | mutating func setProperties(object: Any?, encoder: JSONEncoder) -> throws | Sets the properties object. The value must result in an object | --- ## Custom Event Templates The custom event templates classes have been removed and replaced with new `CustomEvent` init methods. | SDK 18.x template class | SDK 19.x replacement | | ----------------------- | --------------------------------------------- | | AccountEventTemplate | CustomEvent.init(accountTemplate:properties:) | | RetailEventTemplate | CustomEvent.init(retailTemplate:properties:) | | SearchEventTemplate | CustomEvent.init(searchTemplate:properties:) | | MediaEventTemplate | CustomEvent.init(mediaTemplate:properties:) | The LTV (life time value) property use to be set if the template defined a value. This would lead to inconsistent results depending on if the value was set on the template vs setting the value on the generated custom event. The SDK will no longer automatically set the `ltv` property, it now can be set in the template properties: ``` var event = CustomEvent( searchTemplate: .search, properties: CustomEvent.SearchProperties( isLTV: true ) ) event.eventValue = 100.0 ``` ================================================ FILE: Documentation/Migration/migration-guide-19-20.md ================================================ # Airship iOS SDK 19.x to 20.0 Migration Guide The Airship SDK 20.0 introduces major architectural changes including UI refactors for Message Center and Preference Center, a protocol-first architecture for core components, and modern block-based callback alternatives to delegate patterns. The minimum deployment target is raised to iOS 16+. This guide outlines the necessary changes for migrating your app from SDK 19.x to SDK 20.0. **Required Migration Tasks:** - Update Xcode to 26+ - Update deployment target to iOS 16+ - Update SwiftUI view calls for Message Center/Preference Center **Optional Migration Tasks:** - Migrate delegate patterns to block-based callbacks - Update deprecated API calls to new APIs ## Table of Contents - [Breaking Changes](#breaking-changes) - [Preference Center Refactor](#preference-center-refactor) - [Message Center Refactor](#message-center-refactor) - [Protocol Architecture Changes](#protocol-architecture-changes) - [Deprecated APIs](#deprecated-apis) - [Attribute Management](#attribute-management) - [Preference Center Display](#preference-center-display) - [Block-Based Callbacks](#block-based-callbacks) - [Push Notifications](#push-notifications) - [Registration](#registration) - [Deep Links](#deep-links) - [URL Allow List](#url-allow-list) - [Displaying the Message Center](#displaying-the-message-center) - [Displaying the Preference Center](#displaying-the-preference-center) - [In-App Messaging Display Control](#in-app-messaging-display-control) - [Troubleshooting](#troubleshooting) ## Breaking Changes ### Preference Center Refactor The Preference Center has been refactored to provide clearer separation between content and navigation, and to simplify customization. #### View Hierarchy Changes The Preference Center now follows a "Container vs. Content" architecture that separates navigation from content. The main view `PreferenceCenterView` is now a wrapper that provides a `NavigationStack`. The core content, previously known as `PreferenceCenterList`, has been renamed to `PreferenceCenterContent`. - **`PreferenceCenterView`**: This is the container view. It sets up the `NavigationStack` and is responsible for the navigation bar's title and back button. Use this view for a standard Preference Center implementation with navigation. - **`PreferenceCenterContent`**: This is the content view. It loads and displays the list of preferences. Use this view if you want to provide your own navigation or embed the Preference Center within another view. #### API Updates Several types and protocols have been renamed for clarity: - `PreferenceCenterList` → `PreferenceCenterContent` - `PreferenceCenterViewPhase` → `PreferenceCenterContentPhase` - `PreferenceCenterViewLoader` → `PreferenceCenterContentLoader` - `PreferenceCenterViewStyle` → `PreferenceCenterContentStyle` - `PreferenceCenterViewStyleConfiguration` → `PreferenceCenterContentStyleConfiguration` #### Navigation Changes The `PreferenceCenterNavigationStack` enum and the `preferenceCenterNavigationStack()` view modifier have been removed. `PreferenceCenterView` now always uses a `NavigationStack`. If you were previously using `.none`, you should switch to using `PreferenceCenterContent` directly. **Before:** ```swift // To provide custom navigation PreferenceCenterView(preferenceCenterID: "your_id") .preferenceCenterNavigationStack(.none) ``` **After:** ```swift // Use PreferenceCenterContent directly PreferenceCenterContent(preferenceCenterID: "your_id") ``` ### Message Center Refactor The Message Center UI has been refactored for greater flexibility and clearer API boundaries, separating navigation from content. #### View Hierarchy Changes The Message Center now follows a "Container vs. Content" architecture that separates navigation from content. The top-level `MessageCenterView` is now a navigation container. The actual content is rendered by `MessageCenterContent`. The Message Center UI is broken down into several public components that can be used to build a custom experience: - **`MessageCenterView`**: The top-level container that provides a `NavigationStack` or `NavigationSplitView`. Use this view for a standard Message Center implementation. It provides either a `NavigationStack` or a `NavigationSplitView`, which can be controlled via the new `navigationStyle` parameter. - **`MessageCenterContent`**: The core content view that coordinates the message list. Use this view if you need to provide your own navigation or embed the Message Center within a custom view hierarchy. - **`MessageCenterListViewWithNavigation`**: This view displays the list of messages and **is responsible for the navigation bar content**, including the title and the edit/toolbar buttons. - **`MessageCenterListView`**: A simpler view that only displays the list of messages, without any navigation bar items. - **`MessageCenterMessageViewWithNavigation`**: Displays a single message and manages its navigation bar. - **`MessageCenterMessageView`**: Displays a single message without a navigation bar. #### API Updates - `MessageCenterViewStyle` → `MessageCenterContentStyle` - `messageCenterViewStyle()` → `messageCenterContentStyle()` - `MessageCenterStyleConfiguration` → `MessageCenterContentStyleConfiguration` #### Navigation Changes The `MessageCenterNavigationStack` enum and the `messageCenterNavigationStack()` view modifier have been removed. Navigation is now controlled by the `navigationStyle` parameter on `MessageCenterView`. **Before:** ```swift // Basic Message Center MessageCenterView() // With custom navigation MessageCenterView() .messageCenterNavigationStack(.none) ``` **After:** ```swift // Stack-based navigation (default on iPhone) MessageCenterView(navigationStyle: .stack) // Split-view navigation (default on iPad) MessageCenterView(navigationStyle: .split) // To provide custom navigation, use MessageCenterContent MessageCenterContent() ``` ### Protocol Architecture Changes SDK 20.0 refactors core Airship components to use protocols instead of concrete classes. The existing functionality remains the same, but the implementation is now hidden behind protocol interfaces. This change provides better testability, modularity, and allows for easier customization and mocking. #### Class-to-Protocol Conversions Several core Airship classes have been converted to protocols (with the same functionality): - `AirshipPrivacyManager` - `AirshipPermissionsManager` - `MessageCenter` - `InAppAutomation` - `PreferenceCenter` - `InAppMessaging` - `LegacyInAppMessaging` - `AirshipActionRegistry` - `AirshipChannelCapture` - `FeatureFlagManager` #### Protocol Renames Several protocols have been renamed to remove the "Protocol" suffix: - `AirshipAnalyticsProtocol` → `AirshipAnalytics` - `AirshipChannelProtocol` → `AirshipChannel` - `AirshipContactProtocol` → `AirshipContact` - `AirshipPushProtocol` → `AirshipPush` - `PrivacyManagerProtocol` → `AirshipPrivacyManager` - `URLAllowListProtocol` → `AirshipURLAllowList` - `AirshipLocaleManagerProtocol` → `AirshipLocaleManager` - `InAppMessagingProtocol` → `InAppMessaging` - `LegacyInAppMessagingProtocol` → `LegacyInAppMessaging` #### Migration Impact **For most developers:** These changes are primarily internal and won't affect your code. The public APIs remain the same - you can continue using `Airship.contact`, `Airship.privacyManager`, `Airship.messageCenter`, `Airship.inAppAutomation`, `Airship.preferenceCenter`, etc. as before. --- ## Deprecated APIs Several APIs have been deprecated in SDK 20.0 and will be removed in future versions. Update your code to use the recommended alternatives. ### Attribute Management The `set(number:attribute:)` method now accepts Swift numeric types directly instead of `NSNumber`. This change is **backward compatible** - existing code using `Int` or `UInt` literals will continue to work without modification. **Before:** ```swift // Old method using NSNumber Airship.contact.editAttributes { editor in editor.set(number: NSNumber(value: 42), attribute: "age") } ``` **After:** ```swift // Use Swift types - Int, Uint, or Double Airship.contact.editAttributes { editor in editor.set(number: 42, attribute: "age") editor.set(number: 42.0, attribute: "age") editor.set(number: UInt(42), attribute: "age") } ``` ### Preference Center Display **Before:** ```swift // Old method Airship.preferenceCenter.openPreferenceCenter(preferenceCenterID: "my_id") ``` **After:** ```swift // New method Airship.preferenceCenter.display("my_id") ``` --- ## Block-Based Callbacks To provide a more modern and convenient Swift API, SDK 20 introduces block-based (closure) callbacks as an alternative to several common delegate protocols. These new callbacks improve code locality and can reduce boilerplate for simple event handling. The delegate-based approach is still fully supported, but we recommend adopting the new block-based callbacks for new implementations. **The delegate patterns will be deprecated in a future 20.x release and removed in SDK 21.0.0** All new callback closures are `@MainActor` and `@Sendable` to ensure thread safety and simplify UI updates. ### Push Notifications Instead of conforming to `PushNotificationDelegate`, you can now set individual closures on `Airship.push`. If a block is provided for a specific event, the corresponding `PushNotificationDelegate` method will be ignored. **Before:** ```swift // A class that implements the delegate class MyPushDelegate: PushNotificationDelegate { func receivedNotificationResponse(_ response: UNNotificationResponse) async { // Handle response asynchronously await someAsyncTask() } // ... other delegate methods } // In your app, store a strong reference to the delegate class AppDelegate: UIResponder, UIApplicationDelegate { private let pushDelegate = MyPushDelegate() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ... // After takeOff Airship.push.pushNotificationDelegate = pushDelegate return true } } ``` **After:** ```swift // In your app's startup code Airship.push.onReceivedNotificationResponse = { response in // Handle response asynchronously await someAsyncTask() } ``` **New APIs on `Airship.push`:** - `onReceivedForegroundNotification` - `onReceivedBackgroundNotification` - `onReceivedNotificationResponse` - `onExtendPresentationOptions` ### Registration The `RegistrationDelegate` has also been broken down into more granular, event-specific closures. If a block is provided, it will be used instead of the corresponding delegate method. **Before:** ```swift // A class that implements the delegate class MyRegistrationDelegate: RegistrationDelegate { func apnsRegistrationSucceeded(withDeviceToken deviceToken: Data) { print("APNs registration succeeded") } func notificationRegistrationFinished( withAuthorizedSettings authorizedSettings: AirshipAuthorizedNotificationSettings, status: UNAuthorizationStatus ) { print("Notification registration finished with status: \(status)") } } // In your app, store a strong reference to the delegate class AppDelegate: UIResponder, UIApplicationDelegate { private let registrationDelegate = MyRegistrationDelegate() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ... // After takeOff Airship.push.registrationDelegate = registrationDelegate return true } } ``` **After:** ```swift // In your app's startup code // Handle APNS registration updates Airship.push.onAPNSRegistrationFinished = { result in switch result { case .success(let deviceToken): print("APNs registration succeeded: \(deviceToken)") case .failure(let error): print("APNs registration failed: \(error)") } } // Handle user notification registration updates Airship.push.onNotificationRegistrationFinished = { result in print("Notification registration finished with status: \(result.status)") } // Handle changes to authorized notification settings Airship.push.onNotificationAuthorizedSettingsDidChange = { settings in print("Authorized settings changed: \(settings)") } ``` **New APIs on `Airship.push`:** - `onAPNSRegistrationFinished` with `APNSRegistrationResult` - `onNotificationRegistrationFinished` with `NotificationRegistrationResult` - `onNotificationAuthorizedSettingsDidChange` The new callbacks use the following data structures: ```swift /// The result of an APNs registration. public enum APNSRegistrationResult: Sendable { /// Registration was successful and a new device token was received. case success(deviceToken: String) /// Registration failed. case failure(error: any Error) } /// The result of the initial notification registration prompt. public struct NotificationRegistrationResult: Sendable { /// The settings that were authorized at the time of registration. public let authorizedSettings: AirshipAuthorizedNotificationSettings /// The authorization status. public let status: UNAuthorizationStatus #if !os(tvOS) /// Set of the categories that were most recently registered. public let categories: Set<UNNotificationCategory> #endif } ``` ### Deep Links Instead of conforming to `DeepLinkDelegate`, you can now set closures on `Airship`. If the `onDeepLink` block is set, the `DeepLinkDelegate` will be ignored. **Before:** ```swift class MyDeepLinkDelegate: DeepLinkDelegate { func receivedDeepLink(_ deepLink: URL) async { // Handle deep link asynchronously await someNavigationTask(url) } } class AppDelegate: UIResponder, UIApplicationDelegate { private let deepLinkDelegate = MyDeepLinkDelegate() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ... // After takeOff Airship.deepLinkDelegate = deepLinkDelegate return true } } ``` **After:** ```swift Airship.onDeepLink = { url in // Handle deep link asynchronously await someNavigationTask(url) } ``` **New API on `Airship`:** - `onDeepLink` ### URL Allow List Instead of conforming to `URLAllowListDelegate`, you can now set the `onAllowURL` closure on `Airship.urlAllowList`. If the `onAllowURL` block is set, the `URLAllowListDelegate` will be ignored. **Before:** ```swift class MyURLDelegate: URLAllowListDelegate { func allowURL(_ url: URL, scope: URLAllowListScope) -> Bool { // Custom URL validation logic return url.host?.contains("trusted-domain.com") == true } } class AppDelegate: UIResponder, UIApplicationDelegate { private let urlDelegate = MyURLDelegate() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { ... // After takeOff Airship.urlAllowList.delegate = urlDelegate return true } } ``` **After:** ```swift Airship.urlAllowList.onAllowURL = { url, scope in // Custom URL validation logic return url.host?.contains("trusted-domain.com") == true } ``` **New API on `Airship.urlAllowList`:** - `onAllowURL` ### Displaying the Message Center Instead of `MessageCenterDisplayDelegate`, you can now use the `onDisplay` and `onDismissDisplay` closures on `Airship.messageCenter`. The `onDisplay` closure should return `true` if the display was handled, or `false` to let the SDK fall back to its default UI. If the `onDisplay` block is set, the delegate will be ignored. **Before:** ```swift class MyDisplayDelegate: MessageCenterDisplayDelegate { func displayMessageCenter(messageID: String?) { // Display Message Center UI } func dismissMessageCenter() { // Dismiss Message Center UI } } // Store a strong reference private let displayDelegate = MyDisplayDelegate() // In your app's startup code Airship.messageCenter.displayDelegate = displayDelegate ``` **After:** ```swift Airship.messageCenter.onDisplay = { messageID in // Display custom Message Center UI // Return true to prevent the default SDK display behavior. return true } Airship.messageCenter.onDismissDisplay = { // Dismiss Message Center UI } ``` ### Displaying the Preference Center Instead of `PreferenceCenterOpenDelegate`, you can use the new `onDisplay` closure on `Airship.preferenceCenter`. The closure should return `true` if the display was handled, or `false` to let the SDK fall back to its default UI. If the `onDisplay` block is set, the delegate will be ignored. **Before:** ```swift class MyOpenDelegate: PreferenceCenterOpenDelegate { func openPreferenceCenter(preferenceCenterID: String) { // Display Preference Center UI } } // Store a strong reference private let openDelegate = MyOpenDelegate() // In your app's startup code Airship.preferenceCenter.openDelegate = openDelegate ``` **After:** ```swift Airship.preferenceCenter.onDisplay = { preferenceCenterID in // Display custom Preference Center UI // Return true to prevent the default SDK display behavior. return true } ``` **New API on `Airship.preferenceCenter`:** - `onDisplay` ### In-App Messaging Display Control Instead of implementing `InAppMessagingDisplayDelegate`, you can now use the `onIsReadyToDisplay` closure on `Airship.inAppMessaging`. This closure allows you to control when in-app messages are ready to be displayed. If the `onIsReadyToDisplay` block is set, the delegate will be ignored. **Before:** ```swift class MyDisplayDelegate: InAppMessagingDisplayDelegate { func isMessageReadyToDisplay(_ message: InAppMessage, scheduleID: String) -> Bool { // Custom logic to determine if message should be displayed return someCondition } } // Store a strong reference private let displayDelegate = MyDisplayDelegate() // In your app's startup code Airship.inAppMessaging.displayDelegate = displayDelegate ``` **After:** ```swift Airship.inAppMessaging.onIsReadyToDisplay = { message, scheduleID in // Custom logic to determine if message should be displayed return someCondition } ``` **New API on `Airship.inAppMessaging`:** - `onIsReadyToDisplay` --- ## Troubleshooting ### Common Issues **Build Errors After Migration** - Ensure you're using Xcode 26+ and have updated your deployment target to iOS 16+ - Clean your build folder (Product → Clean Build Folder) and rebuild - Check that all SwiftUI view calls have been updated to use the new API names **Message Center/Preference Center Not Displaying** - Verify you're using the correct view names (`MessageCenterView` vs `MessageCenterContent`) - Check that navigation style parameters are set correctly - Ensure you're not mixing old and new API calls **Delegate Methods Not Being Called** - If you've migrated to block-based callbacks, ensure you're not setting both delegates and blocks - Block-based callbacks take precedence over delegate methods - Check that your delegate objects are retained (not deallocated) **Attribute Setting Issues** - The `set(number:attribute:)` method now accepts Swift types directly - `Int` and `UInt` values are automatically bridged to `Double` - If you're still using `NSNumber`, consider migrating to Swift types ### Getting Help If you encounter issues not covered in this guide: - Check the [Airship Documentation](https://docs.airship.com/) - Review the [SDK API Reference](https://docs.airship.com/reference/libraries/ios/) - Contact [Airship Support](https://support.airship.com/) ================================================ FILE: Documentation/abstracts/Guides.md ================================================ ##### [Migration Guide](migration-guide.html) ##### [Migration Guide (Legacy)](migration-guide-legacy.html) ================================================ FILE: Documentation/readme-for-jazzy.md ================================================ # Airship iOS SDK The Airship SDK for iOS provides a simple way to integrate Airship services into your iOS applications. ## Resources - [AirshipCore Docs](https://docs.airship.com/reference/libraries/ios/latest/AirshipCore) - [AirshipAutomation Docs](https://docs.airship.com/reference/libraries/ios/latest/AirshipAutomation) - [AirshipMessageCenter Docs](https://docs.airship.com/reference/libraries/ios/latest/AirshipMessageCenter) - [AirshipPreferenceCenter Docs](https://docs.airship.com/reference/libraries/ios/latest/AirshipPreferenceCenter) - [AirshipFeatureFlags Docs](https://docs.airship.com/reference/libraries/ios/latest/AirshipFeatureFlags) - [AirshipObjectiveC Docs](https://docs.airship.com/reference/libraries/ios/latest/AirshipObjectiveC) - [AirshipNotificationServiceExtension Docs](https://docs.airship.com/reference/libraries/ios/latest/AirshipNotificationServiceExtension) - [Getting started guide](https://docs.airship.com/platform/mobile/setup/sdk/ios) - [Migration Guides](https://github.com/urbanairship/ios-library/tree/main/Documentation/Migration) ================================================ FILE: Gemfile ================================================ source 'https://rubygems.org' gem "cocoapods" gem "jazzy" ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ XCODE ?= 26.2 export XCBEAUTIFY_RENDERER ?= github-actions export TEST_DESTINATION ?= platform=iOS Simulator,OS=latest,name=iPhone 17 Pro Max export TEST_DESTINATION_TVOS ?= platform=tvOS Simulator,OS=latest,name=Apple TV export TEST_DESTINATION_VISIONOS ?= platform=visionOS Simulator,OS=latest,name=Apple Vision Pro export DEVELOPER_DIR = $(shell bash ./scripts/get_xcode_path.sh ${XCODE} $(XCODE_PATH)) export AIRSHIP_VERSION = $(shell bash "./scripts/airship_version.sh") build_path = build derived_data_path = ${build_path}/derived_data archive_path = ${build_path}/archive xcframeworks_path = ${build_path}/xcframeworks xcframeworks_full_path = ${xcframeworks_path}/full xcframeworks_dotnet_path = ${xcframeworks_path}/dotnet package_zip_path = ${build_path}/Airship.zip package_xcframeworks_zip_path = ${build_path}/Airship.xcframeworks.zip package_dotnet_xcframeworks_zip_path = ${build_path}/Airship.dotnet.xcframeworks.zip file_size=${build_path}/size.txt previous_file_size=${build_path}/previous-size.txt .PHONY: setup setup: test ${DEVELOPER_DIR} bundle install --quiet bash ./scripts/check_xcbeautify.sh .PHONY: all all: setup build test pod-lint .PHONY: build build: build-package build-samples .PHONY: build-package build-package: clean-package build-xcframeworks bash ./scripts/package.sh \ "${package_zip_path}" \ "${xcframeworks_full_path}/*.xcframework" \ CHANGELOG.md \ README.md \ LICENSE bash ./scripts/package_xcframeworks.sh "${package_xcframeworks_zip_path}" "${xcframeworks_full_path}/" "Carthage/build" bash ./scripts/package_xcframeworks.sh "${package_dotnet_xcframeworks_zip_path}" "${xcframeworks_dotnet_path}/" "xcframeworks" .PHONY: build-docC build-docC: bash ./scripts/build_docCs.sh $(version) .PHONY: build-xcframeworks build-xcframeworks: setup clean-xcframeworks bash ./scripts/build_xcframeworks.sh "${xcframeworks_path}" "${derived_data_path}" "${archive_path}" .PHONY: build-xcframeworks-no-sign build-xcframeworks-no-sign: setup clean-xcframeworks bash ./scripts/build_xcframeworks.sh "${xcframeworks_path}" "${derived_data_path}" "${archive_path}" "true" .PHONY: build-samples build-samples: build-sample-ios .PHONY: build-sample-ios build-sample-ios: setup bash ./scripts/build_sample.sh "DevApp" "${derived_data_path}" .PHONY: build-sample-watchos build-sample-watchos: setup bash ./scripts/build_sample_watchos.sh "watchOSSample_WatchKit_Extension" "${derived_data_path}" .PHONY: build-airship-objectiveC build-airship-objectiveC: setup bash ./scripts/run_xcodebuild.sh "AirshipObjectiveC" "${derived_data_path}" build .PHONY: test test: setup test-core test-preference-center test-message-center test-automation test-feature-flags test-service-extension .PHONY: test-core test-core: setup bash ./scripts/run_xcodebuild.sh AirshipCore "${derived_data_path}" test .PHONY: test-message-center test-message-center: setup bash ./scripts/run_xcodebuild.sh AirshipMessageCenter "${derived_data_path}" test .PHONY: test-preference-center test-preference-center: setup bash ./scripts/run_xcodebuild.sh AirshipPreferenceCenter "${derived_data_path}" test .PHONY: test-automation test-automation: setup bash ./scripts/run_xcodebuild.sh AirshipAutomation "${derived_data_path}" test .PHONY: test-feature-flags test-feature-flags: setup bash ./scripts/run_xcodebuild.sh AirshipFeatureFlags "${derived_data_path}" test .PHONY: test-service-extension test-service-extension: setup bash ./scripts/run_xcodebuild.sh AirshipNotificationServiceExtension "${derived_data_path}" test .PHONY: pod-publish pod-publish: setup bundle exec pod trunk push Airship.podspec --allow-warnings bundle exec pod trunk push AirshipServiceExtension.podspec --allow-warnings .PHONY: pod-lint pod-lint: pod-lint-tvos pod-lint-ios pod-lint-extensions .PHONY: pod-lint-tvos pod-lint-tvos: setup bundle exec pod lib lint Airship.podspec --verbose --platforms=tvos --fail-fast --skip-tests --no-subspecs --allow-warnings .PHONY: pod-lint-watchos pod-lint-watchos: setup bundle exec pod lib lint Airship.podspec --verbose --platforms=watchos --subspec=Core --fail-fast --skip-tests --no-clean --allow-warnings .PHONY: pod-lint-ios pod-lint-ios: setup bundle exec pod lib lint Airship.podspec --verbose --platforms=ios --fail-fast --skip-tests --no-subspecs --allow-warnings .PHONY: pod-lint-visionos pod-lint-visionos: setup bundle exec pod lib lint Airship.podspec --verbose --platforms=visionOS --fail-fast --skip-tests --no-subspecs --allow-warnings .PHONY: pod-lint-extensions pod-lint-extensions: setup bundle exec pod lib lint AirshipServiceExtension.podspec --verbose --platforms=ios --fail-fast --skip-tests --allow-warnings .PHONY: clean clean: rm -rf "${build_path}" .PHONY: clean-package clean-package: rm -rf "${package_zip_path}" rm -rf "${package_xcframeworks_zip_path}" rm -rf "${package_dotnet_xcframeworks_zip_path}" .PHONY: clean-xcframeworks clean-xcframeworks: # rm -rf "${xcframeworks_path}" .PHONY: compare-framework-size compare-framework-size: build-xcframeworks-no-sign check-size .PHONY: check-size check-size: bash ./scripts/check_size.sh "${xcframeworks_path}" "${file_size}" "${previous_file_size}" ================================================ FILE: Package.swift ================================================ // swift-tools-version:6.0 // Copyright Airship and Contributors import PackageDescription let package = Package( name: "Airship", defaultLocalization: "en", platforms: [.iOS(.v16), .tvOS(.v18), .visionOS(.v1)], products: [ .library( name: "AirshipCore", targets: ["AirshipCore"] ), .library( name: "AirshipAutomation", targets: ["AirshipAutomation"] ), .library( name: "AirshipMessageCenter", targets: ["AirshipMessageCenter"] ), .library( name: "AirshipNotificationServiceExtension", targets: ["AirshipNotificationServiceExtension"] ), .library( name: "AirshipPreferenceCenter", targets: ["AirshipPreferenceCenter"] ), .library( name: "AirshipFeatureFlags", targets: ["AirshipFeatureFlags"] ), .library( name: "AirshipObjectiveC", targets: ["AirshipObjectiveC"] ), .library( name: "AirshipDebug", targets: ["AirshipDebug"] ), ], targets: [ .target( name: "AirshipBasement", path: "Airship/AirshipBasement", exclude: [ "Info.plist", ], sources: ["Source"] ), .target( name: "AirshipCore", dependencies: [.target(name: "AirshipBasement")], path: "Airship/AirshipCore", exclude: [ "Info.plist", "Tests", ], sources: ["Source"], resources: [ .process("Resources") ] ), .target( name: "AirshipAutomation", dependencies: [.target(name: "AirshipCore")], path: "Airship/AirshipAutomation", exclude: [ "Info.plist", "Tests" ], sources: ["Source"], resources: [ .process("Resources") ] ), .target( name: "AirshipMessageCenter", dependencies: [.target(name: "AirshipCore")], path: "Airship/AirshipMessageCenter", exclude: [ "Info.plist", "Tests" ], sources: ["Source"], resources: [ .process("Resources") ] ), .target( name: "AirshipNotificationServiceExtension", path: "AirshipExtensions/AirshipNotificationServiceExtension", exclude: [ "Info.plist", "Tests" ], sources: ["Source"] ), .target( name: "AirshipPreferenceCenter", dependencies: [.target(name: "AirshipCore")], path: "Airship/AirshipPreferenceCenter", exclude: [ "Info.plist", "Tests", ], sources: ["Source"] ), .target( name: "AirshipFeatureFlags", dependencies: [.target(name: "AirshipCore")], path: "Airship/AirshipFeatureFlags", exclude: [ "Info.plist", "Tests", ], sources: ["Source"] ), .target( name: "AirshipObjectiveC", dependencies: [ .target(name: "AirshipBasement"), .target(name: "AirshipCore"), .target(name: "AirshipPreferenceCenter"), .target(name: "AirshipMessageCenter"), .target(name: "AirshipAutomation"), .target(name: "AirshipFeatureFlags") ], path: "Airship/AirshipObjectiveC", sources: ["Source"] ), .target( name: "AirshipDebug", dependencies: [ .target(name: "AirshipCore"), .target(name: "AirshipPreferenceCenter"), .target(name: "AirshipMessageCenter"), .target(name: "AirshipAutomation"), .target(name: "AirshipFeatureFlags") ], path: "Airship/AirshipDebug", exclude: [ "Info.plist", ], sources: ["Source"], resources: [ .process("Resources") ] ), ] ) ================================================ FILE: README.md ================================================ # Airship SDK for Apple [![Swift Package Manager](https://img.shields.io/badge/SPM-supported-DE5C43.svg)](https://swift.org/package-manager/) [![CocoaPods](https://img.shields.io/cocoapods/v/Airship.svg)](https://cocoapods.org/pods/Airship) [![Carthage](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg)](https://github.com/Carthage/Carthage) [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) The Airship SDK for Apple provides a comprehensive way to integrate Airship's customer experience platform into your iOS, tvOS, and visionOS applications. ## Features - **Push Notifications** - Rich, interactive push notifications with deep linking - **Live Activities** - Real-time updates for iOS 16.1+ Dynamic Island and Lock Screen widgets - **In-App Experiences** - Contextual messaging and automation - **Message Center** - Inbox for push notifications and messages - **Preference Center** - User preference management - **Feature Flags** - Dynamic feature toggles and experimentation - **Analytics** - Comprehensive user behavior tracking - **Contacts** - User identification and contact management - **Tags, Attributes & Subscription Lists** - User segmentation, personalization, and subscription management - **Privacy Controls** - Granular data collection and feature management - **SwiftUI Support** - Modern SwiftUI components and views - **Swift 6** - Fully adopted Swift 6 with strict concurrency safety ## Platform Support | Feature | iOS | tvOS | visionOS | |----------------------------------------|-----|---------------|----------| | Push Notifications | ✅ | ✅ | ✅ | | Live Activities | ✅ | ❌ | ❌ | | In-App Experiences | ✅ | ✅<sup>1</sup> | ✅ | | Message Center | ✅ | ✅<sup>2</sup> | ✅ | | Preference Center | ✅ | ✅ | ✅ | | Feature Flags | ✅ | ✅ | ✅ | | Analytics | ✅ | ✅ | ✅ | | Contacts | ✅ | ✅ | ✅ | | Tags, Attributes & Subscription Lists | ✅ | ✅ | ✅ | | Privacy Controls | ✅ | ✅ | ✅ | | SwiftUI Support | ✅ | ✅ | ✅ | <sup>1</sup> tvOS In-App Experiences: Scenes, Banners, and non-HTML In-App Automations are supported. However, scheduled In-App Experiences will no longer display if the app’s cache is wiped due to tvOS storage limitations. <sup>2</sup> tvOS Message Center: Supports Native Message Center. ## Installation Add the Airship SDK to your project using Swift Package Manager: ```swift dependencies: [ .package(url: "https://github.com/urbanairship/ios-library.git", from: "20.4.0") ] ``` In Xcode, add the following products to your target dependencies: - `AirshipCore` (required) - `AirshipMessageCenter` (for Message Center) - `AirshipPreferenceCenter` (for Preference Center) - `AirshipAutomation` (for In-App Experiences, including Scenes, In-App Automation, and Landing Pages) - `AirshipFeatureFlags` (for Feature Flags) - `AirshipNotificationServiceExtension` (for rich push notifications) - `AirshipObjectiveC` (for Objective-C compatibility) - `AirshipDebug` (for debugging tools) For other installation methods (CocoaPods, Carthage, xcframeworks), please see the [getting started guide](https://www.airship.com/docs/developer/sdk-integration/apple/installation/getting-started/). ## Quick Start 1. **Configure and Initialize Airship** in your `AppDelegate` or `App`: ```swift import AirshipCore // In AppDelegate func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { var config = AirshipConfig() config.defaultAppKey = "YOUR_APP_KEY" config.defaultAppSecret = "YOUR_APP_SECRET" try! Airship.takeOff(config) return true } // Or in SwiftUI App @main struct MyApp: App { init() { var config = AirshipConfig() config.defaultAppKey = "YOUR_APP_KEY" config.defaultAppSecret = "YOUR_APP_SECRET" try! Airship.takeOff(config) } var body: some Scene { WindowGroup { ContentView() } } } ``` > **Note**: `Airship.takeOff` should only be called once. 2. **Enable & Request User Notifications**: ```swift await Airship.push.enableUserPushNotifications() ``` ## Requirements - iOS 16.0+ - tvOS 18.0+ - visionOS 1.0+ - Xcode 26.0+ ## Documentation - **[Getting Started](https://docs.airship.com/platform/mobile/setup/sdk/ios/)** - Complete setup guide - **[API Reference](https://urbanairship.github.io/ios-library/)** - Full API documentation - **[Migration Guides](Documentation/Migration/README.md)** - Comprehensive migration documentation - **[Sample Apps](https://github.com/urbanairship/apple-sample-apps)** - Example implementations ## Support - 📚 [Documentation](https://docs.airship.com/) - 🐛 [Report Issues](https://github.com/urbanairship/ios-library/issues) ## License This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. ================================================ FILE: scripts/airship_version.sh ================================================ #!/bin/bash set -o pipefail set -e ROOT_PATH=`dirname "${0}"`/.. echo $(awk <"$ROOT_PATH/Airship/AirshipConfig.xcconfig" "\$1 == \"CURRENT_PROJECT_VERSION\" { print \$3 }") ================================================ FILE: scripts/build_docCs.sh ================================================ #!/bin/bash # build_docs.sh CURRENT_VERSION # - CURRENT_VERSION: The SDK current version. # Adaptive DocC build script that works for both private and public repos set -o pipefail set -e CURRENT_VERSION="$1" # 🔧 CONFIG SCHEMES=( "AirshipCore" "AirshipPreferenceCenter" "AirshipMessageCenter" "AirshipAutomation" "AirshipFeatureFlags" "AirshipObjectiveC" "AirshipNotificationServiceExtension" ) BUILD="build" DOCS_DIR="docs" # 🔍 Detect repository context if [ -n "$GITHUB_REPOSITORY" ]; then # Running in GitHub Actions REPO_NAME=$(basename "$GITHUB_REPOSITORY") else # Running locally - try to detect from git remote REPO_URL=$(git remote get-url origin 2>/dev/null || echo "") if [[ "$REPO_URL" == *"ios-library.git"* ]] || [[ "$REPO_URL" == *"ios-library"* ]]; then REPO_NAME="ios-library" else REPO_NAME="ios-library-dev" fi fi echo "📘 Building DocC for repository: $REPO_NAME" echo "📘 Version: $CURRENT_VERSION" # 🧼 Clean up rm -rf $BUILD rm -rf $DOCS_DIR mkdir -p "$DOCS_DIR" # 📘 Generate DocC for each scheme echo "📘 Building DocC for schemes: ${SCHEMES[*]}" for SCHEME in "${SCHEMES[@]}"; do echo "📘 Building DocC for $SCHEME ..." DERIVED_DATA="$BUILD/$SCHEME" xcodebuild docbuild \ -scheme "$SCHEME" \ -destination 'platform=macOS' \ -derivedDataPath "$DERIVED_DATA" ARCHIVE_PATH=$(find "$DERIVED_DATA" -name "$SCHEME.doccarchive" | head -n 1) if [ -z "$ARCHIVE_PATH" ]; then echo "❌ No doccarchive for $SCHEME in $CURRENT_VERSION" exit 1 fi OUTPUT_PATH="$DOCS_DIR/$SCHEME" mkdir -p "$OUTPUT_PATH" # 🔧 Set hosting base path based on repository if [ "$REPO_NAME" = "ios-library" ]; then HOSTING_BASE_PATH="/ios-library/$CURRENT_VERSION/$SCHEME" else HOSTING_BASE_PATH="/$CURRENT_VERSION/$SCHEME" fi echo "📘 Using hosting base path: $HOSTING_BASE_PATH" $(xcrun --find docc) process-archive \ transform-for-static-hosting \ "$ARCHIVE_PATH" \ --output-path "$OUTPUT_PATH" \ --hosting-base-path "$HOSTING_BASE_PATH" echo "✅ $SCHEME docs ready at $OUTPUT_PATH" done echo "🎉 Docs generated for $REPO_NAME" ================================================ FILE: scripts/build_sample.sh ================================================ #!/bin/bash set -o pipefail set -e ROOT_PATH=`dirname "${0}"`/.. SAMPLE="$1" DERIVED_DATA="$2" if [ $SAMPLE == "tvOSSample" ] then TARGET_SDK='appletvsimulator' else TARGET_SDK='iphonesimulator' fi echo -ne "\n\n *********** BUILDING SAMPLE $SAMPLE *********** \n\n" # Make sure AirshipConfig.plist exists cp -np "${ROOT_PATH}/$SAMPLE/AirshipConfig.plist.sample" "${ROOT_PATH}/$SAMPLE/AirshipConfig.plist" || true # Use Debug configurations and a simulator SDK so the build process doesn't attempt to sign the output xcrun xcodebuild \ -configuration Debug \ -workspace "${ROOT_PATH}/Airship.xcworkspace" \ -scheme "${SAMPLE}" \ -sdk $TARGET_SDK \ -derivedDataPath "$DERIVED_DATA" | xcbeautify --renderer $XCBEAUTIFY_RENDERER ================================================ FILE: scripts/build_sample_watchos.sh ================================================ #!/bin/bash set -o pipefail set -e ROOT_PATH=`dirname "${0}"`/.. SAMPLE="$1" DERIVED_DATA="$2" echo -ne "\n\n *********** BUILDING SAMPLE $SAMPLE *********** \n\n" # Make sure AirshipConfig.plist exists cp -np "${ROOT_PATH}/$SAMPLE/AirshipConfig.plist.sample" "${ROOT_PATH}/$SAMPLE/AirshipConfig.plist" || true # Use Debug configurations and a simulator SDK so the build process doesn't attempt to sign the output xcrun xcodebuild \ -configuration Debug \ -workspace "${ROOT_PATH}/Airship.xcworkspace" \ -scheme $SAMPLE \ -derivedDataPath "$DERIVED_DATA" | xcbeautify --renderer $XCBEAUTIFY_RENDERER ================================================ FILE: scripts/build_xcframeworks.sh ================================================ #!/bin/bash # build_xcframeworks.sh OUTPUT DERIVED_DATA_PATH ARCHIVE_PATH [SKIP_SIGNING] # - OUTPUT: The output directory. # - DERIVED_DATA_PATH: The derived data path # - ARCHIVE_PATH: The archive path # - SKIP_SIGNING: Optional. Set to "true" to skip code signing (useful for size checks) set -o pipefail set -ex ROOT_PATH=`dirname "${0}"`/.. OUTPUT="$1" DERIVED_DATA="$2" ARCHIVE_PATH="$3" SKIP_SIGNING="$4" FULL_ARCHIVE_PATH="$(pwd -P)/$ARCHIVE_PATH" OUTPUT_FULL="$OUTPUT/full" OUTPUT_DOTNET="$OUTPUT/dotnet" mkdir -p "$OUTPUT_FULL" mkdir -p "$OUTPUT_DOTNET" ################## # Build Frameworks ################## function build_archive { # $1 Project # $2 iOS or tvOS local scheme=$1 local sdk="" local simulatorSdk="" local destination="" local simulatorDestination="" if [ $2 == "iOS" ] then sdk="iphoneos" destination="generic/platform=iOS" simulatorSdk="iphonesimulator" simulatorDestination="generic/platform=iOS Simulator" elif [ $2 == "visionOS" ] then sdk="xros" destination="generic/platform=visionOS" simulatorSdk="xrsimulator" simulatorDestination="generic/platform=visionOS Simulator" elif [ $2 == "maccatalyst" ] then destination="generic/platform=macOS,variant=Mac Catalyst,name=Any Mac" else sdk="appletvos" simulatorSdk="appletvsimulator" destination="generic/platform=tvOS" simulatorDestination="generic/platform=tvOS Simulator" fi if [ $2 == "maccatalyst" ] then xcrun xcodebuild archive -quiet \ -workspace "$ROOT_PATH/Airship.xcworkspace" \ -scheme "$scheme" \ -destination "$destination" \ -archivePath "$ARCHIVE_PATH/xcarchive/$scheme/mac.xcarchive" \ -derivedDataPath "$DERIVED_DATA" \ SKIP_INSTALL=NO \ BUILD_LIBRARIES_FOR_DISTRIBUTION=YES | xcbeautify --renderer $XCBEAUTIFY_RENDERER else xcrun xcodebuild archive -quiet \ -workspace "$ROOT_PATH/Airship.xcworkspace" \ -scheme "$scheme" \ -sdk "$sdk" \ -destination "$destination" \ -archivePath "$ARCHIVE_PATH/xcarchive/$scheme/$sdk.xcarchive" \ -derivedDataPath "$DERIVED_DATA" \ SKIP_INSTALL=NO \ BUILD_LIBRARIES_FOR_DISTRIBUTION=YES xcrun xcodebuild archive -quiet \ -workspace "$ROOT_PATH/Airship.xcworkspace" \ -scheme "$scheme" \ -sdk "$simulatorSdk" \ -destination "$simulatorDestination" \ -archivePath "$ARCHIVE_PATH/xcarchive/$scheme/$simulatorSdk.xcarchive" \ -derivedDataPath "$DERIVED_DATA" \ SKIP_INSTALL=NO \ BUILD_LIBRARIES_FOR_DISTRIBUTION=YES | xcbeautify --renderer $XCBEAUTIFY_RENDERER fi } echo -ne "\n\n *********** BUILDING XCFRAMEWORKS *********** \n\n" # tvOS build_archive "AirshipRelease tvOS" "tvOS" # iOS build_archive "AirshipRelease" "iOS" build_archive "AirshipNotificationServiceExtension" "iOS" # Catalyst build_archive "AirshipRelease" "maccatalyst" build_archive "AirshipNotificationServiceExtension" "maccatalyst" # visionOS build_archive "AirshipRelease" "visionOS" # Package AirshipBasement xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipBasement.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipBasement.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipBasement.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipBasement.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipBasement.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/dSYMs/AirshipBasement.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/dSYMs/AirshipBasement.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipBasement.xcframework" # Package AirshipCore xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipCore.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipCore.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipCore.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipCore.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipCore.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/dSYMs/AirshipCore.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/dSYMs/AirshipCore.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipCore.xcframework" # Package AirshipAutomation xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipAutomation.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipAutomation.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipAutomation.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipAutomation.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipAutomation.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipAutomation.xcframework" # Package AirshipMessageCenter xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipMessageCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipMessageCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipMessageCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipMessageCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipMessageCenter.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipMessageCenter.xcframework" # Package AirshipPreferenceCenter xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipPreferenceCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipPreferenceCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipPreferenceCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipPreferenceCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipPreferenceCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/dSYMs/AirshipPreferenceCenter.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/dSYMs/AirshipPreferenceCenter.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipPreferenceCenter.xcframework" # Package AirshipFeatureFlags xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipFeatureFlags.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipFeatureFlags.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipFeatureFlags.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipFeatureFlags.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipFeatureFlags.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvos.xcarchive/dSYMs/AirshipFeatureFlags.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease tvOS/appletvsimulator.xcarchive/dSYMs/AirshipFeatureFlags.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipFeatureFlags.xcframework" # Package AirshipObjectiveC xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipObjectiveC.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipObjectiveC.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipObjectiveC.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipObjectiveC.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipObjectiveC.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipObjectiveC.xcframework" xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/dSYMs/AirshipDebug.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/dSYMs/AirshipDebug.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xros.xcarchive/dSYMs/AirshipDebug.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/xrsimulator.xcarchive/dSYMs/AirshipDebug.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/dSYMs/AirshipDebug.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipDebug.xcframework" # Package AirshipNotificationServiceExtension xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/iphoneos.xcarchive/Products/Library/Frameworks/AirshipNotificationServiceExtension.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/iphoneos.xcarchive/dSYMs/AirshipNotificationServiceExtension.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/mac.xcarchive/Products/Library/Frameworks/AirshipNotificationServiceExtension.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/mac.xcarchive/dSYMs/AirshipNotificationServiceExtension.framework.dSYM" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipNotificationServiceExtension.framework" \ -debug-symbols "$FULL_ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/iphonesimulator.xcarchive/dSYMs/AirshipNotificationServiceExtension.framework.dSYM" \ -output "$OUTPUT_FULL/AirshipNotificationServiceExtension.xcframework" ############################ # Package .NET frameworks ############################ # .NET does not support tvOS or visionOS # Package AirshipBasement (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipBasement.framework" \ -output "$OUTPUT_DOTNET/AirshipBasement.xcframework" # Package AirshipCore (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipCore.framework" \ -output "$OUTPUT_DOTNET/AirshipCore.xcframework" # Package AirshipAutomation (.NET) xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipAutomation.framework" \ -output "$OUTPUT_DOTNET/AirshipAutomation.xcframework" # Package AirshipMessageCenter (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipMessageCenter.framework" \ -output "$OUTPUT_DOTNET/AirshipMessageCenter.xcframework" # Package AirshipPreferenceCenter (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipPreferenceCenter.framework" \ -output "$OUTPUT_DOTNET/AirshipPreferenceCenter.xcframework" # Package AirshipFeatureFlags (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipFeatureFlags.framework" \ -output "$OUTPUT_DOTNET/AirshipFeatureFlags.xcframework" # Package AirshipObjectiveC (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipObjectiveC.framework" \ -output "$OUTPUT_DOTNET/AirshipObjectiveC.xcframework" # Package AirshipDebug (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphoneos.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipRelease/mac.xcarchive/Products/Library/Frameworks/AirshipDebug.framework" \ -output "$OUTPUT_DOTNET/AirshipDebug.xcframework" # Package AirshipNotificationServiceExtension (.NET) xcrun xcodebuild -create-xcframework \ -framework "$ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/iphoneos.xcarchive/Products/Library/Frameworks/AirshipNotificationServiceExtension.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/iphonesimulator.xcarchive/Products/Library/Frameworks/AirshipNotificationServiceExtension.framework" \ -framework "$ARCHIVE_PATH/xcarchive/AirshipNotificationServiceExtension/mac.xcarchive/Products/Library/Frameworks/AirshipNotificationServiceExtension.framework" \ -output "$OUTPUT_DOTNET/AirshipNotificationServiceExtension.xcframework" # Sign the frameworks (unless SKIP_SIGNING is set to "true") if [ "$SKIP_SIGNING" != "true" ]; then echo "Signing frameworks..." codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipBasement.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipCore.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipMessageCenter.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipPreferenceCenter.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipNotificationServiceExtension.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipFeatureFlags.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipAutomation.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipObjectiveC.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_FULL/AirshipDebug.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipBasement.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipCore.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipMessageCenter.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipPreferenceCenter.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipNotificationServiceExtension.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipFeatureFlags.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipAutomation.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipObjectiveC.xcframework" codesign --timestamp -v --sign "Apple Distribution: Urban Airship Inc. (PGJV57GD94)" "$OUTPUT_DOTNET/AirshipDebug.xcframework" else echo "Skipping code signing as requested..." fi ================================================ FILE: scripts/check_size.sh ================================================ #!/bin/bash set -e XCFRAMEWORK_PATH=$1 FILE_SIZE=$2 PREVIOUS_FILE_SIZE=$3 echo "Checking all XCFrameworks in: $XCFRAMEWORK_PATH" echo "---------------------------------------------" # Ensure required arguments are passed if [ -z "$XCFRAMEWORK_PATH" ] || [ -z "$FILE_SIZE" ] || [ -z "$PREVIOUS_FILE_SIZE" ]; then echo "Usage: $0 <xcframeworks_folder> <current_size_file> <previous_size_file>" exit 1 fi # Use a temporary file for safe writing TEMP_FILE=$(mktemp /tmp/xcframework_sizes.XXXXXX) # Collect current sizes for all frameworks found_any=false echo "Current XCFramework sizes:" for f in "$XCFRAMEWORK_PATH"/*.xcframework; do if [ -d "$f" ]; then found_any=true size_kb=$(du -sk "$f" | cut -f1) name=$(basename "$f") echo "$name=$size_kb" >> "$TEMP_FILE" echo " $name → ${size_kb} KB" fi done if [ "$found_any" = false ]; then echo "No .xcframework files found in $XCFRAMEWORK_PATH" rm -f "$TEMP_FILE" exit 1 fi # Compare with previous sizes if [ -f "$PREVIOUS_FILE_SIZE" ] && [ -s "$PREVIOUS_FILE_SIZE" ]; then echo "" echo " Comparing with previous sizes:" echo "---------------------------------------------" # Loop through current sizes while IFS="=" read -r name size; do # Look up previous size by grep if prev=$(grep "^$name=" "$PREVIOUS_FILE_SIZE" 2>/dev/null | cut -d= -f2); then if [ -z "$prev" ]; then echo "$name is new (${size} KB)" else diff=$((size - prev)) if [ $diff -gt 0 ]; then echo "$name grew by ${diff} KB (was ${prev} KB)" elif [ $diff -lt 0 ]; then echo "$name shrank by $((-diff)) KB (was ${prev} KB)" else echo "$name unchanged (${size} KB)" fi fi else echo " $name is new (${size} KB)" fi done < "$TEMP_FILE" else echo "" echo "No previous size file found — creating baseline." fi # Safely move temp file to target FILE_SIZE mv "$TEMP_FILE" "$FILE_SIZE" 2>/dev/null || { echo "Could not overwrite $FILE_SIZE directly, trying sudo..." sudo mv "$TEMP_FILE" "$FILE_SIZE" } # Update previous size file cp "$FILE_SIZE" "$PREVIOUS_FILE_SIZE" 2>/dev/null || { echo "Could not update $PREVIOUS_FILE_SIZE, trying sudo..." sudo cp "$FILE_SIZE" "$PREVIOUS_FILE_SIZE" } echo "" echo "Size check complete. Baseline updated: $PREVIOUS_FILE_SIZE" ================================================ FILE: scripts/check_version.sh ================================================ #!/bin/bash set -e set -x ROOT_PATH=`dirname "${0}"`/.. AIRSHIP_VERSION=$(bash "$ROOT_PATH/scripts/airship_version.sh") if [ $1 = $AIRSHIP_VERSION ]; then exit 0 else exit 1 fi ================================================ FILE: scripts/check_xcbeautify.sh ================================================ #!/bin/bash set -o pipefail set -e if ! which xcbeautify > /dev/null; then echo "Missing xcbeautify!" exit 1 fi ================================================ FILE: scripts/gemini-review-prompt.md ================================================ # Code Review Prompt ## Your Role You review code like your life depends on it. Your main goal is to offer helpful quick GitHub suggestions for: 1. **Spelling errors** in ANY file type - CHECK EVERY WORD including: - Comments (// comments, /* */ comments, # comments) - String literals - Variable names, function names, class names - Documentation - ANY text in the diff 2. **Style violations** in Swift files following the Airship iOS Swift Style Guide below The suggestions should be easy to implement using GitHub suggestion syntax. Check ALL files in the diff, not just Swift files. Even small typos like "Promp" instead of "Prompt" must be caught. ### EXACT Format (DO NOT DEVIATE): ``` File: <exact file path from diff> Line: <exact line number from diff> Comment: <one-line explanation> ```suggestion <exact replacement code that is syntactically valid> ``` **CRITICAL SUGGESTION RULES**: 1. The suggestion block must contain ONLY the single line being replaced 2. NEVER include multiple lines in a suggestion 3. NEVER include partial lines or fragments 4. The suggestion must be the COMPLETE line including all indentation 5. If fixing a comment, include the ENTIRE comment line 6. NEVER remove or add braces, brackets, or parentheses unless they are part of the single line being fixed **EXAMPLE - CORRECT**: If line 28 is: ` // ─── Promp ───────────────────────────────────────────` The suggestion should be: ` // ─── Prompt ───────────────────────────────────────────` **EXAMPLE - WRONG**: Never suggest partial replacements like just `Prompt` or include multiple lines like: ``` } // ─── Prompt ─────────────────────────────────────────── ``` **RESPONSE LIMITS**: Maximum 10 suggestions. Be concise. **OUTPUT FORMAT**: Respond with either: 1. `LGTM 🤖👍` if no issues found 2. Or suggestions using the format below: **WARNING**: The code in the suggestion block MUST be valid, compilable code. Never suggest partial code or code with syntax errors. **BEFORE MAKING ANY SUGGESTION**: 1. Mentally trace through the code execution 2. Verify all referenced variables/functions exist at that point 3. Ensure the suggestion doesn't break the surrounding code 4. Test that the file would still compile/run after applying your change 5. If there's doubt, do not make the suggestion ### Important Rules: - The suggestion block must contain EXACTLY ONE LINE - the complete line being replaced - Include ALL indentation/spacing from the beginning of the line - NEVER suggest multi-line replacements - NEVER suggest partial line replacements - The line number in "Line: X" must match exactly one line in the diff ## Example Output ### Code Suggestions File: Views/ProfileView.swift File: Views/ProfileView.swift Line: 42 Comment: Add [weak self] capture to prevent retain cycle in async closure ```suggestion Task { [weak self] in await self?.updateUser() } ``` --- File: ViewModels/DataManager.swift Line: 78 Comment: Mark method as @MainActor since it updates @Published UI state ```suggestion @MainActor func updateLoadingState() async { self.isLoading = false } ``` --- File: Models/UserSession.swift Line: 34 Comment: UserSession contains mutable reference types - needs @unchecked Sendable with synchronization ```suggestion final class UserSession: Codable, @unchecked Sendable { private let lock = NSLock() ``` --- File: ViewModels/DataManager.swift Line: 23 Comment: Potential race condition - async sequence should be created once and stored ```suggestion private let updateStream = AsyncStream<Update>.makeStream() func observeUpdates() -> AsyncStream<Update> { return updateStream.stream } ``` --- File: Services/APIClient.swift Line: 56 Comment: URLSession task not retained - will be deallocated immediately causing request to fail ```suggestion let task = session.dataTask(with: request) { data, response, error in // handle response } task.resume() return task ``` --- File: Views/ListView.swift Line: 89 Comment: ForEach with index binding causes O(n) lookups - use enumerated() for better performance ```suggestion ForEach(Array(items.enumerated()), id: \.element.id) { index, item in ItemView(item: $items[index]) } ``` ## Airship iOS Swift Style Guide ### File Structure ```swift /* Copyright Airship and Contributors */ // 1. Imports (grouped and ordered) import Foundation #if canImport(UIKit) import UIKit #endif // 2. Type documentation /// Main type description public final class ClassName: Protocol, @unchecked Sendable { // 3. Static properties private static let constantName = "value" // 4. Instance properties (ordered by: visibility then mutability) private let immutableProperty: Type private var mutableProperty: Type public private(set) var readOnlyProperty: Type // 5. Computed properties public var computedProperty: Type { return someValue } // 6. Initializers // 7. Instance methods // 8. Static methods } // 9. Extensions (one per protocol for complex conformances) extension ClassName: ProtocolName { } ``` ### Formatting Rules - **Indentation**: 4 spaces (never tabs) - **Line length**: Maximum 100-120 characters - **Braces**: Opening brace on same line, closing brace on new line - **Spacing**: - Spaces around operators: `x + y`, `a = b` - No space before colon in type declarations: `let name: String` - Space after colon: `name: String` - One blank line between methods - Two blank lines between major sections ### Function Style ```swift // Multi-line parameters aligned with opening parenthesis @MainActor init( dataStore: PreferenceDataStore, config: RuntimeConfig, privacyManager: any PrivacyManagerProtocol ) { self.dataStore = dataStore self.config = config self.privacyManager = privacyManager } // Function calls align continuations with first parameter let channel = ChannelRegistrar( config: config, dataStore: dataStore, privacyManager: privacyManager ) // Chained methods each on new line publisher .compactMap { $0 } .removeDuplicates() .sink { value in // Handle value } ``` ### Naming Conventions - **Types**: `PascalCase` (e.g., `ChannelManager`, `ContactAPIClient`) - **Protocols**: Often end with `Protocol` suffix or describe capability - **Properties/Methods**: `camelCase` (e.g., `channelID`, `enableChannelCreation()`) - **Constants**: `camelCase` for instance, descriptive names for static - **No abbreviations**: Use `identifier` not `id`, `configuration` not `config` (unless established pattern) - **Boolean properties**: Use `is`, `has`, `should` prefixes (e.g., `isEnabled`, `hasChanges`) ### Access Control - **Always explicit** for all top-level declarations - **Order declarations**: public → internal → private - **Use `private(set)`** for read-only public properties - **Use `fileprivate`** sparingly, only when needed across extensions in same file ### Property Patterns ```swift // Thread-safe wrappers for Sendable conformance private let lock = AirshipLock() private let wrapper: AirshipUnsafeSendableWrapper<Type> // Lazy initialization for expensive objects private lazy var expensiveObject: Type = { return Type() }() // Property observers on single line when simple var property: Type { didSet { update() } } ``` ### Error Handling ```swift // Guard for early returns guard let value = optionalValue else { AirshipLogger.warn("Missing required value") return } // Throwing functions func performOperation() async throws -> Result { guard isValid else { throw AirshipErrors.error("Invalid state") } return result } ``` ### Async/Await Patterns ```swift // Async properties public var updates: AsyncStream<Update> { return channel.makeStream() } // Task creation with weak self Task { [weak self] in guard let self else { return } await self.performWork() } // MainActor isolation @MainActor public func updateUI() { // UI updates } ``` ### Protocol Conformance ```swift // Separate extension per protocol for complex conformances extension Type: Equatable { static func == (lhs: Type, rhs: Type) -> Bool { return lhs.id == rhs.id } } // Conditional conformance extension Type: Codable where T: Codable { // Implementation } ``` ### Documentation ```swift /// Brief description of the type or method. /// /// Detailed explanation if needed. /// /// - Parameters: /// - parameter1: Description of first parameter /// - parameter2: Description of second parameter /// - Returns: Description of return value /// - Throws: Description of errors thrown public func documentedMethod(parameter1: Type, parameter2: Type) throws -> ReturnType { // Implementation } // MARK: - Section Headers // Use MARK comments to organize code sections // Inline comments for clarification let result = complexCalculation() // Explain why if not obvious ``` ### Common Patterns - **Avoid force unwraps**: Use `guard`, `if let`, or `??` instead - **Prefer `final class`** unless subclassing is explicitly needed - **Use `@unchecked Sendable`** with proper synchronization for reference types - **Platform-specific code**: Use `#if canImport()` for conditional compilation - **Notification names**: Nest in type-specific extensions - **Static factory methods**: Use `` `default` `` syntax when needed - **Type inference**: Omit type when obvious, include when clarifies intent ### Switch Statements ```swift switch value { case .case1(let associated): handleCase1(associated) case .case2: handleCase2() default: handleDefault() } ``` ### Closure Style ```swift // Trailing closure for last parameter items.map { item in return item.transformed } // Explicit closure parameters for clarity in complex cases items.reduce(into: [:]) { (result: inout [String: Int], item: Item) in result[item.key] = item.value } // Capture lists { [weak self, strong dependency] in guard let self else { return } // Use self and dependency } ``` ================================================ FILE: scripts/get_xcode_path.sh ================================================ #!/bin/bash # get_xcode_path.sh ARG # - ARG: The version number or path set -o pipefail set -e ROOT_PATH=`dirname "${0}"`/.. XCODE_APPS_FINDER=$(mdfind "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode'") XCODE_APPS_FALLBACK=$(find /Applications -iname 'Xcode*.app' -maxdepth 1) XCODE_APPS=$(echo -e "$XCODE_APPS_FINDER\n$XCODE_APPS_FALLBACK" | sort | uniq) PLIST_BUDDY="/usr/libexec/PlistBuddy" XCODE_ARG=$1 function get_plist_value() { "$PLIST_BUDDY" -c "Print :$2" "$1/Contents/Info.plist" } function get_version() { APP_NAME=$(get_plist_value "$1" "CFBundleName") if [[ "$APP_NAME" == "Xcode" ]]; then echo $(get_plist_value "$1" "CFBundleShortVersionString") else echo "" fi } if [ -d "$XCODE_ARG" ] then echo $2 exit 0 fi for APP in $XCODE_APPS; do APP_VERSION=$(get_version $APP) if [ $XCODE_ARG = $APP_VERSION ]; then echo $APP exit 0 fi done echo "Failed to find $XCODE_ARG. Available versions: " 1>&2 for APP in $XCODE_APPS; do APP_VERSION=$(get_version $APP) echo "$APP_VERSION: $APP" 1>&2 done exit 1 ================================================ FILE: scripts/package.sh ================================================ #!/bin/bash # build_docs.sh OUTPUT [PATHS...] # - OUTPUT: The output zip. # - PATHS: A list of directories or files to be included in the zip set -o pipefail set -e ZIP="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" package() { if [ -d "$1" ] then pushd "${1}/.." zip -r --symlinks "${ZIP}" "./$(basename $1)" popd else if [ -f "$1" ] then echo "file: $1" zip -j "${ZIP}" "$1" else for file in $1 do package "$file" done fi fi } BUILD_INFO=$(mktemp -d /tmp/build-XXXXX)/BUILD_INFO echo "Airship SDK v${AIRSHIP_VERSION}" >> ${BUILD_INFO} echo "Build time: `date`" >> ${BUILD_INFO} echo "SDK commit: `git log -n 1 --format='%h'`" >> ${BUILD_INFO} echo "Xcode version: $(xcrun xcodebuild -version | tr '\r\n' ' ')" >> ${BUILD_INFO} package $BUILD_INFO for var in "${@:2}" do package "$var" done ================================================ FILE: scripts/package_xcframeworks.sh ================================================ #!/bin/bash # package_xcframeworks.sh OUTPUT INPUT_DIR [ZIP_ROOT] # - OUTPUT: The output zip. # - INPUT_DIR: A path to the xcframeworks directory to package # - ZIP_ROOT: Optional root folder inside the zip (default: xcframeworks) set -o pipefail set -e ZIP_ROOT="${3:-xcframeworks}" TEMP="$(mktemp -d)" mkdir -p "$TEMP/$ZIP_ROOT" cp -R "$2" "$TEMP/$ZIP_ROOT" cd "$TEMP" zip -r --symlinks xcframeworks.zip "${ZIP_ROOT%%/}" -x "*.DS_Store" cd - cp "$TEMP/xcframeworks.zip" "$1" ================================================ FILE: scripts/pr-review.mjs ================================================ // Node ≥20 ESM – no external dependencies import fs from 'fs/promises'; import path from 'path'; import process from 'process'; const MAX_DIFF_BYTES = 400_000; // ~100 k tokens const MODEL_ID_FROM_ENV = process.env.MODEL_ID; const MODEL_ID = MODEL_ID_FROM_ENV || 'gemini-pro'; if (!MODEL_ID_FROM_ENV) { warn(`MODEL_ID environment variable not set, falling back to default: ${MODEL_ID}. Ensure this is intended and configured in the workflow.`); } const DEBUG = process.env.DEBUG === 'true'; const root = process.cwd(); const log = (m) => console.log(`🪄 ${m}`); const warn = (m) => console.warn(`⚠️ ${m}`); const die = (m) => { console.error(`💥 ${m}`); process.exit(1); }; (async () => { // ─── Diff ───────────────────────────────────────────── const diff = await fs.readFile(path.join(root, 'diff.patch'), 'utf8'); log(`Diff loaded (${(diff.length / 1024).toFixed(1)} KB)`); if (diff.length > MAX_DIFF_BYTES) { warn(`Diff > ${(MAX_DIFF_BYTES / 1024)} KB ⇒ skipping AI review`); return; } // ─── Prompt ─────────────────────────────────────────── const promptPath = path.join(root, 'scripts', 'gemini-review-prompt.md'); const prompt = await fs.readFile(promptPath, 'utf8'); // ─── Validate API key ─────────────────────────────────── if (!process.env.GEMINI_API_KEY) { die('GEMINI_API_KEY environment variable is not set'); } // ─── Gemini call ───────────────────────────────────── const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL_ID}:generateContent?key=${process.env.GEMINI_API_KEY}`; log(`Calling Gemini (${MODEL_ID}) …`); const gemRes = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts: [{ text: `${prompt}\n\n---\n\n${diff}` }] }], generationConfig: { temperature: 0.2, topK: 1, topP: 0.95 } }) }); if (!gemRes.ok) { // Extra diagnostics for the common 403 if (gemRes.status === 403) { die(`Gemini HTTP 403 ➜ Check that: • Generative Language API is **enabled** in your Google Cloud project • The API key has **no application restrictions** (or allows server IPs) • MODEL_ID (“${MODEL_ID}”) is available to your key`); } die(`Gemini HTTP ${gemRes.status}`); } const gemJson = await gemRes.json(); // Better error handling for Gemini response if (gemJson.error) { die(`Gemini API error: ${gemJson.error.message || JSON.stringify(gemJson.error)}`); } if (!gemJson.candidates || !gemJson.candidates.length) { if (DEBUG) { console.log('Full Gemini response:', JSON.stringify(gemJson, null, 2)); } die('Gemini returned no candidates'); } // Check for blocked or filtered responses const candidate = gemJson.candidates[0]; if (candidate.finishReason && candidate.finishReason !== 'STOP') { warn(`Response finish reason: ${candidate.finishReason}`); if (candidate.finishReason === 'SAFETY') { warn('Response blocked by safety filters'); if (DEBUG && candidate.safetyRatings) { console.log('Safety ratings:', JSON.stringify(candidate.safetyRatings, null, 2)); } } } // Log the structure to debug if (DEBUG) { console.log('Candidate structure:', JSON.stringify(candidate, null, 2)); } const gemText = candidate?.content?.parts?.[0]?.text ?? ''; log(`Gemini returned ${(gemText.length / 1024).toFixed(1)} KB`); if (!gemText.trim()) { warn('Gemini response empty – nothing to post'); if (DEBUG) { console.log('Full candidate:', JSON.stringify(candidate, null, 2)); } return; } if (DEBUG) { console.log('=== GEMINI RESPONSE START ==='); console.log(gemText); console.log('=== GEMINI RESPONSE END ==='); } // ─── Handle LGTM response ───────────────────────────── if (gemText.trim() === 'LGTM 🤖👍') { log('No issues found - posting LGTM'); const ghRes = await fetch( `https://api.github.com/repos/${process.env.REPO}/pulls/${process.env.PR_NUMBER}/reviews`, { method: 'POST', headers: { 'Authorization': `token ${process.env.GITHUB_TOKEN}`, 'Accept': 'application/vnd.github+json' }, body: JSON.stringify({ body: 'LGTM 🤖👍', event: 'COMMENT' }) } ); if (!ghRes.ok) die(`GitHub HTTP ${ghRes.status}`); log('LGTM posted successfully'); return; } // ─── Parse suggestions ─────────────────────────────── const comments = []; // First check if response contains "Code Suggestions" header const suggestionsSection = gemText.match(/###?\s*Code Suggestions\s*\n([\s\S]*)/)?.[1] || gemText; const blocks = suggestionsSection.split(/^-{3,}$/m).map(s => s.trim()).filter(Boolean); for (const b of blocks) { // Skip if block is too short or looks like a header if (b.length < 20 || b.match(/^#+\s/)) continue; // Clean up duplicate File: lines (from examples in prompt) const cleanedBlock = b.replace(/^File:.*\nFile:/m, 'File:'); // More flexible regex patterns for parsing const fileMatch = cleanedBlock.match(/^File:\s*(.+?)$/m); const lineMatch = cleanedBlock.match(/^Line:\s*(\d+)/m); const commentMatch = cleanedBlock.match(/^Comment:\s*(.+?)$/m); const suggestionMatch = cleanedBlock.match(/```suggestion\n([\s\S]*?)\n```/); if (fileMatch && lineMatch && commentMatch && suggestionMatch) { const file = fileMatch[1].trim(); const line = parseInt(lineMatch[1], 10); const comment = commentMatch[1].trim(); const suggestion = suggestionMatch[1]; // Validate line number if (isNaN(line) || line < 1) { warn(`Invalid line number ${line} for file ${file}`); continue; } // Clean up file path (remove duplicate "File:" prefix if present) const cleanFile = file.replace(/^File:\s*/, ''); // Skip if this looks like an example from the prompt if (cleanFile.includes('ProfileView.swift') && line === 42) { if (DEBUG) console.log('Skipping example suggestion from prompt'); continue; } const body = `${comment}\n\`\`\`suggestion\n${suggestion}\n\`\`\``; comments.push({ path: cleanFile, line, side: 'RIGHT', body }); } else if (b.includes('File:') || b.includes('Line:')) { // Only warn if it looks like a suggestion block but failed to parse if (DEBUG) { console.log('Failed to parse block:'); console.log('File match:', fileMatch); console.log('Line match:', lineMatch); console.log('Comment match:', commentMatch); console.log('Has suggestion:', !!suggestionMatch); console.log('Block content:', b.slice(0, 200)); } warn(`Unparsable block (missing ${!fileMatch ? 'file' : !lineMatch ? 'line' : !commentMatch ? 'comment' : 'suggestion'})`); } } log(`Parsed ${comments.length} suggestion(s)`); if (!comments.length) { log('No suggestions found after parsing'); // Post a comment indicating the review ran but found no specific issues const ghRes = await fetch( `https://api.github.com/repos/${process.env.REPO}/pulls/${process.env.PR_NUMBER}/reviews`, { method: 'POST', headers: { 'Authorization': `token ${process.env.GITHUB_TOKEN}`, 'Accept': 'application/vnd.github+json' }, body: JSON.stringify({ body: '🤖 **Code review completed** - No issues found', event: 'COMMENT' }) } ); if (!ghRes.ok) warn(`Failed to post completion comment: ${ghRes.status}`); return; } // ─── Post review ───────────────────────────────────── log('Posting review to GitHub…'); const ghRes = await fetch( `https://api.github.com/repos/${process.env.REPO}/pulls/${process.env.PR_NUMBER}/reviews`, { method: 'POST', headers: { 'Authorization': `token ${process.env.GITHUB_TOKEN}`, 'Accept': 'application/vnd.github+json' }, body: JSON.stringify({ body: `🤖 **Code Review** - Found ${comments.length} suggestion${comments.length === 1 ? '' : 's'}`, event: 'COMMENT', comments }) } ); if (!ghRes.ok) die(`GitHub HTTP ${ghRes.status}`); log('Review posted successfully 🎉'); })().catch((e) => die(e.stack || e)); ================================================ FILE: scripts/run_xcodebuild.sh ================================================ #!/bin/bash set -o pipefail set -e set -x ROOT_PATH=`dirname "${0}"`/.. # Usage: run_xcodebuild.sh <scheme> <derived_data_path> [test|build] # If no third parameter is provided, defaults to 'test' SCHEME=$1 DERIVED_DATA_PATH=$2 TARGET_TYPE=${3:-test} # Validate target type if [[ "$TARGET_TYPE" != "test" && "$TARGET_TYPE" != "build" ]]; then echo "Error: Target type must be 'test' or 'build'" echo "Usage: run_xcodebuild.sh <scheme> <derived_data_path> [test|build]" exit 1 fi # Validate required parameters if [[ -z "$SCHEME" || -z "$DERIVED_DATA_PATH" ]]; then echo "Error: Missing required parameters" echo "Usage: run_xcodebuild.sh <scheme> <derived_data_path> [test|build]" exit 1 fi if [[ "$TARGET_TYPE" == "test" ]]; then echo -ne "\n\n *********** RUNNING TESTS $SCHEME *********** \n\n" xcrun xcodebuild \ -destination "${TEST_DESTINATION}" \ -workspace "${ROOT_PATH}/Airship.xcworkspace" \ -scheme $SCHEME \ -derivedDataPath $DERIVED_DATA_PATH \ test | xcbeautify --renderer $XCBEAUTIFY_RENDERER else echo -ne "\n\n *********** BUILDING $SCHEME *********** \n\n" xcrun xcodebuild \ -destination "${TEST_DESTINATION}" \ -workspace "${ROOT_PATH}/Airship.xcworkspace" \ -scheme $SCHEME \ -derivedDataPath $DERIVED_DATA_PATH | xcbeautify --renderer $XCBEAUTIFY_RENDERER fi ================================================ FILE: scripts/update_version.sh ================================================ #!/bin/bash VERSION=$1 ROOT_PATH=`dirname "${0}"`/../ if [ -z "$1" ] then echo "No version number supplied" exit 1 fi # Initialize counters FAILED_COUNT=0 SUCCESS_COUNT=0 echo "Updating version to $VERSION" echo "" # Pods if sed -i '' "s/\(^AIRSHIP_VERSION *= *\)\".*\"/\1\"$VERSION\"/g" $ROOT_PATH/Airship.podspec 2>/dev/null; then echo "✓ Airship.podspec" SUCCESS_COUNT=$((SUCCESS_COUNT+1)) else echo "✗ Airship.podspec" FAILED_COUNT=$((FAILED_COUNT+1)) fi if sed -i '' "s/\(^AIRSHIP_VERSION *= *\)\".*\"/\1\"$VERSION\"/g" $ROOT_PATH/AirshipDebug.podspec 2>/dev/null; then echo "✓ AirshipDebug.podspec" SUCCESS_COUNT=$((SUCCESS_COUNT+1)) else echo "✗ AirshipDebug.podspec" FAILED_COUNT=$((FAILED_COUNT+1)) fi if sed -i '' "s/\(^AIRSHIP_VERSION *= *\)\".*\"/\1\"$VERSION\"/g" $ROOT_PATH/AirshipServiceExtension.podspec 2>/dev/null; then echo "✓ AirshipServiceExtension.podspec" SUCCESS_COUNT=$((SUCCESS_COUNT+1)) else echo "✗ AirshipServiceExtension.podspec" FAILED_COUNT=$((FAILED_COUNT+1)) fi # Airship Config if sed -i '' "s/\CURRENT_PROJECT_VERSION.*/CURRENT_PROJECT_VERSION = $VERSION/g" $ROOT_PATH/Airship/AirshipConfig.xcconfig 2>/dev/null; then echo "✓ Airship/AirshipConfig.xcconfig" SUCCESS_COUNT=$((SUCCESS_COUNT+1)) else echo "✗ Airship/AirshipConfig.xcconfig" FAILED_COUNT=$((FAILED_COUNT+1)) fi # AirshipVersion.swift if sed -i '' "s/\(public static let version *= *\)\".*\"/\1\"$VERSION\"/g" $ROOT_PATH/Airship/AirshipCore/Source/AirshipVersion.swift 2>/dev/null; then echo "✓ Airship/AirshipCore/Source/AirshipVersion.swift" SUCCESS_COUNT=$((SUCCESS_COUNT+1)) else echo "✗ Airship/AirshipCore/Source/AirshipVersion.swift" FAILED_COUNT=$((FAILED_COUNT+1)) fi # Summary echo "" if [ $FAILED_COUNT -gt 0 ]; then echo "⚠️ $SUCCESS_COUNT succeeded, $FAILED_COUNT failed" exit 1 else echo "✓ All $SUCCESS_COUNT files updated successfully" exit 0 fi