Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 26.1.3
* Added a content configuration option to reload the content web view when its load stalls, enabled via "enableContentReloadOnStall" on "CountlyContentConfig", with a configurable stall timeout "setContentReloadOnStallTimeout:" in milliseconds (default 1000).
* Added a content configuration option to disable pinch zoom, disabled via "disableZoom".
* Added a content configuration option to provide a handler for links opened from the content web view, so the app can route its own deep links instead of the SDK opening the system browser, set via "setContentURLHandler:".
* Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly.

## 26.1.2
* ! Minor breaking change ! Raised the minimum supported deployment target to iOS 12 and tvOS 12 (previously iOS 10 and tvOS 10) for compatibility with Xcode 26 and the iOS 26 SDK. Apps targeting iOS 10/11 or tvOS 10/11 are no longer supported.

Expand Down
2 changes: 1 addition & 1 deletion Countly-PL.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'Countly-PL'
s.version = '26.1.2'
s.version = '26.1.3'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.'
s.homepage = 'https://github.com/Countly/countly-sdk-ios'
Expand Down
4 changes: 4 additions & 0 deletions Countly.m
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ - (void)startWithConfig:(CountlyConfig *)config
if(config.content.getWebViewDisplayOption){
CountlyContentBuilderInternal.sharedInstance.webViewDisplayOption = config.content.getWebViewDisplayOption;
}
CountlyContentBuilderInternal.sharedInstance.enableContentReloadOnStall = config.content.getEnableContentReloadOnStall;
CountlyContentBuilderInternal.sharedInstance.contentReloadOnStallTimeout = config.content.getContentReloadOnStallTimeout / 1000.0;
CountlyContentBuilderInternal.sharedInstance.disableZoom = config.content.getDisableZoom;
CountlyContentBuilderInternal.sharedInstance.contentURLHandler = config.content.getContentURLHandler;
#endif

[CountlyPerformanceMonitoring.sharedInstance startWithConfig:config.apm];
Expand Down
2 changes: 1 addition & 1 deletion Countly.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'Countly'
s.version = '26.1.2'
s.version = '26.1.3'
s.license = { :type => 'MIT', :file => 'LICENSE' }
s.summary = 'Countly is an innovative, real-time, open source mobile analytics platform.'
s.homepage = 'https://github.com/Countly/countly-sdk-ios'
Expand Down
4 changes: 2 additions & 2 deletions Countly.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -838,7 +838,7 @@
"@loader_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 26.1.2;
MARKETING_VERSION = 26.1.3;
PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -870,7 +870,7 @@
"@loader_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 10.14;
MARKETING_VERSION = 26.1.2;
MARKETING_VERSION = 26.1.3;
PRODUCT_BUNDLE_IDENTIFIER = ly.count.CountlyiOSSDK;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
2 changes: 1 addition & 1 deletion CountlyCommon.m
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ @interface CountlyCommon ()
#endif
@end

NSString* const kCountlySDKVersion = @"26.1.2";
NSString* const kCountlySDKVersion = @"26.1.3";
NSString* const kCountlySDKName = @"objc-native-ios";

NSString* const kCountlyErrorDomain = @"ly.count.ErrorDomain";
Expand Down
4 changes: 4 additions & 0 deletions CountlyContentBuilderInternal.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, assign) NSTimeInterval zoneTimerInterval;
@property (nonatomic) ContentCallback contentCallback;
@property (nonatomic, assign) WebViewDisplayOption webViewDisplayOption;
@property (nonatomic, assign) BOOL enableContentReloadOnStall;
@property (nonatomic, assign) NSTimeInterval contentReloadOnStallTimeout; // seconds
@property (nonatomic, assign) BOOL disableZoom;
@property (nonatomic, copy, nullable) ContentURLHandler contentURLHandler;
@property (nonatomic, assign) int contentInitialDelay;

+ (instancetype)sharedInstance;
Expand Down
121 changes: 98 additions & 23 deletions CountlyContentBuilderInternal.m
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
@implementation CountlyContentBuilderInternal {
BOOL _isRequestQueueLocked;
BOOL _isCurrentlyContentShown;
BOOL _refreshRunnablePending;
NSTimer *_requestTimer;
NSTimer *_minuteTimer;
dispatch_queue_t _contentQueue;
Expand Down Expand Up @@ -42,39 +43,87 @@ - (instancetype)init
return self;
}

