diff --git a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt index b2b511be..c8d8589a 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt +++ b/browser-switch/src/main/java/com/braintreepayments/api/AuthTabInternalClient.kt @@ -55,6 +55,7 @@ internal class AuthTabInternalClient( launchCustomTabs(context, url, launchType) } } + private fun launchCustomTabs(context: Context, url: Uri, launchType: LaunchType?) { val customTabsIntent = customTabsIntentBuilder.build() when (launchType) { diff --git a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java index de13c4f6..e11db2c4 100644 --- a/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java +++ b/browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchClient.java @@ -6,8 +6,10 @@ import android.content.Intent; import android.net.Uri; +import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultCaller; import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.ActivityResultRegistry; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; @@ -27,9 +29,41 @@ public class BrowserSwitchClient { private ActivityResultLauncher authTabLauncher; private BrowserSwitchRequest pendingAuthTabRequest; + final String registryKey = "BrowserSwitchActivityRegistryKey"; + @Nullable private BrowserSwitchFinalResult authTabCallbackResult; + @Nullable + private AuthTabIntent.AuthResult authTabResult; + + ActivityResultCallback authTabCallback = new ActivityResultCallback<>() { + @Override + public void onActivityResult(AuthTabIntent.AuthResult result) { + authTabResult = result; + } + }; + + void onAuthTabResult(AuthTabIntent.AuthResult result) { + BrowserSwitchFinalResult finalResult; + switch (result.resultCode) { + case AuthTabIntent.RESULT_OK: + if (result.resultUri != null && pendingAuthTabRequest != null) { + finalResult = new BrowserSwitchFinalResult.Success( + result.resultUri, + pendingAuthTabRequest + ); + } else { + finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; + } + break; + default: + finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; + } + authTabCallbackResult = finalResult; + pendingAuthTabRequest = null; + } + /** * Construct a client that manages browser switching with Chrome Custom Tabs fallback only. * This constructor does not initialize Auth Tab support. For Auth Tab functionality, @@ -73,6 +107,38 @@ public BrowserSwitchClient(@NonNull ActivityResultCaller caller) { initializeAuthTabLauncher(caller); } + /** + * Construct a client that manages the logic for browser switching and automatically + * initializes the Auth Tab launcher. Use this constructor for flows where {@link ActivityResultCaller} is not + * available. + * + *

IMPORTANT: This constructor enables the AuthTab functionality, which has several caveats: + * + *

+ * + *

Consider using the default constructor {@link #BrowserSwitchClient()} if these limitations + * are incompatible with your implementation. + * + * @param registry The ActivityResultRegistry used to initialize the Auth Tab launcher + */ + public BrowserSwitchClient(@NonNull ActivityResultRegistry registry) { + this(new BrowserSwitchInspector(), new AuthTabInternalClient()); + initializeAuthTabLauncher(registry); + } + @VisibleForTesting BrowserSwitchClient(BrowserSwitchInspector browserSwitchInspector, AuthTabInternalClient authTabInternalClient) { @@ -88,38 +154,34 @@ public BrowserSwitchClient(@NonNull ActivityResultCaller caller) { initializeAuthTabLauncher(caller); } + @VisibleForTesting + BrowserSwitchClient(@NonNull ActivityResultRegistry registry, + BrowserSwitchInspector browserSwitchInspector, + AuthTabInternalClient authTabInternalClient) { + this(browserSwitchInspector, authTabInternalClient); + initializeAuthTabLauncher(registry); + } + /** * Initialize the Auth Tab launcher. This should be called in the activity/fragment's onCreate() * before it is started. * * @param caller The ActivityResultCaller (Activity or Fragment) used to initialize the Auth Tab launcher */ - private void initializeAuthTabLauncher(@NonNull ActivityResultCaller caller) { - - this.authTabLauncher = AuthTabIntent.registerActivityResultLauncher( + private void initializeAuthTabLauncher(@NonNull ActivityResultCaller caller) { + authTabLauncher = AuthTabIntent.registerActivityResultLauncher( caller, - result -> { - BrowserSwitchFinalResult finalResult; - switch (result.resultCode) { - case AuthTabIntent.RESULT_OK: - if (result.resultUri != null && pendingAuthTabRequest != null) { - finalResult = new BrowserSwitchFinalResult.Success( - result.resultUri, - pendingAuthTabRequest - ); - } else { - finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; - } - break; - default: - finalResult = BrowserSwitchFinalResult.NoResult.INSTANCE; - } - this.authTabCallbackResult = finalResult; - pendingAuthTabRequest = null; - } + authTabCallback ); } + private void initializeAuthTabLauncher(@NonNull ActivityResultRegistry registry) { + authTabLauncher = registry.register( + registryKey, + new AuthTabIntent.AuthenticateUserResultContract(), + authTabCallback + ); + } /** * Restores a pending request after process kill or app restart. @@ -150,6 +212,7 @@ public BrowserSwitchStartResult start(@NonNull Activity activity, @NonNull BrowserSwitchOptions browserSwitchOptions) { return start(activity, browserSwitchOptions, false); } + /** * Open a browser or Auth Tab with a given set of {@link BrowserSwitchOptions} from an Android activity. * @@ -286,6 +349,10 @@ private boolean isValidRequestCode(int requestCode) { * @return a {@link BrowserSwitchFinalResult} */ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull String pendingRequest) { + if (authTabResult != null) { + onAuthTabResult(authTabResult); + authTabResult = null; + } if (authTabCallbackResult != null) { BrowserSwitchFinalResult result = authTabCallbackResult; authTabCallbackResult = null; @@ -316,4 +383,4 @@ public BrowserSwitchFinalResult completeRequest(@NonNull Intent intent, @NonNull boolean isAuthTabSupported(Context context) { return authTabLauncher != null && authTabInternalClient.isAuthTabSupported(context); } -} \ No newline at end of file +} diff --git a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java index 21a38d05..238a72f2 100644 --- a/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java +++ b/browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchClientUnitTest.java @@ -26,6 +26,7 @@ import androidx.activity.result.ActivityResultCallback; import androidx.activity.result.ActivityResultCaller; import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.ActivityResultRegistry; import androidx.browser.auth.AuthTabIntent; import org.json.JSONException; @@ -283,6 +284,18 @@ public void initializeAuthTabLauncher_registersLauncherWithActivity() { } } + @Test + public void initializeAuthTabLauncher_withActivityResultRegistry_callsRegister() { + ActivityResultRegistry registry = mock(ActivityResultRegistry.class); + BrowserSwitchClient sut = new BrowserSwitchClient(registry, browserSwitchInspector, authTabInternalClient); + + verify(registry).register( + eq(sut.registryKey), + any(AuthTabIntent.AuthenticateUserResultContract.class), + eq(sut.authTabCallback) + ); + } + @Test public void start_withAuthTabLauncherInitialized_usesPendingAuthTabRequest() throws BrowserSwitchException { try (MockedStatic mockedAuthTab = mockStatic(AuthTabIntent.class)) { diff --git a/build.gradle b/build.gradle index 372388e5..b7178108 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ buildscript { ext.deps = [ 'annotation' : 'androidx.annotation:annotation:1.7.0', 'appcompat' : 'androidx.appcompat:appcompat:1.6.0', - 'browser' : 'androidx.browser:browser:1.9.0', + 'browser' : 'androidx.browser:browser:1.10.0-alpha02', 'kotlin' : 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20', // test dependencies @@ -38,7 +38,7 @@ buildscript { plugins { id 'io.github.gradle-nexus.publish-plugin' version '1.1.0' id 'org.jetbrains.dokka' version '1.9.10' - id 'org.jetbrains.kotlin.android' version '1.8.10' apply false + id 'org.jetbrains.kotlin.android' version '1.9.10' apply false id 'io.gitlab.arturbosch.detekt' version '1.23.6' } diff --git a/demo/build.gradle b/demo/build.gradle index 22dfc315..8dc7f436 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -39,7 +39,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion '1.4.3' + kotlinCompilerExtensionVersion '1.5.3' } packagingOptions { resources { @@ -58,6 +58,8 @@ dependencies { implementation platform('androidx.compose:compose-bom:2023.03.00') implementation 'androidx.compose.material3:material3' implementation 'androidx.core:core-ktx:1.13.1' + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1" + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.10.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt index e1351488..a2b0706a 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/ComposeActivity.kt @@ -1,162 +1,20 @@ package com.braintreepayments.api.browserswitch.demo -import android.net.Uri import android.os.Bundle -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeGesturesPadding -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.braintreepayments.api.BrowserSwitchClient -import com.braintreepayments.api.BrowserSwitchException -import com.braintreepayments.api.BrowserSwitchFinalResult -import com.braintreepayments.api.BrowserSwitchOptions -import com.braintreepayments.api.BrowserSwitchStartResult -import com.braintreepayments.api.browserswitch.demo.utils.PendingRequestStore -import com.braintreepayments.api.demo.viewmodel.BrowserSwitchViewModel -import org.json.JSONObject class ComposeActivity : ComponentActivity() { - private val viewModel by viewModels() - private lateinit var browserSwitchClient: BrowserSwitchClient - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - browserSwitchClient = BrowserSwitchClient(this) - PendingRequestStore.get(this)?.let { pendingRequest -> - try { - browserSwitchClient.restorePendingRequest(pendingRequest) - } catch (e: BrowserSwitchException) { - Log.e("ComposeActivity", "Failed to restore pending request", e) - PendingRequestStore.clear(this) - } - } setContent { Column(modifier = Modifier.safeGesturesPadding()) { - BrowserSwitchButton { - startBrowserSwitch() - } - BrowserSwitchResult(viewModel = viewModel) - } - } - } - - override fun onResume() { - super.onResume() - PendingRequestStore.get(this)?.let { startedRequest -> - val completeRequestResult = browserSwitchClient.completeRequest(intent, startedRequest) - handleBrowserSwitchResult(completeRequestResult) - PendingRequestStore.clear(this) - intent.data = null - } - } - - private fun handleBrowserSwitchResult(result: BrowserSwitchFinalResult) { - when (result) { - is BrowserSwitchFinalResult.Success -> - viewModel.browserSwitchFinalResult = result - - is BrowserSwitchFinalResult.NoResult -> - viewModel.browserSwitchError = Exception("User did not complete browser switch") - - is BrowserSwitchFinalResult.Failure -> - viewModel.browserSwitchError = result.error - } - } - - private fun startBrowserSwitch() { - val url = buildBrowserSwitchUrl() - val browserSwitchOptions = BrowserSwitchOptions() - .metadata(buildMetadataObject()) - .requestCode(1) - .url(url) - .launchAsNewTask(false) - .returnUrlScheme(RETURN_URL_SCHEME) - - when (val startResult = browserSwitchClient.start(this, browserSwitchOptions)) { - is BrowserSwitchStartResult.Started -> { - PendingRequestStore.put(this, startResult.pendingRequest) + MainContent() } - is BrowserSwitchStartResult.Failure -> - viewModel.browserSwitchError = startResult.error } } - - private fun buildBrowserSwitchUrl(): Uri? { - val url = "https://braintree.github.io/popup-bridge-example/" + - "this_launches_in_popup.html?popupBridgeReturnUrlPrefix=$RETURN_URL_SCHEME://" - return Uri.parse(url) - } - - private fun buildMetadataObject(): JSONObject? { - return JSONObject().put("test_key", "test_value") - } - - companion object { - private const val RETURN_URL_SCHEME = "my-custom-url-scheme-standard" - } -} - -@Composable -fun BrowserSwitchResult(viewModel: BrowserSwitchViewModel) { - val uiState = viewModel.uiState.collectAsState().value - (uiState.browserSwitchFinalResult as? BrowserSwitchFinalResult.Success)?.let { - BrowserSwitchSuccess(result = it) - } - uiState.browserSwitchError?.let { BrowserSwitchError(exception = it) } -} - -@Composable -fun BrowserSwitchButton(onClick: () -> Unit) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onClick - ) { - Text(text = "Start Browser Switch") - } -} - -@Composable -fun BrowserSwitchSuccess(result: BrowserSwitchFinalResult.Success) { - val color = result.returnUrl.getQueryParameter("color") - val selectedColorString = "Selected color: $color" - val metadataOutput = result.requestMetadata?.getString("test_key")?.let { "test_key=$it" } - Column(modifier = Modifier.padding(10.dp)) { - Text( - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - text = "Browser Switch Successful" - ) - Text(text = selectedColorString, color = Color.White) - metadataOutput?.let { - Text(text = "Metadata: $it", color = Color.White) - } - } -} - -@Composable -fun BrowserSwitchError(exception: Exception) { - Column(modifier = Modifier.padding(10.dp)) { - Text( - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - text = "Browser Switch Error" - ) - exception.message?.let { Text(text = it, color = Color.White) } - } } diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainContent.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainContent.kt new file mode 100644 index 00000000..6f13f272 --- /dev/null +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainContent.kt @@ -0,0 +1,182 @@ +package com.braintreepayments.api.browserswitch.demo + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.net.Uri +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeGesturesPadding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.viewmodel.compose.viewModel +import com.braintreepayments.api.BrowserSwitchClient +import com.braintreepayments.api.BrowserSwitchException +import com.braintreepayments.api.BrowserSwitchFinalResult +import com.braintreepayments.api.BrowserSwitchOptions +import com.braintreepayments.api.BrowserSwitchStartResult +import com.braintreepayments.api.browserswitch.demo.utils.PendingRequestStore +import com.braintreepayments.api.browserswitch.demo.viewmodel.BrowserSwitchViewModel +import org.json.JSONObject + +private const val RETURN_URL_SCHEME = "my-custom-url-scheme-standard" + +@Composable +fun MainContent() { + val viewModel: BrowserSwitchViewModel = viewModel { BrowserSwitchViewModel() } + val browserSwitchClient: BrowserSwitchClient = + LocalActivityResultRegistryOwner.current?.let { BrowserSwitchClient(it.activityResultRegistry) } + ?: BrowserSwitchClient() + + val context = LocalContext.current + val activity = context.findActivity() + + LifecycleEventEffect(Lifecycle.Event.ON_CREATE) { + PendingRequestStore.get(context)?.let { pendingRequest -> + try { + browserSwitchClient.restorePendingRequest(pendingRequest) + } catch (e: BrowserSwitchException) { + Log.e("ComposeActivity", "Failed to restore pending request", e) + PendingRequestStore.clear(context) + } + } + } + + Column(modifier = Modifier.safeGesturesPadding()) { + BrowserSwitchButton { + activity?.let { startBrowserSwitch(it, viewModel, browserSwitchClient) } + } + BrowserSwitchResult(viewModel = viewModel) + } + + LifecycleResumeEffect(Unit) { + val context = (context as ComponentActivity) + PendingRequestStore.get(context)?.let { startedRequest -> + val intent = context.intent + val completeRequestResult = browserSwitchClient.completeRequest(intent, startedRequest) + handleBrowserSwitchResult(viewModel, completeRequestResult) + PendingRequestStore.clear(context) + intent.data = null + } + + onPauseOrDispose { lifecycle } + } +} + +fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +private fun handleBrowserSwitchResult(viewModel: BrowserSwitchViewModel, result: BrowserSwitchFinalResult) { + when (result) { + is BrowserSwitchFinalResult.Success -> + viewModel.browserSwitchFinalResult = result + + is BrowserSwitchFinalResult.NoResult -> + viewModel.browserSwitchError = Exception("User did not complete browser switch") + + is BrowserSwitchFinalResult.Failure -> + viewModel.browserSwitchError = result.error + } +} + +private fun startBrowserSwitch( + activity: Activity, + viewModel: BrowserSwitchViewModel, + browserSwitchClient: BrowserSwitchClient +) { + val url = buildBrowserSwitchUrl() + val browserSwitchOptions = BrowserSwitchOptions() + .metadata(buildMetadataObject()) + .requestCode(1) + .url(url) + .launchAsNewTask(false) + .returnUrlScheme(RETURN_URL_SCHEME) + + when (val startResult = browserSwitchClient.start(activity, browserSwitchOptions)) { + is BrowserSwitchStartResult.Started -> { + PendingRequestStore.put(activity.applicationContext, startResult.pendingRequest) + } + is BrowserSwitchStartResult.Failure -> + viewModel.browserSwitchError = startResult.error + } +} + +private fun buildBrowserSwitchUrl(): Uri? { + val url = "https://braintree.github.io/popup-bridge-example/" + + "this_launches_in_popup.html?popupBridgeReturnUrlPrefix=$RETURN_URL_SCHEME://" + return url.toUri() +} + +private fun buildMetadataObject(): JSONObject? { + return JSONObject().put("test_key", "test_value") +} + +@Composable +private fun BrowserSwitchResult(viewModel: BrowserSwitchViewModel) { + val uiState = viewModel.uiState.collectAsState().value + (uiState.browserSwitchFinalResult as? BrowserSwitchFinalResult.Success)?.let { + BrowserSwitchSuccess(result = it) + } + uiState.browserSwitchError?.let { BrowserSwitchError(exception = it) } +} + +@Composable +private fun BrowserSwitchButton(onClick: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onClick + ) { + Text(text = "Start Browser Switch") + } +} + +@Composable +private fun BrowserSwitchSuccess(result: BrowserSwitchFinalResult.Success) { + val color = result.returnUrl.getQueryParameter("color") + val selectedColorString = "Selected color: $color" + val metadataOutput = result.requestMetadata?.getString("test_key")?.let { "test_key=$it" } + Column(modifier = Modifier.padding(10.dp)) { + Text( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + text = "Browser Switch Successful" + ) + Text(text = selectedColorString, color = Color.White) + metadataOutput?.let { + Text(text = "Metadata: $it", color = Color.White) + } + } +} + +@Composable +private fun BrowserSwitchError(exception: Exception) { + Column(modifier = Modifier.padding(10.dp)) { + Text( + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + text = "Browser Switch Error" + ) + exception.message?.let { Text(text = it, color = Color.White) } + } +} diff --git a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/viewmodel/BrowserSwitchViewModel.kt b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/viewmodel/BrowserSwitchViewModel.kt index 984eed79..b2a9b970 100644 --- a/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/viewmodel/BrowserSwitchViewModel.kt +++ b/demo/src/main/java/com/braintreepayments/api/browserswitch/demo/viewmodel/BrowserSwitchViewModel.kt @@ -1,8 +1,7 @@ -package com.braintreepayments.api.demo.viewmodel +package com.braintreepayments.api.browserswitch.demo.viewmodel import androidx.lifecycle.ViewModel import com.braintreepayments.api.BrowserSwitchFinalResult -import com.braintreepayments.api.browserswitch.demo.viewmodel.UiState import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow