Skip to content

Commit 83ffc75

Browse files
fadi-georgeclaude
andcommitted
refactor(iam): extract horizontal inset math and unit test it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 017a6ab commit 83ffc75

6 files changed

Lines changed: 235 additions & 21 deletions

File tree

‎iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,9 @@
633633
DEBAAE552A42174A00BF2C1C /* OSInAppMessageView.h in Headers */ = {isa = PBXBuildFile; fileRef = DEBAAE512A42174A00BF2C1C /* OSInAppMessageView.h */; };
634634
DEBAAE562A42174A00BF2C1C /* OSInAppMessageViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = DEBAAE522A42174A00BF2C1C /* OSInAppMessageViewController.h */; };
635635
DEBAAE572A42174A00BF2C1C /* OSInAppMessageView.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBAAE532A42174A00BF2C1C /* OSInAppMessageView.m */; };
636+
5C1A7E0D2FB0A00100AB0004 /* OSInAppMessageInsets.h in Headers */ = {isa = PBXBuildFile; fileRef = 5C1A7E0D2FB0A00100AB0001 /* OSInAppMessageInsets.h */; };
637+
5C1A7E0D2FB0A00100AB0005 /* OSInAppMessageInsets.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A7E0D2FB0A00100AB0002 /* OSInAppMessageInsets.m */; };
638+
5C1A7E0D2FB0A00100AB0006 /* OSInAppMessageInsetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A7E0D2FB0A00100AB0003 /* OSInAppMessageInsetsTests.swift */; };
636639
DEBAAE602A42175A00BF2C1C /* OSDynamicTriggerController.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBAAE582A42175900BF2C1C /* OSDynamicTriggerController.m */; };
637640
DEBAAE612A42175A00BF2C1C /* OSMessagingController.h in Headers */ = {isa = PBXBuildFile; fileRef = DEBAAE592A42175900BF2C1C /* OSMessagingController.h */; };
638641
DEBAAE622A42175A00BF2C1C /* OSInAppMessageController.h in Headers */ = {isa = PBXBuildFile; fileRef = DEBAAE5A2A42175900BF2C1C /* OSInAppMessageController.h */; };
@@ -1858,6 +1861,9 @@
18581861
DEBAAE512A42174A00BF2C1C /* OSInAppMessageView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSInAppMessageView.h; sourceTree = "<group>"; };
18591862
DEBAAE522A42174A00BF2C1C /* OSInAppMessageViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSInAppMessageViewController.h; sourceTree = "<group>"; };
18601863
DEBAAE532A42174A00BF2C1C /* OSInAppMessageView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSInAppMessageView.m; sourceTree = "<group>"; };
1864+
5C1A7E0D2FB0A00100AB0001 /* OSInAppMessageInsets.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInAppMessageInsets.h; sourceTree = "<group>"; };
1865+
5C1A7E0D2FB0A00100AB0002 /* OSInAppMessageInsets.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSInAppMessageInsets.m; sourceTree = "<group>"; };
1866+
5C1A7E0D2FB0A00100AB0003 /* OSInAppMessageInsetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInAppMessageInsetsTests.swift; sourceTree = "<group>"; };
18611867
DEBAAE582A42175900BF2C1C /* OSDynamicTriggerController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OSDynamicTriggerController.m; sourceTree = "<group>"; };
18621868
DEBAAE592A42175900BF2C1C /* OSMessagingController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSMessagingController.h; sourceTree = "<group>"; };
18631869
DEBAAE5A2A42175900BF2C1C /* OSInAppMessageController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OSInAppMessageController.h; sourceTree = "<group>"; };
@@ -2384,6 +2390,7 @@
23842390
3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */,
23852391
3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */,
23862392
67ECA2928D863073B785F93F /* IamFetchIdentityVerificationTests.swift */,
2393+
5C1A7E0D2FB0A00100AB0003 /* OSInAppMessageInsetsTests.swift */,
23872394
);
23882395
path = OneSignalInAppMessagesTests;
23892396
sourceTree = "<group>";
@@ -3358,6 +3365,8 @@
33583365
DEBAAE532A42174A00BF2C1C /* OSInAppMessageView.m */,
33593366
DEBAAE522A42174A00BF2C1C /* OSInAppMessageViewController.h */,
33603367
DEBAAE502A42174A00BF2C1C /* OSInAppMessageViewController.m */,
3368+
5C1A7E0D2FB0A00100AB0001 /* OSInAppMessageInsets.h */,
3369+
5C1A7E0D2FB0A00100AB0002 /* OSInAppMessageInsets.m */,
33613370
);
33623371
path = UI;
33633372
sourceTree = "<group>";
@@ -3689,6 +3698,7 @@
36893698
DEBAAE662A42175A00BF2C1C /* OSDynamicTriggerController.h in Headers */,
36903699
DEBAAE642A42175A00BF2C1C /* OSTriggerController.h in Headers */,
36913700
DEBAAE562A42174A00BF2C1C /* OSInAppMessageViewController.h in Headers */,
3701+
5C1A7E0D2FB0A00100AB0004 /* OSInAppMessageInsets.h in Headers */,
36923702
DEBAAEB82A4381AE00BF2C1C /* OSInAppMessageMigrationController.h in Headers */,
36933703
DEBAAE7E2A42176800BF2C1C /* OSInAppMessageDisplayStats.h in Headers */,
36943704
DEBAAE882A42176800BF2C1C /* OSInAppMessageClickEvent.h in Headers */,
@@ -4654,6 +4664,7 @@
46544664
3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */,
46554665
3CB35FCB2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift in Sources */,
46564666
6F894909CB6D09258DCCA1C9 /* IamFetchIdentityVerificationTests.swift in Sources */,
4667+
5C1A7E0D2FB0A00100AB0006 /* OSInAppMessageInsetsTests.swift in Sources */,
46574668
);
46584669
runOnlyForDeploymentPostprocessing = 0;
46594670
};
@@ -5142,6 +5153,7 @@
51425153
DEBAAE8C2A42176800BF2C1C /* OSInAppMessagePushPrompt.m in Sources */,
51435154
DEBAAE8E2A42176800BF2C1C /* OSInAppMessageClickResult.m in Sources */,
51445155
DEBAAE572A42174A00BF2C1C /* OSInAppMessageView.m in Sources */,
5156+
5C1A7E0D2FB0A00100AB0005 /* OSInAppMessageInsets.m in Sources */,
51455157
);
51465158
runOnlyForDeploymentPostprocessing = 0;
51475159
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Modified MIT License
3+
*
4+
* Copyright 2026 OneSignal
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* 1. The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* 2. All copies of substantial portions of the Software may only be used in connection
17+
* with services provided by OneSignal.
18+
*
19+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
* THE SOFTWARE.
26+
*/
27+
#import <UIKit/UIKit.h>
28+
29+
NS_ASSUME_NONNULL_BEGIN
30+
31+
@interface OSInAppMessageInsets : NSObject
32+
33+
// Each side is the larger of the margin and its safe area inset. Region frames are in the scene
34+
// view's coordinates; one sitting inside a side safe area, not flush with the edge, adds clearance.
35+
// Full screen messages ignore the safe area and regions.
36+
+ (UIEdgeInsets)horizontalInsetsWithMargin:(CGFloat)margin
37+
safeArea:(UIEdgeInsets)safeArea
38+
width:(CGFloat)width
39+
regionFrames:(NSArray<NSValue *> *)regionFrames
40+
isFullscreen:(BOOL)isFullscreen;
41+
42+
@end
43+
44+
NS_ASSUME_NONNULL_END
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Modified MIT License
3+
*
4+
* Copyright 2026 OneSignal
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* 1. The above copyright notice and this permission notice shall be included in
14+
* all copies or substantial portions of the Software.
15+
*
16+
* 2. All copies of substantial portions of the Software may only be used in connection
17+
* with services provided by OneSignal.
18+
*
19+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
* THE SOFTWARE.
26+
*/
27+
#import "OSInAppMessageInsets.h"
28+
29+
@implementation OSInAppMessageInsets
30+
31+
+ (UIEdgeInsets)horizontalInsetsWithMargin:(CGFloat)margin
32+
safeArea:(UIEdgeInsets)safeArea
33+
width:(CGFloat)width
34+
regionFrames:(NSArray<NSValue *> *)regionFrames
35+
isFullscreen:(BOOL)isFullscreen {
36+
if (isFullscreen) {
37+
return UIEdgeInsetsMake(0, margin, 0, margin);
38+
}
39+
CGFloat left = MAX(margin, safeArea.left);
40+
CGFloat right = MAX(margin, safeArea.right);
41+
// Region frames already include UIKit's interactive margins; the message margin is added on top.
42+
for (NSValue *value in regionFrames) {
43+
CGRect frame = value.CGRectValue;
44+
CGFloat minX = CGRectGetMinX(frame);
45+
CGFloat maxX = CGRectGetMaxX(frame);
46+
if (minX >= 1 && maxX <= safeArea.left + 1) {
47+
left = MAX(left, maxX + margin);
48+
} else if (maxX <= width - 1 && minX >= width - safeArea.right - 1) {
49+
right = MAX(right, width - minX + margin);
50+
}
51+
}
52+
return UIEdgeInsetsMake(0, left, 0, right);
53+
}
54+
55+
@end