- (BOOL)isRequestQueueLockedThreadSafe {
__block BOOL locked = NO;
#pragma mark - Thread-safe flag helpers

// Every BOOL flag below (_isRequestQueueLocked, _isCurrentlyContentShown,
// _refreshRunnablePending) is accessed only through these helpers so all reads/writes are
// serialized on the single serial _contentQueue. The blocks capture the raw ivar pointer,
// which is safe because this class is a never-released singleton (its ivars outlive any
// queued block).
- (BOOL)readFlag:(BOOL *)flag {
if (!_contentQueue) {
return _isRequestQueueLocked;
return *flag;
}
__block BOOL value = NO;
dispatch_sync(_contentQueue, ^{
locked = self->_isRequestQueueLocked;
value = *flag;
});
return locked;
return value;
}

- (void)setRequestQueueLockedThreadSafe:(BOOL)locked {
- (void)writeFlag:(BOOL *)flag value:(BOOL)value {
if (!_contentQueue) {
_isRequestQueueLocked = locked;
*flag = value;
return;
}
dispatch_async(_contentQueue, ^{
self->_isRequestQueueLocked = locked;
*flag = value;
});
}

// Atomically set *flag to YES if it was NO. Returns YES only for the caller that made the
// NO->YES transition, so exactly one caller "wins" a contended flag.
- (BOOL)testAndSetFlag:(BOOL *)flag {
__block BOOL acquired = NO;
if (!_contentQueue) {
if (!*flag) { *flag = YES; acquired = YES; }
return acquired;
}
dispatch_sync(_contentQueue, ^{
if (!*flag) { *flag = YES; acquired = YES; }
});
return acquired;
}

- (BOOL)isRequestQueueLockedThreadSafe {
return [self readFlag:&_isRequestQueueLocked];
}

- (void)setRequestQueueLockedThreadSafe:(BOOL)locked {
[self writeFlag:&_isRequestQueueLocked value:locked];
}

// Atomically claim the single "content is shown" slot. Returns YES if the caller acquired it,
// NO if content is already shown or a racing fetch already claimed it, in which case the caller
// MUST NOT present another web view. Previously the flag was set only when the web view was
// presented (after a network round trip), so two near-simultaneous fetches could both pass the
// guard and present two overlapping web views; this test-and-set closes that window.
- (BOOL)tryBeginContentPresentation {
return [self testAndSetFlag:&_isCurrentlyContentShown];
}

// Release the content slot (called when the shown web view is dismissed, or on reset) so the
// next zone cycle can present again.
- (void)endContentPresentation {
[self writeFlag:&_isCurrentlyContentShown value:NO];
}

- (BOOL)isContentShownThreadSafe {
return [self readFlag:&_isCurrentlyContentShown];
}

- (void)enterContentZone {
if(_isCurrentlyContentShown){

if([self isContentShownThreadSafe]){
CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__);
return;
}

[self enterContentZone:@[]];
}

- (void)enterContentZone:(NSArray<NSString *> *)tags {
if(_isCurrentlyContentShown){
if([self isContentShownThreadSafe]){
CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__);
return;
}
Expand Down Expand Up @@ -128,15 +177,30 @@ - (void)refreshContentZone {
{
return;
}
if(_isCurrentlyContentShown){
if([self isContentShownThreadSafe]){
CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__);
return;
}


// Coalesce refreshes: only one refresh runnable may be pending at a time. Without this,
// multiple refreshContentZone calls before the request queue flushes each append a runnable
// (addQueueFlushRunnable does not dedup); they then all fire together and trigger N
// concurrent content fetches (a burst against the edge) whose extra results are discarded.
if (![self testAndSetFlag:&_refreshRunnablePending]) {
CLY_LOG_I(@"%s a content refresh is already pending, skipping duplicate" ,__FUNCTION__);
return;
}

__weak typeof(self) weakSelf = self;
[CountlyConnectionManager.sharedInstance addQueueFlushRunnable:^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return;
// Clear the pending flag first so a refresh requested during/after this flush can
// schedule the next one.
[strongSelf writeFlag:&strongSelf->_refreshRunnablePending value:NO];
CLY_LOG_I(@"%s queue flueshed, will re-fetch contents" ,__FUNCTION__);
[self exitContentZone];
[self enterContentZone];
[strongSelf exitContentZone];
[strongSelf enterContentZone];
}];
[CountlyConnectionManager.sharedInstance attemptToSendStoredRequests];
}
Expand All @@ -147,7 +211,7 @@ - (void)refreshContentZoneJTE {
CLY_LOG_D(@"%s, refresh content zone is disabled, skipping JTE content refresh", __FUNCTION__);
return;
}
if(_isCurrentlyContentShown){
if([self isContentShownThreadSafe]){
CLY_LOG_I(@"%s a content is already shown, skipping JTE content refresh" ,__FUNCTION__);
return;
}
Expand Down Expand Up @@ -190,7 +254,10 @@ - (void)clearContentState {
- (void)resetInstance {
CLY_LOG_I(@"%s", __FUNCTION__);
[self clearContentState];
_isCurrentlyContentShown = NO;
// Clear the shown flag through the serial content queue (not a raw write) so it cannot
// race a concurrent tryBeginContentPresentation / read on the network-completion thread.
[self endContentPresentation];
[self writeFlag:&_refreshRunnablePending value:NO];
}

- (void)fetchContents {
Expand All @@ -210,11 +277,11 @@ - (void)fetchContents:(void (^)(void))failureCallback contentId:(NSString *)cont
return;
}

if(_isCurrentlyContentShown){
if([self isContentShownThreadSafe]){
CLY_LOG_I(@"%s a content is already shown, skipping" ,__FUNCTION__);
return;
}

if ([self isRequestQueueLockedThreadSafe]) {
return;
}
Expand Down Expand Up @@ -327,7 +394,14 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi
return;
}


// Claim the single content slot before dispatching to main. If content is already shown,
// or a racing content fetch already claimed it, skip: never present a second overlapping
// web view for the same zone.
if (![self tryBeginContentPresentation]) {
CLY_LOG_I(@"%s a content is already shown, skipping duplicate presentation", __FUNCTION__);
return;
}

dispatch_async(dispatch_get_main_queue(), ^ {
// Detect screen orientation
UIInterfaceOrientation orientation = [UIApplication sharedApplication].statusBarOrientation;
Expand All @@ -352,7 +426,7 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi
} dismissBlock:^
{
CLY_LOG_I(@"%s webview dismissed", __FUNCTION__);
self->_isCurrentlyContentShown = NO;
[self endContentPresentation];
self->_minuteTimer = [NSTimer scheduledTimerWithTimeInterval:self->_zoneTimerInterval
target:self
selector:@selector(enterContentZone)
Expand All @@ -363,7 +437,8 @@ - (void)showContentWithHtmlPath:(NSString *)urlString placementCoordinates:(NSDi
}
}];
CLY_LOG_I(@"%s webview initiated pausing content calls ", __FUNCTION__);
self->_isCurrentlyContentShown = YES;
// The shown slot was already claimed synchronously by tryBeginContentPresentation
// above, before this async block was dispatched.
[self clearContentState];
});
}
Expand Down
47 changes: 47 additions & 0 deletions CountlyContentConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ typedef enum: NSUInteger
} WebViewDisplayOption;

typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary<NSString *, id>* contentData);

