Skip to content

Commit eb38364

Browse files
pnetoclaude
andcommitted
fix: resolve detekt TooManyFunctions and instrumentation test cancel bug
Extract app-switch logic into AppSwitchHandler to bring PopupBridgeClient from 17 functions down to 9 (threshold: 11). Guard the app-switch return path with expectingAppSwitchReturn so browser-switch cancel URLs are not intercepted when no native app was launched. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 839a4ce commit eb38364

3 files changed

Lines changed: 134 additions & 137 deletions

File tree

PopupBridge/src/main/java/com/braintreepayments/api/PopupBridgeClient.kt

Lines changed: 15 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
package com.braintreepayments.api
22

33
import android.annotation.SuppressLint
4-
import android.content.ActivityNotFoundException
54
import android.content.Intent
65
import android.net.Uri
7-
import android.os.Handler
8-
import android.os.Looper
96
import android.webkit.WebView
107
import androidx.activity.ComponentActivity
118
import androidx.core.net.toUri
@@ -15,10 +12,10 @@ import com.braintreepayments.api.PopupBridgeAnalytics.POPUP_BRIDGE_FAILED
1512
import com.braintreepayments.api.PopupBridgeAnalytics.POPUP_BRIDGE_SUCCEEDED
1613
import com.braintreepayments.api.internal.AnalyticsClient
1714
import com.braintreepayments.api.internal.AnalyticsParamRepository
15+
import com.braintreepayments.api.internal.AppSwitchHandler
1816
import com.braintreepayments.api.internal.PendingRequestRepository
1917
import com.braintreepayments.api.internal.PopupBridgeJavascriptInterface
2018
import com.braintreepayments.api.internal.PopupBridgeJavascriptInterface.Companion.POPUP_BRIDGE_URL_HOST
21-
import com.braintreepayments.api.internal.PAYPAL_APP_PACKAGE
2219
import com.braintreepayments.api.internal.isPayPalInstalled
2320
import java.lang.ref.WeakReference
2421
import kotlinx.coroutines.CoroutineScope
@@ -32,7 +29,7 @@ class PopupBridgeClient @SuppressLint("SetJavaScriptEnabled") internal construct
3229
private val returnUrlScheme: String,
3330
private val popupBridgeWebViewClient: PopupBridgeWebViewClient,
3431
private val browserSwitchClient: BrowserSwitchClient,
35-
private val enablePopupBridgeAppSwitch: Boolean = false,
32+
private val enablePopupBridgeAppSwitch: Boolean = false,
3633
private val pendingRequestRepository: PendingRequestRepository =
3734
PendingRequestRepository(activity.applicationContext),
3835
private val coroutineScope: CoroutineScope = activity.lifecycleScope,
@@ -49,7 +46,6 @@ private val enablePopupBridgeAppSwitch: Boolean = false,
4946
) {
5047
private val activityRef = WeakReference(activity)
5148
private val webViewRef = WeakReference(webView)
52-
private val mainHandler = Handler(Looper.getMainLooper())
5349

5450
private var navigationListener: PopupBridgeNavigationListener? = null
5551
private var messageListener: PopupBridgeMessageListener? = null
@@ -58,19 +54,18 @@ private val enablePopupBridgeAppSwitch: Boolean = false,
5854
/**
5955
* Browser-switch path only: ensures [handleReturnToApp] completes the pending browser request at most once
6056
* when both `onResume` and `onNewIntent` invoke it (singleTop / singleTask / singleInstance).
61-
*
62-
* Not used for native app-switch returns; see [expectingAppSwitchReturn].
6357
*/
6458
@Volatile
6559
private var isHandlingReturnToApp = false
6660

67-
/**
68-
* Set to `true` when [launchApp] starts an app-switch checkout so we only accept the following
69-
* popupbridgev1 deep link as that flow's return (ignores stale links). Cleared when
70-
* [handleAppSwitchReturn] runs or when launch falls back to the browser.
71-
*/
72-
@Volatile
73-
private var expectingAppSwitchReturn = false
61+
private val appSwitchHandler = AppSwitchHandler(
62+
activityRef = activityRef,
63+
analyticsClient = analyticsClient,
64+
onOpenUrl = { url -> openUrl(url) },
65+
onError = { e -> errorListener?.onError(e) },
66+
onCanceled = { runCanceledJavaScript() },
67+
onComplete = { uri -> runNotifyCompleteJavaScript(uri) },
68+
)
7469

7570
/**
7671
* Create a new instance of [PopupBridgeClient].
@@ -122,7 +117,7 @@ private val enablePopupBridgeAppSwitch: Boolean = false,
122117

123118
with(popupBridgeJavascriptInterface) {
124119
onOpen = { url -> openUrl(url) }
125-
onLaunchApp = { url -> this@PopupBridgeClient.launchApp(url) }
120+
onLaunchApp = { url -> appSwitchHandler.launchApp(url) }
126121
onSendMessage = { messageName, data ->
127122
messageListener?.onMessageReceived(messageName, data)
128123
}
@@ -143,8 +138,8 @@ private val enablePopupBridgeAppSwitch: Boolean = false,
143138

144139
fun handleReturnToApp(intent: Intent) {
145140
val returnUri = intent.data
146-
if (enablePopupBridgeAppSwitch && returnUri != null && returnUri.isAppSwitchReturnUri()) {
147-
handleAppSwitchReturn(returnUri)
141+
if (enablePopupBridgeAppSwitch && appSwitchHandler.shouldHandleReturn(returnUri)) {
142+
appSwitchHandler.handleReturn(returnUri!!)
148143
return
149144
}
150145

@@ -169,116 +164,15 @@ private val enablePopupBridgeAppSwitch: Boolean = false,
169164
)
170165
is BrowserSwitchFinalResult.NoResult -> runCanceledJavaScript()
171166
}
172-
clearPopupBridgeReturnIntentIfPresent("browser_switch_result_consumed")
167+
appSwitchHandler.clearReturnIntentIfPresent()
173168
isHandlingReturnToApp = false
174169
}
175170
}
176171

177-
/**
178-
* Entry point when the web page calls window.popupBridge.launchApp(url).
179-
* Posts to the main thread so [startActivity] is never called from a background thread
180-
* (JavascriptInterface callbacks can be invoked off the main thread).
181-
*/
182-
private fun launchApp(url: String?) {
183-
val activity = activityRef.get() as? ComponentActivity ?: run {
184-
return
185-
}
186-
mainHandler.post { launchAppAssumingMainThread(url, activity) }
187-
}
188-
189-
private fun launchAppAssumingMainThread(url: String?, activity: ComponentActivity) {
190-
if (url.isNullOrBlank()) {
191-
errorListener?.onError(IllegalArgumentException("Invalid URL for app launch"))
192-
return
193-
}
194-
195-
clearPopupBridgeReturnIntentIfPresent("launching_new_app_switch")
196-
expectingAppSwitchReturn = true
197-
198-
val uri = url?.toUri() ?: run {
199-
errorListener?.onError(IllegalArgumentException("Invalid URL for app launch"))
200-
return
201-
}
202-
203-
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
204-
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
205-
if (uri.isPayPalAppSwitchUri()) {
206-
setPackage(PAYPAL_APP_PACKAGE)
207-
}
208-
}
209-
210-
try {
211-
activity.startActivity(intent)
212-
analyticsClient.sendEvent(PopupBridgeAnalytics.POPUP_BRIDGE_APP_LAUNCHED)
213-
} catch (_: ActivityNotFoundException) {
214-
expectingAppSwitchReturn = false
215-
analyticsClient.sendEvent(PopupBridgeAnalytics.POPUP_BRIDGE_APP_LAUNCH_FAILED)
216-
openUrl(url)
217-
}
218-
}
219-
220-
private fun Uri.isPayPalAppSwitchUri(): Boolean {
221-
val normalizedHost = host?.removePrefix("www.")
222-
return scheme.equals("https", ignoreCase = true) &&
223-
normalizedHost == "paypal.com" &&
224-
path.orEmpty().startsWith("/app-switch-checkout")
225-
}
226-
227-
private fun Uri.isAppSwitchReturnUri(): Boolean {
228-
if (host != POPUP_BRIDGE_URL_HOST) {
229-
return false
230-
}
231-
232-
if (!fragment.isNullOrBlank()) {
233-
return true
234-
}
235-
236-
return hasAppSwitchPath()
237-
}
238-
239-
private fun Uri.isCancelUri(): Boolean {
240-
val normalizedPath = path.orEmpty().lowercase()
241-
return normalizedPath.contains("oncancel") ||
242-
normalizedPath.contains("/cancel")
243-
}
244-
245-
private fun Uri.hasAppSwitchPath(): Boolean {
246-
val normalizedPath = path.orEmpty().lowercase()
247-
return normalizedPath.contains("onapprove") ||
248-
normalizedPath.contains("onerror") ||
249-
normalizedPath.contains("/approve") ||
250-
normalizedPath.contains("/error") ||
251-
isCancelUri()
252-
}
253-
254-
/**
255-
* Handles the return deep link from a native PayPal/Venmo app switch.
256-
*
257-
* Runs only when [expectingAppSwitchReturn] is true (set by [launchApp] before starting the
258-
* native app) so stale popupbridgev1 links do not complete or cancel the wrong session.
259-
*/
260-
private fun handleAppSwitchReturn(returnUri: Uri) {
261-
if (!expectingAppSwitchReturn) {
262-
return
263-
}
264-
expectingAppSwitchReturn = false
265-
266-
analyticsClient.sendEvent(PopupBridgeAnalytics.POPUP_BRIDGE_APP_SWITCH_RETURNED)
267-
clearPopupBridgeReturnIntentIfPresent("app_switch_return_consumed")
268-
269-
if (returnUri.isCancelUri()) {
270-
runCanceledJavaScript()
271-
} else {
272-
runNotifyCompleteJavaScript(returnUri)
273-
}
274-
}
275-
276172
private fun openUrl(url: String?) {
277173
analyticsClient.sendEvent(PopupBridgeAnalytics.POPUP_BRIDGE_STARTED)
278174

279-
val activity = activityRef.get() ?: run {
280-
return
281-
}
175+
val activity = activityRef.get() ?: return
282176
val browserSwitchOptions = BrowserSwitchOptions()
283177
.requestCode(REQUEST_CODE)
284178
.url(url?.toUri())
@@ -360,20 +254,6 @@ private val enablePopupBridgeAppSwitch: Boolean = false,
360254
)
361255
}
362256

