Skip to content

Commit 3cdf780

Browse files
evan-masseauclaude
andcommitted
feat: Add form lifecycle hooks for in-app forms (Part of MAGE-287)
Adds FormLifecycleEvent sealed interface with event-specific data, FormLifecycleCallback registration API, and lifecycle event firing from PresentationManager and NativeBridge. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 7f2a1d5 commit 3cdf780

16 files changed

Lines changed: 679 additions & 44 deletions

sample/src/main/java/com/klaviyo/sample/SampleApplication.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import android.app.Application
44
import android.content.Context
55
import android.widget.Toast
66
import com.klaviyo.analytics.Klaviyo
7+
import com.klaviyo.core.Registry
8+
import com.klaviyo.forms.FormLifecycleEvent.FormCtaClicked
9+
import com.klaviyo.forms.FormLifecycleEvent.FormDismissed
10+
import com.klaviyo.forms.FormLifecycleEvent.FormShown
711
import com.klaviyo.forms.registerForInAppForms
12+
import com.klaviyo.forms.registerFormLifecycleCallback
813
import com.klaviyo.location.registerGeofencing
914

1015
class SampleApplication : Application() {
@@ -22,6 +27,26 @@ class SampleApplication : Application() {
2227
// If not using a deep link handler, Klaviyo will send an Intent to your app with the deep link in intent.data
2328
showToast("Deep link to: $uri")
2429
}
30+
.registerFormLifecycleCallback { event ->
31+
// OPTIONAL SETUP NOTE: Register a callback to receive form lifecycle events
32+
// This allows you to track when forms are shown, dismissed, or when CTAs are clicked
33+
when (event) {
34+
is FormShown -> {
35+
Registry.log.debug("Form shown: ${event.formId} ${event.formName}")
36+
showToast("Form shown: ${event.formId} ${event.formName}")
37+
}
38+
is FormDismissed -> {
39+
Registry.log.debug("Form dismissed: ${event.formId} ${event.formName}")
40+
showToast("Form dismissed: ${event.formId} ${event.formName}")
41+
}
42+
is FormCtaClicked -> {
43+
Registry.log.debug(
44+
"Form CTA: ${event.buttonLabel} -> ${event.deepLinkUrl}"
45+
)
46+
showToast("Form CTA: ${event.buttonLabel} -> ${event.deepLinkUrl}")
47+
}
48+
}
49+
}
2550
}
2651
}
2752

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.klaviyo.forms
2+
3+
/**
4+
* Internal contextual metadata about the in-app form being presented.
5+
* Used by [com.klaviyo.forms.presentation.PresentationState] to track the current form.
6+
*/
7+
internal data class FormContext(
8+
val formId: String?,
9+
val formName: String?
10+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.klaviyo.forms
2+
3+
/**
4+
* Functional interface for handling form lifecycle events.
5+
*
6+
* Implement this interface to receive callbacks when in-app form lifecycle events occur.
7+
* All callbacks are invoked on the UI thread.
8+
*
9+
* Example usage:
10+
* ```
11+
* Klaviyo.registerFormLifecycleCallback { event ->
12+
* when (event) {
13+
* is FormLifecycleEvent.FormShown -> Log.d("Forms", "Form shown: ${event.formId}")
14+
* is FormLifecycleEvent.FormDismissed -> Log.d("Forms", "Form dismissed: ${event.formId}")
15+
* is FormLifecycleEvent.FormCtaClicked -> Log.d("Forms", "CTA: ${event.buttonLabel}")
16+
* }
17+
* }
18+
* ```
19+
*/
20+
fun interface FormLifecycleCallback {
21+
/**
22+
* Called when a form lifecycle event occurs.
23+
*
24+
* @param event The lifecycle event, containing form metadata and event-specific data.
25+
*/
26+
fun onFormLifecycleEvent(event: FormLifecycleEvent)
27+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.klaviyo.forms
2+
3+
/**
4+
* Represents a lifecycle event of an in-app form, carrying contextual metadata
5+
* about the form and event-specific data.
6+
*
7+
* Use [formId] and [formName] to identify the form associated with any event.
8+
* For CTA-specific data, match on [FormCtaClicked] to access [FormCtaClicked.buttonLabel]
9+
* and [FormCtaClicked.deepLinkUrl].
10+
*/
11+
sealed interface FormLifecycleEvent {
12+
/**
13+
* The form ID of the form associated with this event, or null if unavailable.
14+
*/
15+
val formId: String?
16+
17+
/**
18+
* The display name of the form associated with this event, or null if unavailable.
19+
*/
20+
val formName: String?
21+
22+
/**
23+
* Triggered when a form is shown to the user.
24+
*/
25+
data class FormShown(
26+
override val formId: String?,
27+
override val formName: String?
28+
) : FormLifecycleEvent
29+
30+
/**
31+
* Triggered when a form is dismissed (closed) by the user.
32+
*/
33+
data class FormDismissed(
34+
override val formId: String?,
35+
override val formName: String?
36+
) : FormLifecycleEvent
37+
38+
/**
39+
* Triggered when a user taps a call-to-action (CTA) button in the form.
40+
*
41+
* @property buttonLabel The text label of the CTA button, or null if unavailable.
42+
* @property deepLinkUrl The deep link URL configured for the CTA, or null if not configured.
43+
*/
44+
data class FormCtaClicked(
45+
override val formId: String?,
46+
override val formName: String?,
47+
val buttonLabel: String?,
48+
val deepLinkUrl: String?
49+
) : FormLifecycleEvent
50+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.klaviyo.forms
2+
3+
import com.klaviyo.core.Registry
4+
5+
/**
6+
* Invoke the registered form lifecycle callback on the UI thread, if one is registered.
7+
*
8+
* Shared utility used by both [com.klaviyo.forms.bridge.KlaviyoNativeBridge]
9+
* and [com.klaviyo.forms.presentation.KlaviyoPresentationManager].
10+
*/
11+
internal fun invokeFormLifecycleCallback(event: FormLifecycleEvent) {
12+
Registry.getOrNull<FormLifecycleCallback>()?.let { callback ->
13+
Registry.threadHelper.runOnUiThread {
14+
try {
15+
callback.onFormLifecycleEvent(event)
16+
} catch (e: Exception) {
17+
Registry.log.error("Form lifecycle callback threw an exception", e)
18+
}
19+
}
20+
}
21+
}

sdk/forms/src/main/java/com/klaviyo/forms/KlaviyoFormsProvider.kt

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,58 @@ internal class KlaviyoFormsProvider : FormsProvider {
5656
}
5757
}
5858

59+
/**
60+
* Register a callback to receive [FormLifecycleEvent]s.
61+
*
62+
* The callback will be invoked on the UI thread whenever a form is shown, dismissed,
63+
* or a CTA button is clicked. Only one callback can be registered at a time;
64+
* calling this again replaces the previous registration.
65+
*
66+
* @param callback The [FormLifecycleCallback] to invoke on lifecycle events.
67+
*/
68+
fun Klaviyo.registerFormLifecycleCallback(callback: FormLifecycleCallback): Klaviyo =
69+
safeApply { Registry.register<FormLifecycleCallback>(callback) }
70+
71+
/**
72+
* Remove the previously registered form lifecycle callback.
73+
* After calling this, no further lifecycle events will be delivered.
74+
*/
75+
fun Klaviyo.unregisterFormLifecycleCallback(): Klaviyo =
76+
safeApply { Registry.unregister<FormLifecycleCallback>() }
77+
78+
/**
79+
* Java-friendly static methods for form lifecycle callbacks.
80+
* Kotlin users should use the extension functions on [Klaviyo] instead.
81+
*
82+
* Note: These wrappers live in the `forms` module (rather than in [KlaviyoForms] in `forms-core`)
83+
* because [FormLifecycleCallback] is defined in the `forms` module and `forms-core` cannot depend
84+
* on `forms`.
85+
*/
86+
object KlaviyoFormLifecycleCallbacks {
87+
/**
88+
* Register a callback to receive form lifecycle events.
89+
* Java-friendly static method.
90+
*
91+
* @param callback The [FormLifecycleCallback] to invoke on lifecycle events.
92+
* @see Klaviyo.registerFormLifecycleCallback
93+
*/
94+
@JvmStatic
95+
fun registerFormLifecycleCallback(callback: FormLifecycleCallback) {
96+
Klaviyo.registerFormLifecycleCallback(callback)
97+
}
98+
99+
/**
100+
* Remove the previously registered form lifecycle callback.
101+
* Java-friendly static method.
102+
*
103+
* @see Klaviyo.unregisterFormLifecycleCallback
104+
*/
105+
@JvmStatic
106+
fun unregisterFormLifecycleCallback() {
107+
Klaviyo.unregisterFormLifecycleCallback()
108+
}
109+
}
110+
59111
/**
60112
* Check if IAF services are registered in the Klaviyo registry.
61113
*/

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

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import com.klaviyo.analytics.Klaviyo
1111
import com.klaviyo.analytics.linking.DeepLinking
1212
import com.klaviyo.analytics.networking.ApiClient
1313
import com.klaviyo.core.Registry
14+
import com.klaviyo.forms.FormContext
15+
import com.klaviyo.forms.FormLifecycleEvent
1416
import com.klaviyo.forms.bridge.NativeBridgeMessage.Abort
1517
import com.klaviyo.forms.bridge.NativeBridgeMessage.FormDisappeared
1618
import com.klaviyo.forms.bridge.NativeBridgeMessage.FormWillAppear
@@ -19,6 +21,7 @@ import com.klaviyo.forms.bridge.NativeBridgeMessage.JsReady
1921
import com.klaviyo.forms.bridge.NativeBridgeMessage.OpenDeepLink
2022
import com.klaviyo.forms.bridge.NativeBridgeMessage.TrackAggregateEvent
2123
import com.klaviyo.forms.bridge.NativeBridgeMessage.TrackProfileEvent
24+
import com.klaviyo.forms.invokeFormLifecycleCallback
2225
import com.klaviyo.forms.presentation.PresentationManager
2326
import com.klaviyo.forms.unregisterFromInAppForms
2427
import com.klaviyo.forms.webview.WebViewClient
@@ -71,7 +74,7 @@ internal class KlaviyoNativeBridge() : NativeBridge {
7174
is TrackAggregateEvent -> createAggregateEvent(bridgeMessage)
7275
is TrackProfileEvent -> createProfileEvent(bridgeMessage)
7376
is OpenDeepLink -> deepLink(bridgeMessage)
74-
is FormDisappeared -> close()
77+
is FormDisappeared -> close(bridgeMessage)
7578
is Abort -> abort(bridgeMessage.reason)
7679
}
7780
} catch (e: Exception) {
@@ -92,8 +95,11 @@ internal class KlaviyoNativeBridge() : NativeBridge {
9295
/**
9396
* Notify the client that the webview should be shown
9497
*/
95-
private fun show(bridgeMessage: FormWillAppear) = Registry.get<PresentationManager>()
96-
.present(bridgeMessage.formId)
98+
private fun show(bridgeMessage: FormWillAppear) {
99+
Registry.get<PresentationManager>().present(
100+
FormContext(bridgeMessage.formId, bridgeMessage.formName)
101+
)
102+
}
97103

98104
/**
99105
* Handle a [TrackAggregateEvent] message by creating an API call
@@ -115,14 +121,30 @@ internal class KlaviyoNativeBridge() : NativeBridge {
115121
* We alleviate this race condition by postponing till next activity resumes if current activity is null.
116122
*/
117123
private fun deepLink(message: OpenDeepLink) {
118-
message.route?.let { DeepLinking.handleDeepLink(it.toUri()) }
119-
?: Registry.log.warning("Deep link CTA with no Android route configured")
124+
invokeFormLifecycleCallback(
125+
FormLifecycleEvent.FormCtaClicked(
126+
formId = message.formId,
127+
formName = message.formName,
128+
buttonLabel = message.buttonLabel,
129+
deepLinkUrl = message.route
130+
)
131+
)
132+
Registry.log.debug("Form CTA clicked: ${message.formId}")
133+
message.route?.let {
134+
DeepLinking.handleDeepLink(it.toUri())
135+
} ?: Registry.log.warning("Form CTA with no Android route configured")
120136
}
121137

122138
/**
123-
* Instruct presentation manager to dismiss the form overlay activity
139+
* Instruct presentation manager to dismiss the form overlay activity.
140+
* The presentation manager handles firing the [FORM_DISMISSED][FormLifecycleEvent.FORM_DISMISSED]
141+
* callback and guarding against duplicate events.
124142
*/
125-
private fun close() = Registry.get<PresentationManager>().dismiss()
143+
private fun close(bridgeMessage: FormDisappeared) {
144+
Registry.get<PresentationManager>().dismiss(
145+
FormContext(bridgeMessage.formId, bridgeMessage.formName)
146+
)
147+
}
126148

127149
/**
128150
* Handle a [Abort] message by logging the reason and destroying the webview

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ internal sealed class NativeBridgeMessage {
2828
* Sent from the onsite-in-app-forms when a form is about to appear as a signal to present the webview
2929
*
3030
* @param formId The form ID of the form that is appearing
31+
* @param formName The name of the form that is appearing
3132
*/
3233
data class FormWillAppear(
33-
val formId: FormId?
34+
val formId: FormId?,
35+
val formName: String?
3436
) : NativeBridgeMessage()
3537

3638
/**
@@ -57,18 +59,26 @@ internal sealed class NativeBridgeMessage {
5759
* Sent from the onsite-in-app-forms when a deep link is opened
5860
*
5961
* @param route The deep link route to be opened (usually a URL), or null if no Android route is configured
62+
* @param formId The form ID of the form that triggered the deep link
63+
* @param formName The name of the form that triggered the deep link
64+
* @param buttonLabel The text label of the CTA button that was clicked
6065
*/
6166
data class OpenDeepLink(
62-
val route: String?
67+
val route: String?,
68+
val formId: FormId?,
69+
val formName: String?,
70+
val buttonLabel: String?
6371
) : NativeBridgeMessage()
6472

6573
/**
6674
* Sent from the onsite-in-app-forms when a form is closed as a signal to dismiss the webview
6775
*
6876
* @param formId The form ID of the form that is disappearing
77+
* @param formName The name of the form that is disappearing
6978
*/
7079
data class FormDisappeared(
71-
val formId: FormId?
80+
val formId: FormId?,
81+
val formName: String?
7282
) : NativeBridgeMessage()
7383

7484
/**
@@ -119,7 +129,8 @@ internal sealed class NativeBridgeMessage {
119129
keyName<HandShook>() -> HandShook
120130

121131
keyName<FormWillAppear>() -> FormWillAppear(
122-
formId = jsonData.optString("formId").takeIf { it.isNotEmpty() }
132+
formId = jsonData.optString("formId").takeIf { it.isNotEmpty() },
133+
formName = jsonData.optString("formName").takeIf { it.isNotEmpty() }
123134
)
124135

125136
keyName<TrackAggregateEvent>() -> TrackAggregateEvent(
@@ -134,11 +145,15 @@ internal sealed class NativeBridgeMessage {
134145
)
135146

136147
keyName<OpenDeepLink>() -> OpenDeepLink(
137-
route = jsonData.getDeepLink()
148+
route = jsonData.getDeepLink(),
149+
formId = jsonData.optString("formId").takeIf { it.isNotEmpty() },
150+
formName = jsonData.optString("formName").takeIf { it.isNotEmpty() },
151+
buttonLabel = jsonData.optString("buttonLabel").takeIf { it.isNotEmpty() }
138152
)
139153

140154
keyName<FormDisappeared>() -> FormDisappeared(
141-
formId = jsonData.optString("formId").takeIf { it.isNotEmpty() }
155+
formId = jsonData.optString("formId").takeIf { it.isNotEmpty() },
156+
formName = jsonData.optString("formName").takeIf { it.isNotEmpty() }
142157
)
143158

144159
keyName<Abort>() -> Abort(

0 commit comments

Comments
 (0)