Skip to content

Commit d457147

Browse files
[SDK-337] Fix for status bar overlap on in-app messages (#988)
1 parent 54f6505 commit d457147

4 files changed

Lines changed: 219 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,38 @@ All notable changes to this project will be documented in this file.
33
This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## [Unreleased]
6+
### Added
7+
- New `IterableInAppDisplayMode` enum to control how in-app messages interact with system bars. Configure via `IterableConfig.Builder.setInAppDisplayMode()`:
8+
- `FORCE_EDGE_TO_EDGE` (default) — draws in-app content behind system bars with transparent status and navigation bars. This preserves the previous SDK behavior.
9+
- `FOLLOW_APP_LAYOUT` — matches the host app's system bar configuration automatically.
10+
- `FORCE_FULLSCREEN` — hides the status bar entirely for all in-app messages.
11+
- `FORCE_RESPECT_BOUNDS` — ensures in-app content never overlaps system bars, keeping UI elements like the close button always accessible.
12+
- Added `imageScaleType` to `IterableEmbeddedViewConfig` to allow configuring how the image is scaled within the 16:9 container for embedded message views.
13+
- Added default values to all `IterableEmbeddedViewConfig` constructor parameters for easier configuration.
14+
615
### Fixed
716
- Fixed `IterableEmbeddedView` card layout rendering issues: image now displays at a 16:9 aspect ratio instead of collapsing to zero height, card container no longer expands to fill the parent, missing end margin on the card is now applied, bottom spacing on buttons is no longer cut off, and the image properly clips to the card's rounded corners.
817
- Fixed `ConcurrentModificationException` crash during device token registration caused by concurrent access to `deviceAttributes`.
918
- Fixed possible `NoSuchMethodException` crash on Android 5-10 caused by using `Map.of()` which is unavailable on those versions
1019

11-
### Added
12-
- Added `imageScaleType` to `IterableEmbeddedViewConfig` to allow configuring how the image is scaled within the 16:9 container for embedded message views.
13-
- Added default values to all `IterableEmbeddedViewConfig` constructor parameters for easier configuration.
20+
### Migration guide
21+
**No action required for most apps.** The default `FORCE_EDGE_TO_EDGE` preserves the existing behavior where in-app content draws behind system bars.
22+
23+
If the close button in your fullscreen in-app messages is obscured by the status bar, you can fix it by choosing one of these modes:
24+
25+
```java
26+
// Automatically match the host app's system bar configuration
27+
IterableConfig config = new IterableConfig.Builder()
28+
.setInAppDisplayMode(IterableInAppDisplayMode.FOLLOW_APP_LAYOUT)
29+
.build();
30+
```
31+
32+
```java
33+
// Ensure in-app content never goes behind system bars
34+
IterableConfig config = new IterableConfig.Builder()
35+
.setInAppDisplayMode(IterableInAppDisplayMode.FORCE_RESPECT_BOUNDS)
36+
.build();
37+
```
1438

1539
### Removed
1640
- Removed insecure `AES/CBC/PKCS5Padding` encryption from `IterableDataEncryptor`. The SDK now exclusively uses `AES/GCM/NoPadding`. The legacy CBC algorithm was only used on Android versions below KitKat (API 19), which have been unsupported since `minSdkVersion` was raised to 21.

iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ public class IterableConfig {
140140
@Nullable
141141
final IterableAPIMobileFrameworkInfo mobileFrameworkInfo;
142142

143+
/**
144+
* Controls how in-app messages interact with the system bars (status bar, navigation bar).
145+
* Defaults to {@link IterableInAppDisplayMode#FORCE_EDGE_TO_EDGE}.
146+
*/
147+
final IterableInAppDisplayMode inAppDisplayMode;
148+
143149
/**
144150
* Base URL for Webview content loading. Specifically used to enable CORS for external resources.
145151
* If null or empty, defaults to empty string (original behavior with about:blank origin).
@@ -183,6 +189,7 @@ private IterableConfig(Builder builder) {
183189
decryptionFailureHandler = builder.decryptionFailureHandler;
184190
mobileFrameworkInfo = builder.mobileFrameworkInfo;
185191
webViewBaseUrl = builder.webViewBaseUrl;
192+
inAppDisplayMode = builder.inAppDisplayMode;
186193
}
187194

188195
public static class Builder {
@@ -211,6 +218,7 @@ public static class Builder {
211218
private IterableIdentityResolution identityResolution = new IterableIdentityResolution();
212219
private IterableUnknownUserHandler iterableUnknownUserHandler;
213220
private String webViewBaseUrl;
221+
private IterableInAppDisplayMode inAppDisplayMode = IterableInAppDisplayMode.FORCE_EDGE_TO_EDGE;
214222

215223
public Builder() {}
216224

@@ -453,6 +461,17 @@ public Builder setMobileFrameworkInfo(@NonNull IterableAPIMobileFrameworkInfo mo
453461
return this;
454462
}
455463

464+
/**
465+
* Set how in-app messages interact with the system bars (status bar, navigation bar).
466+
* Defaults to {@link IterableInAppDisplayMode#FORCE_EDGE_TO_EDGE}, which preserves existing behavior.
467+
* @param inAppDisplayMode the display mode for in-app messages
468+
*/
469+
@NonNull
470+
public Builder setInAppDisplayMode(@NonNull IterableInAppDisplayMode inAppDisplayMode) {
471+
this.inAppDisplayMode = inAppDisplayMode;
472+
return this;
473+
}
474+
456475
/**
457476
* Set the base URL for WebView content loading. Used to enable CORS for external resources.
458477
* If not set or null, defaults to empty string (original behavior with about:blank origin).
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.iterable.iterableapi;
2+
3+
/**
4+
* Controls how in-app messages interact with the system bars (status bar, navigation bar).
5+
* <p>
6+
* This setting is configured via {@link IterableConfig.Builder#setInAppDisplayMode(IterableInAppDisplayMode)}
7+
* and applies globally to all in-app messages displayed by the SDK.
8+
*/
9+
public enum IterableInAppDisplayMode {
10+
11+
/**
12+
* The in-app message follows the host app's current layout configuration.
13+
* If the app is edge-to-edge, the in-app will display edge-to-edge.
14+
* If the app respects system bar bounds, the in-app will too.
15+
*/
16+
FOLLOW_APP_LAYOUT,
17+
18+
/**
19+
* Default. Forces in-app messages to display edge-to-edge, drawing content behind system bars.
20+
* The in-app content will extend behind the status bar and navigation bar.
21+
* This preserves the behavior of previous SDK versions.
22+
*/
23+
FORCE_EDGE_TO_EDGE,
24+
25+
/**
26+
* Forces in-app messages to display in fullscreen mode, hiding the status bar entirely.
27+
* Uses legacy FLAG_FULLSCREEN on API &lt; 30 and WindowInsetsController on API 30+.
28+
*/
29+
FORCE_FULLSCREEN,
30+
31+
/**
32+
* Forces in-app messages to respect system bar boundaries.
33+
* Content will never draw behind the status bar or navigation bar,
34+
* ensuring UI elements like the close button are always accessible.
35+
*/
36+
FORCE_RESPECT_BOUNDS
37+
}

iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java

Lines changed: 136 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import android.content.DialogInterface;
77
import android.content.res.Resources;
88
import android.graphics.Color;
9-
import android.graphics.Point;
109
import android.graphics.Rect;
1110
import android.graphics.drawable.ColorDrawable;
1211
import android.graphics.drawable.Drawable;
@@ -17,8 +16,6 @@
1716
import android.os.Bundle;
1817
import android.os.Handler;
1918
import android.os.Looper;
20-
import android.util.DisplayMetrics;
21-
import android.view.Display;
2219
import android.view.Gravity;
2320
import android.view.LayoutInflater;
2421
import android.view.OrientationEventListener;
@@ -36,7 +33,9 @@
3633
import androidx.core.graphics.ColorUtils;
3734
import androidx.core.graphics.Insets;
3835
import androidx.core.view.ViewCompat;
36+
import androidx.core.view.WindowCompat;
3937
import androidx.core.view.WindowInsetsCompat;
38+
import androidx.core.view.WindowInsetsControllerCompat;
4039
import androidx.fragment.app.DialogFragment;
4140

4241
public class IterableInAppFragmentHTMLNotification extends DialogFragment implements IterableWebView.HTMLNotificationCallbacks {
@@ -79,6 +78,8 @@ public class IterableInAppFragmentHTMLNotification extends DialogFragment implem
7978
private boolean shouldAnimate;
8079
private double inAppBackgroundAlpha;
8180
private String inAppBackgroundColor;
81+
private boolean hostIsEdgeToEdge;
82+
private IterableInAppDisplayMode displayMode = IterableInAppDisplayMode.FOLLOW_APP_LAYOUT;
8283

8384
public static IterableInAppFragmentHTMLNotification createInstance(@NonNull String htmlString, boolean callbackOnCancel, @NonNull IterableHelper.IterableUrlCallback clickCallback, @NonNull IterableInAppLocation location, @NonNull String messageId, @NonNull Double backgroundAlpha, @NonNull Rect padding) {
8485
return IterableInAppFragmentHTMLNotification.createInstance(htmlString, callbackOnCancel, clickCallback, location, messageId, backgroundAlpha, padding, false, new IterableInAppMessage.InAppBgColor(null, 0.0f));
@@ -150,6 +151,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) {
150151
}
151152

152153
notification = this;
154+
displayMode = resolveDisplayMode();
153155
}
154156

155157
@NonNull
@@ -177,13 +179,8 @@ public void onCancel(DialogInterface dialog) {
177179
applyWindowGravity(dialog.getWindow(), "onCreateDialog");
178180
}
179181

180-
if (getInAppLayout(insetPadding) == InAppLayout.FULLSCREEN) {
181-
dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
182-
} else if (getInAppLayout(insetPadding) != InAppLayout.TOP) {
183-
// For TOP layout in-app, status bar will be opaque so that the in-app content does not overlap with translucent status bar.
184-
// For other non-fullscreen in-apps layouts (BOTTOM and CENTER), status bar will be translucent
185-
dialog.getWindow().setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS, WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
186-
}
182+
hostIsEdgeToEdge = isHostActivityEdgeToEdge();
183+
configureSystemBarsForMode(dialog.getWindow());
187184
return dialog;
188185
}
189186

@@ -192,10 +189,6 @@ public void onCancel(DialogInterface dialog) {
192189
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
193190
getDialog().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
194191

195-
if (getInAppLayout(insetPadding) == InAppLayout.FULLSCREEN) {
196-
getDialog().getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
197-
}
198-
199192
// Set initial window gravity based on inset padding (only for non-fullscreen)
200193
if (getInAppLayout(insetPadding) != InAppLayout.FULLSCREEN) {
201194
applyWindowGravity(getDialog().getWindow(), "onCreateView");
@@ -299,9 +292,7 @@ public void run() {
299292
@Override
300293
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
301294
super.onViewCreated(view, savedInstanceState);
302-
// Handle edge-to-edge insets with modern approach (only for non-fullscreen)
303-
// Full screen in-apps should not have padding from system bars
304-
if (getInAppLayout(insetPadding) != InAppLayout.FULLSCREEN) {
295+
if (shouldApplySystemBarInsets()) {
305296
ViewCompat.setOnApplyWindowInsetsListener(view, (v, insets) -> {
306297
Insets sysBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
307298
v.setPadding(0, sysBars.top, 0, sysBars.bottom);
@@ -513,11 +504,7 @@ public void run() {
513504
}
514505
};
515506

516-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
517-
webView.postOnAnimationDelayed(dismissWebViewRunnable, 400);
518-
} else {
519-
webView.postDelayed(dismissWebViewRunnable, 400);
520-
}
507+
webView.postOnAnimationDelayed(dismissWebViewRunnable, 400);
521508
}
522509

523510
private void processMessageRemoval() {
@@ -606,30 +593,11 @@ public void run() {
606593
return;
607594
}
608595

609-
DisplayMetrics displayMetrics = activity.getResources().getDisplayMetrics();
610596
Window window = notification.getDialog().getWindow();
611597
Rect insetPadding = notification.insetPadding;
612598

613-
WindowManager wm = (WindowManager) getContext().getSystemService(Context.WINDOW_SERVICE);
614-
Display display = wm.getDefaultDisplay();
615-
Point size = new Point();
616-
617-
// Get the correct screen size based on api level
618-
// https://stackoverflow.com/questions/35780980/getting-the-actual-screen-height-android
619-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
620-
display.getRealSize(size);
621-
} else {
622-
display.getSize(size);
623-
}
624-
625-
int webViewWidth = size.x;
626-
int webViewHeight = size.y;
627-
628-
//Check if the dialog is full screen
629599
if (insetPadding.bottom == 0 && insetPadding.top == 0) {
630-
//Handle full screen
631-
window.setLayout(webViewWidth, webViewHeight);
632-
getDialog().getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
600+
window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT);
633601
} else {
634602
// Resize the WebView directly with explicit size
635603
float relativeHeight = height * getResources().getDisplayMetrics().density;
@@ -716,6 +684,132 @@ static int roundToNearest90Degrees(int orientation) {
716684
}
717685
}
718686

687+
private IterableInAppDisplayMode resolveDisplayMode() {
688+
try {
689+
IterableConfig config = IterableApi.sharedInstance.config;
690+
if (config != null) {
691+
return config.inAppDisplayMode;
692+
}
693+
} catch (Exception e) {
694+
IterableLogger.w(TAG, "Could not resolve display mode from config, using default");
695+
}
696+
return IterableInAppDisplayMode.FORCE_EDGE_TO_EDGE;
697+
}
698+
699+
@SuppressWarnings("deprecation")
700+
private void configureSystemBarsForMode(Window window) {
701+
if (window == null) return;
702+
703+
switch (displayMode) {
704+
case FORCE_EDGE_TO_EDGE:
705+
applyEdgeToEdge(window);
706+
break;
707+
708+
case FORCE_FULLSCREEN:
709+
hideStatusBar(window);
710+
break;
711+
712+
case FORCE_RESPECT_BOUNDS:
713+
applyRespectBounds(window);
714+
break;
715+
716+
case FOLLOW_APP_LAYOUT:
717+
default:
718+
configureSystemBarsFollowingApp(window);
719+
break;
720+
}
721+
}
722+
723+
private void applyEdgeToEdge(Window window) {
724+
WindowCompat.setDecorFitsSystemWindows(window, false);
725+
// On API 35+, system bars are transparent by default; these setters are no-ops
726+
if (Build.VERSION.SDK_INT < 35) {
727+
window.setStatusBarColor(Color.TRANSPARENT);
728+
window.setNavigationBarColor(Color.TRANSPARENT);
729+
}
730+
}
731+
732+
@SuppressWarnings("deprecation")
733+
private void hideStatusBar(Window window) {
734+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
735+
WindowCompat.setDecorFitsSystemWindows(window, false);
736+
WindowInsetsControllerCompat controller = WindowCompat.getInsetsController(window, window.getDecorView());
737+
controller.hide(WindowInsetsCompat.Type.statusBars());
738+
controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
739+
} else {
740+
window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
741+
}
742+
}
743+
744+
private void applyRespectBounds(Window window) {
745+
WindowCompat.setDecorFitsSystemWindows(window, true);
746+
}
747+
748+
@SuppressWarnings("deprecation")
749+
private void configureSystemBarsFollowingApp(Window window) {
750+
Activity activity = getActivity();
751+
if (activity == null || activity.getWindow() == null) return;
752+
753+
if (hostIsEdgeToEdge) {
754+
applyEdgeToEdge(window);
755+
} else {
756+
if (Build.VERSION.SDK_INT < 35) {
757+
window.setStatusBarColor(activity.getWindow().getStatusBarColor());
758+
window.setNavigationBarColor(activity.getWindow().getNavigationBarColor());
759+
}
760+
}
761+
}
762+
763+
private boolean shouldApplySystemBarInsets() {
764+
switch (displayMode) {
765+
case FORCE_EDGE_TO_EDGE:
766+
case FORCE_FULLSCREEN:
767+
return false;
768+
case FORCE_RESPECT_BOUNDS:
769+
return true;
770+
case FOLLOW_APP_LAYOUT:
771+
default:
772+
InAppLayout layout = getInAppLayout(insetPadding);
773+
return layout != InAppLayout.FULLSCREEN && hostIsEdgeToEdge;
774+
}
775+
}
776+
777+
private boolean isHostActivityEdgeToEdge() {
778+
Activity activity = getActivity();
779+
if (activity == null || activity.getWindow() == null) return false;
780+
781+
if (hasEdgeToEdgeLegacyFlags(activity)) {
782+
return true;
783+
}
784+
785+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
786+
return isContentDrawnBehindSystemBars(activity);
787+
}
788+
789+
return false;
790+
}
791+
792+
@SuppressWarnings("deprecation")
793+
private boolean hasEdgeToEdgeLegacyFlags(Activity activity) {
794+
int flags = activity.getWindow().getDecorView().getSystemUiVisibility();
795+
return (flags & View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) != 0;
796+
}
797+
798+
private boolean isContentDrawnBehindSystemBars(Activity activity) {
799+
View contentView = activity.findViewById(android.R.id.content);
800+
if (contentView == null) return false;
801+
802+
int contentTop = getViewTopPositionInWindow(contentView);
803+
boolean statusBarPushesContentDown = contentTop > 0;
804+
return !statusBarPushesContentDown;
805+
}
806+
807+
private int getViewTopPositionInWindow(View view) {
808+
int[] position = new int[2];
809+
view.getLocationInWindow(position);
810+
return position[1];
811+
}
812+
719813
/**
720814
* Sets the window gravity based on inset padding
721815
*

0 commit comments

Comments
 (0)