From 166265b32d6a9c6a3c2c85673d50978a174d6bd2 Mon Sep 17 00:00:00 2001 From: Akash Malhotra Date: Tue, 8 Sep 2026 10:51:34 +0530 Subject: [PATCH 1/2] added verbose and granular logs to make debugging easier for devs and clients --- .../project.pbxproj | 4 + .../BaseCTNotificationContentViewController.h | 3 +- .../BaseCTNotificationContentViewController.m | 39 ++- .../CTNotificationViewController.m | 260 ++++++++++++++---- .../Controller/CTCarouselController.swift | 125 +++++++-- .../CTContentSliderController.swift | 124 ++++++--- ...CTProductDisplayLinearViewController.swift | 45 ++- ...ProductDisplayVerticalViewController.swift | 45 ++- .../Controller/CTRatingsViewController.swift | 182 +++++++----- .../Controller/CTSingleMediaController.swift | 59 ++-- .../CTTimerTemplateController.swift | 178 ++++++++---- .../Templates/Timer/View/CTTimerBoxView.swift | 3 + .../Utility/CTNotificationContentLogger.swift | 69 +++++ .../Templates/Utility/CTUtility.swift | 245 ++++++++++++----- .../Templates/Utility/Extensions.swift | 11 +- .../Templates/Utility/GlobalConstants.swift | 6 + .../CTVerticalImageController.swift | 78 ++++-- .../Controller/CTWebViewController.swift | 28 +- .../Controller/CTZeroBezelController.swift | 59 ++-- .../Views/CTCaptionedImageView.swift | 37 ++- README.md | 39 +++ 21 files changed, 1218 insertions(+), 421 deletions(-) create mode 100644 CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift diff --git a/CTNotificationContent.xcodeproj/project.pbxproj b/CTNotificationContent.xcodeproj/project.pbxproj index 22da571..cb61fb5 100644 --- a/CTNotificationContent.xcodeproj/project.pbxproj +++ b/CTNotificationContent.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 32DFD84F28BCCCBB00E72588 /* CTSingleMediaController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DFD84128BCCCBB00E72588 /* CTSingleMediaController.swift */; }; 32DFD85028BCCCBB00E72588 /* GlobalConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DFD84428BCCCBB00E72588 /* GlobalConstants.swift */; }; 32DFD85128BCCCBB00E72588 /* CTUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DFD84528BCCCBB00E72588 /* CTUtility.swift */; }; + AC10AA0228BCCCBB00E72588 /* CTNotificationContentLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC10AA0128BCCCBB00E72588 /* CTNotificationContentLogger.swift */; }; 32E55C0828D87FDD00F1395D /* ct_star_outline.png in Resources */ = {isa = PBXBuildFile; fileRef = 32E55C0628D87FDD00F1395D /* ct_star_outline.png */; }; 32E55C0928D87FDD00F1395D /* ct_star_filled.png in Resources */ = {isa = PBXBuildFile; fileRef = 32E55C0728D87FDD00F1395D /* ct_star_filled.png */; }; 488F31A72816FFF300AE3AC8 /* ct_next_button.png in Resources */ = {isa = PBXBuildFile; fileRef = 488F31A22816FFF300AE3AC8 /* ct_next_button.png */; }; @@ -71,6 +72,7 @@ 32DFD84128BCCCBB00E72588 /* CTSingleMediaController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CTSingleMediaController.swift; sourceTree = ""; }; 32DFD84428BCCCBB00E72588 /* GlobalConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlobalConstants.swift; sourceTree = ""; }; 32DFD84528BCCCBB00E72588 /* CTUtility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CTUtility.swift; sourceTree = ""; }; + AC10AA0128BCCCBB00E72588 /* CTNotificationContentLogger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CTNotificationContentLogger.swift; sourceTree = ""; }; 32E55C0628D87FDD00F1395D /* ct_star_outline.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = ct_star_outline.png; sourceTree = ""; }; 32E55C0728D87FDD00F1395D /* ct_star_filled.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = ct_star_filled.png; sourceTree = ""; }; 488F31A22816FFF300AE3AC8 /* ct_next_button.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = ct_next_button.png; sourceTree = ""; }; @@ -319,6 +321,7 @@ 0B0115BD2CAFFD8900985815 /* Extensions.swift */, 32DFD84428BCCCBB00E72588 /* GlobalConstants.swift */, 32DFD84528BCCCBB00E72588 /* CTUtility.swift */, + AC10AA0128BCCCBB00E72588 /* CTNotificationContentLogger.swift */, ); path = Utility; sourceTree = ""; @@ -540,6 +543,7 @@ 488F31AF2817000200AE3AC8 /* CTCaptionedImageView.swift in Sources */, 32C575EE28F744E600BF0A3F /* CTRatingsViewController.swift in Sources */, 32DFD85128BCCCBB00E72588 /* CTUtility.swift in Sources */, + AC10AA0228BCCCBB00E72588 /* CTNotificationContentLogger.swift in Sources */, 48D6ACBA2F9248BF00A17777 /* VerticalImageProperties.swift in Sources */, 48D6ACBB2F9248BF00A17777 /* CTVerticalImageController.swift in Sources */, 32DFD84928BCCCBB00E72588 /* TimerTemplateProperties.swift in Sources */, diff --git a/CTNotificationContent/BaseCTNotificationContentViewController.h b/CTNotificationContent/BaseCTNotificationContentViewController.h index f0793ea..3b51535 100644 --- a/CTNotificationContent/BaseCTNotificationContentViewController.h +++ b/CTNotificationContent/BaseCTNotificationContentViewController.h @@ -9,7 +9,8 @@ - (NSString *)getDeeplinkUrl; // must override in subclass -- (CTNotificationViewController *)getParentViewController; +/// Returns nil when this controller is not attached to a CTNotificationViewController. +- (nullable CTNotificationViewController *)getParentViewController; - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event; diff --git a/CTNotificationContent/BaseCTNotificationContentViewController.m b/CTNotificationContent/BaseCTNotificationContentViewController.m index 59d8783..3a6c551 100644 --- a/CTNotificationContent/BaseCTNotificationContentViewController.m +++ b/CTNotificationContent/BaseCTNotificationContentViewController.m @@ -1,6 +1,17 @@ #import "BaseCTNotificationContentViewController.h" #import "CTNotificationViewController.h" +#if __has_include() +#import +#else +#import "CTNotificationContent-Swift.h" +#endif + +#define CTContentLogInfo(fmt, ...) \ + [CTNotificationContentLogger logInfo:[NSString stringWithFormat:fmt, ##__VA_ARGS__] from:@(__PRETTY_FUNCTION__)] +#define CTContentLogError(fmt, ...) \ + [CTNotificationContentLogger logError:[NSString stringWithFormat:fmt, ##__VA_ARGS__] from:@(__PRETTY_FUNCTION__)] + @interface BaseCTNotificationContentViewController () @end @@ -20,20 +31,38 @@ - (NSString *)getDeeplinkUrl{ } - (CTNotificationViewController *)getParentViewController { - return (CTNotificationViewController*)self.parentViewController; + UIViewController *parent = self.parentViewController; + if (parent == nil) { + CTContentLogError(@"Nil parentViewController, cannot forward tap, controller=%@", + NSStringFromClass([self class])); + return nil; + } + if (![parent isKindOfClass:[CTNotificationViewController class]]) { + CTContentLogError(@"Expected CTNotificationViewController parent, got %@, cannot forward tap", + NSStringFromClass([parent class])); + return nil; + } + return (CTNotificationViewController *)parent; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - if (@available(iOS 12.0, *)) { NSString *url = [self getDeeplinkUrl]; if (!url || url.length == 0) { + CTContentLogInfo(@"Tap with no deeplink, performing notification default action"); + [[self extensionContext] performNotificationDefaultAction]; + return; + } + NSURL *deeplink = [[NSURL alloc] initWithString:url]; + if (deeplink == nil) { + CTContentLogError(@"Deeplink parse failed, performing notification default action, url=%@", url); [[self extensionContext] performNotificationDefaultAction]; - }else{ - [[self getParentViewController] openUrl:([[NSURL alloc] initWithString:url])]; + return; } + CTContentLogInfo(@"Tap, opening deeplink, url=%@", url); + [[self getParentViewController] openUrl:deeplink]; } else { - // Fallback on earlier versions + CTContentLogError(@"Tap handling requires iOS 12 or later, ignoring"); } } diff --git a/CTNotificationContent/CTNotificationViewController.m b/CTNotificationContent/CTNotificationViewController.m index 6c3c7a9..c8ccff6 100644 --- a/CTNotificationContent/CTNotificationViewController.m +++ b/CTNotificationContent/CTNotificationViewController.m @@ -9,6 +9,27 @@ #import "CTNotificationContent-Swift.h" #endif +// Short names for the logger. __PRETTY_FUNCTION__ gives the class and the +// method, so the log line points at the exact place the message came from. +#define CTContentLogInfo(fmt, ...) \ + [CTNotificationContentLogger logInfo:[NSString stringWithFormat:fmt, ##__VA_ARGS__] from:@(__PRETTY_FUNCTION__)] +#define CTContentLogError(fmt, ...) \ + [CTNotificationContentLogger logError:[NSString stringWithFormat:fmt, ##__VA_ARGS__] from:@(__PRETTY_FUNCTION__)] + +/// Name of a response option, for the logs. The raw values are 0, 1 and 2. +/// A name tells the reader what the extension asked the system to do. +static NSString *CTResponseOptionName(UNNotificationContentExtensionResponseOption option) { + switch (option) { + case UNNotificationContentExtensionResponseOptionDoNotDismiss: + return @"doNotDismiss"; + case UNNotificationContentExtensionResponseOptionDismiss: + return @"dismiss"; + case UNNotificationContentExtensionResponseOptionDismissAndForwardAction: + return @"dismissAndForwardAction"; + } + return [NSString stringWithFormat:@"unknown(%lu)", (unsigned long)option]; +} + typedef NS_ENUM(NSInteger, CTNotificationContentType) { CTNotificationContentTypeContentSlider = 0, CTNotificationContentTypeSingleMedia = 1, @@ -53,9 +74,34 @@ @interface CTNotificationViewController () @implementation CTNotificationViewController BOOL isFromProductDisplay = false; +/// Reads a payload value that the Swift controllers expect as text. +/// The Swift properties are non optional, so a value of another type would +/// stop the extension. Empty text is returned instead. +static NSString *CTContentStringValue(NSDictionary *content, NSString *key) { + id value = content[key]; + if (value == nil) { + return @""; + } + if ([value isKindOfClass:[NSString class]]) { + return value; + } + if ([value isKindOfClass:[NSNumber class]]) { + return [value stringValue]; + } + [CTNotificationContentLogger logError:[NSString stringWithFormat:@"Expected NSString for key=%@, got %@, using empty string", key, NSStringFromClass([value class])] + from:@"CTContentStringValue"]; + return @""; +} + - (void)viewDidLoad { [super viewDidLoad]; - + + // First line the extension writes. If a report has no CTNotificationContent + // lines at all, the system never started the extension. The cause is then + // outside this SDK. Check that the target is embedded in the app. Check that + // UNNotificationExtensionCategory matches the category in the payload. + CTContentLogInfo(@"Extension view loaded, frame=%@", NSStringFromCGRect(self.view.frame)); + self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; } @@ -63,16 +109,20 @@ - (void)didReceiveNotification:(UNNotification *)notification { _content = notification.request.content.userInfo; _notification = notification; + CTContentLogInfo(@"Received notification, id=%@, payloadKeys=[%@]", + notification.request.identifier, + [[_content allKeys] componentsJoinedByString:@", "]); + [self updateContentType:_content]; - + switch (self.contentType) { case CTNotificationContentTypeContentSlider: { CTContentSliderController *contentController = [[CTContentSliderController alloc] init]; - [contentController setData:_content[kContentSlider]]; + [contentController setData:CTContentStringValue(_content, kContentSlider)]; [contentController setTemplateCaption:notification.request.content.title]; [contentController setTemplateSubcaption:notification.request.content.body]; if (_content[kDeeplinkURL] != nil) { - [contentController setDeeplinkURL:_content[kDeeplinkURL]]; + [contentController setDeeplinkURL:CTContentStringValue(_content, kDeeplinkURL)]; } [self addChildViewController:contentController]; contentController.view.frame = self.view.frame; @@ -84,13 +134,13 @@ - (void)didReceiveNotification:(UNNotification *)notification { CTSingleMediaController *contentController = [[CTSingleMediaController alloc] init]; [contentController setCaption:notification.request.content.title]; [contentController setSubCaption:notification.request.content.body]; - [contentController setMediaType:_content[kSingleMediaType]]; - [contentController setMediaURL:_content[kSingleMediaURL]]; + [contentController setMediaType:CTContentStringValue(_content, kSingleMediaType)]; + [contentController setMediaURL:CTContentStringValue(_content, kSingleMediaURL)]; if (_content[kSingleMediaDescription] != nil) { - [contentController setMediaDescription:_content[kSingleMediaDescription]]; + [contentController setMediaDescription:CTContentStringValue(_content, kSingleMediaDescription)]; } if (_content[kDeeplinkURL] != nil) { - [contentController setDeeplinkURL:_content[kDeeplinkURL]]; + [contentController setDeeplinkURL:CTContentStringValue(_content, kDeeplinkURL)]; } [self addChildViewController:contentController]; contentController.view.frame = self.view.frame; @@ -107,7 +157,7 @@ - (void)didReceiveNotification:(UNNotification *)notification { [contentController setTemplateCaption:notification.request.content.title]; [contentController setTemplateSubcaption:notification.request.content.body]; if (_content[kDeeplinkURL] != nil) { - [contentController setDeeplinkURL:_content[kDeeplinkURL]]; + [contentController setDeeplinkURL:CTContentStringValue(_content, kDeeplinkURL)]; } [contentController setTemplateType:kTemplateBasic]; [self setupContentController:contentController]; @@ -146,6 +196,7 @@ - (void)didReceiveNotification:(UNNotification *)notification { BaseCTNotificationContentViewController *contentController = [CTUtiltiy getControllerTypeWithJsonString:self.jsonString]; [self setupContentController:contentController]; }else{ + CTContentLogError(@"Product display unavailable, falling back to basic template"); isFromProductDisplay = true; goto basic; } @@ -162,19 +213,36 @@ - (void)didReceiveNotification:(UNNotification *)notification { } break; default: + CTContentLogError(@"No controller mapped for contentType=%ld, view left empty", (long)self.contentType); break; } - + + if (self.contentViewController == nil) { + CTContentLogError(@"Controller construction failed, view left empty"); + return; + } + self.view.frame = self.contentViewController.view.frame; self.preferredContentSize = self.contentViewController.preferredContentSize; + // A size of zero in either direction means the expanded view has no area on + // screen. The user then sees an empty space where the template should be. + if (self.preferredContentSize.width <= 0 || self.preferredContentSize.height <= 0) { + CTContentLogError(@"Zero preferredContentSize, expanded view will not be visible, controller=%@, size=%@", + NSStringFromClass([self.contentViewController class]), + NSStringFromCGSize(self.preferredContentSize)); + return; + } + CTContentLogInfo(@"Content view ready, controller=%@, size=%@", + NSStringFromClass([self.contentViewController class]), + NSStringFromCGSize(self.preferredContentSize)); } - (void)setupContentController:(id)contentController{ - [contentController setData:self.jsonString]; + [contentController setData:self.jsonString ?: @""]; [contentController setTemplateCaption:_notification.request.content.title]; [contentController setTemplateSubcaption:_notification.request.content.body]; if (_content[kDeeplinkURL] != nil) { - [contentController setDeeplinkURL:_content[kDeeplinkURL]]; + [contentController setDeeplinkURL:CTContentStringValue(_content, kDeeplinkURL)]; } [self addChildViewController:contentController]; [contentController view].frame = self.view.frame; @@ -184,66 +252,142 @@ - (void)setupContentController:(id)contentController{ - (void)updateContentType:(NSDictionary *)content { if (content[kContentSlider] != nil) { + CTContentLogInfo(@"Resolved template=contentSlider, matched key=%@", kContentSlider); self.contentType = CTNotificationContentTypeContentSlider; - } else { - if (content[kTemplateId] != nil) { - if (content[kJSON] != nil) { - self.jsonString = content[kJSON]; - } else { - self.jsonString = [self createJSONData:content]; - } + return; + } - if ([content[kTemplateId] isEqualToString:kTemplateBasic]) { - self.contentType = CTNotificationContentTypeBasicTemplate; - } else if ([content[kTemplateId] isEqualToString:kTemplateAutoCarousel]) { - self.contentType = CTNotificationContentTypeAutoCarousel; - } else if ([content[kTemplateId] isEqualToString:kTemplateManualCarousel]) { - self.contentType = CTNotificationContentTypeManualCarousel; - } else if ([content[kTemplateId] isEqualToString:kTemplateTimer]) { - self.contentType = CTNotificationContentTypeTimerTemplate; - }else if ([content[kTemplateId] isEqualToString:kTemplateZeroBezel]) { - self.contentType = CTNotificationContentTypeZeroBezel; - }else if ([content[kTemplateId] isEqualToString:kTemplateWebView]) { - self.contentType = CTNotificationContentTypeWebView; - }else if ([content[kTemplateId] isEqualToString:kTemplateProductDisplay]) { - self.contentType = CTNotificationContentTypeProductDisplay; - }else if ([content[kTemplateId] isEqualToString:kTemplateRating]) { - self.contentType = CTNotificationContentTypeRating; - }else if ([content[kTemplateId] isEqualToString:kTemplateVerticalImage]) { - self.contentType = CTNotificationContentTypeVerticalImage; - } else { - // Invalid pt_id value fallback to basic. - self.contentType = CTNotificationContentTypeBasicTemplate; - } - } else if (content[kSingleMediaType] != nil && content[kSingleMediaURL] != nil) { + id templateId = content[kTemplateId]; + if (templateId == nil) { + if (content[kSingleMediaType] != nil && content[kSingleMediaURL] != nil) { + CTContentLogInfo(@"Resolved template=singleMedia, matched keys=%@,%@", + kSingleMediaType, kSingleMediaURL); self.contentType = CTNotificationContentTypeSingleMedia; } else { - // Invalid payload data fallback to basic. + CTContentLogError(@"Missing key=%@ and no single media keys, falling back to basic template", kTemplateId); self.contentType = CTNotificationContentTypeBasicTemplate; } + return; + } + + if (![templateId isKindOfClass:[NSString class]]) { + CTContentLogError(@"Expected NSString for key=%@, got %@, falling back to basic template", + kTemplateId, NSStringFromClass([templateId class])); + self.jsonString = [self createJSONData:content]; + self.contentType = CTNotificationContentTypeBasicTemplate; + return; } + + id json = content[kJSON]; + if (json == nil) { + CTContentLogInfo(@"Missing key=%@, building json from flat payload keys", kJSON); + self.jsonString = [self createJSONData:content]; + } else if (![json isKindOfClass:[NSString class]]) { + CTContentLogError(@"Expected NSString for key=%@, got %@, building json from flat payload keys", + kJSON, NSStringFromClass([json class])); + self.jsonString = [self createJSONData:content]; + } else { + self.jsonString = json; + } + + if ([templateId isEqualToString:kTemplateBasic]) { + self.contentType = CTNotificationContentTypeBasicTemplate; + } else if ([templateId isEqualToString:kTemplateAutoCarousel]) { + self.contentType = CTNotificationContentTypeAutoCarousel; + } else if ([templateId isEqualToString:kTemplateManualCarousel]) { + self.contentType = CTNotificationContentTypeManualCarousel; + } else if ([templateId isEqualToString:kTemplateTimer]) { + self.contentType = CTNotificationContentTypeTimerTemplate; + } else if ([templateId isEqualToString:kTemplateZeroBezel]) { + self.contentType = CTNotificationContentTypeZeroBezel; + } else if ([templateId isEqualToString:kTemplateWebView]) { + self.contentType = CTNotificationContentTypeWebView; + } else if ([templateId isEqualToString:kTemplateProductDisplay]) { + self.contentType = CTNotificationContentTypeProductDisplay; + } else if ([templateId isEqualToString:kTemplateRating]) { + self.contentType = CTNotificationContentTypeRating; + } else if ([templateId isEqualToString:kTemplateVerticalImage]) { + self.contentType = CTNotificationContentTypeVerticalImage; + } else { + // Invalid pt_id value fallback to basic. + CTContentLogError(@"Unknown pt_id=%@ for this SDK version, falling back to basic template", templateId); + self.contentType = CTNotificationContentTypeBasicTemplate; + return; + } + CTContentLogInfo(@"Resolved template from pt_id=%@", templateId); + [CTUtiltiy logPayloadCheckForTemplate:templateId jsonString:self.jsonString ?: @""]; } - (NSString *)createJSONData:(NSDictionary *)content { // create JSON Data from individual keys provided. NSMutableDictionary *json = [[NSMutableDictionary alloc] init]; - for (NSString *key in content) { - // Values received can be of NSNumber class, so keeping all values as NSString so that we can decode for type String in swift and typecast into our desired data types. - NSString *value = content[key]; - [json setObject:value forKey:key]; + for (id key in content) { + if (![key isKindOfClass:[NSString class]]) { + CTContentLogError(@"Skipping non NSString payload key=%@", key); + continue; + } + id value = content[key]; + // The Swift models declare every field as text. A number is written as + // text here so the decoder accepts it. + if ([value isKindOfClass:[NSString class]]) { + json[key] = value; + } else if ([value isKindOfClass:[NSNumber class]]) { + json[key] = [value stringValue]; + } else { + CTContentLogError(@"Skipping key=%@, unsupported value type %@", + key, NSStringFromClass([value class])); + } + } + + NSError *error = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:json options:0 error:&error]; + if (jsonData == nil) { + CTContentLogError(@"JSON serialization failed, template will render its no data layout, error=%@", + error.localizedDescription); + return @""; } - NSData *jsonData = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil]; NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + if (jsonString == nil) { + CTContentLogError(@"JSON is not valid UTF-8, template will render its no data layout"); + return @""; + } + CTContentLogInfo(@"Built json from %lu payload keys", (unsigned long)json.count); return jsonString; } - (void)preferredContentSizeDidChangeForChildContentContainer:(id)container { self.preferredContentSize = self.contentViewController.preferredContentSize; + + // Images arrive after the first layout, so a template can resize itself + // later. This is the last size the system is told about. The size checked + // in didReceiveNotification is only the first one. + if (self.preferredContentSize.width <= 0 || self.preferredContentSize.height <= 0) { + CTContentLogError(@"Resized to zero preferredContentSize, expanded view will not be visible, controller=%@", + NSStringFromClass([self.contentViewController class])); + return; + } + CTContentLogInfo(@"Resized, size=%@", NSStringFromCGSize(self.preferredContentSize)); +} + +- (void)didReceiveMemoryWarning { + [super didReceiveMemoryWarning]; + + // The system stops an extension that keeps using memory after this warning. + // A report that stops right here means the extension was stopped. Large + // images are the usual cause. The pixel sizes are in the download lines. + CTContentLogError(@"Memory warning, the system may stop the extension"); } - (void)didReceiveNotificationResponse:(UNNotificationResponse *)response completionHandler:(void (^)(UNNotificationContentExtensionResponseOption))completion { + CTContentLogInfo(@"Received action=%@", response.actionIdentifier); + if (self.contentViewController == nil) { + CTContentLogError(@"No controller to handle action=%@, staying open", response.actionIdentifier); + completion(UNNotificationContentExtensionResponseOptionDoNotDismiss); + return; + } UNNotificationContentExtensionResponseOption actionResponseOption = [self.contentViewController handleAction:response.actionIdentifier]; + CTContentLogInfo(@"Handled action=%@, responseOption=%@", response.actionIdentifier, CTResponseOptionName(actionResponseOption)); [self userDidReceiveNotificationResponse:response]; completion(actionResponseOption); } @@ -260,16 +404,32 @@ - (void)userDidReceiveNotificationResponse:(UNNotificationResponse *)response { // convenience - (void)openUrl:(NSURL *)url { + if (url == nil) { + CTContentLogError(@"Nil url, performing notification default action"); + if (@available(iOS 12.0, *)) { + [self.extensionContext performNotificationDefaultAction]; + } + return; + } + if (self.extensionContext == nil) { + CTContentLogError(@"Nil extensionContext, cannot open url=%@", url.absoluteString); + return; + } + + CTContentLogInfo(@"Opening url=%@", url.absoluteString); [self.extensionContext openURL:url completionHandler:^(BOOL success) { // IF THE DEEP LINK DIDNT WORK, OPEN PARENT APP - if (!success) { + if (success) { + CTContentLogInfo(@"Opened url=%@", url.absoluteString); + } else { + CTContentLogError(@"openURL failed for url=%@, performing notification default action", url.absoluteString); if (@available(iOS 12.0, *)) { [self.extensionContext performNotificationDefaultAction]; } else { // Fallback on earlier versions } } - + // This removes the clicked notification from Notification Center when clicked in expanded view. UNUserNotificationCenter *current = [UNUserNotificationCenter currentNotificationCenter]; [current getDeliveredNotificationsWithCompletionHandler:^(NSArray * _Nonnull notifications) { diff --git a/CTNotificationContent/Templates/Carousel/Controller/CTCarouselController.swift b/CTNotificationContent/Templates/Carousel/Controller/CTCarouselController.swift index 86fc347..72715ed 100644 --- a/CTNotificationContent/Templates/Carousel/Controller/CTCarouselController.swift +++ b/CTNotificationContent/Templates/Carousel/Controller/CTCarouselController.swift @@ -39,6 +39,7 @@ import UserNotificationsUI let recognizer1 = UITapGestureRecognizer(target: self, action: #selector(openDeeplink)) contentView.addGestureRecognizer(recognizer1) + CTContentLog.info("Carousel controller started, templateType=\(templateType), fromProductDisplay=\(isFromProductDisplay)") jsonContent = CTUtiltiy.loadContentData(data: data) createView() @@ -55,6 +56,7 @@ import UserNotificationsUI func createView() { guard let jsonContent = jsonContent else { // Show default alert view and update constraints when json data is not available. + CTContentLog.error("Nil payload data, rendering caption only") setUpConstraints() return } @@ -118,20 +120,32 @@ import UserNotificationsUI if gifData == nil { CTUtiltiy.checkImageUrlValid(imageUrl: basicImageDetails.url) { [weak self] (imageData) in DispatchQueue.main.async { + guard let self = self else { + CTContentLog.error("Controller deallocated before image arrived") + return + } if imageData != nil { - let itemComponents = CaptionedImageViewComponents(caption: self!.templateCaption, subcaption: self!.templateSubcaption, imageUrl: basicImageDetails.url, actionUrl: actionUrl, bgColor: self!.bgColor, captionColor: self!.captionColor, subcaptionColor: self!.subcaptionColor, bgColorDark: self!.bgColorDark, captionColorDark: self!.captionColorDark, subcaptionColorDark: self!.subcaptionColorDark, imageDescription: basicImageDetails.description ?? CTAccessibility.kDefaultImageDescription) + let itemComponents = CaptionedImageViewComponents(caption: self.templateCaption, subcaption: self.templateSubcaption, imageUrl: basicImageDetails.url, actionUrl: actionUrl, bgColor: self.bgColor, captionColor: self.captionColor, subcaptionColor: self.subcaptionColor, bgColorDark: self.bgColorDark, captionColorDark: self.captionColorDark, subcaptionColorDark: self.subcaptionColorDark, imageDescription: basicImageDetails.description ?? CTAccessibility.kDefaultImageDescription) let itemView = CTCaptionedImageView(components: itemComponents, isGifSupported: true) - self?.itemViews.append(itemView) + self.itemViews.append(itemView) + CTContentLog.info("Basic template rendering image, url=\(basicImageDetails.url)") + } else { + CTContentLog.error("Image load failed, rendering caption only") } - self?.setUpConstraints() + self.setUpConstraints() } } } else { DispatchQueue.main.async { - let itemComponents = CaptionedImageViewComponents(caption: self!.templateCaption, subcaption: self!.templateSubcaption, imageUrl: basicGifDetails.url, actionUrl: actionUrl, bgColor: self!.bgColor, captionColor: self!.captionColor, subcaptionColor: self!.subcaptionColor, bgColorDark: self!.bgColorDark, captionColorDark: self!.captionColorDark, subcaptionColorDark: self!.subcaptionColorDark, imageDescription: basicGifDetails.description ?? CTAccessibility.kDefaultImageDescription) + guard let self = self else { + CTContentLog.error("Controller deallocated before gif arrived") + return + } + let itemComponents = CaptionedImageViewComponents(caption: self.templateCaption, subcaption: self.templateSubcaption, imageUrl: basicGifDetails.url, actionUrl: actionUrl, bgColor: self.bgColor, captionColor: self.captionColor, subcaptionColor: self.subcaptionColor, bgColorDark: self.bgColorDark, captionColorDark: self.captionColorDark, subcaptionColorDark: self.subcaptionColorDark, imageDescription: basicGifDetails.description ?? CTAccessibility.kDefaultImageDescription) let itemView = CTCaptionedImageView(components: itemComponents, isGifSupported: true) - self?.itemViews.append(itemView) - self?.setUpConstraints() + self.itemViews.append(itemView) + CTContentLog.info("Basic template rendering gif, url=\(basicGifDetails.url)") + self.setUpConstraints() } } } @@ -147,22 +161,39 @@ import UserNotificationsUI imageUrls.append((url: url, description: jsonContent.pt_img3_alt_text, deeplink: resolveDeeplink(jsonContent.pt_dl3, fallback: actionUrl))) } + if imageUrls.isEmpty { + CTContentLog.error("No image urls in payload, rendering caption only") + } else { + CTContentLog.info("Loading \(imageUrls.count) carousel images") + } + let dispatchGroup = DispatchGroup() var orderedItemViews = [Int: CTCaptionedImageView]() for (index, imageDetails) in imageUrls.enumerated() { dispatchGroup.enter() CTUtiltiy.checkImageUrlValid(imageUrl: imageDetails.url) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - let itemComponents = CaptionedImageViewComponents(caption: self!.templateCaption, subcaption: self!.templateSubcaption, imageUrl: imageDetails.url, actionUrl: imageDetails.deeplink, bgColor: self!.bgColor, captionColor: self!.captionColor, subcaptionColor: self!.subcaptionColor, bgColorDark: self!.bgColorDark, captionColorDark: self!.captionColorDark, subcaptionColorDark: self!.subcaptionColorDark, imageDescription: imageDetails.description ?? "\(CTAccessibility.kDefaultImageDescription) \(index + 1)") - orderedItemViews[index] = CTCaptionedImageView(components: itemComponents, isGifSupported: false) + defer { dispatchGroup.leave() } + guard let self = self else { + CTContentLog.error("Controller deallocated before image \(index + 1) arrived") + return } - dispatchGroup.leave() + guard imageData != nil else { + CTContentLog.error("Skipping image \(index + 1), load failed, url=\(imageDetails.url)") + return + } + let itemComponents = CaptionedImageViewComponents(caption: self.templateCaption, subcaption: self.templateSubcaption, imageUrl: imageDetails.url, actionUrl: imageDetails.deeplink, bgColor: self.bgColor, captionColor: self.captionColor, subcaptionColor: self.subcaptionColor, bgColorDark: self.bgColorDark, captionColorDark: self.captionColorDark, subcaptionColorDark: self.subcaptionColorDark, imageDescription: imageDetails.description ?? "\(CTAccessibility.kDefaultImageDescription) \(index + 1)") + orderedItemViews[index] = CTCaptionedImageView(components: itemComponents, isGifSupported: false) } } } - dispatchGroup.notify(queue: .main) { + dispatchGroup.notify(queue: .main) { [weak self] in + guard let self = self else { + CTContentLog.error("Controller deallocated before images arrived") + return + } self.itemViews = (0..= itemViews.count { + CTContentLog.error("Index \(currentItemIndex) out of range for \(itemViews.count) items, resetting to 0") + currentItemIndex = 0 + } currentItemView = itemViews[currentItemIndex] contentView.addSubview(currentItemView) currentItemView.translatesAutoresizingMaskIntoConstraints = false @@ -237,7 +278,12 @@ import UserNotificationsUI // Show Next and Previous button for manual carousel. - nextButtonImage = UIImage(named: "ct_next_button", in: Bundle(for: type(of: self)), compatibleWith: nil)! + let bundle = Bundle(for: type(of: self)) + if let image = UIImage(named: "ct_next_button", in: bundle, compatibleWith: nil) { + nextButtonImage = image + } else { + CTContentLog.error("Missing bundle asset ct_next_button, next button has no icon") + } nextButton.setImage(nextButtonImage, for: .normal) nextButton.addTarget(self, action: #selector(nextButtonTapped), for: .touchUpInside) nextButton.accessibilityLabel = "Next image" @@ -245,7 +291,11 @@ import UserNotificationsUI nextButton.accessibilityTraits = .button nextButton.accessibilityIdentifier = CTAccessibility.kCarouselNextButtonIdentifier - previousButtonImage = UIImage(named: "ct_previous_button", in: Bundle(for: type(of: self)), compatibleWith: nil)! + if let image = UIImage(named: "ct_previous_button", in: bundle, compatibleWith: nil) { + previousButtonImage = image + } else { + CTContentLog.error("Missing bundle asset ct_previous_button, previous button has no icon") + } previousButton.setImage(previousButtonImage, for: .normal) previousButton.addTarget(self, action: #selector(previousButtonTapped), for: .touchUpInside) previousButton.accessibilityLabel = "Previous image" @@ -310,18 +360,30 @@ import UserNotificationsUI showPrevious() } - @objc func openDeeplink() { - let urlString = itemViews[currentItemIndex].components.actionUrl - if !urlString.isEmpty { - if let url = URL(string: urlString) { - getParentViewController().open(url) - } + /// Deeplink of the image on screen. Empty when there is no image. + private var currentActionUrl: String { + guard currentItemIndex >= 0, currentItemIndex < itemViews.count else { + CTContentLog.error("No item at index \(currentItemIndex)/\(itemViews.count), no deeplink available") + return "" } - else { + return itemViews[currentItemIndex].components.actionUrl + } + + @objc func openDeeplink() { + let urlString = currentActionUrl + if urlString.isEmpty { + CTContentLog.info("Tap with no deeplink at index=\(currentItemIndex), performing notification default action") if #available(iOS 12.0, *) { self.extensionContext?.performNotificationDefaultAction() } + return } + guard let url = URL(string: urlString) else { + CTContentLog.error("Deeplink parse failed, index=\(currentItemIndex), url=\(urlString)") + return + } + CTContentLog.info("Tap, opening deeplink, index=\(currentItemIndex), url=\(urlString)") + getParentViewController()?.open(url) } @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { @@ -338,14 +400,18 @@ import UserNotificationsUI } else if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink if itemViews.count > 0 { - let urlString = itemViews[currentItemIndex].components.actionUrl - if !urlString.isEmpty { - if let url = URL(string: urlString) { - getParentViewController().open(url) - } + let urlString = currentActionUrl + if urlString.isEmpty { + CTContentLog.info("No deeplink at index=\(currentItemIndex), dismissing") + } else if let url = URL(string: urlString) { + CTContentLog.info("Opening deeplink, index=\(currentItemIndex), url=\(urlString)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed, index=\(currentItemIndex), url=\(urlString)") } return .dismiss } + CTContentLog.error("No items, forwarding action to host app") return .dismissAndForwardAction } return .doNotDismiss @@ -361,10 +427,15 @@ import UserNotificationsUI func moveSlider(direction: Int) { guard let _ = getParentViewController() else { + CTContentLog.info("Controller detached, stopping autoplay timer") timer?.invalidate() timer = nil return } + guard !itemViews.isEmpty else { + CTContentLog.error("No items to page through") + return + } currentItemView.removeFromSuperview() currentItemIndex = currentItemIndex + direction @@ -373,6 +444,7 @@ import UserNotificationsUI } else if currentItemIndex < 0 { currentItemIndex = itemViews.count - 1 } + CTContentLog.info("Paging to index=\(currentItemIndex)/\(itemViews.count), direction=\(direction)") currentItemView = itemViews[currentItemIndex] contentView.addSubview(currentItemView) @@ -397,8 +469,7 @@ import UserNotificationsUI } @objc public override func getDeeplinkUrl() -> String! { - let deeplink = itemViews[currentItemIndex].components.actionUrl - return deeplink + return currentActionUrl } private func resolveDeeplink(_ deeplink: String?, fallback: String) -> String { diff --git a/CTNotificationContent/Templates/ContentSlider/Controller/CTContentSliderController.swift b/CTNotificationContent/Templates/ContentSlider/Controller/CTContentSliderController.swift index c0857e2..e33edde 100644 --- a/CTNotificationContent/Templates/ContentSlider/Controller/CTContentSliderController.swift +++ b/CTNotificationContent/Templates/ContentSlider/Controller/CTContentSliderController.swift @@ -32,54 +32,71 @@ fileprivate enum Constants { contentView = UIView(frame: view.frame) view.addSubview(contentView) + CTContentLog.info("Content slider controller started") jsonContent = CTUtiltiy.loadContentData(data: data) createView() } func createView() { guard let jsonContent = jsonContent else { + CTContentLog.error("Nil payload data, rendering caption only") setUpConstraints() return } - + orientation = jsonContent.orientation autoDismiss = jsonContent.autoDismiss showPaging = jsonContent.showsPaging autoPlay = jsonContent.autoPlay + CTContentLog.info("Loading \(jsonContent.items.count) items, orientation=\(orientation), autoPlay=\(autoPlay), paging=\(showPaging), autoDismiss=\(autoDismiss)") + // The downloads finish in any order, so each result is kept under its + // own position. The arrays are filled in payload order at the end. let dispatchGroup = DispatchGroup() - var imageIndex = 1 - for (_,item) in jsonContent.items.enumerated() { + var orderedItemViews = [Int: CTCaptionedImageView]() + var orderedItems = [Int: [String: String]]() + for (index, item) in jsonContent.items.enumerated() { dispatchGroup.enter() CTUtiltiy.checkImageUrlValid(imageUrl: item.imageUrl) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - var title = item.caption - if title.isEmpty { - title = self!.templateCaption - } - var subTiltle = item.subcaption - if subTiltle.isEmpty { - subTiltle = self!.templateSubcaption - } - var action = item.actionUrl - if action.isEmpty { - action = self!.deeplinkURL - } - - let itemComponents = CaptionedImageViewComponents(caption: title, subcaption: subTiltle, imageUrl: item.imageUrl, actionUrl: action, bgColor: ConstantKeys.kDefaultColor, captionColor: ConstantKeys.kHexBlackColor, subcaptionColor: ConstantKeys.kHexLightGrayColor, bgColorDark: ConstantKeys.kDefaultColorDark, captionColorDark: ConstantKeys.kHexWhiteColor, subcaptionColorDark: ConstantKeys.kHexDarkGrayColor, imageDescription: item.imgAltText ?? "\(CTAccessibility.kDefaultImageDescription) \(imageIndex)") - let itemView = CTCaptionedImageView(components: itemComponents, isGifSupported: false) - self?.itemViews.append(itemView) - imageIndex = imageIndex + 1 - - let keyItem = [Constants.kCaption : item.caption, Constants.kSubcaption : item.subcaption, Constants.kImageUrl : item.imageUrl, Constants.kActionUrl : item.actionUrl] - self?.items.append(keyItem) + defer { dispatchGroup.leave() } + guard let self = self else { + CTContentLog.error("Controller deallocated before item \(index + 1) arrived") + return + } + guard imageData != nil else { + CTContentLog.error("Skipping item \(index + 1), image load failed, url=\(item.imageUrl)") + return + } + + var title = item.caption + if title.isEmpty { + title = self.templateCaption + } + var subTiltle = item.subcaption + if subTiltle.isEmpty { + subTiltle = self.templateSubcaption } - dispatchGroup.leave() + var action = item.actionUrl + if action.isEmpty { + action = self.deeplinkURL + } + + let itemComponents = CaptionedImageViewComponents(caption: title, subcaption: subTiltle, imageUrl: item.imageUrl, actionUrl: action, bgColor: ConstantKeys.kDefaultColor, captionColor: ConstantKeys.kHexBlackColor, subcaptionColor: ConstantKeys.kHexLightGrayColor, bgColorDark: ConstantKeys.kDefaultColorDark, captionColorDark: ConstantKeys.kHexWhiteColor, subcaptionColorDark: ConstantKeys.kHexDarkGrayColor, imageDescription: item.imgAltText ?? "\(CTAccessibility.kDefaultImageDescription) \(index + 1)") + orderedItemViews[index] = CTCaptionedImageView(components: itemComponents, isGifSupported: false) + orderedItems[index] = [Constants.kCaption : item.caption, Constants.kSubcaption : item.subcaption, Constants.kImageUrl : item.imageUrl, Constants.kActionUrl : item.actionUrl] } } } - dispatchGroup.notify(queue: .main) { + dispatchGroup.notify(queue: .main) { [weak self] in + guard let self = self else { + CTContentLog.error("Controller deallocated before items arrived") + return + } + let positions = (0..= itemViews.count { + CTContentLog.error("Index \(currentItemIndex) out of range for \(itemViews.count) items, resetting to 0") + currentItemIndex = 0 + } currentItemView = itemViews[currentItemIndex] contentView.addSubview(currentItemView) currentItemView.translatesAutoresizingMaskIntoConstraints = false @@ -159,6 +186,25 @@ fileprivate enum Constants { } + /// Deeplink of the item on screen. Empty when there is no item. + private var currentActionUrl: String { + guard currentItemIndex >= 0, currentItemIndex < itemViews.count else { + CTContentLog.error("No item at index \(currentItemIndex)/\(itemViews.count), no deeplink available") + return "" + } + return itemViews[currentItemIndex].components.actionUrl + } + + /// Payload of the item on screen, reported back to the host app. + /// itemViews holds one more entry than items when the fallback caption view + /// is shown, so the index is checked against items on its own. + private var currentItemProperties: [String: String] { + guard currentItemIndex >= 0, currentItemIndex < items.count else { + return [:] + } + return items[currentItemIndex] + } + @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction1 { // Maps to show previous @@ -171,13 +217,19 @@ fileprivate enum Constants { } else if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink if itemViews.count > 0 { - let urlString = itemViews[currentItemIndex].components.actionUrl - getParentViewController().userDidPerformAction(ConstantKeys.kOpenedContentUrlAction, withProperties: items[currentItemIndex]) - if let url = URL(string: urlString) { - getParentViewController().open(url) + let urlString = currentActionUrl + getParentViewController()?.userDidPerformAction(ConstantKeys.kOpenedContentUrlAction, withProperties: currentItemProperties) + if urlString.isEmpty { + CTContentLog.info("No deeplink for current item") + } else if let url = URL(string: urlString) { + CTContentLog.info("Opening deeplink, url=\(urlString)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed, url=\(urlString)") } return (autoDismiss == 1) ? .dismiss : .doNotDismiss } + CTContentLog.error("No items, forwarding action to host app") return .dismissAndForwardAction } return .doNotDismiss @@ -193,9 +245,14 @@ fileprivate enum Constants { func moveSlider(direction: Int) { guard let _ = getParentViewController() else { + CTContentLog.info("Controller detached, stopping autoplay timer") stopAutoPlay() return } + guard !itemViews.isEmpty else { + CTContentLog.error("No items to page through") + return + } currentItemView.removeFromSuperview() currentItemIndex = currentItemIndex + direction @@ -216,7 +273,7 @@ fileprivate enum Constants { ]) pageControl.currentPage = currentItemIndex - getParentViewController().userDidPerformAction(ConstantKeys.kViewContentItemAction, withProperties: items[currentItemIndex]) + getParentViewController()?.userDidPerformAction(ConstantKeys.kViewContentItemAction, withProperties: currentItemProperties) } func startAutoPlay() { @@ -231,7 +288,6 @@ fileprivate enum Constants { } @objc public override func getDeeplinkUrl() -> String! { - let deeplink = itemViews[currentItemIndex].components.actionUrl - return deeplink + return currentActionUrl } } diff --git a/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayLinearViewController.swift b/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayLinearViewController.swift index 4da10bd..4bb2656 100644 --- a/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayLinearViewController.swift +++ b/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayLinearViewController.swift @@ -76,9 +76,12 @@ import UserNotificationsUI } @IBAction func buyAction(_ sender: UIButton) { - if let url = URL(string: deeplink){ - getParentViewController().open(url) + guard let url = URL(string: deeplink) else { + CTContentLog.error("Buy button deeplink parse failed, url=\(deeplink)") + return } + CTContentLog.info("Buy button tapped, opening deeplink, url=\(deeplink)") + getParentViewController()?.open(url) } @objc func smallImageAction(_ sender: UITapGestureRecognizer) { @@ -108,8 +111,10 @@ import UserNotificationsUI func createView() { guard let jsonContent = jsonContent else { + CTContentLog.error("Nil payload data, view left empty") return } + CTContentLog.info("Linear product display controller started") let viewWidth = view.frame.size.width let viewHeight = (viewWidth * (Constraints.kLandscapeMultiplier)) + buyBtnOutlet.frame.height + smallImageBtn1.frame.height + 50 //imageview + buynowbutton + smallimageview + padding @@ -121,32 +126,39 @@ import UserNotificationsUI CTUtiltiy.checkImageUrlValid(imageUrl: jsonContent.pt_img1) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - self?.smallImageBtn1.image = imageData - self?.bigImageView.image = imageData - self?.smallImageBtn1.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description - self?.bigImageView.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description + guard imageData != nil else { + CTContentLog.error("Image load failed for pt_img1, slot left empty, url=\(jsonContent.pt_img1)") + return } + self?.smallImageBtn1.image = imageData + self?.bigImageView.image = imageData + self?.smallImageBtn1.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description + self?.bigImageView.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description } } CTUtiltiy.checkImageUrlValid(imageUrl: jsonContent.pt_img2) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - self?.smallImageBtn2.image = imageData - self?.smallImageBtn2.accessibilityLabel = jsonContent.pt_img2_alt_text ?? CTAccessibility.kDefaultSmallImage2Description + guard imageData != nil else { + CTContentLog.error("Image load failed for pt_img2, slot left empty, url=\(jsonContent.pt_img2)") + return } + self?.smallImageBtn2.image = imageData + self?.smallImageBtn2.accessibilityLabel = jsonContent.pt_img2_alt_text ?? CTAccessibility.kDefaultSmallImage2Description } } if let img3 = jsonContent.pt_img3, !img3.isEmpty ,(jsonContent.pt_bt3 != nil && jsonContent.pt_st3 != nil && jsonContent.pt_dl3 != nil){ CTUtiltiy.checkImageUrlValid(imageUrl: img3) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - self?.smallImageBtn3.image = imageData - self?.smallImageBtn3.accessibilityLabel = jsonContent.pt_img3_alt_text ?? CTAccessibility.kDefaultSmallImage3Description + guard imageData != nil else { + CTContentLog.error("Image load failed for pt_img3, slot left empty, url=\(img3)") + return } + self?.smallImageBtn3.image = imageData + self?.smallImageBtn3.accessibilityLabel = jsonContent.pt_img3_alt_text ?? CTAccessibility.kDefaultSmallImage3Description } } }else{ + CTContentLog.info("Skipping product 3, image or text missing") self.smallImageBtn3.isUserInteractionEnabled=false } @@ -215,8 +227,11 @@ import UserNotificationsUI @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink - if let url = URL(string: deeplink){ - getParentViewController().open(url) + if let url = URL(string: deeplink) { + CTContentLog.info("Opening product deeplink, url=\(deeplink)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Product deeplink parse failed, url=\(deeplink)") } return .dismiss } diff --git a/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayVerticalViewController.swift b/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayVerticalViewController.swift index 01ebbd9..178e456 100644 --- a/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayVerticalViewController.swift +++ b/CTNotificationContent/Templates/ProductDisplay/Controller/CTProductDisplayVerticalViewController.swift @@ -81,9 +81,12 @@ import UserNotificationsUI } @IBAction func buyAction(_ sender: UIButton) { - if let url = URL(string: deeplink){ - getParentViewController().open(url) + guard let url = URL(string: deeplink) else { + CTContentLog.error("Buy button deeplink parse failed, url=\(deeplink)") + return } + CTContentLog.info("Buy button tapped, opening deeplink, url=\(deeplink)") + getParentViewController()?.open(url) } @objc func smallImageAction(_ sender: UITapGestureRecognizer) { @@ -125,8 +128,10 @@ import UserNotificationsUI func createView() { guard let jsonContent = jsonContent else { + CTContentLog.error("Nil payload data, view left empty") return } + CTContentLog.info("Vertical product display controller started") let viewWidth = view.frame.size.width let viewHeight = ((viewWidth/2) * Constraints.kLandscapeMultiplier) + titleLabel.frame.height + subTitleLabel.frame.height + 60 @@ -140,32 +145,39 @@ import UserNotificationsUI CTUtiltiy.checkImageUrlValid(imageUrl: jsonContent.pt_img1) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - self?.smallImageBtn1.image = imageData - self?.bigImageView.image = imageData - self?.smallImageBtn1.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description - self?.bigImageView.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description + guard imageData != nil else { + CTContentLog.error("Image load failed for pt_img1, slot left empty, url=\(jsonContent.pt_img1)") + return } + self?.smallImageBtn1.image = imageData + self?.bigImageView.image = imageData + self?.smallImageBtn1.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description + self?.bigImageView.accessibilityLabel = jsonContent.pt_img1_alt_text ?? CTAccessibility.kDefaultSmallImage1Description } } CTUtiltiy.checkImageUrlValid(imageUrl: jsonContent.pt_img2) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - self?.smallImageBtn2.image = imageData - self?.smallImageBtn2.accessibilityLabel = jsonContent.pt_img2_alt_text ?? CTAccessibility.kDefaultSmallImage2Description + guard imageData != nil else { + CTContentLog.error("Image load failed for pt_img2, slot left empty, url=\(jsonContent.pt_img2)") + return } + self?.smallImageBtn2.image = imageData + self?.smallImageBtn2.accessibilityLabel = jsonContent.pt_img2_alt_text ?? CTAccessibility.kDefaultSmallImage2Description } } if let img3 = jsonContent.pt_img3, !img3.isEmpty ,(jsonContent.pt_bt3 != nil && jsonContent.pt_st3 != nil && jsonContent.pt_dl3 != nil && jsonContent.pt_price3 != nil){ CTUtiltiy.checkImageUrlValid(imageUrl: img3) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - self?.smallImageBtn3.image = imageData - self?.smallImageBtn3.accessibilityLabel = jsonContent.pt_img3_alt_text ?? CTAccessibility.kDefaultSmallImage3Description + guard imageData != nil else { + CTContentLog.error("Image load failed for pt_img3, slot left empty, url=\(img3)") + return } + self?.smallImageBtn3.image = imageData + self?.smallImageBtn3.accessibilityLabel = jsonContent.pt_img3_alt_text ?? CTAccessibility.kDefaultSmallImage3Description } } }else{ + CTContentLog.info("Skipping product 3, image or text missing") self.smallImageBtn3.isUserInteractionEnabled=false } @@ -243,8 +255,11 @@ import UserNotificationsUI @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink - if let url = URL(string: deeplink){ - getParentViewController().open(url) + if let url = URL(string: deeplink) { + CTContentLog.info("Opening product deeplink, url=\(deeplink)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Product deeplink parse failed, url=\(deeplink)") } return .dismiss } diff --git a/CTNotificationContent/Templates/Rating/Controller/CTRatingsViewController.swift b/CTNotificationContent/Templates/Rating/Controller/CTRatingsViewController.swift index ef18c78..69b69bb 100644 --- a/CTNotificationContent/Templates/Rating/Controller/CTRatingsViewController.swift +++ b/CTNotificationContent/Templates/Rating/Controller/CTRatingsViewController.swift @@ -227,6 +227,16 @@ import SDWebImage preferredContentSize = CGSize(width: viewWidth, height: viewHeight) } + /// Star icon from the SDK bundle. Returns nil when the asset is missing. + private func starImage(filled: Bool) -> UIImage? { + let name = filled ? "ct_star_filled" : "ct_star_outline" + guard let image = UIImage(named: name, in: Bundle(for: type(of: self)), compatibleWith: nil) else { + CTContentLog.error("Missing bundle asset \(name), star slot left empty") + return nil + } + return image + } + func addGestureReconizerToImageView(){ let tapGR1 = UITapGestureRecognizer(target: self, action: #selector(selectStar)) tapGR1.delegate = self @@ -244,7 +254,7 @@ import SDWebImage threeStarImageView.addGestureRecognizer(tapGR3) let tapGR4 = UITapGestureRecognizer(target: self, action: #selector(selectStar)) - tapGR3.delegate = self + tapGR4.delegate = self fourStarImageView.isUserInteractionEnabled = true fourStarImageView.addGestureRecognizer(tapGR4) @@ -259,60 +269,68 @@ import SDWebImage var deepLink:String? switch sender.view?.tag{ case 1: - oneStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - twoStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - threeStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - fourStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - fiveStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) + oneStarImageView.image = starImage(filled: true) + twoStarImageView.image = starImage(filled: false) + threeStarImageView.image = starImage(filled: false) + fourStarImageView.image = starImage(filled: false) + fiveStarImageView.image = starImage(filled: false) deepLink = jsonContent?.pt_dl1 break case 2: - oneStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - twoStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - threeStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - fourStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - fiveStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) + oneStarImageView.image = starImage(filled: true) + twoStarImageView.image = starImage(filled: true) + threeStarImageView.image = starImage(filled: false) + fourStarImageView.image = starImage(filled: false) + fiveStarImageView.image = starImage(filled: false) deepLink = jsonContent?.pt_dl2 break case 3: - oneStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - twoStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - threeStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - fourStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - fiveStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) + oneStarImageView.image = starImage(filled: true) + twoStarImageView.image = starImage(filled: true) + threeStarImageView.image = starImage(filled: true) + fourStarImageView.image = starImage(filled: false) + fiveStarImageView.image = starImage(filled: false) deepLink = jsonContent?.pt_dl3 break case 4: - oneStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - twoStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - threeStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - fourStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - fiveStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) + oneStarImageView.image = starImage(filled: true) + twoStarImageView.image = starImage(filled: true) + threeStarImageView.image = starImage(filled: true) + fourStarImageView.image = starImage(filled: true) + fiveStarImageView.image = starImage(filled: false) deepLink = jsonContent?.pt_dl4 break case 5: - oneStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - twoStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - threeStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - fourStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) - fiveStarImageView.image = UIImage(named: "ct_star_filled", in: Bundle(for: type(of: self)), compatibleWith: nil) + oneStarImageView.image = starImage(filled: true) + twoStarImageView.image = starImage(filled: true) + threeStarImageView.image = starImage(filled: true) + fourStarImageView.image = starImage(filled: true) + fiveStarImageView.image = starImage(filled: true) deepLink = jsonContent?.pt_dl5 break default: + CTContentLog.error("Tap on star with unknown tag=\(sender.view?.tag ?? -1), ignoring") break } - - if let url = deepLink{ - if let url = URL(string: url){ - getParentViewController().open(url) - } - }else{ - if let url = jsonContent?.pt_dl1{ - if let url = URL(string: url){ - getParentViewController().open(url) - } + + let rating = sender.view?.tag ?? 0 + // The star's own deeplink is used. The first star's deeplink is the + // fallback when the tapped star has none. + var target = deepLink ?? "" + if target.isEmpty { + target = jsonContent?.pt_dl1 ?? "" + if !target.isEmpty { + CTContentLog.info("No deeplink for star \(rating), falling back to star 1 deeplink") } } + if target.isEmpty { + CTContentLog.info("Tap on star \(rating), no deeplink, ignoring") + } else if let url = URL(string: target) { + CTContentLog.info("Tap on star \(rating), opening deeplink, url=\(target)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed for star \(rating), url=\(target)") + } } func createView() { @@ -321,11 +339,11 @@ import SDWebImage contentView.addSubview(subTitleLabel) contentView.addSubview(titleLabel) - oneStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - twoStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - threeStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - fourStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) - fiveStarImageView.image = UIImage(named: "ct_star_outline", in: Bundle(for: type(of: self)), compatibleWith: nil) + oneStarImageView.image = starImage(filled: false) + twoStarImageView.image = starImage(filled: false) + threeStarImageView.image = starImage(filled: false) + fourStarImageView.image = starImage(filled: false) + fiveStarImageView.image = starImage(filled: false) starStackView.addArrangedSubview(oneStarImageView) starStackView.addArrangedSubview(twoStarImageView) @@ -334,9 +352,11 @@ import SDWebImage starStackView.addArrangedSubview(fiveStarImageView) guard let jsonContent = jsonContent else { + CTContentLog.error("Nil payload data, rendering caption and empty stars only") return } - + CTContentLog.info("Rating controller started") + if let title = jsonContent.pt_title, !title.isEmpty{ templateCaption = title } @@ -367,19 +387,28 @@ import SDWebImage self.subTitleLabel.setHTMLText(templateSubcaption) if let gif = jsonContent.pt_gif, !gif.isEmpty, let url = URL(string: gif) { - self.bigImageView.sd_setImage(with: url, completed: { [weak self] (image, _, _, _) in - if image != nil { - self?.bigImageView.accessibilityLabel = jsonContent.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription - self?.updateUI() - } else { - self?.showImageView() + CTContentLog.info("Loading gif, url=\(gif)") + self.bigImageView.sd_setImage(with: url, completed: { [weak self] (image, error, _, _) in + guard let self = self else { + CTContentLog.error("Controller deallocated before gif arrived") + return } + guard image != nil else { + CTContentLog.error("Gif load failed, falling back to still image, url=\(gif), error=\(error?.localizedDescription ?? "nil image, no error")") + self.showImageView() + return + } + self.bigImageView.accessibilityLabel = jsonContent.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription + self.updateUI() }) } else { + if let gif = jsonContent.pt_gif, !gif.isEmpty { + CTContentLog.error("Gif url parse failed, falling back to still image, url=\(gif)") + } self.showImageView() } - - if let bg = jsonContent.pt_bg,!bgColor.isEmpty{ + + if let bg = jsonContent.pt_bg, !bg.isEmpty{ bgColor = bg } if let titleColor = jsonContent.pt_title_clr, !titleColor.isEmpty { @@ -497,10 +526,13 @@ import SDWebImage @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink - if !deeplinkURL.isEmpty { - if let url = URL(string: deeplinkURL) { - getParentViewController().open(url) - } + if deeplinkURL.isEmpty { + CTContentLog.info("No deeplink, dismissing") + } else if let url = URL(string: deeplinkURL) { + CTContentLog.info("Opening deeplink, url=\(deeplinkURL)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed, url=\(deeplinkURL)") } return .dismiss } @@ -512,26 +544,32 @@ import SDWebImage } func showImageView() { - if templateBigImage != "" { - CTUtiltiy.checkImageUrlValid(imageUrl: templateBigImage) { [weak self] (imageData) in - DispatchQueue.main.async { - if imageData != nil { - self?.bigImageView.image = imageData - self?.bigImageView.accessibilityLabel = self?.bigImageAltText ?? CTAccessibility.kDefaultImageDescription - self?.updateUI() - }else{ - //handle when image url is invalid - self?.templateBigImage = "" - self?.templateBigGif = "" - self?.updateUI() - } - } - } - } else { - //handle when image is not provided - templateBigImage = "" + guard !templateBigImage.isEmpty else { + CTContentLog.info("Missing pt_big_img, rendering without image") templateBigGif = "" self.updateUI() + return + } + CTUtiltiy.checkImageUrlValid(imageUrl: templateBigImage) { [weak self] (imageData) in + DispatchQueue.main.async { + guard let self = self else { + CTContentLog.error("Controller deallocated before image arrived") + return + } + guard imageData != nil else { + CTContentLog.error("Image load failed, rendering without image, url=\(self.templateBigImage)") + // The layout is picked from these two values, so they are + // cleared before the layout is chosen again. + self.templateBigImage = "" + self.templateBigGif = "" + self.updateUI() + return + } + CTContentLog.info("Image rendered, url=\(self.templateBigImage)") + self.bigImageView.image = imageData + self.bigImageView.accessibilityLabel = self.bigImageAltText ?? CTAccessibility.kDefaultImageDescription + self.updateUI() + } } } } diff --git a/CTNotificationContent/Templates/SingleMedia/Controller/CTSingleMediaController.swift b/CTNotificationContent/Templates/SingleMedia/Controller/CTSingleMediaController.swift index b18e094..ee0401f 100644 --- a/CTNotificationContent/Templates/SingleMedia/Controller/CTSingleMediaController.swift +++ b/CTNotificationContent/Templates/SingleMedia/Controller/CTSingleMediaController.swift @@ -45,7 +45,10 @@ import AVFoundation createFrameWithImage() + CTContentLog.info("Single media controller started, mediaType=\(mediaType.isEmpty ? "nil" : mediaType), url=\(mediaURL)") + if mediaType == ConstantKeys.kMediaTypeVideo || mediaType == ConstantKeys.kMediaTypeAudio { + CTContentLog.info("mediaType video and audio not supported yet, rendering caption only, url=\(mediaURL)") // TODO: Remove mediaURL = "" when video template is supported. mediaURL = "" createVideoView() @@ -53,20 +56,22 @@ import AVFoundation createImageView() } } - + func createVideoView() { createBasicCaptionView() guard let urlToVideo = URL(string: mediaURL) else { + CTContentLog.error("Media url parse failed, rendering caption only, url=\(mediaURL)") createFrameWithoutImage() return } - + if AVAsset(url: urlToVideo).isPlayable { + CTContentLog.info("Media is playable, rendering player") let player = AVPlayer(url: urlToVideo) videoPlayerView.player = player - + contentView.addSubview(videoPlayerView) videoPlayerView.translatesAutoresizingMaskIntoConstraints = false let imageHeight = contentView.frame.size.height - CTUtiltiy.getCaptionHeight() @@ -80,8 +85,17 @@ import AVFoundation videoPlayerView.player?.play() isPlaying = true - playImage = UIImage(named: "ct_play_button", in: Bundle(for: type(of: self)), compatibleWith: nil)! - pauseImage = UIImage(named: "ct_pause_button", in: Bundle(for: type(of: self)), compatibleWith: nil)! + let bundle = Bundle(for: type(of: self)) + if let image = UIImage(named: "ct_play_button", in: bundle, compatibleWith: nil) { + playImage = image + } else { + CTContentLog.error("Missing bundle asset ct_play_button, play button has no icon") + } + if let image = UIImage(named: "ct_pause_button", in: bundle, compatibleWith: nil) { + pauseImage = image + } else { + CTContentLog.error("Missing bundle asset ct_pause_button, pause button has no icon") + } playPauseButton.setImage(pauseImage, for: .normal) playPauseButton.addTarget(self, action: #selector(playPauseButtonTapped(_:)), for: .touchUpInside) @@ -100,26 +114,32 @@ import AVFoundation playPauseButton.heightAnchor.constraint(greaterThanOrEqualToConstant: 44.0) ]) } else { - // Video url is invalid. + CTContentLog.error("Media is not playable, rendering caption only, url=\(mediaURL)") createFrameWithoutImage() } } - + func createImageView() { CTUtiltiy.checkImageUrlValid(imageUrl: mediaURL) { [weak self] (imageData) in DispatchQueue.main.async { + guard let self = self else { + CTContentLog.error("Controller deallocated before image arrived") + return + } if imageData != nil { - let itemComponents = CaptionedImageViewComponents(caption: self!.caption, subcaption: self!.subCaption, imageUrl: self!.mediaURL, actionUrl: self!.deeplinkURL, bgColor: ConstantKeys.kDefaultColor, captionColor: ConstantKeys.kHexBlackColor, subcaptionColor: ConstantKeys.kHexLightGrayColor, bgColorDark: ConstantKeys.kDefaultColorDark, captionColorDark: ConstantKeys.kHexWhiteColor, subcaptionColorDark: ConstantKeys.kHexDarkGrayColor, imageDescription: self!.mediaDescription) - self?.currentItemView = CTCaptionedImageView(components: itemComponents, isGifSupported: false) + CTContentLog.info("Image rendered, url=\(self.mediaURL)") + let itemComponents = CaptionedImageViewComponents(caption: self.caption, subcaption: self.subCaption, imageUrl: self.mediaURL, actionUrl: self.deeplinkURL, bgColor: ConstantKeys.kDefaultColor, captionColor: ConstantKeys.kHexBlackColor, subcaptionColor: ConstantKeys.kHexLightGrayColor, bgColorDark: ConstantKeys.kDefaultColorDark, captionColorDark: ConstantKeys.kHexWhiteColor, subcaptionColorDark: ConstantKeys.kHexDarkGrayColor, imageDescription: self.mediaDescription) + self.currentItemView = CTCaptionedImageView(components: itemComponents, isGifSupported: false) } else { - let itemComponents = CaptionedImageViewComponents(caption: self!.caption, subcaption: self!.subCaption, imageUrl: "", actionUrl: self!.deeplinkURL, bgColor: ConstantKeys.kDefaultColor, captionColor: ConstantKeys.kHexBlackColor, subcaptionColor: ConstantKeys.kHexLightGrayColor, imageDescription: "") - self?.currentItemView = CTCaptionedImageView(components: itemComponents, isGifSupported: false) - self?.createFrameWithoutImage() + CTContentLog.error("Image load failed, rendering caption only, url=\(self.mediaURL)") + let itemComponents = CaptionedImageViewComponents(caption: self.caption, subcaption: self.subCaption, imageUrl: "", actionUrl: self.deeplinkURL, bgColor: ConstantKeys.kDefaultColor, captionColor: ConstantKeys.kHexBlackColor, subcaptionColor: ConstantKeys.kHexLightGrayColor, imageDescription: "") + self.currentItemView = CTCaptionedImageView(components: itemComponents, isGifSupported: false) + self.createFrameWithoutImage() } - self?.setUpConstraints() + self.setUpConstraints() } } - + let itemComponents = CaptionedImageViewComponents(caption: caption, subcaption: subCaption, imageUrl: mediaURL, actionUrl: deeplinkURL, bgColor: ConstantKeys.kDefaultColor, captionColor: ConstantKeys.kHexBlackColor, subcaptionColor: ConstantKeys.kHexLightGrayColor, imageDescription: mediaDescription) currentItemView = CTCaptionedImageView(components: itemComponents, isGifSupported: false) @@ -180,10 +200,13 @@ import AVFoundation @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink - if !deeplinkURL.isEmpty { - if let url = URL(string: deeplinkURL) { - getParentViewController().open(url) - } + if deeplinkURL.isEmpty { + CTContentLog.info("No deeplink, dismissing") + } else if let url = URL(string: deeplinkURL) { + CTContentLog.info("Opening deeplink, url=\(deeplinkURL)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed, url=\(deeplinkURL)") } return .dismiss } diff --git a/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift b/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift index 315af56..a7eed33 100644 --- a/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift +++ b/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift @@ -136,19 +136,26 @@ import SDWebImage setTruncatingHTMLText(templateSubcaption, on: subcaptionLabel) guard let jsonContent = jsonContent else { + CTContentLog.error("Nil payload data, rendering caption only") return } if let threshold = jsonContent.pt_timer_threshold { if let deliveredAt = notificationDeliveryDate { let elapsed = Int(Date().timeIntervalSince(deliveredAt)) thresholdSeconds = max(0, threshold - elapsed) + CTContentLog.info("Using pt_timer_threshold=\(threshold), deliveredAt=\(Int(deliveredAt.timeIntervalSince1970)), elapsed=\(elapsed)") } else { thresholdSeconds = threshold + CTContentLog.error("Nil notificationDeliveryDate, using pt_timer_threshold=\(threshold) without subtracting elapsed time") } } else if let endTime = jsonContent.pt_timer_end { let currentTime = Date().timeIntervalSince1970 thresholdSeconds = endTime - Int(currentTime) + CTContentLog.info("Using pt_timer_end=\(endTime), now=\(Int(currentTime))") + } else { + CTContentLog.error("Missing pt_timer_threshold and pt_timer_end, starting at 0, rendering expired state") } + CTContentLog.info("Timer controller started, secondsLeft=\(thresholdSeconds)") if let title = jsonContent.pt_title, !title.isEmpty { setTruncatingHTMLText(title, on: captionLabel) @@ -202,18 +209,29 @@ import SDWebImage // Load image only if timer is not ended. if thresholdSeconds > 0 { if let gif = jsonContent.pt_gif, !gif.isEmpty, let url = URL(string: gif) { - self.imageView.sd_setImage(with: url, completed: { [weak self] (image, _, _, _) in - if image != nil { - self?.imageView.accessibilityLabel = jsonContent.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription - self?.activateImageViewContraints() - self?.createFrameWithImage() - } else { - self?.showImageView() + CTContentLog.info("Loading gif, url=\(gif)") + self.imageView.sd_setImage(with: url, completed: { [weak self] (image, error, _, _) in + guard let self = self else { + CTContentLog.error("Controller deallocated before gif arrived") + return + } + guard image != nil else { + CTContentLog.error("Gif load failed, falling back to still image, url=\(gif), error=\(error?.localizedDescription ?? "nil image, no error")") + self.showImageView() + return } + self.imageView.accessibilityLabel = jsonContent.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription + self.activateImageViewContraints() + self.createFrameWithImage() }) } else { + if let gif = jsonContent.pt_gif, !gif.isEmpty { + CTContentLog.error("Gif url parse failed, falling back to still image, url=\(gif)") + } self.showImageView() } + } else { + CTContentLog.info("Timer already expired, skipping running state image load") } } @@ -250,7 +268,10 @@ import SDWebImage } func setupConstraints() { - let activeTimerView: UIView = timerBoxView! + guard let activeTimerView: UIView = timerBoxView else { + CTContentLog.error("Nil timer box view, skipping timer layout") + return + } let initialTrailingConstant: CGFloat = thresholdSeconds > 0 ? -timerReservedWidth(showHours: thresholdSeconds > 3600) : -Constraints.kCaptionLeftPadding @@ -299,33 +320,49 @@ import SDWebImage } func updateViewForExpiredTime() { - if let jsonContent = jsonContent { - if let title = jsonContent.pt_title_alt, !title.isEmpty { - setTruncatingHTMLText(title, on: captionLabel) - } - if let msg = jsonContent.pt_msg_alt, !msg.isEmpty { - setTruncatingHTMLText(msg, on: subcaptionLabel) - } - if let bigImgAlt = jsonContent.pt_big_img_alt, !bigImgAlt.isEmpty { - bigImageAlt = bigImgAlt - } - if let bigImgAltAlt = jsonContent.pt_big_img_alt_alt_text, !bigImgAltAlt.isEmpty { - bigImageAltAltText = bigImgAltAlt - } - if let gifAlt = jsonContent.pt_gif_alt, !gifAlt.isEmpty, let url = URL(string: gifAlt) { - CTUtiltiy.checkImageUrlValid(imageUrl: gifAlt) { [weak self] (imageData) in - if imageData != nil { - self?.showAltGifView(url) - } else { - self?.showAltImageView() + guard let jsonContent = jsonContent else { + CTContentLog.error("Timer expired but payload data is nil, leaving view unchanged") + return + } + CTContentLog.info("Timer expired, rendering expired texts and image") + if let title = jsonContent.pt_title_alt, !title.isEmpty { + setTruncatingHTMLText(title, on: captionLabel) + } + if let msg = jsonContent.pt_msg_alt, !msg.isEmpty { + setTruncatingHTMLText(msg, on: subcaptionLabel) + } + if let bigImgAlt = jsonContent.pt_big_img_alt, !bigImgAlt.isEmpty { + bigImageAlt = bigImgAlt + } + if let bigImgAltAlt = jsonContent.pt_big_img_alt_alt_text, !bigImgAltAlt.isEmpty { + bigImageAltAltText = bigImgAltAlt + } + if let gifAlt = jsonContent.pt_gif_alt, !gifAlt.isEmpty, let url = URL(string: gifAlt) { + CTContentLog.info("Loading expired gif, url=\(gifAlt)") + CTUtiltiy.checkImageUrlValid(imageUrl: gifAlt) { [weak self] (imageData) in + // The callback runs off the main thread. Every view call below + // has to be made on the main thread. + DispatchQueue.main.async { + guard let self = self else { + CTContentLog.error("Controller deallocated before expired gif was checked") + return + } + guard imageData != nil else { + CTContentLog.error("Expired gif unreachable, falling back to expired still image, url=\(gifAlt)") + self.showAltImageView() + return } + self.showAltGifView(url) } - } else { - self.showAltImageView() } - - updateInterfaceColors() + } else { + if let gifAlt = jsonContent.pt_gif_alt, !gifAlt.isEmpty { + CTContentLog.error("Expired gif url parse failed, falling back to expired still image, url=\(gifAlt)") + } + self.showAltImageView() } + + updateInterfaceColors() } func createFrameWithoutImage() { @@ -361,10 +398,13 @@ import SDWebImage @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink - if !deeplinkURL.isEmpty { - if let url = URL(string: deeplinkURL) { - getParentViewController().open(url) - } + if deeplinkURL.isEmpty { + CTContentLog.info("No deeplink, dismissing") + } else if let url = URL(string: deeplinkURL) { + CTContentLog.info("Opening deeplink, url=\(deeplinkURL)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed, url=\(deeplinkURL)") } return .dismiss } @@ -376,10 +416,21 @@ import SDWebImage } func showImageView() { - guard bigImage != "" else { return } + guard !bigImage.isEmpty else { + CTContentLog.info("Missing pt_big_img, rendering caption only") + return + } CTUtiltiy.checkImageUrlValid(imageUrl: bigImage) { [weak self] (imageData) in DispatchQueue.main.async { - guard let self, imageData != nil else { return } + guard let self = self else { + CTContentLog.error("Controller deallocated before image arrived") + return + } + guard imageData != nil else { + CTContentLog.error("Image load failed, rendering caption only, url=\(self.bigImage)") + return + } + CTContentLog.info("Image rendered, url=\(self.bigImage)") self.imageView.image = imageData self.imageView.accessibilityLabel = self.bigImageAltText ?? CTAccessibility.kDefaultImageDescription self.activateImageViewContraints() @@ -387,31 +438,46 @@ import SDWebImage } } } - + func showAltGifView(_ url: URL) { - self.imageView.sd_setImage(with: url, completed: { [weak self] (image, _, _, _) in - if image != nil { - self?.imageView.accessibilityLabel = self?.bigImageAltAltText ?? CTAccessibility.kDefaultImageDescription - self?.createFrameWithImage() - self?.activateImageViewContraints() - } else { - self?.showAltImageView() + self.imageView.sd_setImage(with: url, completed: { [weak self] (image, error, _, _) in + guard let self = self else { + CTContentLog.error("Controller deallocated before expired gif arrived") + return + } + guard image != nil else { + CTContentLog.error("Expired gif load failed, falling back to expired still image, url=\(url.absoluteString), error=\(error?.localizedDescription ?? "nil image, no error")") + self.showAltImageView() + return } + CTContentLog.info("Expired gif rendered") + self.imageView.accessibilityLabel = self.bigImageAltAltText ?? CTAccessibility.kDefaultImageDescription + self.createFrameWithImage() + self.activateImageViewContraints() }) } - + func showAltImageView() { - if bigImageAlt != "" { - // Load expired image, if available. - CTUtiltiy.checkImageUrlValid(imageUrl: bigImageAlt) { [weak self] (imageData) in - DispatchQueue.main.async { - if imageData != nil { - self?.imageView.image = imageData - self?.imageView.accessibilityLabel = self?.bigImageAltAltText ?? CTAccessibility.kDefaultImageDescription - self?.createFrameWithImage() - self?.activateImageViewContraints() - } + guard !bigImageAlt.isEmpty else { + CTContentLog.info("Missing pt_big_img_alt, keeping image already on screen") + return + } + // Load expired image, if available. + CTUtiltiy.checkImageUrlValid(imageUrl: bigImageAlt) { [weak self] (imageData) in + DispatchQueue.main.async { + guard let self = self else { + CTContentLog.error("Controller deallocated before expired image arrived") + return + } + guard imageData != nil else { + CTContentLog.error("Expired image load failed, keeping image already on screen, url=\(self.bigImageAlt)") + return } + CTContentLog.info("Expired image rendered, url=\(self.bigImageAlt)") + self.imageView.image = imageData + self.imageView.accessibilityLabel = self.bigImageAltAltText ?? CTAccessibility.kDefaultImageDescription + self.createFrameWithImage() + self.activateImageViewContraints() } } } diff --git a/CTNotificationContent/Templates/Timer/View/CTTimerBoxView.swift b/CTNotificationContent/Templates/Timer/View/CTTimerBoxView.swift index f3cd405..9660f77 100644 --- a/CTNotificationContent/Templates/Timer/View/CTTimerBoxView.swift +++ b/CTNotificationContent/Templates/Timer/View/CTTimerBoxView.swift @@ -110,6 +110,9 @@ class CTTimerBoxView: UIView { gradientLayer = grad backgroundColor = .clear } else { + if isGradient { + CTContentLog.error("Invalid gradient colors for style=\(style), using plain background, clr1=\(properties.pt_chrono_grad_clr1 ?? "nil"), clr2=\(properties.pt_chrono_grad_clr2 ?? "nil")") + } gradientLayer?.removeFromSuperlayer() gradientLayer = nil diff --git a/CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift b/CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift new file mode 100644 index 0000000..93e2af7 --- /dev/null +++ b/CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift @@ -0,0 +1,69 @@ +import Foundation +import os.log + +/// Writes SDK logs to the unified logging system. +/// +/// A notification content extension runs in its own process. Xcode is normally +/// not attached to that process, so `print` output is lost. Logs written here +/// can be read in Console.app, and they are also included in a sysdiagnose. +/// +/// The subsystem is shared with the CleverTap iOS SDK, so one filter shows +/// logs from both. Filter on the category to see only this SDK. +/// +/// The name is deliberately different from `CTLogger` in the CleverTap iOS SDK. +/// Both frameworks are linked into the same extension process. Two classes with +/// the same Objective-C name would make the runtime pick one of them at random. +@objc(CTNotificationContentLogger) +public final class CTContentLog: NSObject { + + @objc public static let subsystem: String = "com.clevertap.sdk" + @objc public static let category: String = "CTNotificationContent" + + /// Every line starts with this. `[CleverTap]` matches the prefix the + /// CleverTap iOS SDK uses, so one text filter picks up both frameworks. + /// `[NotificationContent]` tells the two apart. + private static let prefix: String = "[CleverTap][NotificationContent]" + + private static let log = OSLog(subsystem: subsystem, category: category) + + /// Every line goes out at `.default`, including the ones from `error`. + /// + /// The system keeps `.info` and `.debug` messages in memory only. They are + /// dropped before a client can collect them. `.default` messages are written + /// to disk. A client can reproduce a problem and send us the log after. + /// + /// `CTLogger` in the CleverTap iOS SDK also sends every line at `.default`. + /// One set of steps then works for both frameworks. + static func info(_ message: String, file: String = #fileID, function: String = #function) { + emit(message, location: callSite(file, function)) + } + + static func error(_ message: String, file: String = #fileID, function: String = #function) { + emit(message, location: callSite(file, function)) + } + + private static func emit(_ message: String, location: String) { + // The unified log hides %@ arguments by default. %{public}@ keeps them readable. + os_log("%{public}@ %{public}@: %{public}@", log: log, type: .default, prefix, location, message) + } + + /// Type and function the log came from, for example + /// `CTCarouselController.showNext()`. + private static func callSite(_ file: String, _ function: String) -> String { + let name = (file as NSString).lastPathComponent + let type = name.hasSuffix(".swift") ? String(name.dropLast(6)) : name + return "\(type).\(function)" + } + + /// Entry point for the Objective-C files. Swift callers should use `info`, + /// which fills in the call site on its own. + @objc(logInfo:from:) + public static func logInfo(_ message: String, from location: String) { + emit(message, location: location) + } + + @objc(logError:from:) + public static func logError(_ message: String, from location: String) { + emit(message, location: location) + } +} diff --git a/CTNotificationContent/Templates/Utility/CTUtility.swift b/CTNotificationContent/Templates/Utility/CTUtility.swift index 8bdcd99..4505ba3 100644 --- a/CTNotificationContent/Templates/Utility/CTUtility.swift +++ b/CTNotificationContent/Templates/Utility/CTUtility.swift @@ -1,92 +1,207 @@ @objc public class CTUtiltiy: NSObject { + + /// How long one image download may take. + /// + /// The system stops a notification content extension a few seconds after it + /// asks the extension to draw. The default request timeout is 60 seconds. + /// That is longer than the extension is alive. A download that hangs takes + /// the whole process down. Nothing is drawn and no line is written, because + /// the completion handler never runs. A shorter timeout turns that case into + /// an ordinary failure. The failure is logged. The template then draws its + /// caption only layout. + private static let imageRequestTimeout: TimeInterval = 10 + static func checkImageUrlValid(imageUrl: String, completionBlock: @escaping (UIImage?) -> Void) -> Void { - if let url = URL(string: imageUrl) { - let dataTask = URLSession.shared.dataTask(with: url) { (data, response, error) in - guard let data = data, error == nil else { - // Invalid url or error in loading url. - completionBlock(nil) - return - } - guard let imageData = UIImage(data: data) else { - // Image can't be loaded from url. - completionBlock(nil) - return - } - - // Image url is valid. - completionBlock(imageData) - } - dataTask.resume() - } else { - // Image url is empty. + guard !imageUrl.isEmpty else { + // Not an error. Most templates make some images optional. + CTContentLog.info("Empty image url, skipping download") completionBlock(nil) + return } - } - - static func webViewURLReachable(webViewURL: String, completion: @escaping (Bool) -> ()) { - if let url = URL(string: webViewURL){ - var request = URLRequest(url: url) - request.httpMethod = "HEAD" - URLSession.shared.dataTask(with: request) { _, response, _ in - completion((response as? HTTPURLResponse)?.statusCode == 200) - }.resume() + guard let url = URL(string: imageUrl) else { + CTContentLog.error("Image url parse failed, skipping download, url=\(imageUrl)") + completionBlock(nil) + return } + + CTContentLog.info("Downloading image, timeout=\(Int(imageRequestTimeout))s, url=\(imageUrl)") + let startedAt = Date() + var request = URLRequest(url: url) + request.timeoutInterval = imageRequestTimeout + let dataTask = URLSession.shared.dataTask(with: request) { (data, response, error) in + let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000) + if let error = error { + CTContentLog.error("Image download failed, url=\(imageUrl), elapsedMs=\(elapsedMs), error=\(error.localizedDescription)") + completionBlock(nil) + return + } + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + CTContentLog.error("Image download returned HTTP \(httpResponse.statusCode), url=\(imageUrl), elapsedMs=\(elapsedMs)") + completionBlock(nil) + return + } + guard let data = data, !data.isEmpty else { + CTContentLog.error("Image download returned empty body, url=\(imageUrl), elapsedMs=\(elapsedMs)") + completionBlock(nil) + return + } + guard let imageData = UIImage(data: data) else { + CTContentLog.error("Image decode failed, url=\(imageUrl), elapsedMs=\(elapsedMs), bytes=\(data.count)") + completionBlock(nil) + return + } + + CTContentLog.info("Image downloaded, url=\(imageUrl), elapsedMs=\(elapsedMs), bytes=\(data.count), size=\(Int(imageData.size.width))x\(Int(imageData.size.height))") + completionBlock(imageData) + } + dataTask.resume() } - + static func getCaptionHeight() -> CGFloat { return Constraints.kCaptionHeight + Constraints.kSubCaptionHeight + Constraints.kBottomPadding } // Decoding data static func loadContentData(data:String)->T? where T:Decodable{ - var jsonContent: T? = nil - if let configData = data.data(using: .utf8) { - do { - jsonContent = try JSONDecoder().decode(T.self, from: configData) - } catch let error { - print("Failed to load: \(error.localizedDescription)") - jsonContent = nil - } + guard !data.isEmpty else { + CTContentLog.error("Empty payload, cannot decode \(T.self), template will render its no data layout") + return nil + } + guard let configData = data.data(using: .utf8) else { + CTContentLog.error("Payload is not valid UTF-8, cannot decode \(T.self)") + return nil + } + do { + let jsonContent = try JSONDecoder().decode(T.self, from: configData) + CTContentLog.info("Decoded \(T.self)") + return jsonContent + } catch { + // localizedDescription on a DecodingError only says the data was not valid. + // String(describing:) names the key that is missing or has the wrong type. + CTContentLog.error("Decode failed for \(T.self), template will render its no data layout, error=\(String(describing: error))") + CTContentLog.error("Failed payload=\(data)") + return nil } - return jsonContent } // Required keys check for product display template @objc public static func isRequiredKeysProvided(jsonString: String)->Bool{ - if #available(iOS 12.0, *) { - let jsonContent: ProductDisplayProperties? = CTUtiltiy.loadContentData(data: jsonString) - if ( jsonContent?.pt_img1 == nil || jsonContent?.pt_img2 == nil || jsonContent?.pt_bt1 == nil || jsonContent?.pt_bt2 == nil || jsonContent?.pt_st1 == nil || jsonContent?.pt_st2 == nil || jsonContent?.pt_dl1 == nil || jsonContent?.pt_dl2 == nil || jsonContent?.pt_price1 == nil || jsonContent?.pt_price2 == nil || jsonContent?.pt_product_display_action == nil){ - return false - }else{ - return true - } - }else{ + guard #available(iOS 12.0, *) else { + CTContentLog.error("Product display requires iOS 12 or later, falling back to basic template") + return false + } + // Every required key is a non optional field on ProductDisplayProperties. + // A missing key makes the decode fail, so a nil result means a key is + // missing. loadContentData already logged which key it was. + guard CTUtiltiy.loadContentData(data: jsonString) as ProductDisplayProperties? != nil else { + CTContentLog.error("Product display payload missing a required key, falling back to basic template") return false - } + } + CTContentLog.info("Product display payload has all required keys") + return true } - + + /// One thing a template needs in its payload. + /// + /// The rule passes when any key in `keys` is present and not empty. Most + /// rules hold a single key. A rule holds more than one key when the + /// template can draw itself from either of them. + private struct PayloadRequirement { + let keys: [String] + /// What the user sees when the rule fails. Support reads this line + /// next to a screenshot, so it names the visible result. + let effect: String + } + + /// What each template needs before it can draw its full layout. + /// + /// Only keys whose absence changes what the user sees are listed here. + /// `pt_title` and `pt_msg` are not listed. The extension falls back to the + /// title and the body of the notification itself, so a payload without them + /// still draws text. + private static func payloadRequirements(for templateId: String) -> [PayloadRequirement] { + switch templateId { + case TemplateConstants.kTemplateBasic: + return [PayloadRequirement(keys: ["pt_big_img", "pt_gif"], effect: "caption only, no image")] + case TemplateConstants.kTemplateAutoCarousel, TemplateConstants.kTemplateManualCarousel: + return [PayloadRequirement(keys: ["pt_img1", "pt_img2", "pt_img3"], effect: "caption only, no slides")] + case TemplateConstants.kTemplateTimer: + return [PayloadRequirement(keys: ["pt_timer_threshold", "pt_timer_end"], effect: "timer starts at zero, expired state")] + case TemplateConstants.kTemplateZeroBezel: + return [PayloadRequirement(keys: ["pt_big_img", "pt_gif"], effect: "caption only, no image")] + case TemplateConstants.kTemplateWebView: + return [PayloadRequirement(keys: ["pt_url"], effect: "empty web view")] + case TemplateConstants.kTemplateRating: + return [PayloadRequirement(keys: ["pt_big_img", "pt_gif"], effect: "no image above the stars"), + PayloadRequirement(keys: ["pt_default_dl"], effect: "a rating tap opens nothing")] + case TemplateConstants.kTemplateVerticalImage: + return [PayloadRequirement(keys: ["pt_big_img", "pt_gif"], effect: "texts only, no image")] + default: + // Product display is checked by isRequiredKeysProvided. Content + // slider and single media do not use this payload shape. + return [] + } + } + + /// Reports the keys a template needs but did not get. + /// + /// The client sends us the payload of a push that drew nothing. This runs + /// before any drawing and before any download. It names every missing key + /// in one line, so the reader does not have to read the whole payload. + /// Nothing here changes what the template draws. + @objc(logPayloadCheckForTemplate:jsonString:) + public static func logPayloadCheck(templateId: String, jsonString: String) { + let requirements = payloadRequirements(for: templateId) + guard !requirements.isEmpty else { return } + + guard let jsonData = jsonString.data(using: .utf8), + let payload = (try? JSONSerialization.jsonObject(with: jsonData)) as? [String: Any] else { + CTContentLog.error("Payload is not a json object, cannot check required keys, template=\(templateId)") + return + } + + // Every rule is checked. A short circuit would name only the first + // missing key. One report should name all of them. + let unmet = requirements.filter { requirement in + !requirement.keys.contains { hasValue(payload, $0) } + } + guard !unmet.isEmpty else { + CTContentLog.info("Required keys present, template=\(templateId)") + return + } + + let details = unmet.map { requirement -> String in + let names = requirement.keys.joined(separator: " or ") + return "\(names) -> \(requirement.effect)" + } + CTContentLog.error("Required keys missing, template=\(templateId), missing=[\(details.joined(separator: "; "))]") + } + + /// True when the payload carries something usable under this key. + /// A key set to null counts as missing. Text counts only when it is not empty. + private static func hasValue(_ payload: [String: Any], _ key: String) -> Bool { + guard let value = payload[key], !(value is NSNull) else { + return false + } + if let text = value as? String { + return !text.isEmpty + } + return true + } + //Get controller type between vertical and linear, for product display template @objc public static func getControllerType(jsonString: String) -> BaseCTNotificationContentViewController{ let jsonContent: ProductDisplayProperties? = CTUtiltiy.loadContentData(data: jsonString) - if (jsonContent?.pt_product_display_linear != nil){ - if - ((jsonContent?.pt_product_display_linear?.localizedCaseInsensitiveContains("true"))!) { - let contentController: CTProductDisplayLinearViewController = CTProductDisplayLinearViewController() -// contentController.data = jsonString - contentController.jsonContent = jsonContent - return contentController - }else{ - let contentController: CTProductDisplayVerticalViewController = - CTProductDisplayVerticalViewController() -// contentController.data = jsonString - contentController.jsonContent = jsonContent - return contentController - } - }else{ - let contentController: CTProductDisplayVerticalViewController = CTProductDisplayVerticalViewController() -// contentController.data = jsonString + let isLinear = jsonContent?.pt_product_display_linear?.localizedCaseInsensitiveContains("true") ?? false + if isLinear { + CTContentLog.info("Resolved product display layout=linear") + let contentController = CTProductDisplayLinearViewController() contentController.jsonContent = jsonContent return contentController } + CTContentLog.info("Resolved product display layout=vertical, pt_product_display_linear=\(jsonContent?.pt_product_display_linear ?? "nil")") + let contentController = CTProductDisplayVerticalViewController() + contentController.jsonContent = jsonContent + return contentController } } diff --git a/CTNotificationContent/Templates/Utility/Extensions.swift b/CTNotificationContent/Templates/Utility/Extensions.swift index 5e6190d..48ae6a3 100644 --- a/CTNotificationContent/Templates/Utility/Extensions.swift +++ b/CTNotificationContent/Templates/Utility/Extensions.swift @@ -10,8 +10,13 @@ import UIKit extension UILabel { func setHTMLText(_ htmlText: String) { - let modifiedFont = NSString(format: "%@" as NSString, htmlText) as String - guard let data = modifiedFont.data(using: .unicode) else { return } + let pointSize = self.font?.pointSize ?? UIFont.systemFontSize + let modifiedFont = NSString(format: "%@" as NSString, htmlText) as String + guard let data = modifiedFont.data(using: .unicode) else { + CTContentLog.error("HTML to unicode data encode failed, rendering plain text, text=\(htmlText)") + self.text = htmlText + return + } do { let attributedString = try NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, @@ -19,7 +24,7 @@ extension UILabel { documentAttributes: nil) self.attributedText = attributedString } catch { - print("Error setting HTML text: \\(error.localizedDescription)") + CTContentLog.error("HTML parse failed, rendering plain text, text=\(htmlText), error=\(error.localizedDescription)") self.text = htmlText } } diff --git a/CTNotificationContent/Templates/Utility/GlobalConstants.swift b/CTNotificationContent/Templates/Utility/GlobalConstants.swift index ca362aa..ac24ceb 100644 --- a/CTNotificationContent/Templates/Utility/GlobalConstants.swift +++ b/CTNotificationContent/Templates/Utility/GlobalConstants.swift @@ -2,6 +2,12 @@ enum TemplateConstants { static let kTemplateBasic: String = "pt_basic" static let kTemplateAutoCarousel: String = "pt_carousel" static let kTemplateManualCarousel: String = "pt_manual_carousel" + static let kTemplateTimer: String = "pt_timer" + static let kTemplateZeroBezel: String = "pt_zero_bezel" + static let kTemplateWebView: String = "pt_web_view" + static let kTemplateProductDisplay: String = "pt_product_display" + static let kTemplateRating: String = "pt_rating" + static let kTemplateVerticalImage: String = "pt_vertical_img" } enum Constraints { diff --git a/CTNotificationContent/Templates/VerticalImage/Controller/CTVerticalImageController.swift b/CTNotificationContent/Templates/VerticalImage/Controller/CTVerticalImageController.swift index f2d4bd1..a20b028 100644 --- a/CTNotificationContent/Templates/VerticalImage/Controller/CTVerticalImageController.swift +++ b/CTNotificationContent/Templates/VerticalImage/Controller/CTVerticalImageController.swift @@ -154,7 +154,11 @@ import SDWebImage contentView.addSubview(text2Label) contentView.addSubview(ctaButton) - guard let json = jsonContent else { return } + guard let json = jsonContent else { + CTContentLog.error("Nil payload data, view left empty") + return + } + CTContentLog.info("Vertical image controller started") // Resolve title and message from payload or system notification fields if let title = json.pt_title, !title.isEmpty { templateCaption = title } @@ -200,18 +204,27 @@ import SDWebImage // Load image — GIF takes priority over static image if let gif = json.pt_gif, !gif.isEmpty, let url = URL(string: gif) { - bigImageView.sd_setImage(with: url) { [weak self] (image, _, _, _) in + CTContentLog.info("Loading gif, url=\(gif)") + bigImageView.sd_setImage(with: url) { [weak self] (image, error, _, _) in DispatchQueue.main.async { - if image != nil { - self?.bigImageView.accessibilityLabel = json.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription - self?.configureScaleType(json.pt_scale_type) - self?.setFrameWithImage() - } else { - self?.loadStaticImage() + guard let self = self else { + CTContentLog.error("Controller deallocated before gif arrived") + return } + guard image != nil else { + CTContentLog.error("Gif load failed, falling back to still image, url=\(gif), error=\(error?.localizedDescription ?? "nil image, no error")") + self.loadStaticImage() + return + } + self.bigImageView.accessibilityLabel = json.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription + self.configureScaleType(json.pt_scale_type) + self.setFrameWithImage() } } } else { + if let gif = json.pt_gif, !gif.isEmpty { + CTContentLog.error("Gif url parse failed, falling back to still image, url=\(gif)") + } loadStaticImage() } @@ -292,21 +305,27 @@ import SDWebImage func loadStaticImage() { guard let imgUrl = jsonContent?.pt_big_img, !imgUrl.isEmpty else { + CTContentLog.info("Missing pt_big_img, rendering texts only") showFallbackTextView() return } CTUtiltiy.checkImageUrlValid(imageUrl: imgUrl) { [weak self] (imageData) in DispatchQueue.main.async { - guard let self = self else { return } - if let imageData = imageData { - self.bigImageView.image = imageData - self.bigImageView.accessibilityLabel = self.bigImageAltText ?? CTAccessibility.kDefaultImageDescription - self.configureScaleType(self.jsonContent?.pt_scale_type) - self.setFrameWithImage() - } else { + guard let self = self else { + CTContentLog.error("Controller deallocated before image arrived") + return + } + guard let imageData = imageData else { + CTContentLog.error("Image load failed, rendering texts only, url=\(imgUrl)") self.showFallbackTextView() + return } + CTContentLog.info("Image rendered, url=\(imgUrl)") + self.bigImageView.image = imageData + self.bigImageView.accessibilityLabel = self.bigImageAltText ?? CTAccessibility.kDefaultImageDescription + self.configureScaleType(self.jsonContent?.pt_scale_type) + self.setFrameWithImage() } } } @@ -454,12 +473,24 @@ import SDWebImage @objc private func ctaButtonTapped() { if !buttonDeeplink.isEmpty, let url = URL(string: buttonDeeplink) { - getParentViewController().open(url) + CTContentLog.info("CTA button tapped, opening deeplink, url=\(buttonDeeplink)") + getParentViewController()?.open(url) + return + } + if buttonDeeplink.isEmpty { + CTContentLog.info("CTA button has no deeplink, performing notification default action") } else { - if #available(iOS 12.0, *) { - extensionContext?.performNotificationDefaultAction() - } + CTContentLog.error("CTA button deeplink parse failed, performing notification default action, url=\(buttonDeeplink)") + } + guard #available(iOS 12.0, *) else { + CTContentLog.error("Notification default action requires iOS 12 or later, ignoring") + return + } + guard let context = extensionContext else { + CTContentLog.error("Nil extensionContext, cannot perform notification default action") + return } + context.performNotificationDefaultAction() } private func applyGradientIfNeeded() { @@ -534,8 +565,13 @@ import SDWebImage @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction3 { - if !deeplinkURL.isEmpty, let url = URL(string: deeplinkURL) { - getParentViewController().open(url) + if deeplinkURL.isEmpty { + CTContentLog.info("No deeplink, dismissing") + } else if let url = URL(string: deeplinkURL) { + CTContentLog.info("Opening deeplink, url=\(deeplinkURL)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed, url=\(deeplinkURL)") } return .dismiss } diff --git a/CTNotificationContent/Templates/WebView/Controller/CTWebViewController.swift b/CTNotificationContent/Templates/WebView/Controller/CTWebViewController.swift index 5504e26..4d0e4ca 100644 --- a/CTNotificationContent/Templates/WebView/Controller/CTWebViewController.swift +++ b/CTNotificationContent/Templates/WebView/Controller/CTWebViewController.swift @@ -44,17 +44,23 @@ import WebKit if let payloadURL = jsonContent?.pt_url, !payloadURL.isEmpty { webViewUrl = payloadURL } - let url = NSURL(string: webViewUrl) - let request = NSURLRequest(url: url! as URL) + // init and load request in webview. webView = WKWebView(frame: contentView.frame) webView.navigationDelegate = self - webView.load(request as URLRequest) webView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(webView) activateWebViewContraints() - + + if webViewUrl.isEmpty { + CTContentLog.error("Missing pt_url, web view left empty") + } else if let url = URL(string: webViewUrl) { + CTContentLog.info("Loading web view, url=\(webViewUrl)") + webView.load(URLRequest(url: url)) + } else { + CTContentLog.error("Web view url parse failed, view left empty, url=\(webViewUrl)") + } + guard let jsonContent = jsonContent else { return } @@ -86,7 +92,7 @@ import WebKit // Maps to run the relevant deeplink if !deeplinkURL.isEmpty { if let url = URL(string: deeplinkURL) { - getParentViewController().open(url) + getParentViewController()?.open(url) } } return .dismiss @@ -104,10 +110,14 @@ import WebKit extension CTWebViewController: WKNavigationDelegate { public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - print(error.localizedDescription) + CTContentLog.error("Provisional navigation failed, url=\(webViewUrl), error=\(error.localizedDescription)") } - + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - print(error.localizedDescription) + CTContentLog.error("Navigation failed, url=\(webViewUrl), error=\(error.localizedDescription)") + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + CTContentLog.info("Web view finished loading, url=\(webViewUrl)") } } diff --git a/CTNotificationContent/Templates/ZeroBezel/Controller/CTZeroBezelController.swift b/CTNotificationContent/Templates/ZeroBezel/Controller/CTZeroBezelController.swift index 90a7200..3d57375 100644 --- a/CTNotificationContent/Templates/ZeroBezel/Controller/CTZeroBezelController.swift +++ b/CTNotificationContent/Templates/ZeroBezel/Controller/CTZeroBezelController.swift @@ -75,6 +75,7 @@ import SDWebImage contentView = UIView(frame: view.frame) view.addSubview(contentView) + CTContentLog.info("Zero bezel controller started") jsonContent = CTUtiltiy.loadContentData(data: data) createView() setupConstraints() @@ -119,9 +120,10 @@ import SDWebImage contentView.addSubview(titleLabel) guard let jsonContent = jsonContent else { + CTContentLog.error("Nil payload data, rendering caption only") return } - + if let title = jsonContent.pt_title, !title.isEmpty{ templateCaption = title } @@ -145,13 +147,20 @@ import SDWebImage self.subTitleLabel.setHTMLText(templateSubcaption) if let gif = jsonContent.pt_gif, !gif.isEmpty, let url = URL(string: gif) { - self.bigImageView.sd_setImage(with: url, completed: { [weak self] (image, _, _, _) in + CTContentLog.info("Loading gif, url=\(gif)") + self.bigImageView.sd_setImage(with: url, completed: { [weak self] (image, error, _, _) in + guard let self = self else { + CTContentLog.error("Controller deallocated before gif arrived") + return + } if image != nil { - self?.bigImageView.accessibilityLabel = jsonContent.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription - self?.activateImageViewContraints() - self?.createFrameWithImage() + CTContentLog.info("Gif rendered, url=\(gif)") + self.bigImageView.accessibilityLabel = jsonContent.pt_big_img_alt_text ?? CTAccessibility.kDefaultImageDescription + self.activateImageViewContraints() + self.createFrameWithImage() } else { - self?.showImageView() + CTContentLog.error("Gif load failed, falling back to still image, url=\(gif), error=\(error?.localizedDescription ?? "nil image, no error")") + self.showImageView() } }) } else { @@ -212,10 +221,13 @@ import SDWebImage @objc public override func handleAction(_ action: String) -> UNNotificationContentExtensionResponseOption { if action == ConstantKeys.kAction3 { // Maps to run the relevant deeplink - if !deeplinkURL.isEmpty { - if let url = URL(string: deeplinkURL) { - getParentViewController().open(url) - } + if deeplinkURL.isEmpty { + CTContentLog.info("No deeplink, dismissing") + } else if let url = URL(string: deeplinkURL) { + CTContentLog.info("Opening deeplink, url=\(deeplinkURL)") + getParentViewController()?.open(url) + } else { + CTContentLog.error("Deeplink parse failed, url=\(deeplinkURL)") } return .dismiss } @@ -253,16 +265,25 @@ import SDWebImage } func showImageView() { - if templateBigImage != "" { - CTUtiltiy.checkImageUrlValid(imageUrl: templateBigImage) { [weak self] (imageData) in - DispatchQueue.main.async { - if imageData != nil { - self?.bigImageView.image = imageData - self?.bigImageView.accessibilityLabel = self?.bigImageAltText ?? CTAccessibility.kDefaultImageDescription - self?.activateImageViewContraints() - self?.createFrameWithImage() - } + guard templateBigImage != "" else { + CTContentLog.error("Missing pt_big_img, rendering caption only") + return + } + CTUtiltiy.checkImageUrlValid(imageUrl: templateBigImage) { [weak self] (imageData) in + DispatchQueue.main.async { + guard let self = self else { + CTContentLog.error("Controller deallocated before image arrived") + return + } + guard imageData != nil else { + CTContentLog.error("Image load failed, rendering caption only, url=\(self.templateBigImage)") + return } + CTContentLog.info("Image rendered, url=\(self.templateBigImage)") + self.bigImageView.image = imageData + self.bigImageView.accessibilityLabel = self.bigImageAltText ?? CTAccessibility.kDefaultImageDescription + self.activateImageViewContraints() + self.createFrameWithImage() } } } diff --git a/CTNotificationContent/Views/CTCaptionedImageView.swift b/CTNotificationContent/Views/CTCaptionedImageView.swift index c8ab970..e998827 100644 --- a/CTNotificationContent/Views/CTCaptionedImageView.swift +++ b/CTNotificationContent/Views/CTCaptionedImageView.swift @@ -68,22 +68,31 @@ class CTCaptionedImageView : UIView { addSubview(subcaptionLabel) if isGifSupported { - if let url = URL(string: components.imageUrl) { - self.imageView.sd_setImage(with: url, completed: { [weak self] (image, _, _, _) in - if image != nil { - self?.imageView.accessibilityLabel = self?.components.imageDescription - self?.activateImageViewContraints() - } - }) + guard let url = URL(string: components.imageUrl) else { + CTContentLog.error("Gif url parse failed, rendering caption only, url=\(components.imageUrl)") + return } + self.imageView.sd_setImage(with: url, completed: { [weak self] (image, error, _, _) in + guard let self = self else { return } + guard image != nil else { + CTContentLog.error("Gif load failed, rendering caption only, url=\(self.components.imageUrl), error=\(error?.localizedDescription ?? "nil image, no error")") + return + } + CTContentLog.info("Gif loaded, url=\(self.components.imageUrl)") + self.imageView.accessibilityLabel = self.components.imageDescription + self.activateImageViewContraints() + }) } else { CTUtiltiy.checkImageUrlValid(imageUrl: components.imageUrl) { [weak self] (imageData) in DispatchQueue.main.async { - if imageData != nil { - self?.imageView.image = imageData - self?.imageView.accessibilityLabel = self?.components.imageDescription - self?.activateImageViewContraints() + guard let self = self else { return } + guard let imageData = imageData else { + CTContentLog.error("Nil image data, rendering caption only, url=\(self.components.imageUrl)") + return } + self.imageView.image = imageData + self.imageView.accessibilityLabel = self.components.imageDescription + self.activateImageViewContraints() } } } @@ -155,6 +164,11 @@ extension UIColor { // Must be 6 or 8 characters guard hexString.count == 6 || hexString.count == 8 else { + if !hex.isEmpty { + // An empty value is normal. It means the payload left the colour + // out, and the view keeps its own default. + CTContentLog.error("Invalid hex color, expected 6 or 8 digits, using default, value=\(hex)") + } return nil } @@ -178,6 +192,7 @@ extension UIColor { return } + CTContentLog.error("Hex color parse failed, using default, value=\(hex)") return nil } } diff --git a/README.md b/README.md index 502a8d5..db8ae12 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Starting with iOS 10 you can add custom content views to iOS push notifications. - [Template Types](#template-types) - [Template Keys](#template-keys) - [Sample App](#sample-app) +- [Debug Logs](#debug-logs) - [Changelog](#changelog) # 🎉 Installation # @@ -469,6 +470,44 @@ pt_json | Optional | Above keys in JSON format - See [an example SwiftPM project here](./ExampleSwiftPM). +# Debug Logs # + +[(Back to top)](#table-of-contents) + +The SDK writes logs to the unified logging system. Logging is always on. There is nothing to enable in code and nothing to configure in the payload. The logs are present in release builds too. + +A Notification Content Extension runs in its own process, so `print` output does not reach the Xcode console unless you attach to that process. The commands below do not need Xcode. + +Connect the device to a Mac and run: + +```sh +log stream --predicate 'subsystem == "com.clevertap.sdk"' +``` + +To read logs the device already recorded, instead of watching new ones: + +```sh +log show --predicate 'subsystem == "com.clevertap.sdk"' --last 30m +``` + +The subsystem is shared with the CleverTap iOS SDK, so both commands show logs from both SDKs. To see only this SDK, add the category: + +```sh +log stream --predicate 'subsystem == "com.clevertap.sdk" AND category == "CTNotificationContent"' +``` + +Every line looks like this: + +``` +[CleverTap][NotificationContent] CTCarouselController.showNext(): Paging to index=2/4 +``` + +`[CleverTap]` is the prefix the CleverTap iOS SDK also uses. `[NotificationContent]` marks the lines that come from this SDK. The part before the colon is the type and the function the line came from. + +You can also use Console.app instead of the commands above. Select the device in the sidebar, then put `subsystem:com.clevertap.sdk` in the search field. Choose **Action > All Messages**. The **Errors and Faults** view hides these logs. + +When you report a problem to CleverTap, please attach the output of the `log show` command above. + # Changelog # [(Back to top)](#table-of-contents) From 75f1ff31d5281a669d9a13efbd03e10eb58515f5 Mon Sep 17 00:00:00 2001 From: Akash Malhotra Date: Fri, 11 Sep 2026 11:59:29 +0530 Subject: [PATCH 2/2] added debug level and static method to enable debug level logs --- .../CTNotificationViewController.m | 6 +++ .../CTTimerTemplateController.swift | 3 ++ .../Utility/CTNotificationContentLogger.swift | 42 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CTNotificationContent/CTNotificationViewController.m b/CTNotificationContent/CTNotificationViewController.m index c8ccff6..fa27b57 100644 --- a/CTNotificationContent/CTNotificationViewController.m +++ b/CTNotificationContent/CTNotificationViewController.m @@ -15,6 +15,8 @@ [CTNotificationContentLogger logInfo:[NSString stringWithFormat:fmt, ##__VA_ARGS__] from:@(__PRETTY_FUNCTION__)] #define CTContentLogError(fmt, ...) \ [CTNotificationContentLogger logError:[NSString stringWithFormat:fmt, ##__VA_ARGS__] from:@(__PRETTY_FUNCTION__)] +#define CTContentLogDebug(fmt, ...) \ + [CTNotificationContentLogger logDebug:[NSString stringWithFormat:fmt, ##__VA_ARGS__] from:@(__PRETTY_FUNCTION__)] /// Name of a response option, for the logs. The raw values are 0, 1 and 2. /// A name tells the reader what the extension asked the system to do. @@ -316,6 +318,10 @@ - (void)updateContentType:(NSDictionary *)content { } CTContentLogInfo(@"Resolved template from pt_id=%@", templateId); [CTUtiltiy logPayloadCheckForTemplate:templateId jsonString:self.jsonString ?: @""]; + + // The whole payload. This is the one thing that explains most reports, but + // it is long and it can hold customer data. So it needs the debug level. + CTContentLogDebug(@"Payload json=%@", self.jsonString ?: @""); } - (NSString *)createJSONData:(NSDictionary *)content { diff --git a/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift b/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift index a7eed33..f0f77de 100644 --- a/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift +++ b/CTNotificationContent/Templates/Timer/Controller/CTTimerTemplateController.swift @@ -303,6 +303,9 @@ import SDWebImage let hr = thresholdSeconds / 3600 let min = thresholdSeconds / 60 % 60 let sec = thresholdSeconds % 60 + // One line per second. That is too much for the normal logs. It shows + // whether the countdown really runs, and at what speed. + CTContentLog.debug("Tick, secondsLeft=\(thresholdSeconds)") if thresholdSeconds > 0 { if hr < 1 { setTimerText(String(format: "%02i:%02i", min, sec)) diff --git a/CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift b/CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift index 93e2af7..8bd5d44 100644 --- a/CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift +++ b/CTNotificationContent/Templates/Utility/CTNotificationContentLogger.swift @@ -26,6 +26,32 @@ public final class CTContentLog: NSObject { private static let log = OSLog(subsystem: subsystem, category: category) + /// Controls the extra lines from `debug`. Zero keeps them off. + /// + /// `info` and `error` never look at this value. Those two are always on. + /// The whole point of this SDK's logs is a client who already hit a bug + /// and cannot be asked to turn anything on and try again. + /// + /// This matches `CTLogger` in the CleverTap iOS SDK. There an info line + /// passes on any level, and a debug line needs a level above zero. + private static var debugLevel: Int32 = 0 + + /// Turns the extra `debug` lines on. Pass 1 or more. + /// + /// Call this inside the notification content extension, not in the + /// `AppDelegate` of the app. The extension is a separate process with its + /// own copy of this value. A call in the app cannot reach it. The right + /// place is your `CTNotificationViewController` subclass, before + /// `super.viewDidLoad()`. + @objc public static func setDebugLevel(_ level: Int32) { + debugLevel = level + emit("Debug level set to \(level)", location: "CTContentLog.setDebugLevel") + } + + @objc public static func getDebugLevel() -> Int32 { + return debugLevel + } + /// Every line goes out at `.default`, including the ones from `error`. /// /// The system keeps `.info` and `.debug` messages in memory only. They are @@ -42,6 +68,16 @@ public final class CTContentLog: NSObject { emit(message, location: callSite(file, function)) } + /// Detail that is too noisy to write on every push. + /// + /// Nothing is written unless `setDebugLevel` was called with 1 or more. + /// Use this for whole payloads, for layout numbers, and for anything that + /// repeats many times in one render. + static func debug(_ message: String, file: String = #fileID, function: String = #function) { + guard debugLevel > 0 else { return } + emit(message, location: callSite(file, function)) + } + private static func emit(_ message: String, location: String) { // The unified log hides %@ arguments by default. %{public}@ keeps them readable. os_log("%{public}@ %{public}@: %{public}@", log: log, type: .default, prefix, location, message) @@ -66,4 +102,10 @@ public final class CTContentLog: NSObject { public static func logError(_ message: String, from location: String) { emit(message, location: location) } + + @objc(logDebug:from:) + public static func logDebug(_ message: String, from location: String) { + guard debugLevel > 0 else { return } + emit(message, location: location) + } }