363-
private fun clearPopupBridgeReturnIntentIfPresent(reason: String) {
364-
val activity = activityRef.get() ?: return
365-
val currentIntent = activity.intent ?: return
366-
val currentData = currentIntent.data ?: return
367-
368-
if (currentData.host != POPUP_BRIDGE_URL_HOST) {
369-
return
370-
}
371-
372-
activity.intent = Intent(currentIntent).apply {
373-
data = null
374-
}
375-
}
376-
377257
private fun runJavaScriptInWebView(script: String) {
378258
webViewRef.get()?.post(
379259
Runnable { webViewRef.get()?.evaluateJavascript(script, null) }
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.braintreepayments.api.internal
2+
3+
import android.content.ActivityNotFoundException
4+
import android.content.Intent
5+
import android.net.Uri
6+
import android.os.Handler
7+
import android.os.Looper
8+
import androidx.activity.ComponentActivity
9+
import androidx.core.net.toUri
10+
import com.braintreepayments.api.PopupBridgeAnalytics
11+
import com.braintreepayments.api.internal.PopupBridgeJavascriptInterface.Companion.POPUP_BRIDGE_URL_HOST
12+
import java.lang.ref.WeakReference
13+
14+
internal class AppSwitchHandler(
15+
private val activityRef: WeakReference<ComponentActivity>,
16+
private val analyticsClient: AnalyticsClient,
17+
private val onOpenUrl: (String?) -> Unit,
18+
private val onError: (Exception) -> Unit,
19+
private val onCanceled: () -> Unit,
20+
private val onComplete: (Uri) -> Unit,
21+
) {
22+
private val mainHandler = Handler(Looper.getMainLooper())
23+
24+
@Volatile
25+
var expectingAppSwitchReturn: Boolean = false
26+
private set
27+
28+
fun launchApp(url: String?) {
29+
val activity = activityRef.get() as? ComponentActivity ?: return
30+
mainHandler.post { launchAppOnMainThread(url, activity) }
31+
}
32+
33+
private fun launchAppOnMainThread(url: String?, activity: ComponentActivity) {
34+
if (url.isNullOrBlank()) {
35+
onError(IllegalArgumentException("Invalid URL for app launch"))
36+
return
37+
}
38+
39+
clearReturnIntentIfPresent()
40+
expectingAppSwitchReturn = true
41+
42+
val uri = url.toUri()
43+
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
44+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
45+
if (uri.isPayPalAppSwitchUri()) {
46+
setPackage(PAYPAL_APP_PACKAGE)
47+
}
48+
}
49+
50+
try {
51+
activity.startActivity(intent)
52+
analyticsClient.sendEvent(PopupBridgeAnalytics.POPUP_BRIDGE_APP_LAUNCHED)
53+
} catch (_: ActivityNotFoundException) {
54+
expectingAppSwitchReturn = false
55+
analyticsClient.sendEvent(PopupBridgeAnalytics.POPUP_BRIDGE_APP_LAUNCH_FAILED)
56+
onOpenUrl(url)
57+
}
58+
}
59+
60+
fun handleReturn(returnUri: Uri) {
61+
if (!expectingAppSwitchReturn) return
62+
expectingAppSwitchReturn = false
63+
64+
analyticsClient.sendEvent(PopupBridgeAnalytics.POPUP_BRIDGE_APP_SWITCH_RETURNED)
65+
clearReturnIntentIfPresent()
66+
67+
if (returnUri.isCancelUri()) {
68+
onCanceled()
69+
} else {
70+
onComplete(returnUri)
71+
}
72+
}
73+
74+
fun clearReturnIntentIfPresent() {
75+
val activity = activityRef.get() ?: return
76+
val currentIntent = activity.intent ?: return
77+
val currentData = currentIntent.data ?: return
78+
79+
if (currentData.host != POPUP_BRIDGE_URL_HOST) return
80+
81+
activity.intent = Intent(currentIntent).apply { data = null }
82+
}
83+
84+
fun shouldHandleReturn(uri: Uri?): Boolean =
85+
expectingAppSwitchReturn && uri != null && isAppSwitchReturnUri(uri)
86+
87+
fun isAppSwitchReturnUri(uri: Uri): Boolean {
88+
if (uri.host != POPUP_BRIDGE_URL_HOST) return false
89+
if (!uri.fragment.isNullOrBlank()) return true
90+
return uri.hasAppSwitchPath()
91+
}
92+
93+
private fun Uri.isPayPalAppSwitchUri(): Boolean {
94+
val normalizedHost = host?.removePrefix("www.")
95+
return scheme.equals("https", ignoreCase = true) &&
96+
normalizedHost == "paypal.com" &&
97+
path.orEmpty().startsWith("/app-switch-checkout")
98+
}
99+
100+
private fun Uri.isCancelUri(): Boolean {
101+
val normalizedPath = path.orEmpty().lowercase()
102+
return normalizedPath.contains("oncancel") || normalizedPath.contains("/cancel")
103+
}
104+
105+
private fun Uri.hasAppSwitchPath(): Boolean {
106+
val normalizedPath = path.orEmpty().lowercase()
107+
return normalizedPath.contains("onapprove") ||
108+
normalizedPath.contains("onerror") ||
109+
normalizedPath.contains("/approve") ||
110+
normalizedPath.contains("/error") ||
111+
isCancelUri()
112+
}
113+
}

PopupBridge/src/test/java/com/braintreepayments/api/PopupBridgeClientUnitTest.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ class PopupBridgeClientUnitTest {
452452
every { intent.data } returns appSwitchReturnUri
453453

454454
initializeClient(enablePopupBridgeAppSwitch = true)
455+
setPrivateExpectingAppSwitchReturn(subject, true)
455456

456457
subject.handleReturnToApp(intent)
457458
testScheduler.advanceUntilIdle()
@@ -734,9 +735,12 @@ class PopupBridgeClientUnitTest {
734735
}
735736

736737
private fun setPrivateExpectingAppSwitchReturn(client: PopupBridgeClient, value: Boolean) {
737-
val field = PopupBridgeClient::class.java.getDeclaredField("expectingAppSwitchReturn")
738+
val handlerField = PopupBridgeClient::class.java.getDeclaredField("appSwitchHandler")
739+
handlerField.isAccessible = true
740+
val handler = handlerField.get(client)
741+
val field = handler.javaClass.getDeclaredField("expectingAppSwitchReturn")
738742
field.isAccessible = true
739-
field.setBoolean(client, value)
743+
field.setBoolean(handler, value)
740744
}
741745

742746
// endregion

0 commit comments

Comments
 (0)