Skip to content
Draft
1 change: 1 addition & 0 deletions sdk/forms/src/main/assets/InAppFormsTemplate.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
data-forms-data-environment='FORMS_ENVIRONMENT'
data-klaviyo-local-tracking="1"
data-klaviyo-profile="{}"
data-klaviyo-device='DEVICE_INFO'
>
<meta charset="UTF-8">
<meta name="viewport"
Expand Down
191 changes: 191 additions & 0 deletions sdk/forms/src/main/java/com/klaviyo/forms/bridge/DeviceInfo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package com.klaviyo.forms.bridge

import android.content.res.Configuration
import android.content.res.Resources
import android.util.DisplayMetrics
import android.view.Surface
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import com.klaviyo.core.Registry
import kotlin.math.roundToInt
import org.json.JSONObject

/**
* Describes the device's current physical display characteristics, exposed to onsite JS
* via the `data-klaviyo-device` attribute on the HTML `<head>` element.
*
* The shape intentionally mirrors CSSOM conventions so onsite code can treat the payload
* as a reliable, orientation-aware substitute for `window.screen.*` during the synchronous
* HTML parse phase, before the webview attaches to the view hierarchy.
*/
internal data class DeviceInfo(
val screenWidthDp: Int,
val screenHeightDp: Int,
val insetTopDp: Int,
val insetBottomDp: Int,
val insetLeftDp: Int,
val insetRightDp: Int,
val orientation: Orientation,
val dpr: Int
) {
/**
* Serializes the device info into its JSON representation as published on the
* `data-klaviyo-device` head attribute.
*/
fun toJson(): String = JSONObject()
.put(
KEY_SCREEN,
JSONObject()
.put(KEY_WIDTH, screenWidthDp)
.put(KEY_HEIGHT, screenHeightDp)
)
.put(
KEY_SAFE_AREA_INSETS,
JSONObject()
.put(KEY_TOP, insetTopDp)
.put(KEY_BOTTOM, insetBottomDp)
.put(KEY_LEFT, insetLeftDp)
.put(KEY_RIGHT, insetRightDp)
)
.put(KEY_ORIENTATION, orientation.cssValue)
.put(KEY_DPR, dpr)
.toString()

/**
* CSSOM `ScreenOrientation.type` vocabulary.
* See https://drafts.csswg.org/screen-orientation/#enumdef-orientationtype
*/
internal enum class Orientation(val cssValue: String) {
PortraitPrimary("portrait-primary"),
PortraitSecondary("portrait-secondary"),
LandscapePrimary("landscape-primary"),
LandscapeSecondary("landscape-secondary");

companion object {
/**
* Derives the CSSOM orientation label from the Android [Configuration.orientation]
* and [android.view.Display.getRotation] values.
*
* Android's rotation values describe the counter-clockwise rotation applied to the
* natural orientation; pairing that with whether the logical orientation is portrait
* or landscape gives us enough to pick the `*-primary` vs `*-secondary` flavor.
*
* Caveat: this mapping assumes a natural-portrait device, which holds for all phones
* and is sufficient for our product scope. On natural-landscape devices (some tablets
* and foldables) the rotation-to-CSSOM mapping may diverge from
* [`screen.orientation.type`](https://drafts.csswg.org/screen-orientation/#dom-screenorientation-type):
* for example, `rotation = 90` on a natural-landscape device is reported here as
* `portrait-secondary`, where the web platform would report `portrait-primary`.
*/
fun from(configOrientation: Int, rotation: Int): Orientation {
val isPortrait = configOrientation != Configuration.ORIENTATION_LANDSCAPE
return when (rotation) {
Surface.ROTATION_0 -> if (isPortrait) PortraitPrimary else LandscapePrimary
Surface.ROTATION_90 -> if (isPortrait) PortraitSecondary else LandscapePrimary
Surface.ROTATION_180 -> if (isPortrait) PortraitSecondary else LandscapeSecondary
Surface.ROTATION_270 -> if (isPortrait) PortraitPrimary else LandscapeSecondary
else -> if (isPortrait) PortraitPrimary else LandscapePrimary
}
}
}
}

companion object {
private const val KEY_SCREEN = "screen"
private const val KEY_WIDTH = "width"
private const val KEY_HEIGHT = "height"
private const val KEY_SAFE_AREA_INSETS = "safeAreaInsets"
private const val KEY_TOP = "top"
private const val KEY_BOTTOM = "bottom"
private const val KEY_LEFT = "left"
private const val KEY_RIGHT = "right"
private const val KEY_ORIENTATION = "orientation"
private const val KEY_DPR = "dpr"

/**
* Build a [DeviceInfo] snapshot from the given display metrics and insets.
*
* Separating computation from Android platform lookups keeps the logic pure and
* testable. See [DeviceInfoProvider] for the live-device lookup entry point.
*/
fun from(
displayMetrics: DisplayMetrics,
configuration: Configuration,
rotation: Int,
insetLeftPx: Int,
insetTopPx: Int,
insetRightPx: Int,
insetBottomPx: Int
): DeviceInfo {
// Guard against pathological DisplayMetrics (density <= 0) which would cause
// NaN from pxToDp. This most commonly occurs in test doubles.
val safeDensity = displayMetrics.density.takeIf { it > 0f } ?: 1f
fun pxToDpRounded(px: Int): Int = (px / safeDensity).roundToInt()
return DeviceInfo(
screenWidthDp = pxToDpRounded(displayMetrics.widthPixels),
screenHeightDp = pxToDpRounded(displayMetrics.heightPixels),
insetTopDp = pxToDpRounded(insetTopPx),
insetBottomDp = pxToDpRounded(insetBottomPx),
insetLeftDp = pxToDpRounded(insetLeftPx),
insetRightDp = pxToDpRounded(insetRightPx),
orientation = Orientation.from(configuration.orientation, rotation),
dpr = safeDensity.roundToInt().coerceAtLeast(1)
)
}
}
}