‎iOS_SDK/OneSignalSDK/OneSignalInAppMessages/UI/OSInAppMessageViewController.m‎

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
#import "OSInAppMessageViewController.h"
2929
#import "OSInAppMessageView.h"
30+
#import "OSInAppMessageInsets.h"
3031
#import "OSInAppMessageController.h"
3132
#import "OSInAppMessageBridgeEvent.h"
3233
#import <OneSignalOutcomes/OneSignalOutcomes.h>
@@ -418,34 +419,26 @@ - (UIView *)sceneView {
418419
return self.view;
419420
}
420421

421-
// Each side uses the larger of the margin and its safe area inset, and on iOS 27.1+ keeps the margin
422-
// from hardware inside a side safe area (e.g. a foldable's camera), matching system notification banners.
423-
// Regions flush with the edge, like a status bar column, are already covered by the safe area.
424-
// Region frames already include UIKit's interactive margins; the message margin is added on top.
422+
// On iOS 27.1+ the message also keeps clear of hardware inside a side safe area (e.g. a foldable's camera),
423+
// matching system notification banners.
425424
- (UIEdgeInsets)horizontalInsets {
426425
UIView *sceneView = [self sceneView];
427426
CGFloat margin = self.useWidthMargin ? [OneSignalCoreHelper sizeToScale:MESSAGE_MARGIN] : 0;
428-
UIEdgeInsets safeArea = self.isFullscreen ? UIEdgeInsetsZero : sceneView.safeAreaInsets;
429-
CGFloat left = MAX(margin, safeArea.left);
430-
CGFloat right = MAX(margin, safeArea.right);
427+
NSMutableArray<NSValue *> *regionFrames = [NSMutableArray new];
431428
// Full screen content clears hardware itself, using the insets passed through setSafeAreaInsets
432-
if (self.isFullscreen) {
433-
return UIEdgeInsetsMake(0, left, 0, right);
434-
}
435-
if (@available(iOS 27.1, *)) {
436-
CGFloat width = sceneView.bounds.size.width;
437-
id kind = [(id<OSReservedRegionKindClass>)NSClassFromString(@"UIViewReservedRegionKind") occlusionRegionKind];
438-
for (id<OSReservedRegion> region in [(id<OSReservedRegionQuerying>)sceneView reservedRegionsOfKind:kind]) {
439-
CGFloat minX = CGRectGetMinX(region.frame);
440-
CGFloat maxX = CGRectGetMaxX(region.frame);
441-
if (minX >= 1 && maxX <= safeArea.left + 1) {
442-
left = MAX(left, maxX + margin);
443-
} else if (maxX <= width - 1 && minX >= width - safeArea.right - 1) {
444-
right = MAX(right, width - minX + margin);
429+
if (!self.isFullscreen) {
430+
if (@available(iOS 27.1, *)) {
431+
id kind = [(id<OSReservedRegionKindClass>)NSClassFromString(@"UIViewReservedRegionKind") occlusionRegionKind];
432+
for (id<OSReservedRegion> region in [(id<OSReservedRegionQuerying>)sceneView reservedRegionsOfKind:kind]) {
433+
[regionFrames addObject:[NSValue valueWithCGRect:region.frame]];
445434
}
446435
}
447436
}
448-
return UIEdgeInsetsMake(0, left, 0, right);
437+
return [OSInAppMessageInsets horizontalInsetsWithMargin:margin
438+
safeArea:sceneView.safeAreaInsets
439+
width:sceneView.bounds.size.width
440+
regionFrames:regionFrames
441+
isFullscreen:self.isFullscreen];
449442
}
450443

451444
- (void)updateHorizontalInsets {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
Modified MIT License
3+
4+
Copyright 2026 OneSignal
5+
6+
Permission is hereby granted, free of charge, to any person obtaining a copy
7+
of this software and associated documentation files (the "Software"), to deal
8+
in the Software without restriction, including without limitation the rights
9+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
copies of the Software, and to permit persons to whom the Software is
11+
furnished to do so, subject to the following conditions:
12+
13+
1. The above copyright notice and this permission notice shall be included in
14+
all copies or substantial portions of the Software.
15+
16+
2. All copies of substantial portions of the Software may only be used in connection
17+
with services provided by OneSignal.
18+
19+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25+
THE SOFTWARE.
26+
*/
27+
28+
import XCTest
29+
30+
final class OSInAppMessageInsetsTests: XCTestCase {
31+
private let width: CGFloat = 800
32+
private let margin: CGFloat = 24
33+
// Landscape with a side safe area on each side, e.g. a camera housing on the left.
34+
private let safeArea = UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 47)
35+
36+
private func insets(margin: CGFloat? = nil,
37+
safeArea: UIEdgeInsets? = nil,
38+
regions: [CGRect] = [],
39+
isFullscreen: Bool = false) -> UIEdgeInsets {
40+
OSInAppMessageInsets.horizontalInsets(withMargin: margin ?? self.margin,
41+
safeArea: safeArea ?? self.safeArea,
42+
width: width,
43+
regionFrames: regions.map { NSValue(cgRect: $0) },
44+
isFullscreen: isFullscreen)
45+
}
46+
47+
func testNoRegions_usesSafeAreaWhenLargerThanMargin() {
48+
XCTAssertEqual(insets(), UIEdgeInsets(top: 0, left: 59, bottom: 0, right: 47))
49+
}
50+
51+
func testMarginAndSafeArea_takesMaxNotSum() {
52+
let result = insets(safeArea: UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 30))
53+
XCTAssertEqual(result, UIEdgeInsets(top: 0, left: 24, bottom: 0, right: 30))
54+
}
55+
56+
func testNoSafeArea_usesMargin() {
57+
XCTAssertEqual(insets(safeArea: .zero), UIEdgeInsets(top: 0, left: 24, bottom: 0, right: 24))
58+
}
59+
60+
func testNoWidthMargin_usesSafeAreaOnly() {
61+
let result = insets(margin: 0, safeArea: UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 0))
62+
XCTAssertEqual(result, UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 0))
63+
}
64+
65+
func testFullscreen_ignoresSafeAreaAndRegions() {
66+
let region = CGRect(x: 10, y: 100, width: 40, height: 80)
67+
XCTAssertEqual(insets(regions: [region], isFullscreen: true),
68+
UIEdgeInsets(top: 0, left: 24, bottom: 0, right: 24))
69+
XCTAssertEqual(insets(margin: 0, regions: [region], isFullscreen: true), .zero)
70+
}
71+
72+
func testRegionInsideLeftSafeArea_addsClearance() {
73+
let region = CGRect(x: 10, y: 100, width: 40, height: 80)
74+
XCTAssertEqual(insets(regions: [region]), UIEdgeInsets(top: 0, left: 74, bottom: 0, right: 47))
75+
}
76+
77+
func testRegionInsideRightSafeArea_addsClearance() {
78+
let region = CGRect(x: 760, y: 100, width: 30, height: 80)
79+
XCTAssertEqual(insets(regions: [region]), UIEdgeInsets(top: 0, left: 59, bottom: 0, right: 64))
80+
}
81+
82+
func testRegionsOnBothSides_addClearanceToEach() {
83+
let left = CGRect(x: 10, y: 100, width: 40, height: 80)
84+
let right = CGRect(x: 760, y: 100, width: 30, height: 80)
85+
XCTAssertEqual(insets(regions: [left, right]), UIEdgeInsets(top: 0, left: 74, bottom: 0, right: 64))
86+
}
87+
88+
func testRegionClearanceSmallerThanSafeArea_keepsSafeArea() {
89+
let region = CGRect(x: 2, y: 100, width: 20, height: 80)
90+
XCTAssertEqual(insets(regions: [region]), UIEdgeInsets(top: 0, left: 59, bottom: 0, right: 47))
91+
}
92+
93+
func testRegionFlushWithEdge_isIgnored() {
94+
let left = CGRect(x: 0, y: 0, width: 59, height: 400)
95+
let right = CGRect(x: 753, y: 0, width: 47, height: 400)
96+
XCTAssertEqual(insets(regions: [left, right]), UIEdgeInsets(top: 0, left: 59, bottom: 0, right: 47))
97+
}
98+
99+
func testRegionStraddlingSafeAreaEdge_isIgnored() {
100+
let left = CGRect(x: 40, y: 100, width: 40, height: 80)
101+
let right = CGRect(x: 740, y: 100, width: 40, height: 80)
102+
XCTAssertEqual(insets(regions: [left, right]), UIEdgeInsets(top: 0, left: 59, bottom: 0, right: 47))
103+
}
104+
105+
func testRegionWithinOnePointOfSafeAreaEdge_addsClearance() {
106+
let region = CGRect(x: 10, y: 100, width: 50, height: 80)
107+
XCTAssertEqual(insets(regions: [region]).left, 84)
108+
}
109+
}

‎iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OneSignalInAppMessagesTests-Bridging-Header.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#import "OSInAppMessageController.h"
77
#import "OSMessagingController.h"
88
#import "OSInAppMessagingRequests.h"
9+
#import "OSInAppMessageInsets.h"
910

1011
// Expose private properties and methods for testing
1112
@interface OSMessagingController (Testing)

0 commit comments

Comments
 (0)