Skip to content

Commit ecef37b

Browse files
amber-klaviyoevan-masseauclaude
authored
Feat/floating forms (#441)
* Implement WindowManager approach for floating forms (POC) Add FloatingFormWindow class that uses WindowManager.addView() with TYPE_APPLICATION_PANEL to display floating forms as window overlays, allowing the host Activity to remain fully interactive (including keyboard input). Key changes: - FormLayout data classes for layout configuration (position, dimensions, margins) - FloatingFormWindow for WindowManager-based presentation - KlaviyoPresentationManager now routes to Activity or WindowManager based on layout - WebViewClient.getWebView() for accessing the WebView instance - Bump formWillAppear handshake version to 2 for layout support - Hardcoded TEST_LAYOUT in KlaviyoNativeBridge for testing (BOTTOM_RIGHT, 300x200dp) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: Correct bugs found in code review of floating forms POC - Restore activity.startActivity() in presentActivity() to avoid AndroidRuntimeException when starting from application context - Move all view manipulation into runOnUiThread in FloatingFormWindow.show() to prevent crashes from off-UI-thread view access - Call detachWebView() in floating window dismiss path for consistency - Restore webView.visibility = VISIBLE before adding to FloatingFormWindow - Remove unnecessary cast in dismiss(), fix inline android.view.ViewGroup FQN - Eliminate !! operator, use takeUnless pattern for floatingLayout capture - Add TODO comments for orientation change handling, debug background, deprecated API, and unused updateLayout scaffolding Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: Simplify FloatingFormWindow.dismiss() by removing redundant child removal Since detachWebView() is called before window.dismiss() in the manager, explicitly removing the child WebView before windowManager.removeView() is redundant and fragile (relies on getChildAt(0) index assumption). Removing the extra step simplifies the code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Downgrade error log to warning, add TODOs for BugBot feedback - Change ERROR→WARNING for null WebView guard in presentFloatingWindow() since this is a state check, not an exception (aligns with AGENTS.md logging guidelines: ERROR requires an exception) - Add TODO documenting that presentationState should ideally transition to Presented inside the runOnUiThread callback (POC shortcut noted) - Add TODO to hostActivity explaining it's scaffolding for future orientation change recovery and dynamic layout updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Address PR feedback — replace suspicious test ID, add zoom TODO - Replace test form ID '64CjgW' with 'aPiKeY' to avoid resembling a real key - Add TODO for testing toPixels with system-wide zoom/scaling settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: Rename Margins to Offsets across floating forms POC Aligns naming with the bridge contract where these are SDK-applied offsets of the form content, not CSS-style margins. Renamed: Margins → Offsets, margins → offsets in FormLayout, FloatingFormWindow, KlaviyoNativeBridge, and all tests. JSON key also updated from "margins" to "offsets". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * MAGE-328 (#427) * MAGE-328: cleanup PoC * support rotation with floating form open * resolve WindowLeaked error * backgrounding and margins * keyboard with bottom form * handle keyboard behavior * pr-loop: 1 * pr-loop: 2 * address memory leak concerns * add test coverage for floating forms * bugbot fixes * bugbot * bugbot * correct bottom offset direction * address PR feedback * fix bugbot * bugbot * MAGE-323: safe zone handling (#432) * MAGE-323: safe zone handling * offset should be user + safezone * horizantal safe zones * fix: address review feedback and fix CI * refactor: observe keyboard via probe view to avoid clobbering host app callbacks (#438) Previously, FloatingFormWindow set WindowInsetsAnimationCallback directly on the host activity's root view, silently replacing any callback the host app had registered there. The fallback OnApplyWindowInsetsListener had the same clobbering problem. Replace with a two-layer approach that doesn't touch the host app's view hierarchy: 1. Inject a zero-size probe View as a direct child of the host DecorView and attach WindowInsetsAnimationCallback there. Since DISPATCH_MODE_STOP on a sibling only blocks recursion into that sibling's subtree (not other children of the same parent), our probe view receives animation callbacks independently. Uses DISPATCH_MODE_CONTINUE_ON_SUBTREE to signal no intent to interfere with dispatch. 2. Fall back to ViewTreeObserver.OnGlobalLayoutListener on the DecorView. This is fully additive (add vs set), unaffected by DISPATCH_MODE_STOP, and handles pre-API 30 devices where the compat animation shim may not reach a non-root child. When an ancestor blocks the animation callback, the form snaps to its shifted position rather than tracking the keyboard slide frame-by-frame. The isAnimatingKeyboard flag prevents both paths from applying a shift simultaneously. Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> * resolve bugbot * resolve bugbot again * resolve bugbot 3 * resolve bugbot 4 * resolve bugbot 5 * resolve bugbot 6 * resolve bugbot 7 * resolve bugbot 8 * resolve bugbot 9 --------- Co-authored-by: Evan C Masseau <5167687+evan-masseau@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Evan Masseau <evan.masseau@klaviyo.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Evan C Masseau <5167687+evan-masseau@users.noreply.github.com>
1 parent 6924768 commit ecef37b

15 files changed

Lines changed: 1868 additions & 56 deletions
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package com.klaviyo.forms.bridge
2+
3+
import android.view.Gravity
4+
import org.json.JSONObject
5+
6+
/**
7+
* Position options for floating forms
8+
*/
9+
internal enum class FormPosition {
10+
FULLSCREEN,
11+
TOP,
12+
BOTTOM,
13+
TOP_LEFT,
14+
TOP_RIGHT,
15+
BOTTOM_LEFT,
16+
BOTTOM_RIGHT,
17+
CENTER;
18+
19+
/**
20+
* Returns true if this position uses horizontal centering (CENTER_HORIZONTAL or CENTER).
21+
* These positions need width adjusted for horizontal safe area insets so forms
22+
* don't extend into display cutouts in landscape.
23+
*/
24+
fun isHorizontallyCentered(): Boolean = when (this) {
25+
TOP, BOTTOM, CENTER, FULLSCREEN -> true
26+
TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT -> false
27+
}
28+
29+
/**
30+
* Convert form position to Android Gravity flags
31+
*/
32+
fun toGravity(): Int = when (this) {
33+
FULLSCREEN -> Gravity.CENTER
34+
TOP -> Gravity.TOP or Gravity.CENTER_HORIZONTAL
35+
BOTTOM -> Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
36+
TOP_LEFT -> Gravity.TOP or Gravity.START
37+
TOP_RIGHT -> Gravity.TOP or Gravity.END
38+
BOTTOM_LEFT -> Gravity.BOTTOM or Gravity.START
39+
BOTTOM_RIGHT -> Gravity.BOTTOM or Gravity.END
40+
CENTER -> Gravity.CENTER
41+
}
42+
43+
companion object {
44+
fun fromString(value: String?): FormPosition = when (value?.uppercase()) {
45+
"FULLSCREEN" -> FULLSCREEN
46+
"TOP" -> TOP
47+
"BOTTOM" -> BOTTOM
48+
"TOP_LEFT" -> TOP_LEFT
49+
"TOP_RIGHT" -> TOP_RIGHT
50+
"BOTTOM_LEFT" -> BOTTOM_LEFT
51+
"BOTTOM_RIGHT" -> BOTTOM_RIGHT
52+
"CENTER" -> CENTER
53+
else -> FULLSCREEN
54+
}
55+
}
56+
}
57+
58+
/**
59+
* Unit type for dimensions
60+
*/
61+
internal enum class DimensionUnit {
62+
PERCENT,
63+
FIXED;
64+
65+
companion object {
66+
fun fromString(value: String?): DimensionUnit = when (value?.uppercase()) {
67+
"PERCENT" -> PERCENT
68+
"FIXED" -> FIXED
69+
else -> FIXED
70+
}
71+
}
72+
}
73+
74+
/**
75+
* Represents a dimension value with its unit type
76+
*/
77+
internal data class Dimension(
78+
val value: Float,
79+
val unit: DimensionUnit
80+
) {
81+
/**
82+
* Convert dimension to pixels
83+
*
84+
* @param screenDimension The screen dimension (width or height) in pixels
85+
* @param density The screen density (pixels per dp)
86+
* @return The dimension in pixels
87+
*/
88+
// TODO: Test on devices with different zoom states
89+
// (system-wide settings for controlling content scaling)
90+
fun toPixels(screenDimension: Int, density: Float): Int = when (unit) {
91+
DimensionUnit.PERCENT -> (screenDimension * (value / 100f)).toInt()
92+
DimensionUnit.FIXED -> (value * density).toInt()
93+
}
94+
95+
companion object {
96+
fun fromJson(json: JSONObject?): Dimension? {
97+
if (json == null) return null
98+
return Dimension(
99+
value = json.optDouble("value", 0.0).toFloat(),
100+
unit = DimensionUnit.fromString(json.optString("unit"))
101+
)
102+
}
103+
104+
/**
105+
* Create a fixed dimension in dp
106+
*/
107+
fun dp(value: Float): Dimension = Dimension(value, DimensionUnit.FIXED)
108+
109+
/**
110+
* Create a percent dimension
111+
*/
112+
fun percent(value: Float): Dimension = Dimension(value, DimensionUnit.PERCENT)
113+
}
114+
}
115+
116+
/**
117+
* Offsets around the form in dp
118+
*/
119+
internal data class Offsets(
120+
val top: Float = 0f,
121+
val bottom: Float = 0f,
122+
val left: Float = 0f,
123+
val right: Float = 0f
124+
) {
125+
companion object {
126+
fun fromJson(json: JSONObject?): Offsets {
127+
if (json == null) return Offsets()
128+
return Offsets(
129+
top = json.optDouble("top", 0.0).toFloat(),
130+
bottom = json.optDouble("bottom", 0.0).toFloat(),
131+
left = json.optDouble("left", 0.0).toFloat(),
132+
right = json.optDouble("right", 0.0).toFloat()
133+
)
134+
}
135+
136+
/**
137+
* Create uniform offsets
138+
*/
139+
fun all(value: Float): Offsets = Offsets(value, value, value, value)
140+
}
141+
}
142+
143+
/**
144+
* Complete layout configuration for a form
145+
*/
146+
internal data class FormLayout(
147+
val position: FormPosition,
148+
val width: Dimension,
149+
val height: Dimension,
150+
val offsets: Offsets = Offsets()
151+
) {
152+
/**
153+
* Returns true if this layout represents a fullscreen form
154+
*/
155+
val isFullscreen: Boolean
156+
get() = position == FormPosition.FULLSCREEN
157+
158+
companion object {
159+
fun fromJson(json: JSONObject?): FormLayout? {
160+
if (json == null) return null
161+
162+
val position = FormPosition.fromString(json.optString("position"))
163+
val width = Dimension.fromJson(json.optJSONObject("width")) ?: return null
164+
val height = Dimension.fromJson(json.optJSONObject("height")) ?: return null
165+
val offsets = Offsets.fromJson(json.optJSONObject("margin"))
166+
167+
return FormLayout(
168+
position = position,
169+
width = width,
170+
height = height,
171+
offsets = offsets
172+
)
173+
}
174+
}
175+
}

sdk/forms/src/main/java/com/klaviyo/forms/bridge/KlaviyoNativeBridge.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ internal class KlaviyoNativeBridge : NativeBridge {
9595
* Notify the client that the webview should be shown
9696
*/
9797
private fun show(bridgeMessage: FormWillAppear) {
98-
Registry.get<PresentationManager>().present()
98+
Registry.get<PresentationManager>().present(bridgeMessage.formId, bridgeMessage.layout)
9999

100100
if (bridgeMessage.formId.isEmpty() || bridgeMessage.formName.isEmpty()) {
101101
Registry.log.warning(

sdk/forms/src/main/java/com/klaviyo/forms/bridge/NativeBridgeMessage.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@ internal sealed class NativeBridgeMessage {
2929
*
3030
* @param formId The form ID of the form that is appearing
3131
* @param formName The name of the form that is appearing
32+
* @param layout The optional layout configuration for the form
3233
*/
3334
data class FormWillAppear(
3435
val formId: FormId,
35-
val formName: String
36+
val formName: String,
37+
val layout: FormLayout? = null
3638
) : NativeBridgeMessage()
3739

3840
/**
@@ -104,7 +106,7 @@ internal sealed class NativeBridgeMessage {
104106
internal val handShakeData by lazy {
105107
listOf(
106108
HandshakeSpec(keyName<HandShook>(), 1),
107-
HandshakeSpec(keyName<FormWillAppear>(), 1),
109+
HandshakeSpec(keyName<FormWillAppear>(), 2),
108110
HandshakeSpec(keyName<TrackAggregateEvent>(), 1),
109111
HandshakeSpec(keyName<TrackProfileEvent>(), 1),
110112
// Version 2 issues deep link after closing the form (v1 was before close, causing a timing issue)
@@ -130,7 +132,8 @@ internal sealed class NativeBridgeMessage {
130132

131133
keyName<FormWillAppear>() -> FormWillAppear(
132134
formId = jsonData.optString("formId"),
133-
formName = jsonData.optString("formName")
135+
formName = jsonData.optString("formName"),
136+
layout = FormLayout.fromJson(jsonData.optJSONObject("layout"))
134137
)
135138

136139
keyName<TrackAggregateEvent>() -> TrackAggregateEvent(

0 commit comments

Comments
 (0)