Skip to content

Commit 829bd6c

Browse files
[SDK-100] compose support (#1015)
1 parent 6b4dc1a commit 829bd6c

16 files changed

Lines changed: 2573 additions & 13 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ 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 support for in-app messages in fully Jetpack Compose apps using a Dialog-based renderer (`IterableInAppDialogNotification`), removing the requirement for a `FragmentActivity`.
67

78
## [3.8.0]
89
### Added
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package com.iterable.iterableapi
2+
3+
import android.content.Context
4+
import android.graphics.Color
5+
import android.graphics.drawable.ColorDrawable
6+
import android.graphics.drawable.Drawable
7+
import android.graphics.drawable.TransitionDrawable
8+
import android.view.View
9+
import android.view.Window
10+
import android.view.animation.AnimationUtils
11+
import androidx.annotation.AnimRes
12+
import androidx.annotation.RestrictTo
13+
import androidx.core.graphics.ColorUtils
14+
15+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
16+
internal class InAppAnimationService {
17+
18+
fun createInAppBackgroundDrawable(hexColor: String?, alpha: Double): ColorDrawable? {
19+
val backgroundColor = try {
20+
if (!hexColor.isNullOrEmpty()) {
21+
Color.parseColor(hexColor)
22+
} else {
23+
Color.BLACK
24+
}
25+
} catch (e: IllegalArgumentException) {
26+
IterableLogger.w(TAG, "Invalid background color: $hexColor. Using BLACK.", e)
27+
Color.BLACK
28+
}
29+
30+
val backgroundWithAlpha = ColorUtils.setAlphaComponent(
31+
backgroundColor,
32+
(alpha * 255).toInt()
33+
)
34+
35+
return ColorDrawable(backgroundWithAlpha)
36+
}
37+
38+
fun animateWindowBackground(window: Window, from: Drawable, to: Drawable, shouldAnimate: Boolean) {
39+
if (shouldAnimate) {
40+
val layers = arrayOf(from, to)
41+
val transition = TransitionDrawable(layers)
42+
window.setBackgroundDrawable(transition)
43+
transition.startTransition(IterableConstants.ITERABLE_IN_APP_BACKGROUND_ANIMATION_DURATION)
44+
} else {
45+
window.setBackgroundDrawable(to)
46+
}
47+
}
48+
49+
fun showInAppBackground(window: Window, hexColor: String?, alpha: Double, shouldAnimate: Boolean) {
50+
val backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha)
51+
52+
if (backgroundDrawable == null) {
53+
IterableLogger.w(TAG, "Failed to create background drawable")
54+
return
55+
}
56+
57+
if (shouldAnimate) {
58+
val transparentDrawable = ColorDrawable(Color.TRANSPARENT)
59+
animateWindowBackground(window, transparentDrawable, backgroundDrawable, true)
60+
} else {
61+
window.setBackgroundDrawable(backgroundDrawable)
62+
}
63+
}
64+
65+
/**
66+
* Returns the enter animation resource for the given in-app layout, mirroring the
67+
* behavior of [IterableInAppFragmentHTMLNotification] so Compose/Dialog hosts get the
68+
* same slide/fade animations as Fragment hosts.
69+
*/
70+
@AnimRes
71+
fun getEnterAnimationResource(layout: InAppLayoutService.InAppLayout): Int {
72+
return when (layout) {
73+
InAppLayoutService.InAppLayout.TOP -> R.anim.slide_down_custom
74+
InAppLayoutService.InAppLayout.BOTTOM -> R.anim.slide_up_custom
75+
InAppLayoutService.InAppLayout.CENTER,
76+
InAppLayoutService.InAppLayout.FULLSCREEN -> R.anim.fade_in_custom
77+
}
78+
}
79+
80+
/**
81+
* Returns the exit animation resource for the given in-app layout, mirroring the
82+
* behavior of [IterableInAppFragmentHTMLNotification].
83+
*/
84+
@AnimRes
85+
fun getExitAnimationResource(layout: InAppLayoutService.InAppLayout): Int {
86+
return when (layout) {
87+
InAppLayoutService.InAppLayout.TOP -> R.anim.top_exit
88+
InAppLayoutService.InAppLayout.BOTTOM -> R.anim.bottom_exit
89+
InAppLayoutService.InAppLayout.CENTER,
90+
InAppLayoutService.InAppLayout.FULLSCREEN -> R.anim.fade_out_custom
91+
}
92+
}
93+
94+
fun showAndAnimateWebView(
95+
webView: View,
96+
shouldAnimate: Boolean,
97+
context: Context?,
98+
layout: InAppLayoutService.InAppLayout
99+
) {
100+
webView.alpha = 1.0f
101+
webView.visibility = View.VISIBLE
102+
103+
if (shouldAnimate && context != null) {
104+
try {
105+
val anim = AnimationUtils.loadAnimation(context, getEnterAnimationResource(layout))
106+
anim.duration = IterableConstants.ITERABLE_IN_APP_ANIMATION_DURATION.toLong()
107+
webView.startAnimation(anim)
108+
} catch (e: Exception) {
109+
IterableLogger.w(TAG, "Failed to start enter animation", e)
110+
}
111+
}
112+
}
113+
114+
/**
115+
* Plays the layout-appropriate exit animation on the given view. Returns `true` when
116+
* an animation was started, `false` otherwise (either because [shouldAnimate] was
117+
* false or loading the animation failed). Callers should schedule dismissal
118+
* accordingly.
119+
*/
120+
fun hideAndAnimateWebView(
121+
webView: View,
122+
shouldAnimate: Boolean,
123+
context: Context?,
124+
layout: InAppLayoutService.InAppLayout
125+
): Boolean {
126+
if (!shouldAnimate || context == null) {
127+
return false
128+
}
129+
return try {
130+
val anim = AnimationUtils.loadAnimation(context, getExitAnimationResource(layout))
131+
anim.duration = IterableConstants.ITERABLE_IN_APP_ANIMATION_DURATION.toLong()
132+
webView.startAnimation(anim)
133+
true
134+
} catch (e: Exception) {
135+
IterableLogger.w(TAG, "Failed to start exit animation", e)
136+
false
137+
}
138+
}
139+
140+
fun hideInAppBackground(window: Window, hexColor: String?, alpha: Double, shouldAnimate: Boolean) {
141+
if (shouldAnimate) {
142+
val backgroundDrawable = createInAppBackgroundDrawable(hexColor, alpha)
143+
val transparentDrawable = ColorDrawable(Color.TRANSPARENT)
144+
145+
if (backgroundDrawable != null) {
146+
animateWindowBackground(window, backgroundDrawable, transparentDrawable, true)
147+
}
148+
} else {
149+
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
150+
}
151+
}
152+
153+
fun prepareViewForDisplay(view: View) {
154+
view.alpha = 0f
155+
view.visibility = View.INVISIBLE
156+
}
157+
158+
companion object {
159+
private const val TAG = "InAppAnimService"
160+
}
161+
}
162+
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package com.iterable.iterableapi
2+
3+
import android.app.Activity
4+
import android.graphics.Color
5+
import android.os.Build
6+
import android.view.View
7+
import android.view.Window
8+
import android.view.WindowManager
9+
import androidx.annotation.RestrictTo
10+
import androidx.core.view.WindowCompat
11+
import androidx.core.view.WindowInsetsCompat
12+
import androidx.core.view.WindowInsetsControllerCompat
13+
14+
/**
15+
* Resolves [IterableInAppDisplayMode] and applies the corresponding window/system-bar
16+
* configuration. Shared by both [IterableInAppFragmentHTMLNotification] (FragmentActivity
17+
* hosts) and [IterableInAppDialogNotification] (ComponentActivity / Compose hosts) so the
18+
* two renderers stay aligned on display-mode behavior.
19+
*/
20+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
21+
internal class InAppDisplayModeService {
22+
23+
fun resolveDisplayMode(): IterableInAppDisplayMode {
24+
return try {
25+
IterableApi.sharedInstance.config?.inAppDisplayMode ?: DEFAULT_MODE
26+
} catch (e: Exception) {
27+
IterableLogger.w(TAG, "Could not resolve display mode from config, using default")
28+
DEFAULT_MODE
29+
}
30+
}
31+
32+
fun configureSystemBarsForMode(
33+
window: Window?,
34+
mode: IterableInAppDisplayMode,
35+
hostActivity: Activity?,
36+
hostIsEdgeToEdge: Boolean
37+
) {
38+
if (window == null) return
39+
40+
when (mode) {
41+
IterableInAppDisplayMode.FORCE_EDGE_TO_EDGE -> applyEdgeToEdge(window)
42+
IterableInAppDisplayMode.FORCE_FULLSCREEN -> hideStatusBar(window, hostActivity)
43+
IterableInAppDisplayMode.FORCE_RESPECT_BOUNDS -> applyRespectBounds(window, hostActivity)
44+
IterableInAppDisplayMode.FOLLOW_APP_LAYOUT ->
45+
configureSystemBarsFollowingApp(window, hostActivity, hostIsEdgeToEdge)
46+
}
47+
}
48+
49+
fun shouldApplySystemBarInsets(
50+
mode: IterableInAppDisplayMode,
51+
isFullscreenLayout: Boolean,
52+
hostIsEdgeToEdge: Boolean
53+
): Boolean {
54+
return when (mode) {
55+
IterableInAppDisplayMode.FORCE_EDGE_TO_EDGE,
56+
IterableInAppDisplayMode.FORCE_FULLSCREEN -> false
57+
IterableInAppDisplayMode.FORCE_RESPECT_BOUNDS -> true
58+
IterableInAppDisplayMode.FOLLOW_APP_LAYOUT ->
59+
!isFullscreenLayout && hostIsEdgeToEdge
60+
}
61+
}
62+
63+
fun isHostActivityEdgeToEdge(activity: Activity?): Boolean {
64+
if (activity == null || activity.window == null) return false
65+
66+
if (hasEdgeToEdgeLegacyFlags(activity)) {
67+
return true
68+
}
69+
70+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
71+
isContentDrawnBehindSystemBars(activity)
72+
} else {
73+
false
74+
}
75+
}
76+
77+
private fun applyEdgeToEdge(window: Window) {
78+
WindowCompat.setDecorFitsSystemWindows(window, false)
79+
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
80+
if (Build.VERSION.SDK_INT < 35) {
81+
@Suppress("DEPRECATION")
82+
window.statusBarColor = Color.TRANSPARENT
83+
@Suppress("DEPRECATION")
84+
window.navigationBarColor = Color.TRANSPARENT
85+
}
86+
}
87+
88+
@Suppress("DEPRECATION")
89+
private fun hideStatusBar(window: Window, hostActivity: Activity?) {
90+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
91+
WindowCompat.setDecorFitsSystemWindows(window, false)
92+
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
93+
} else {
94+
window.setFlags(
95+
WindowManager.LayoutParams.FLAG_FULLSCREEN,
96+
WindowManager.LayoutParams.FLAG_FULLSCREEN
97+
)
98+
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
99+
}
100+
hideSystemBarsOnWindow(window)
101+
hideHostSystemBars(hostActivity)
102+
}
103+
104+
private fun hideSystemBarsOnWindow(window: Window) {
105+
val controller = WindowCompat.getInsetsController(window, window.decorView)
106+
controller.systemBarsBehavior =
107+
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
108+
controller.hide(WindowInsetsCompat.Type.systemBars())
109+
}
110+
111+
private fun hideHostSystemBars(hostActivity: Activity?) {
112+
val hostWindow = hostActivity?.window ?: return
113+
val controller = WindowCompat.getInsetsController(hostWindow, hostWindow.decorView)
114+
controller.systemBarsBehavior =
115+
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
116+
controller.hide(WindowInsetsCompat.Type.systemBars())
117+
}
118+
119+
fun restoreHostSystemBars(hostActivity: Activity?) {
120+
val hostWindow = hostActivity?.window ?: return
121+
val controller = WindowCompat.getInsetsController(hostWindow, hostWindow.decorView)
122+
controller.show(WindowInsetsCompat.Type.systemBars())
123+
}
124+
125+
fun applyContentInsetsForMode(
126+
contentView: View,
127+
mode: IterableInAppDisplayMode,
128+
isFullscreenLayout: Boolean,
129+
hostActivity: Activity?,
130+
hostIsEdgeToEdge: Boolean
131+
) {
132+
if (!shouldApplySystemBarInsets(mode, isFullscreenLayout, hostIsEdgeToEdge)) return
133+
if (hostActivity == null) return
134+
val (top, bottom) = resolveSystemBarInsets(hostActivity)
135+
contentView.setPadding(0, top, 0, bottom)
136+
}
137+
138+
private fun resolveSystemBarInsets(hostActivity: Activity): Pair<Int, Int> {
139+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
140+
val sysBars = hostActivity.windowManager.currentWindowMetrics
141+
.windowInsets
142+
.getInsets(android.view.WindowInsets.Type.systemBars())
143+
if (sysBars.top > 0 || sysBars.bottom > 0) {
144+
return sysBars.top to sysBars.bottom
145+
}
146+
}
147+
val resources = hostActivity.resources
148+
return resourceDimen(resources, "status_bar_height") to
149+
resourceDimen(resources, "navigation_bar_height")
150+
}
151+
152+
private fun resourceDimen(resources: android.content.res.Resources, name: String): Int {
153+
val resId = resources.getIdentifier(name, "dimen", "android")
154+
return if (resId > 0) resources.getDimensionPixelSize(resId) else 0
155+
}
156+
157+
private fun applyRespectBounds(window: Window, hostActivity: Activity?) {
158+
WindowCompat.setDecorFitsSystemWindows(window, false)
159+
window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
160+
copyHostSystemBarAppearance(window, hostActivity)
161+
}
162+
163+
private fun copyHostSystemBarAppearance(window: Window, hostActivity: Activity?) {
164+
val hostWindow = hostActivity?.window ?: return
165+
if (Build.VERSION.SDK_INT < 35) {
166+
@Suppress("DEPRECATION")
167+
window.statusBarColor = hostWindow.statusBarColor
168+
@Suppress("DEPRECATION")
169+
window.navigationBarColor = hostWindow.navigationBarColor
170+
}
171+
val hostController = WindowCompat.getInsetsController(hostWindow, hostWindow.decorView)
172+
val ourController = WindowCompat.getInsetsController(window, window.decorView)
173+
ourController.isAppearanceLightStatusBars = hostController.isAppearanceLightStatusBars
174+
ourController.isAppearanceLightNavigationBars = hostController.isAppearanceLightNavigationBars
175+
}
176+
177+
private fun configureSystemBarsFollowingApp(
178+
window: Window,
179+
hostActivity: Activity?,
180+
hostIsEdgeToEdge: Boolean
181+
) {
182+
if (hostActivity == null || hostActivity.window == null) return
183+
184+
if (hostIsEdgeToEdge) {
185+
applyEdgeToEdge(window)
186+
} else if (Build.VERSION.SDK_INT < 35) {
187+
@Suppress("DEPRECATION")
188+
window.statusBarColor = hostActivity.window.statusBarColor
189+
@Suppress("DEPRECATION")
190+
window.navigationBarColor = hostActivity.window.navigationBarColor
191+
}
192+
}
193+
194+
@Suppress("DEPRECATION")
195+
private fun hasEdgeToEdgeLegacyFlags(activity: Activity): Boolean {
196+
val flags = activity.window.decorView.systemUiVisibility
197+
return (flags and View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) != 0
198+
}
199+
200+
private fun isContentDrawnBehindSystemBars(activity: Activity): Boolean {
201+
val contentView = activity.findViewById<View>(android.R.id.content) ?: return false
202+
val position = IntArray(2)
203+
contentView.getLocationInWindow(position)
204+
val statusBarPushesContentDown = position[1] > 0
205+
return !statusBarPushesContentDown
206+
}
207+
208+
companion object {
209+
private const val TAG = "InAppDisplayModeSvc"
210+
211+
@JvmField
212+
internal val DEFAULT_MODE: IterableInAppDisplayMode = IterableInAppDisplayMode.FORCE_EDGE_TO_EDGE
213+
}
214+
}

0 commit comments

Comments
 (0)