Skip to content

Commit 60e68d3

Browse files
feat(forms): handle IAF openExternalUrl bridge event [PUSH-733] (#499)
2 parents 52ccc42 + 32b5cd4 commit 60e68d3

9 files changed

Lines changed: 353 additions & 4 deletions

File tree

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,7 +799,8 @@ object to the `registerForInAppForms()` method. For example, to set a session ti
799799

800800
> Form lifecycle events are available in SDK version 4.4.0 and higher.
801801

802-
You can register a handler to receive callbacks whenever a form is shown, dismissed, or a CTA button is tapped.
802+
You can register a handler to receive callbacks whenever a form is shown, dismissed, or a CTA button is tapped
803+
(distinguishing in-app deep-link CTAs from CTAs that open an external URL in the browser).
803804
This is useful for forwarding engagement data to a third-party analytics platform such as Amplitude, Segment, or Mixpanel.
804805

805806
The handler is invoked on the **main thread**, so avoid performing long-running or blocking work inside it.
@@ -810,6 +811,7 @@ The handler is invoked on the **main thread**, so avoid performing long-running
810811
```kotlin
811812
import com.klaviyo.analytics.Klaviyo
812813
import com.klaviyo.forms.FormLifecycleEvent.FormCtaClicked
814+
import com.klaviyo.forms.FormLifecycleEvent.FormCtaExternalUrlClicked
813815
import com.klaviyo.forms.FormLifecycleEvent.FormDismissed
814816
import com.klaviyo.forms.FormLifecycleEvent.FormShown
815817
import com.klaviyo.forms.registerFormLifecycleHandler
@@ -831,6 +833,14 @@ The handler is invoked on the **main thread**, so avoid performing long-running
831833
// "deepLinkUrl" to event.deepLinkUrl.toString()
832834
// ))
833835
}
836+
is FormCtaExternalUrlClicked -> {
837+
// e.g. myAnalytics.track("Form External URL Clicked", mapOf(
838+
// "formId" to event.formId,
839+
// "formName" to event.formName,
840+
// "buttonLabel" to event.buttonLabel,
841+
// "externalUrl" to event.externalUrl.toString()
842+
// ))
843+
}
834844
}
835845
}
836846

@@ -853,6 +863,8 @@ The handler is invoked on the **main thread**, so avoid performing long-running
853863
// e.g. myAnalytics.track("Form Dismissed", ...)
854864
} else if (event instanceof FormLifecycleEvent.FormCtaClicked ctaClicked) {
855865
// e.g. myAnalytics.track("Form CTA Clicked", ...)
866+
} else if (event instanceof FormLifecycleEvent.FormCtaExternalUrlClicked externalUrlClicked) {
867+
// e.g. myAnalytics.track("Form External URL Clicked", ...)
856868
}
857869
});
858870

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.widget.Toast
66
import com.klaviyo.analytics.Klaviyo
77
import com.klaviyo.core.Registry
88
import com.klaviyo.forms.FormLifecycleEvent.FormCtaClicked
9+
import com.klaviyo.forms.FormLifecycleEvent.FormCtaExternalUrlClicked
910
import com.klaviyo.forms.FormLifecycleEvent.FormDismissed
1011
import com.klaviyo.forms.FormLifecycleEvent.FormShown
1112
import com.klaviyo.forms.registerForInAppForms
@@ -46,6 +47,11 @@ class SampleApplication : Application() {
4647
"Form Lifecycle: CTA ${event.buttonLabel} -> ${event.deepLinkUrl} from ${event.formName} (${event.formId})"
4748
)
4849
}
50+
is FormCtaExternalUrlClicked -> {
51+
Registry.log.debug(
52+
"Form Lifecycle: External URL CTA ${event.buttonLabel} -> ${event.externalUrl} from ${event.formName} (${event.formId})"
53+
)
54+
}
4955
}
5056
}
5157
}

sdk/fixtures/src/main/java/com/klaviyo/fixtures/MockIntent.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ data class MockIntent(
2323
val packageName: CapturingSlot<String> = slot(),
2424
val flags: CapturingSlot<Int> = slot(),
2525
val className: CapturingSlot<String> = slot(),
26-
val bundle: Bundle = mockk<Bundle>()
26+
val bundle: Bundle = mockk<Bundle>(),
27+
val categories: MutableList<String> = mutableListOf()
2728
) {
2829
companion object {
2930
/**
@@ -38,7 +39,7 @@ data class MockIntent(
3839
val mockIntent = MockIntent(mockk<Intent>(relaxed = true)).apply {
3940
every { intent.addFlags(any()) } returns intent
4041
}
41-
val (intent, action, data, packageName, flags, className, bundle) = mockIntent
42+
val (intent, action, data, packageName, flags, className, bundle, categories) = mockIntent
4243

4344
mockkConstructor(Intent::class)
4445

@@ -49,7 +50,11 @@ data class MockIntent(
4950
every { anyConstructed<Intent>().setPackage(capture(packageName)) } returns intent
5051
every { anyConstructed<Intent>().setFlags(capture(flags)) } returns intent
5152
every { anyConstructed<Intent>().putExtras(any<Bundle>()) } returns intent
52-
every { anyConstructed<Intent>().addFlags(any()) } returns intent
53+
every { anyConstructed<Intent>().addFlags(capture(flags)) } returns intent
54+
every { anyConstructed<Intent>().addCategory(any()) } answers {
55+
categories.add(it.invocation.args[0] as String)
56+
intent
57+
}
5358
every { anyConstructed<Intent>().extras } returns bundle
5459
every { anyConstructed<Intent>().resolveActivity(any()) } returns mockk()
5560

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,22 @@ sealed interface FormLifecycleEvent {
6060
val buttonLabel: String,
6161
val deepLinkUrl: Uri
6262
) : FormLifecycleEvent
63+
64+
/**
65+
* Triggered when a user taps a call-to-action (CTA) button in a form
66+
* that opens an external web URL in the default browser.
67+
*
68+
* Fired after the SDK has dispatched the browser intent. Distinct from
69+
* [FormCtaClicked] because the host app loses focus to the browser, which
70+
* typically warrants different telemetry and UI handling.
71+
*
72+
* @property buttonLabel The text label of the CTA button.
73+
* @property externalUrl The web URL opened in the default browser.
74+
*/
75+
data class FormCtaExternalUrlClicked(
76+
override val formId: String,
77+
override val formName: String,
78+
val buttonLabel: String,
79+
val externalUrl: Uri
80+
) : FormLifecycleEvent
6381
}

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import androidx.webkit.WebViewFeature.WEB_MESSAGE_LISTENER
1010
import com.klaviyo.analytics.Klaviyo
1111
import com.klaviyo.analytics.linking.DeepLinking
1212
import com.klaviyo.analytics.networking.ApiClient
13+
import com.klaviyo.core.Constants.ALLOWED_OPEN_URL_SCHEMES
1314
import com.klaviyo.core.Registry
15+
import com.klaviyo.core.utils.startActivityIfResolved
1416
import com.klaviyo.forms.FormLifecycleEvent
1517
import com.klaviyo.forms.FormLifecycleHandler
1618
import com.klaviyo.forms.bridge.NativeBridgeMessage.Abort
@@ -19,6 +21,7 @@ import com.klaviyo.forms.bridge.NativeBridgeMessage.FormWillAppear
1921
import com.klaviyo.forms.bridge.NativeBridgeMessage.HandShook
2022
import com.klaviyo.forms.bridge.NativeBridgeMessage.JsReady
2123
import com.klaviyo.forms.bridge.NativeBridgeMessage.OpenDeepLink
24+
import com.klaviyo.forms.bridge.NativeBridgeMessage.OpenExternalUrl
2225
import com.klaviyo.forms.bridge.NativeBridgeMessage.TrackAggregateEvent
2326
import com.klaviyo.forms.bridge.NativeBridgeMessage.TrackProfileEvent
2427
import com.klaviyo.forms.presentation.PresentationManager
@@ -73,6 +76,7 @@ internal class KlaviyoNativeBridge : NativeBridge {
7376
is TrackAggregateEvent -> createAggregateEvent(bridgeMessage)
7477
is TrackProfileEvent -> createProfileEvent(bridgeMessage)
7578
is OpenDeepLink -> deepLink(bridgeMessage)
79+
is OpenExternalUrl -> openExternalUrl(bridgeMessage)
7680
is FormDisappeared -> close(bridgeMessage)
7781
is Abort -> abort(bridgeMessage.reason)
7882
}
@@ -155,6 +159,54 @@ internal class KlaviyoNativeBridge : NativeBridge {
155159
)
156160
}
157161

162+
/**
163+
* Handle an [OpenExternalUrl] message by opening the URL in the default browser.
164+
*
165+
* Unlike [deepLink], the intent is not package-scoped (no `setPackage`), so the OS routes it
166+
* to the default browser, bypassing any registered deep link handler — mirroring
167+
* [com.klaviyo.forms.webview.KlaviyoWebViewClient.shouldOverrideUrlLoading]. The `NEW_TASK`
168+
* intent launches independently of the overlay activity, so no grace period is needed.
169+
* Fires [FormLifecycleEvent.FormCtaExternalUrlClicked] after dispatch.
170+
*
171+
* The URL's scheme is checked against [ALLOWED_OPEN_URL_SCHEMES] before dispatch — the same
172+
* allowlist gate applied to push's `open_url`/`web_url` fields (see
173+
* [com.klaviyo.pushFcm.KlaviyoRemoteMessage], PUSH-834) — to avoid routing dangerous or
174+
* unintended URIs (e.g. `intent:`, `javascript:`, `file:`) through the SDK. `smsto:` is
175+
* included for both platforms since Android has a handler for it (iOS omits it only because
176+
* iOS Messages has no `smsto:` handler). The intent itself is built by
177+
* [DeepLinking.makeExternalIntent], shared with the push `open_url` path.
178+
*/
179+
private fun openExternalUrl(message: OpenExternalUrl) {
180+
val externalUri = message.url.toUri()
181+
182+
if (externalUri.scheme?.lowercase() !in ALLOWED_OPEN_URL_SCHEMES) {
183+
Registry.log.warning(
184+
"openExternalUrl url '$externalUri' has a scheme not in the allowed list; ignoring."
185+
)
186+
return
187+
}
188+
189+
DeepLinking.makeExternalIntent(externalUri).startActivityIfResolved(
190+
Registry.config.applicationContext
191+
)
192+
193+
if (message.formId.isEmpty() || message.formName.isEmpty()) {
194+
Registry.log.warning(
195+
"OpenExternalUrl missing required fields, skipping lifecycle callback"
196+
)
197+
return
198+
}
199+
200+
invokeFormLifecycleHandler(
201+
FormLifecycleEvent.FormCtaExternalUrlClicked(
202+
formId = message.formId,
203+
formName = message.formName,
204+
buttonLabel = message.buttonLabel,
205+
externalUrl = externalUri
206+
)
207+
)
208+
}
209+
158210
/**
159211
* Instruct presentation manager to dismiss the form overlay activity.
160212
*/

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ internal sealed class NativeBridgeMessage {
7272
val buttonLabel: String
7373
) : NativeBridgeMessage()
7474

75+
/**
76+
* Sent from the onsite-in-app-forms when a CTA opens an external URL in the default browser.
77+
*
78+
* Unlike [OpenDeepLink], this routes to the default browser (no host-app package scoping)
79+
* rather than through any registered deep link handler.
80+
*
81+
* @param url The web URL to open in the default browser
82+
* @param formId The form ID of the form that triggered the action
83+
* @param formName The name of the form that triggered the action
84+
* @param buttonLabel The text label of the CTA button that was clicked
85+
*/
86+
data class OpenExternalUrl(
87+
val url: String,
88+
val formId: FormId,
89+
val formName: String,
90+
val buttonLabel: String
91+
) : NativeBridgeMessage()
92+
7593
/**
7694
* Sent from the onsite-in-app-forms when a form is closed as a signal to dismiss the webview
7795
*
@@ -111,6 +129,7 @@ internal sealed class NativeBridgeMessage {
111129
HandshakeSpec(keyName<TrackProfileEvent>(), 1),
112130
// Version 2 issues deep link after closing the form (v1 was before close, causing a timing issue)
113131
HandshakeSpec(keyName<OpenDeepLink>(), 2),
132+
HandshakeSpec(keyName<OpenExternalUrl>(), 1),
114133
HandshakeSpec(keyName<FormDisappeared>(), 1),
115134
HandshakeSpec(keyName<Abort>(), 1)
116135
)
@@ -154,6 +173,15 @@ internal sealed class NativeBridgeMessage {
154173
buttonLabel = jsonData.optString("buttonLabel")
155174
)
156175

176+
keyName<OpenExternalUrl>() -> OpenExternalUrl(
177+
url = jsonData.optString("url").ifEmpty {
178+
throw IllegalStateException("openExternalUrl message missing url")
179+
},
180+
formId = jsonData.optString("formId"),
181+
formName = jsonData.optString("formName"),
182+
buttonLabel = jsonData.optString("buttonLabel")
183+
)
184+
157185
keyName<FormDisappeared>() -> FormDisappeared(
158186
formId = jsonData.optString("formId"),
159187
formName = jsonData.optString("formName")

sdk/forms/src/test/java/com/klaviyo/forms/InAppFormsJavaApiTest.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,21 @@ public void testKlaviyoFormsRegisterLifecycleHandler() {
103103
public void testKlaviyoFormsUnregisterLifecycleHandler() {
104104
KlaviyoForms.unregisterFormLifecycleHandler();
105105
}
106+
107+
@Test
108+
public void testFormCtaExternalUrlClickedAccessibleFromJava() {
109+
// Confirms the new lifecycle case and its getters (including the Uri-typed
110+
// externalUrl) are reachable from Java in a FormLifecycleHandler lambda.
111+
FormLifecycleHandler callback = (event) -> {
112+
if (event instanceof FormLifecycleEvent.FormCtaExternalUrlClicked) {
113+
FormLifecycleEvent.FormCtaExternalUrlClicked externalUrlClicked =
114+
(FormLifecycleEvent.FormCtaExternalUrlClicked) event;
115+
String ignored = externalUrlClicked.getButtonLabel()
116+
+ externalUrlClicked.getExternalUrl().toString()
117+
+ externalUrlClicked.getFormId()
118+
+ externalUrlClicked.getFormName();
119+
}
120+
};
121+
KlaviyoForms.registerFormLifecycleHandler(callback);
122+
}
106123
}

0 commit comments

Comments
 (0)