/**
* Live-device lookup for [DeviceInfo]. Reads from the currently tracked activity when available,
* falling back to the system-wide resources so early callers (before activity attachment) still
* produce a reasonable snapshot.
*/
internal object DeviceInfoProvider {

/**
* Snapshot the current device state. Prefers values from the tracked activity so that
* safe-area insets and rotation reflect the actual window placement rather than the raw
* display.
*/
fun current(): DeviceInfo {
val activity = Registry.lifecycleMonitor.currentActivity
val resources = activity?.resources ?: Resources.getSystem()
val displayMetrics = resources.displayMetrics
val configuration = resources.configuration

val rotation = runCatching {
@Suppress("DEPRECATION")
activity?.windowManager?.defaultDisplay?.rotation ?: Surface.ROTATION_0
}.getOrDefault(Surface.ROTATION_0)

data class InsetsPx(val left: Int, val top: Int, val right: Int, val bottom: Int)
val insetsPx = runCatching {
activity?.window?.decorView?.rootWindowInsets?.let { raw ->
val compat = WindowInsetsCompat.toWindowInsetsCompat(raw)
val insets = compat.getInsets(systemBars() or displayCutout())
InsetsPx(insets.left, insets.top, insets.right, insets.bottom)
}
}.getOrNull() ?: InsetsPx(0, 0, 0, 0)

return DeviceInfo.from(
displayMetrics = displayMetrics,
configuration = configuration,
rotation = rotation,
insetLeftPx = insetsPx.left,
insetTopPx = insetsPx.top,
insetRightPx = insetsPx.right,
insetBottomPx = insetsPx.bottom
)
}
}

/**
* Escapes a JSON payload for embedding inside a single-quoted JS string literal.
*
* Only backslashes and single quotes need escaping — JSON is otherwise JS-safe because
* [JSONObject] already escapes double quotes, control characters, and non-ASCII characters.
*/
internal fun String.jsEscape(): String = this
.replace("\\", "\\\\")
.replace("'", "\\'")
48 changes: 35 additions & 13 deletions sdk/forms/src/main/java/com/klaviyo/forms/bridge/FormLayout.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.klaviyo.forms.bridge

import android.view.Gravity
import com.klaviyo.core.Registry
import java.util.concurrent.atomic.AtomicBoolean
import org.json.JSONObject

/**
Expand All @@ -16,16 +18,6 @@ internal enum class FormPosition {
BOTTOM_RIGHT,
CENTER;

/**
* Returns true if this position uses horizontal centering (CENTER_HORIZONTAL or CENTER).
* These positions need width adjusted for horizontal safe area insets so forms
* don't extend into display cutouts in landscape.
*/
fun isHorizontallyCentered(): Boolean = when (this) {
TOP, BOTTOM, CENTER, FULLSCREEN -> true
TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT -> false
}

/**
* Convert form position to Android Gravity flags
*/
Expand Down Expand Up @@ -147,7 +139,14 @@ internal data class FormLayout(
val position: FormPosition,
val width: Dimension,
val height: Dimension,
val offsets: Offsets = Offsets()
val offsets: Offsets = Offsets(),
/**
* When true (default), the SDK adds safe-area insets to the provided [offsets]
* when positioning the form. When false, the SDK uses [offsets] as-is and does
* not account for safe-area at all — onsite is fully responsible for baking
* any safe-area inset it wants into [offsets].
*/
val addSafeAreaInsetsToOffsets: Boolean = true
) {
/**
* Returns true if this layout represents a fullscreen form
Expand All @@ -156,19 +155,42 @@ internal data class FormLayout(
get() = position == FormPosition.FULLSCREEN

companion object {
/**
* Guards the one-time deprecation log emitted when a payload uses the legacy
* `margin` wire key instead of the new `offsets` key. Scoped to the process
* lifetime — the webview is re-created per session, so this effectively emits
* at most once per webview session (and at most once per process, whichever
* comes first).
*/
private val loggedMarginDeprecation = AtomicBoolean(false)

fun fromJson(json: JSONObject?): FormLayout? {
if (json == null) return null

val position = FormPosition.fromString(json.optString("position"))
val width = Dimension.fromJson(json.optJSONObject("width")) ?: return null
val height = Dimension.fromJson(json.optJSONObject("height")) ?: return null
val offsets = Offsets.fromJson(json.optJSONObject("margin"))

// Prefer the new `offsets` wire key; fall back to legacy `margin` for
// backward compatibility with older onsite payloads. Log once per session
// when we hit the fallback so we can track deprecation in the wild.
val offsetsJson = json.optJSONObject("offsets") ?: json.optJSONObject("margin")?.also {
if (loggedMarginDeprecation.compareAndSet(false, true)) {
Registry.log.verbose(
"FormLayout payload used deprecated `margin` key; " +
"expected `offsets`. Update onsite to emit `offsets`."
)
}
}
val offsets = Offsets.fromJson(offsetsJson)
val addSafeAreaInsetsToOffsets = json.optBoolean("addSafeAreaInsetsToOffsets", true)

return FormLayout(
position = position,
width = width,
height = height,
offsets = offsets
offsets = offsets,
addSafeAreaInsetsToOffsets = addSafeAreaInsetsToOffsets
)
}
}
Expand Down
Loading
Loading