Skip to content

Commit e04a181

Browse files
authored
feat(iaf) Android in-app form lifecycle hook support (#434)
2 parents 90473ab + f7d631a commit e04a181

18 files changed

Lines changed: 847 additions & 121 deletions

File tree

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

Lines changed: 26 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.registerFormLifecycleHandler
813
import com.klaviyo.location.registerGeofencing
914

1015
class SampleApplication : Application() {
@@ -22,6 +27,27 @@ 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+
.registerFormLifecycleHandler { 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(
36+
"Form Lifecycle: ${event.formName} (${event.formId}) Shown"
37+
)
38+
}
39+
is FormDismissed -> {
40+
Registry.log.debug(
41+
"Form Lifecycle: ${event.formName} (${event.formId}) Dismissed"
42+
)
43+
}
44+
is FormCtaClicked -> {
45+
Registry.log.debug(
46+
"Form Lifecycle: CTA ${event.buttonLabel} -> ${event.deepLinkUrl} from ${event.formName} (${event.formId})"
47+
)
48+
}
49+
}
50+
}
2551
}
2652
}
2753

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.klaviyo.forms
2+
3+
import android.net.Uri
4+
5+
/**
6+
* Represents a lifecycle event of an in-app form, carrying contextual metadata
7+
* about the form and event-specific data.
8+
*
9+
* Use [formId] and [formName] to identify the form associated with any event.
10+
* For CTA-specific data, match on [FormCtaClicked] to access [FormCtaClicked.buttonLabel]
11+
* and [FormCtaClicked.deepLinkUrl].
12+
*/
13+
sealed interface FormLifecycleEvent {
14+
/**
15+
* The form ID of the form associated with this event.
16+
*/
17+
val formId: String
18+
19+
/**
20+
* The display name of the form associated with this event.
21+
*/
22+
val formName: String
23+
24+
/**
25+
* Triggered when a form is shown to the user.
26+
*
27+
* Fired after the SDK has initiated form presentation.
28+
*/
29+
data class FormShown(
30+
override val formId: String,
31+
override val formName: String
32+
) : FormLifecycleEvent
33+
34+
/**
35+
* Triggered when a form is dismissed by the user.
36+
*
37+
* Fired after the SDK has initiated form dismissal. Fires for
38+
* user-initiated dismissals (e.g. tapping outside, close button).
39+
* Does not fire when the SDK tears down the form internally
40+
* (session timeouts, aborts).
41+
*/
42+
data class FormDismissed(
43+
override val formId: String,
44+
override val formName: String
45+
) : FormLifecycleEvent
46+
47+
/**
48+
* Triggered when a user taps a call-to-action (CTA) button in a form
49+
* that has a deep link URL configured.
50+
*
51+
* Fired after the SDK has initiated deep link navigation. Not emitted
52+
* if no deep link URL is configured for the CTA.
53+
*
54+
* @property buttonLabel The text label of the CTA button.
55+
* @property deepLinkUrl The deep link URI configured for the CTA.
56+
*/
57+
data class FormCtaClicked(
58+
override val formId: String,
59+
override val formName: String,
60+
val buttonLabel: String,
61+
val deepLinkUrl: Uri
62+
) : FormLifecycleEvent
63+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.klaviyo.forms
2+
3+
/**
4+
* Functional interface for handling form lifecycle events.
5+
*
6+
* Implement this interface to receive notifications when in-app form lifecycle events occur.
7+
*
8+
* **Threading:** The handler is always invoked on the main thread.
9+
* Lifecycle callbacks fire after the SDK has already initiated the corresponding
10+
* action (presentation, dismissal, or deep link navigation). Avoid performing
11+
* long-running or blocking work in this handler, as it runs on the main thread.
12+
*
13+
* Example usage:
14+
* ```
15+
* Klaviyo.registerFormLifecycleHandler { event ->
16+
* when (event) {
17+
* is FormLifecycleEvent.FormShown -> Log.d("Forms", "Form shown: ${event.formId}")
18+
* is FormLifecycleEvent.FormDismissed -> Log.d("Forms", "Form dismissed: ${event.formId}")
19+
* is FormLifecycleEvent.FormCtaClicked -> Log.d("Forms", "CTA: ${event.buttonLabel}")
20+
* }
21+
* }
22+
* ```
23+
*/
24+
fun interface FormLifecycleHandler {
25+
/**
26+
* Called when a form lifecycle event occurs.
27+
*
28+
* @param event The lifecycle event, containing form metadata and event-specific data.
29+
*/
30+
fun onFormLifecycleEvent(event: FormLifecycleEvent)
31+
}

sdk/forms-core/src/main/java/com/klaviyo/forms/InAppForms.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,27 @@ fun Klaviyo.unregisterFromInAppForms(): Klaviyo =
3838
safeApply { provider.unregister() }
3939
} ?: throw MissingKlaviyoModule("forms")
4040

