Skip to content

Commit

Permalink
Add deep link support as a fallback to app links (#1223)
Browse files Browse the repository at this point in the history
* Add deep link support as a fallback to app links

* Add unit tests and kdoc

* Add a null check for both app link and deep link urls

* Update changelog for deepLinkFallbackUrlScheme
  • Loading branch information
tdchow authored Dec 4, 2024
1 parent bafeda7 commit 0cfbf0c
Show file tree
Hide file tree
Showing 11 changed files with 485 additions and 74 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import androidx.annotation.RestrictTo
import com.braintreepayments.api.sharedutils.HttpResponseCallback
import com.braintreepayments.api.sharedutils.HttpResponseTiming
import com.braintreepayments.api.sharedutils.ManifestValidator
import com.braintreepayments.api.sharedutils.Time
import org.json.JSONException
import org.json.JSONObject

Expand All @@ -22,12 +21,12 @@ class BraintreeClient internal constructor(
authorization: Authorization,
returnUrlScheme: String,
appLinkReturnUri: Uri?,
deepLinkFallbackUrlScheme: String? = null,
sdkComponent: SdkComponent = SdkComponent.create(applicationContext),
private val httpClient: BraintreeHttpClient = BraintreeHttpClient(),
private val graphQLClient: BraintreeGraphQLClient = BraintreeGraphQLClient(),
private val configurationLoader: ConfigurationLoader = ConfigurationLoader.instance,
private val manifestValidator: ManifestValidator = ManifestValidator(),
private val time: Time = Time(),
private val merchantRepository: MerchantRepository = MerchantRepository.instance,
private val analyticsClient: AnalyticsClient = AnalyticsClient(),
) {
Expand All @@ -47,13 +46,15 @@ class BraintreeClient internal constructor(
returnUrlScheme: String? = null,
appLinkReturnUri: Uri? = null,
integrationType: IntegrationType? = null,
deepLinkFallbackUrlScheme: String? = null,
) : this(
applicationContext = context.applicationContext,
authorization = Authorization.fromString(authorization),
returnUrlScheme = returnUrlScheme
?: "${getAppPackageNameWithoutUnderscores(context.applicationContext)}.braintree",
appLinkReturnUri = appLinkReturnUri,
integrationType = integrationType ?: IntegrationType.CUSTOM,
deepLinkFallbackUrlScheme = deepLinkFallbackUrlScheme
)

init {
Expand All @@ -73,6 +74,9 @@ class BraintreeClient internal constructor(
if (appLinkReturnUri != null) {
it.appLinkReturnUri = appLinkReturnUri
}
if (deepLinkFallbackUrlScheme != null) {
it.deepLinkFallbackUrlScheme = deepLinkFallbackUrlScheme
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.braintreepayments.api.core

import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.annotation.RestrictTo
import com.braintreepayments.api.core.GetReturnLinkUseCase.ReturnLinkResult

/**
* Use case that returns a return link that should be used for navigating from App Switch / CCT back into the merchant
* app. It handles both App Links and Deep Links.
*
* If a user unchecks the "Open supported links" checkbox in the Android OS settings for the merchant's app. If this
* setting is unchecked, this use case will return [ReturnLinkResult.DeepLink], otherwise [ReturnLinkResult.AppLink]
* will be returned.
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class GetReturnLinkUseCase(private val merchantRepository: MerchantRepository) {

sealed class ReturnLinkResult {
data class AppLink(val appLinkReturnUri: Uri) : ReturnLinkResult()

data class DeepLink(val deepLinkFallbackUrlScheme: String) : ReturnLinkResult()

data class Failure(val exception: Exception) : ReturnLinkResult()
}

operator fun invoke(): ReturnLinkResult {
val context = merchantRepository.applicationContext
val intent = Intent(Intent.ACTION_VIEW, merchantRepository.appLinkReturnUri).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
}
val resolvedActivity = context.packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY)
return if (resolvedActivity?.activityInfo?.packageName == context.packageName) {
merchantRepository.appLinkReturnUri?.let {
ReturnLinkResult.AppLink(it)
} ?: run {
ReturnLinkResult.Failure(BraintreeException("App Link Return Uri is null"))
}
} else {
merchantRepository.deepLinkFallbackUrlScheme?.let {
ReturnLinkResult.DeepLink(it)
} ?: run {
ReturnLinkResult.Failure(BraintreeException("Deep Link fallback return url is null"))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class MerchantRepository {
lateinit var returnUrlScheme: String
var appLinkReturnUri: Uri? = null

var deepLinkFallbackUrlScheme: String? = null

companion object {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import com.braintreepayments.api.BrowserSwitchClient
import com.braintreepayments.api.sharedutils.HttpResponseCallback
import com.braintreepayments.api.sharedutils.ManifestValidator
import com.braintreepayments.api.sharedutils.NetworkResponseCallback
import com.braintreepayments.api.sharedutils.Time
import com.braintreepayments.api.testutils.Fixtures
import io.mockk.*
import org.json.JSONException
Expand Down Expand Up @@ -319,10 +318,7 @@ class BraintreeClientUnitTest {
.configuration(configuration)
.build()

val time: Time = mockk()
every { time.currentTime } returns 123

val sut = createBraintreeClient(configurationLoader, time)
val sut = createBraintreeClient(configurationLoader)
sut.sendAnalyticsEvent("event.started")

verify {
Expand Down Expand Up @@ -432,7 +428,6 @@ class BraintreeClientUnitTest {

private fun createBraintreeClient(
configurationLoader: ConfigurationLoader = mockk(),
time: Time = Time(),
appLinkReturnUri: Uri? = Uri.parse("https://example.com"),
merchantRepository: MerchantRepository = MerchantRepository.instance
) = BraintreeClient(
Expand All @@ -446,7 +441,6 @@ class BraintreeClientUnitTest {
analyticsClient = analyticsClient,
manifestValidator = manifestValidator,
configurationLoader = configurationLoader,
time = time,
merchantRepository = merchantRepository,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.braintreepayments.api.core

import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
import org.junit.Before
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

@RunWith(RobolectricTestRunner::class)
class GetReturnLinkUseCaseUnitTest {

private val merchantRepository: MerchantRepository = mockk(relaxed = true)
private val context: Context = mockk(relaxed = true)
private val resolveInfo = ResolveInfo()
private val activityInfo = ActivityInfo()
private val contextPackageName = "context.package.name"
private val appLinkReturnUri = Uri.parse("https://example.com")
private val deepLinkFallbackUrlScheme = "com.braintreepayments.demo"

lateinit var subject: GetReturnLinkUseCase

@Before
fun setUp() {
every { merchantRepository.applicationContext } returns context
every { merchantRepository.appLinkReturnUri } returns appLinkReturnUri
every { merchantRepository.deepLinkFallbackUrlScheme } returns deepLinkFallbackUrlScheme
every { context.packageName } returns contextPackageName
resolveInfo.activityInfo = activityInfo
every { context.packageManager.resolveActivity(any<Intent>(), any<Int>()) } returns resolveInfo

subject = GetReturnLinkUseCase(merchantRepository)
}

@Test
fun `when invoke is called and app link is available, APP_LINK is returned`() {
activityInfo.packageName = "context.package.name"

val result = subject()

assertEquals(GetReturnLinkUseCase.ReturnLinkResult.AppLink(appLinkReturnUri), result)
}

@Test
fun `when invoke is called and app link is not available, DEEP_LINK is returned`() {
activityInfo.packageName = "different.package.name"

val result = subject()

assertEquals(GetReturnLinkUseCase.ReturnLinkResult.DeepLink(deepLinkFallbackUrlScheme), result)
}

@Test
fun `when invoke is called and deep link is available but null, Failure is returned`() {
activityInfo.packageName = "different.package.name"
every { merchantRepository.deepLinkFallbackUrlScheme } returns null

val result = subject()

assertTrue { result is GetReturnLinkUseCase.ReturnLinkResult.Failure }
assertEquals(
"Deep Link fallback return url is null",
(result as GetReturnLinkUseCase.ReturnLinkResult.Failure).exception.message
)
}

@Test
fun `when invoke is called and app link is available but null, Failure is returned`() {
activityInfo.packageName = "context.package.name"
every { merchantRepository.appLinkReturnUri } returns null

val result = subject()

assertTrue { result is GetReturnLinkUseCase.ReturnLinkResult.Failure }
assertEquals(
"App Link Return Uri is null",
(result as GetReturnLinkUseCase.ReturnLinkResult.Failure).exception.message
)
}
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## unreleased

* PayPal
* Add `deepLinkFallbackUrlScheme` to `PayPalClient` constructor params for supporting deep link fallback
* LocalPayment
* Make LocalPaymentAuthRequestParams public (fixes #1207)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
});

payPalClient = new PayPalClient(
requireContext(),
super.getAuthStringArg(),
Uri.parse("https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")
requireContext(),
super.getAuthStringArg(),
Uri.parse("https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments"),
"com.braintreepayments.demo.braintree"
);
payPalLauncher = new PayPalLauncher();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.braintreepayments.api.core.BraintreeClient
import com.braintreepayments.api.core.BraintreeException
import com.braintreepayments.api.core.BraintreeRequestCodes
import com.braintreepayments.api.core.Configuration
import com.braintreepayments.api.core.GetReturnLinkUseCase
import com.braintreepayments.api.core.LinkType
import com.braintreepayments.api.core.MerchantRepository
import com.braintreepayments.api.core.UserCanceledException
Expand All @@ -24,6 +25,7 @@ class PayPalClient internal constructor(
private val braintreeClient: BraintreeClient,
private val internalPayPalClient: PayPalInternalClient = PayPalInternalClient(braintreeClient),
private val merchantRepository: MerchantRepository = MerchantRepository.instance,
private val getReturnLinkUseCase: GetReturnLinkUseCase = GetReturnLinkUseCase(merchantRepository)
) {

/**
Expand All @@ -48,14 +50,23 @@ class PayPalClient internal constructor(
* @param context an Android Context
* @param authorization a Tokenization Key or Client Token used to authenticate
* @param appLinkReturnUrl A [Uri] containing the Android App Link website associated with
* your application to be used to return to your app from the PayPal
* payment flows.
* your application to be used to return to your app from the PayPal payment flows.
* @param deepLinkFallbackUrlScheme A return url scheme that will be used as a deep link fallback when returning to
* your app via App Link is not available (buyer unchecks the "Open supported links" setting).
*/
constructor(
context: Context,
authorization: String,
appLinkReturnUrl: Uri
) : this(BraintreeClient(context, authorization, null, appLinkReturnUrl))
appLinkReturnUrl: Uri,
deepLinkFallbackUrlScheme: String? = null
) : this(
BraintreeClient(
context = context,
authorization = authorization,
deepLinkFallbackUrlScheme = deepLinkFallbackUrlScheme,
appLinkReturnUri = appLinkReturnUrl
)
)

/**
* Starts the PayPal payment flow by creating a [PayPalPaymentAuthRequestParams] to be
Expand Down Expand Up @@ -89,6 +100,7 @@ class PayPalClient internal constructor(
}
}

@Suppress("TooGenericExceptionCaught")
private fun sendPayPalRequest(
context: Context,
payPalRequest: PayPalRequest,
Expand All @@ -112,14 +124,16 @@ class PayPalClient internal constructor(
braintreeClient.sendAnalyticsEvent(PayPalAnalytics.APP_SWITCH_STARTED, analyticsParams)
}

callback.onPayPalPaymentAuthRequest(
PayPalPaymentAuthRequest.ReadyToLaunch(payPalResponse)
)
} catch (exception: JSONException) {
callbackCreatePaymentAuthFailure(
callback,
PayPalPaymentAuthRequest.Failure(exception)
)
callback.onPayPalPaymentAuthRequest(PayPalPaymentAuthRequest.ReadyToLaunch(payPalResponse))
} catch (exception: Exception) {
when (exception) {
is JSONException,
is BraintreeException -> {
callbackCreatePaymentAuthFailure(callback, PayPalPaymentAuthRequest.Failure(exception))
}

else -> throw exception
}
}
} else {
callbackCreatePaymentAuthFailure(
Expand All @@ -130,7 +144,7 @@ class PayPalClient internal constructor(
}
}

@Throws(JSONException::class)
@Throws(JSONException::class, BraintreeException::class)
private fun buildBrowserSwitchOptions(
paymentAuthRequest: PayPalPaymentAuthRequestParams
): BrowserSwitchOptions {
Expand All @@ -152,10 +166,22 @@ class PayPalClient internal constructor(

return BrowserSwitchOptions()
.requestCode(BraintreeRequestCodes.PAYPAL.code)
.appLinkUri(merchantRepository.appLinkReturnUri)
.url(Uri.parse(paymentAuthRequest.approvalUrl))
.launchAsNewTask(braintreeClient.launchesBrowserSwitchAsNewTask())
.metadata(metadata)
.apply {
when (val returnLinkResult = getReturnLinkUseCase()) {
is GetReturnLinkUseCase.ReturnLinkResult.AppLink -> {
appLinkUri(returnLinkResult.appLinkReturnUri)
}

is GetReturnLinkUseCase.ReturnLinkResult.DeepLink -> {
returnUrlScheme(returnLinkResult.deepLinkFallbackUrlScheme)
}

is GetReturnLinkUseCase.ReturnLinkResult.Failure -> throw returnLinkResult.exception
}
}
}

/**
Expand Down
Loading

0 comments on commit 0cfbf0c

Please sign in to comment.