Skip to content

Commit e550d0b

Browse files
committed
feat(onramp): add two-tier WebView postMessage timeout with auth-aware watchdog
Detects dead/unresponsive Coinbase OnRamp WebViews with a 30s initial timeout after page load and 15s inter-event timeout. The watchdog pauses during pending_payment_auth (user is authenticating with their bank) to avoid false positives, and resumes on the next event. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 59b30d2 commit e550d0b

6 files changed

Lines changed: 153 additions & 10 deletions

File tree

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,9 @@
568568
<string name="error_title_onrampInternal">Something Went Wrong</string>
569569
<string name="error_description_onrampInternal">Please try again. We appreciate your patience</string>
570570

571+
<string name="error_title_onrampTimeout">Payment Timed Out</string>
572+
<string name="error_description_onrampTimeout">The payment session took too long to respond. Please try again.</string>
573+
571574
<string name="error_title_onrampTransactionSendFailed">Something Went Wrong</string>
572575
<string name="error_description_onrampTransactionSendFailed">We are working with the Coinbase team to resolve the issue. Your card will be refunded in the meantime. Please try again later</string>
573576

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampHandler.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ private fun showOnRampFailure(resources: Resources, error: CoinbaseOnRampWebErro
133133
}
134134

135135
is CoinbaseOnRampWebError.Internal,
136-
is CoinbaseOnRampWebError.GooglePayButtonNotFound -> {
136+
is CoinbaseOnRampWebError.GooglePayButtonNotFound,
137+
is CoinbaseOnRampWebError.WebViewTimeout -> {
137138
BottomBarManager.showError(
138139
title = resources.getString(R.string.error_title_onrampInternal),
139140
message = resources.getString(R.string.error_description_onrampInternal),

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampWebview.kt

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,20 @@ fun CoinbaseOnRampWebview(
3333
onPaymentFailure: (CoinbaseOnRampWebError) -> Unit,
3434
onCancel: () -> Unit,
3535
) {
36+
var cleanup: (() -> Unit)? = null
3637
ComposeWebView(
3738
modifier = Modifier
3839
.fillMaxSize()
3940
.alpha(0f),
4041
url = paymentLinkUrl,
4142
factoryExtension = {
42-
configureForCoinbaseOnRamp(
43+
cleanup = configureForCoinbaseOnRamp(
4344
onPaymentSuccess = { onPaymentSuccess(orderId) },
4445
onPaymentFailure = onPaymentFailure,
4546
onCancel = onCancel,
4647
)
47-
}
48+
},
49+
onRelease = { cleanup?.invoke() },
4850
)
4951
}
5052

@@ -53,13 +55,61 @@ private fun WebView.configureForCoinbaseOnRamp(
5355
onPaymentSuccess: () -> Unit,
5456
onPaymentFailure: (CoinbaseOnRampWebError) -> Unit,
5557
onCancel: () -> Unit,
56-
) {
58+
): () -> Unit {
5759
val startMark = TimeSource.Monotonic.markNow()
5860
trace(tag = "CoinbaseOnRamp", message = "WebView configured")
5961

6062
val autoClickTriggered = AtomicBoolean(false)
63+
val terminalEventReceived = AtomicBoolean(false)
6164
var messageListenerInstalled = false
6265

66+
var initialTimeoutRunnable: Runnable? = null
67+
var interEventTimeoutRunnable: Runnable? = null
68+
69+
fun cancelAllTimeouts() {
70+
initialTimeoutRunnable?.let { removeCallbacks(it) }
71+
interEventTimeoutRunnable?.let { removeCallbacks(it) }
72+
}
73+
74+
val timeoutAction = Runnable {
75+
if (terminalEventReceived.compareAndSet(false, true)) {
76+
trace(tag = "CoinbaseOnRamp", message = "WebView timeout fired")
77+
onPaymentFailure(CoinbaseOnRampWebError.WebViewTimeout())
78+
}
79+
}
80+
81+
fun scheduleInterEventTimeout() {
82+
val runnable = Runnable { timeoutAction.run() }
83+
interEventTimeoutRunnable = runnable
84+
postDelayed(runnable, INTER_EVENT_TIMEOUT_MS)
85+
}
86+
87+
val wrappedOnPaymentSuccess: () -> Unit = {
88+
terminalEventReceived.set(true)
89+
post { cancelAllTimeouts() }
90+
onPaymentSuccess()
91+
}
92+
93+
val wrappedOnPaymentFailure: (CoinbaseOnRampWebError) -> Unit = { error ->
94+
terminalEventReceived.set(true)
95+
post { cancelAllTimeouts() }
96+
onPaymentFailure(error)
97+
}
98+
99+
val wrappedOnCancel: () -> Unit = {
100+
terminalEventReceived.set(true)
101+
post { cancelAllTimeouts() }
102+
onCancel()
103+
}
104+
105+
val heartbeat: () -> Unit = {
106+
post { cancelAllTimeouts(); scheduleInterEventTimeout() }
107+
}
108+
109+
val pauseWatchdog: () -> Unit = {
110+
post { cancelAllTimeouts() }
111+
}
112+
63113
settings.javaScriptEnabled = true
64114
settings.domStorageEnabled = true
65115
settings.javaScriptCanOpenWindowsAutomatically = true
@@ -68,9 +118,11 @@ private fun WebView.configureForCoinbaseOnRamp(
68118

69119
val eventHandler = CoinbaseOnRampEventHandler(
70120
startMark = startMark,
71-
onPaymentSuccess = onPaymentSuccess,
72-
onPaymentFailure = onPaymentFailure,
73-
onCancel = onCancel,
121+
onPaymentSuccess = wrappedOnPaymentSuccess,
122+
onPaymentFailure = wrappedOnPaymentFailure,
123+
onCancel = wrappedOnCancel,
124+
onHeartbeat = heartbeat,
125+
onPauseWatchdog = pauseWatchdog,
74126
onAutoClickGPay = {
75127
if (autoClickTriggered.compareAndSet(false, true)) {
76128
post {
@@ -100,6 +152,12 @@ private fun WebView.configureForCoinbaseOnRamp(
100152
)
101153
view?.evaluateJavascript(CoinbaseOnRampScripts.MESSAGE_BRIDGE, null)
102154

155+
// Start the initial timeout — if no postMessage event arrives within
156+
// INITIAL_TIMEOUT_MS after page load, the WebView is considered dead.
157+
val runnable = Runnable { timeoutAction.run() }
158+
initialTimeoutRunnable = runnable
159+
view?.postDelayed(runnable, INITIAL_TIMEOUT_MS)
160+
103161
// Fallback: if load_success already fired before the bridge was installed,
104162
// trigger auto-click after a delay to give the bridge a chance first.
105163
view?.postDelayed({
@@ -116,7 +174,7 @@ private fun WebView.configureForCoinbaseOnRamp(
116174
error: WebResourceError?
117175
) {
118176
if (request?.isForMainFrame == true) {
119-
onPaymentFailure(CoinbaseOnRampWebError.GuestGooglePayError())
177+
wrappedOnPaymentFailure(CoinbaseOnRampWebError.GuestGooglePayError())
120178
}
121179
}
122180
}
@@ -133,4 +191,9 @@ private fun WebView.configureForCoinbaseOnRamp(
133191
)
134192
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
135193
}
136-
}
194+
195+
return { cancelAllTimeouts() }
196+
}
197+
198+
private const val INITIAL_TIMEOUT_MS = 30_000L
199+
private const val INTER_EVENT_TIMEOUT_MS = 15_000L

apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandler.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,15 @@ internal class CoinbaseOnRampEventHandler(
135135
private val onPaymentFailure: (CoinbaseOnRampWebError) -> Unit,
136136
private val onCancel: () -> Unit,
137137
private val onAutoClickGPay: () -> Unit,
138+
private val onHeartbeat: () -> Unit = {},
139+
private val onPauseWatchdog: () -> Unit = {},
138140
) {
139141
private var errorReported = false
140142
fun handleEvent(eventJson: String) {
141143
trace(tag = "CoinbaseOnRamp", message = eventJson)
142144
try {
143145
val obj = JSONObject(eventJson)
146+
var pauseWatchdog = false
144147
when (val eventName = obj.optString("eventName")) {
145148
"onramp_api.load_success" -> {
146149
trace(
@@ -197,6 +200,13 @@ internal class CoinbaseOnRampEventHandler(
197200
// cancel: no-op per spec — GPay button re-shows automatically
198201
"onramp_api.cancel" -> onCancel()
199202

203+
// User is authenticating (bank login, 2FA, etc.) — pause the
204+
// watchdog so the inter-event timeout doesn't false-fire while
205+
// the user is interacting with the payment sheet.
206+
"onramp_api.pending_payment_auth" -> {
207+
pauseWatchdog = true
208+
}
209+
200210
"timing.gpay_button_clicked" -> {
201211
trace(
202212
tag = "CoinbaseOnRamp",
@@ -230,6 +240,12 @@ internal class CoinbaseOnRampEventHandler(
230240
)
231241
}
232242
}
243+
244+
if (pauseWatchdog) {
245+
onPauseWatchdog()
246+
} else {
247+
onHeartbeat()
248+
}
233249
} catch (e: Exception) {
234250
trace(tag = "CoinbaseOnRamp", message = "Error parsing event", error = e)
235251
}
@@ -248,6 +264,7 @@ sealed class CoinbaseOnRampWebError(val data: String? = null): Throwable() {
248264
class GuestGooglePayNotReady(data: String? = null) : CoinbaseOnRampWebError(data)
249265
class Internal(data: String? = null) : CoinbaseOnRampWebError(data), NotifiableError
250266
class GooglePayButtonNotFound(data: String? = null) : CoinbaseOnRampWebError(data), NotifiableError
267+
class WebViewTimeout(data: String? = null) : CoinbaseOnRampWebError(data), NotifiableError
251268

252269
companion object {
253270
fun fromErrorCode(errorCode: String, data: String? = null): CoinbaseOnRampWebError {

apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/internal/CoinbaseOnRampEventHandlerTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import org.robolectric.RobolectricTestRunner
66
import org.robolectric.annotation.Config
77
import kotlin.test.assertEquals
88
import kotlin.time.TimeSource
9+
import com.getcode.utils.NotifiableError
10+
import kotlin.test.assertFalse
911
import kotlin.test.assertIs
1012
import kotlin.test.assertNotNull
1113
import kotlin.test.assertTrue
@@ -17,6 +19,8 @@ class CoinbaseOnRampEventHandlerTest {
1719
private var successCount = 0
1820
private var cancelCount = 0
1921
private var autoClickCount = 0
22+
private var heartbeatCount = 0
23+
private var pauseWatchdogCount = 0
2024
private var lastError: CoinbaseOnRampWebError? = null
2125

2226
private val handler = CoinbaseOnRampEventHandler(
@@ -25,6 +29,8 @@ class CoinbaseOnRampEventHandlerTest {
2529
onPaymentFailure = { lastError = it },
2630
onCancel = { cancelCount++ },
2731
onAutoClickGPay = { autoClickCount++ },
32+
onHeartbeat = { heartbeatCount++ },
33+
onPauseWatchdog = { pauseWatchdogCount++ },
2834
)
2935

3036
// --- Event routing ---
@@ -131,6 +137,50 @@ class CoinbaseOnRampEventHandlerTest {
131137
assertEquals(0, successCount)
132138
assertTrue(lastError == null)
133139
}
140+
141+
// --- Heartbeat / watchdog ---
142+
143+
@Test
144+
fun heartbeatFiredForStandardEvents() {
145+
val events = listOf(
146+
"""{"eventName":"onramp_api.load_success"}""",
147+
"""{"eventName":"onramp_api.polling_success"}""",
148+
"""{"eventName":"onramp_api.cancel"}""",
149+
"""{"eventName":"onramp_api.commit_success"}""",
150+
"""{"eventName":"onramp_api.payment_authorized"}""",
151+
"""{"eventName":"timing.gpay_button_clicked"}""",
152+
)
153+
events.forEachIndexed { index, event ->
154+
handler.handleEvent(event)
155+
assertEquals(index + 1, heartbeatCount, "heartbeat not fired for: $event")
156+
}
157+
assertEquals(0, pauseWatchdogCount)
158+
}
159+
160+
@Test
161+
fun pendingPaymentAuthPausesWatchdog() {
162+
handler.handleEvent("""{"eventName":"onramp_api.pending_payment_auth"}""")
163+
assertEquals(0, heartbeatCount)
164+
assertEquals(1, pauseWatchdogCount)
165+
}
166+
167+
@Test
168+
fun heartbeatNotFiredOnMalformedJson() {
169+
handler.handleEvent("not json")
170+
assertEquals(0, heartbeatCount)
171+
assertEquals(0, pauseWatchdogCount)
172+
}
173+
174+
@Test
175+
fun heartbeatResumesAfterPendingPaymentAuth() {
176+
handler.handleEvent("""{"eventName":"onramp_api.pending_payment_auth"}""")
177+
assertEquals(1, pauseWatchdogCount)
178+
assertEquals(0, heartbeatCount)
179+
180+
handler.handleEvent("""{"eventName":"onramp_api.payment_authorized"}""")
181+
assertEquals(1, heartbeatCount)
182+
assertEquals(1, pauseWatchdogCount)
183+
}
134184
}
135185

136186
class CoinbaseOnRampWebErrorTest {
@@ -169,4 +219,11 @@ class CoinbaseOnRampWebErrorTest {
169219
fun fromErrorCodeCaseSensitive() {
170220
assertIs<CoinbaseOnRampWebError.Unknown>(CoinbaseOnRampWebError.fromErrorCode("error_code_internal"))
171221
}
222+
223+
@Test
224+
fun webViewTimeoutImplementsNotifiableError() {
225+
val error = CoinbaseOnRampWebError.WebViewTimeout()
226+
assertIs<NotifiableError>(error)
227+
assertIs<Throwable>(error)
228+
}
172229
}

apps/flipcash/shared/web/src/main/kotlin/com/flipcash/app/web/ComposeWebView.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ fun ComposeWebView(
1010
url: String,
1111
modifier: Modifier = Modifier,
1212
factoryExtension: WebView.() -> Unit = { },
13+
onRelease: (WebView) -> Unit = { },
1314
) {
1415
AndroidView(
1516
modifier = modifier,
1617
factory = { WebView(it).apply(factoryExtension) },
17-
update = { it.loadUrl(url) }
18+
update = { it.loadUrl(url) },
19+
onRelease = onRelease,
1820
)
1921
}

0 commit comments

Comments
 (0)