// Handler the host app can provide to take over opening a link tapped in the content web view
// (e.g. to route the app's own deep link to the right screen). Return YES if the app handled
// the URL; return NO to let the SDK open it in the system browser as usual. Called on the main
// thread.
typedef BOOL (^ContentURLHandler)(NSURL *url);
#endif

@interface CountlyContentConfig : NSObject
Expand Down Expand Up @@ -59,6 +65,47 @@ typedef void (^ContentCallback)(ContentStatus contentStatus, NSDictionary<NSStri
- (void) setWebviewDisplayOption:(WebViewDisplayOption) webViewDisplayOption;

- (WebViewDisplayOption)getWebViewDisplayOption;

/**
* This is an experimental feature and it can have breaking changes.
* Enables reloading the content web view when a load stalls (does not appear within ~1
* second) instead of closing it. A reload reuses the already-warm connection and cached
* assets, which recovers loads that fail because a server/edge drops the initial burst of
* parallel resource connections. This is a one-way switch: once enabled it stays enabled.
* Disabled by default.
*/
- (void)enableContentReloadOnStall;
- (BOOL)getEnableContentReloadOnStall;

/**
* This is an experimental feature and it can have breaking changes.
* Sets how long a content load may stall (not appear) before the SDK reloads it, in
* milliseconds. Only used when reload-on-stall is enabled (see enableContentReloadOnStall).
* Default is 1000 (1 second). Can be changed at any time before starting the SDK.
*/
- (void)setContentReloadOnStallTimeout:(NSUInteger)milliseconds;
- (NSUInteger)getContentReloadOnStallTimeout;

/**
* This is an experimental feature and it can have breaking changes.
* Disables user zoom (pinch and double-tap) in the content web view. The page's own
* viewport width and initial scale are preserved; only zooming is turned off. This is a
* one-way switch: once enabled it stays enabled. Disabled by default.
*/
- (void)disableZoom;
- (BOOL)getDisableZoom;

/**
* This is an experimental feature and it can have breaking changes.
* Sets a handler that is called when a link is opened from the content web view (an external
* link or the content "link" action), letting the host app take over instead of the SDK
* opening the system browser. This is how an app routes its own deep links (custom scheme or
* https) to the correct screen. The handler receives the URL and returns YES if it handled it;
* returning NO (or not setting a handler) makes the SDK open the URL in the system browser as
* before. Called on the main thread.
*/
- (void)setContentURLHandler:(nullable ContentURLHandler)handler;
- (nullable ContentURLHandler)getContentURLHandler;
#endif

NS_ASSUME_NONNULL_END
Expand Down
Loading
Loading