41+
/**
42+
* Register a handler to receive [FormLifecycleEvent] events.
43+
*
44+
* The handler is invoked whenever a form is shown, dismissed,
45+
* or a CTA button is clicked. Only one handler can be registered at a time;
46+
* calling this again replaces the previous registration.
47+
*
48+
* **Threading:** The handler is always invoked on the main thread.
49+
*
50+
* @param handler The [FormLifecycleHandler] to invoke on lifecycle events.
51+
*/
52+
fun Klaviyo.registerFormLifecycleHandler(handler: FormLifecycleHandler): Klaviyo =
53+
safeApply { Registry.register<FormLifecycleHandler>(handler) }
54+
55+
/**
56+
* Remove the previously registered form lifecycle handler.
57+
* After calling this, no further lifecycle events will be delivered.
58+
*/
59+
fun Klaviyo.unregisterFormLifecycleHandler(): Klaviyo =
60+
safeApply { Registry.unregister<FormLifecycleHandler>() }
61+
4162
/**
4263
* Java-friendly static methods for In-App Forms.
4364
* Kotlin users should use the extension functions on [Klaviyo] instead.
@@ -68,4 +89,27 @@ object KlaviyoForms {
6889
fun unregisterFromInAppForms() {
6990
Klaviyo.unregisterFromInAppForms()
7091
}
92+
93+
/**
94+
* Register a handler to receive form lifecycle events.
95+
* Java-friendly static method.
96+
*
97+
* @param handler The [FormLifecycleHandler] to invoke on lifecycle events.
98+
* @see Klaviyo.registerFormLifecycleHandler
99+
*/
100+
@JvmStatic
101+
fun registerFormLifecycleHandler(handler: FormLifecycleHandler) {
102+
Klaviyo.registerFormLifecycleHandler(handler)
103+
}
104+
105+
/**
106+
* Remove the previously registered form lifecycle handler.
107+
* Java-friendly static method.
108+
*
109+
* @see Klaviyo.unregisterFormLifecycleHandler
110+
*/
111+
@JvmStatic
112+
fun unregisterFormLifecycleHandler() {
113+
Klaviyo.unregisterFormLifecycleHandler()
114+
}
71115
}

sdk/forms/src/main/assets/klaviyo-js-bridge.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,19 +93,16 @@ window.openForm = function (formId) {
9393
}
9494

9595
/**
96-
* Close a Klaviyo form by formId, or any currently open forms
96+
* Close any currently open Klaviyo forms
9797
*
98-
* @param {string} formId
9998
* @returns {boolean}
10099
*/
101-
window.closeForm = function (formId) {
100+
window.closeForm = function () {
102101
document.head.dispatchEvent(
103102
new CustomEvent(
104103
'closeForm',
105104
{
106-
detail: {
107-
formId: formId
108-
}
105+
detail: {}
109106
}
110107
)
111108
)

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,9 @@ internal interface JsBridge {
4141
fun openForm(formId: FormId)
4242

4343
/**
44-
* Close a form in the webview by [FormId]
45-
* If no ID provided, close any currently open forms.
44+
* Close all currently open forms in the webview.
4645
*/
47-
fun closeForm(formId: FormId?)
46+
fun closeForm()
4847

4948
/**
5049
* Injects safe area insets into the webview

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,7 @@ internal class KlaviyoJsBridge : JsBridge {
4646
formId
4747
)
4848

49-
override fun closeForm(formId: FormId?) = evaluateJavascript(
50-
HelperFunction.closeForm,
51-
formId ?: ""
52-
)
49+
override fun closeForm() = evaluateJavascript(HelperFunction.closeForm)
5350

5451
override fun profileMutation(profile: ImmutableProfile) = evaluateJavascript(
5552
HelperFunction.profileMutation,

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

Lines changed: 77 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.FormLifecycleEvent
15+
import com.klaviyo.forms.FormLifecycleHandler
1416
import com.klaviyo.forms.bridge.NativeBridgeMessage.Abort
1517
import com.klaviyo.forms.bridge.NativeBridgeMessage.FormDisappeared
1618
import com.klaviyo.forms.bridge.NativeBridgeMessage.FormWillAppear
@@ -27,7 +29,7 @@ import com.klaviyo.forms.webview.WebViewClient
2729
* An instance of this class is injected into a [com.klaviyo.forms.webview.KlaviyoWebView] as a global property
2830
* on the window. It receives and interprets messages from klaviyo.js over the native bridge
2931
*/
30-
internal class KlaviyoNativeBridge() : NativeBridge {
32+
internal class KlaviyoNativeBridge : NativeBridge {
3133

3234
/**
3335
* This is the name that will be used to access the bridge from JS, i.e. window.KlaviyoNativeBridge
@@ -71,7 +73,7 @@ internal class KlaviyoNativeBridge() : NativeBridge {
7173
is TrackAggregateEvent -> createAggregateEvent(bridgeMessage)
7274
is TrackProfileEvent -> createProfileEvent(bridgeMessage)
7375
is OpenDeepLink -> deepLink(bridgeMessage)
74-
is FormDisappeared -> close()
76+
is FormDisappeared -> close(bridgeMessage)
7577
is Abort -> abort(bridgeMessage.reason)
7678
}
7779
} catch (e: Exception) {
@@ -92,8 +94,20 @@ internal class KlaviyoNativeBridge() : NativeBridge {
9294
/**
9395
* Notify the client that the webview should be shown
9496
*/
95-
private fun show(bridgeMessage: FormWillAppear) = Registry.get<PresentationManager>()
96-
.present(bridgeMessage.formId)
97+
private fun show(bridgeMessage: FormWillAppear) {
98+
Registry.get<PresentationManager>().present()
99+
100+
if (bridgeMessage.formId.isEmpty() || bridgeMessage.formName.isEmpty()) {
101+
Registry.log.warning(
102+
"FormWillAppear missing required fields, skipping lifecycle callback"
103+
)
104+
return
105+
}
106+
107+
invokeFormLifecycleHandler(
108+
FormLifecycleEvent.FormShown(bridgeMessage.formId, bridgeMessage.formName)
109+
)
110+
}
97111

98112
/**
99113
* Handle a [TrackAggregateEvent] message by creating an API call
@@ -114,17 +128,73 @@ internal class KlaviyoNativeBridge() : NativeBridge {
114128
* There is a brief window between our overlay activity pausing and the next activity resuming.
115129
* We alleviate this race condition by postponing till next activity resumes if current activity is null.
116130
*/
117-
private fun deepLink(message: OpenDeepLink) = DeepLinking.handleDeepLink(message.route.toUri())
131+
private fun deepLink(message: OpenDeepLink) {
132+
val deepLinkUri = message.route?.toUri()
133+
134+
if (deepLinkUri == null) {
135+
Registry.log.warning("Form CTA with no Android route configured: ${message.formId}")
136+
return
137+
}
138+
139+
DeepLinking.handleDeepLink(deepLinkUri)
140+
141+
if (message.formId.isEmpty() || message.formName.isEmpty()) {
142+
Registry.log.warning(
143+
"OpenDeepLink missing required fields, skipping lifecycle callback"
144+
)
145+
return
146+
}
147+
148+
invokeFormLifecycleHandler(
149+
FormLifecycleEvent.FormCtaClicked(
150+
formId = message.formId,
151+
formName = message.formName,
152+
buttonLabel = message.buttonLabel,
153+
deepLinkUrl = deepLinkUri
154+
)
155+
)
156+
}
118157

119158
/**
120-
* Instruct presentation manager to dismiss the form overlay activity
159+
* Instruct presentation manager to dismiss the form overlay activity.
121160
*/
122-
private fun close() = Registry.get<PresentationManager>().dismiss()
161+
private fun close(bridgeMessage: FormDisappeared) {
162+
Registry.get<PresentationManager>().dismiss()
163+
164+
if (bridgeMessage.formId.isEmpty() || bridgeMessage.formName.isEmpty()) {
165+
Registry.log.warning(
166+
"FormDisappeared missing required fields, skipping lifecycle callback"
167+
)
168+
return
169+
}
170+
171+
invokeFormLifecycleHandler(
172+
FormLifecycleEvent.FormDismissed(bridgeMessage.formId, bridgeMessage.formName)
173+
)
174+
}
123175

124176
/**
125177
* Handle a [Abort] message by logging the reason and destroying the webview
126178
*/
127179
private fun abort(reason: String) = Klaviyo.unregisterFromInAppForms().also {
128180
Registry.log.error("IAF aborted, reason: $reason")
129181
}
182+
183+
/**
184+
* Invoke the registered form lifecycle callback on the main thread, if one is registered.
185+
* Dispatches to main thread for consistency across bridge paths:
186+
* Modern WebView versions, using [WEB_MESSAGE_LISTENER] are already on main thread,
187+
* but [JavascriptInterface] sends its messages on a background thread.
188+
*/
189+
private fun invokeFormLifecycleHandler(event: FormLifecycleEvent) {
190+
Registry.getOrNull<FormLifecycleHandler>()?.let { callback ->
191+
Registry.threadHelper.runOnUiThread {
192+
try {
193+
callback.onFormLifecycleEvent(event)
194+
} catch (e: Exception) {
195+
Registry.log.error("Form lifecycle callback threw an exception", e)
196+
}
197+
}
198+
}
199+
}
130200
}

0 commit comments

Comments
 (0)