├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ └── setup_java │ │ └── action.yml └── workflows │ ├── release.yml │ ├── release_snapshot.yml │ ├── static_analysis.yml │ └── tests.yml ├── .gitignore ├── .project ├── .settings └── org.eclipse.buildship.core.prefs ├── ACKNOWLEDGEMENTS.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── browser-switch ├── build.gradle └── src │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── braintreepayments │ │ │ └── api │ │ │ ├── BrowserSwitchClient.java │ │ │ ├── BrowserSwitchException.java │ │ │ ├── BrowserSwitchFinalResult.kt │ │ │ ├── BrowserSwitchInspector.java │ │ │ ├── BrowserSwitchOptions.java │ │ │ ├── BrowserSwitchRequest.java │ │ │ ├── BrowserSwitchStartResult.kt │ │ │ └── ChromeCustomTabsInternalClient.java │ └── res │ │ └── values │ │ └── strings.xml │ └── test │ ├── java │ └── com │ │ └── braintreepayments │ │ └── api │ │ ├── BrowserSwitchClientUnitTest.java │ │ ├── BrowserSwitchInspectorUnitTest.java │ │ └── BrowserSwitchRequestUnitTest.kt │ └── resources │ ├── org │ └── powermock │ │ └── extensions │ │ └── configuration.properties │ └── robolectric.properties ├── build.gradle ├── ci ├── demo ├── build.gradle ├── debug.keystore └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── braintreepayments │ │ └── api │ │ └── demo │ │ └── BrowserSwitchTest.java │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── braintreepayments │ │ └── api │ │ └── browserswitch │ │ └── demo │ │ ├── ComposeActivity.kt │ │ ├── DemoActivitySingleTop.java │ │ ├── DemoFragment.java │ │ ├── MainActivity.java │ │ ├── utils │ │ └── PendingRequestStore.kt │ │ └── viewmodel │ │ ├── BrowserSwitchViewModel.kt │ │ └── UiState.kt │ └── res │ ├── layout │ ├── activity_main.xml │ └── demo_fragment.xml │ ├── mipmap-hdpi │ └── ic_launcher.png │ ├── mipmap-mdpi │ └── ic_launcher.png │ ├── mipmap-xhdpi │ └── ic_launcher.png │ ├── mipmap-xxhdpi │ └── ic_launcher.png │ ├── mipmap-xxxhdpi │ └── ic_launcher.png │ └── values │ ├── arrays.xml │ ├── strings.xml │ └── styles.xml ├── docs ├── browser-switch │ ├── com.braintreepayments.api │ │ ├── -browser-switch-client │ │ │ ├── -browser-switch-client.html │ │ │ ├── assert-can-perform-browser-switch.html │ │ │ ├── capture-result.html │ │ │ ├── clear-active-requests.html │ │ │ ├── complete-request.html │ │ │ ├── deliver-result-from-cache.html │ │ │ ├── deliver-result.html │ │ │ ├── get-result-from-cache.html │ │ │ ├── get-result.html │ │ │ ├── index.html │ │ │ ├── parse-result.html │ │ │ └── start.html │ │ ├── -browser-switch-exception │ │ │ ├── -browser-switch-exception.html │ │ │ └── index.html │ │ ├── -browser-switch-final-result │ │ │ ├── -failure │ │ │ │ ├── error.html │ │ │ │ └── index.html │ │ │ ├── -no-result │ │ │ │ └── index.html │ │ │ ├── -success │ │ │ │ ├── index.html │ │ │ │ ├── request-code.html │ │ │ │ ├── request-metadata.html │ │ │ │ ├── request-url.html │ │ │ │ └── return-url.html │ │ │ └── index.html │ │ ├── -browser-switch-options │ │ │ ├── -browser-switch-options.html │ │ │ ├── app-link-uri.html │ │ │ ├── index.html │ │ │ ├── is-launch-as-new-task.html │ │ │ ├── launch-as-new-task.html │ │ │ ├── metadata.html │ │ │ ├── request-code.html │ │ │ ├── return-url-scheme.html │ │ │ └── url.html │ │ ├── -browser-switch-request │ │ │ ├── -browser-switch-request.html │ │ │ ├── app-link-uri.html │ │ │ ├── index.html │ │ │ ├── metadata.html │ │ │ ├── request-code.html │ │ │ ├── return-url-scheme.html │ │ │ └── url.html │ │ ├── -browser-switch-result │ │ │ ├── deep-link-url.html │ │ │ ├── get-request-code.html │ │ │ ├── get-request-metadata.html │ │ │ ├── get-request-url.html │ │ │ ├── index.html │ │ │ ├── status.html │ │ │ └── to-json.html │ │ ├── -browser-switch-start-result │ │ │ ├── -failure │ │ │ │ ├── -failure.html │ │ │ │ ├── error.html │ │ │ │ └── index.html │ │ │ ├── -started │ │ │ │ ├── -started.html │ │ │ │ ├── index.html │ │ │ │ └── pending-request.html │ │ │ └── index.html │ │ ├── -browser-switch-status │ │ │ ├── -c-a-n-c-e-l-e-d.html │ │ │ ├── -s-u-c-c-e-s-s.html │ │ │ └── index.html │ │ └── index.html │ ├── index.html │ └── navigation.html ├── images │ ├── anchor-copy-button.svg │ ├── arrow_down.svg │ ├── burger.svg │ ├── copy-icon.svg │ ├── copy-successful-icon.svg │ ├── footer-go-to-link.svg │ ├── go-to-top-icon.svg │ ├── logo-icon.svg │ ├── nav-icons │ │ ├── abstract-class-kotlin.svg │ │ ├── abstract-class.svg │ │ ├── annotation-kotlin.svg │ │ ├── annotation.svg │ │ ├── class-kotlin.svg │ │ ├── class.svg │ │ ├── enum-kotlin.svg │ │ ├── enum.svg │ │ ├── exception-class.svg │ │ ├── field-value.svg │ │ ├── field-variable.svg │ │ ├── function.svg │ │ ├── interface-kotlin.svg │ │ ├── interface.svg │ │ ├── object.svg │ │ └── typealias-kotlin.svg │ └── theme-toggle.svg ├── index.html ├── navigation.html ├── package-list ├── scripts │ ├── clipboard.js │ ├── main.js │ ├── navigation-loader.js │ ├── pages.json │ ├── platform-content-handler.js │ ├── prism.js │ ├── sourceset_dependencies.js │ └── symbol-parameters-wrapper_deferred.js └── styles │ ├── font-jb-sans-auto.css │ ├── jetbrains-mono.css │ ├── logo-styles.css │ ├── main.css │ ├── prism.css │ └── style.css ├── gradle.properties ├── gradle ├── gradle-publish.gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── scripts └── start-local-development.sh ├── settings.gradle ├── v2_MIGRATION.md └── v3_MIGRATION_GUIDE.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @braintree/team-sdk-android 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | --- 5 | 6 | 7 | 8 | **Integration Details (please complete the following information):** 9 | - SDK/Library Version: [e.g. 1.x.x] 10 | - Environment: [e.g. Sandbox or Production] 11 | - Android Version: [e.g. Android Q] 12 | - Device [e.g. Google Pixel 3 XL] 13 | 14 | **Describe the bug** 15 | Description of what the bug is. Please include as many details as possible. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. See error 22 | 23 | **Expected behavior** 24 | Description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for our SDK 4 | --- 5 | 6 | **Is your feature request related to a problem? Please describe.** 7 | A clear and concise description of what the problem is. 8 | 9 | **Describe the solution you'd like** 10 | Description of what you want to happen. Follow the [user story](https://en.wikipedia.org/wiki/User_story) format to clearly describe the use case. 11 | 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thank you for your contribution to Braintree. 2 | 3 | 4 | ### Summary of changes 5 | 6 | - 7 | 8 | ### Checklist 9 | 10 | - [ ] Added a changelog entry 11 | 12 | ### Authors 13 | > List GitHub usernames for everyone who contributed to this pull request. 14 | 15 | - 16 | -------------------------------------------------------------------------------- /.github/actions/setup_java/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Java 2 | description: Set up Java 3 | runs: 4 | using: composite 5 | steps: 6 | - name: Set up Java 7 | uses: actions/setup-java@v3 8 | with: 9 | java-version: '17' 10 | distribution: zulu 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'Version to release' 7 | required: true 8 | env: 9 | SIGNING_KEY_FILE: /home/runner/secretKey.gpg 10 | jobs: 11 | build_aar: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repository 16 | uses: actions/checkout@v2 17 | - name: Setup Java 18 | uses: ./.github/actions/setup_java 19 | - name: Decode Signing Key 20 | run: ./ci decode_signing_key 21 | env: 22 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }} 23 | - name: Assemble 24 | run: ./ci build 25 | env: 26 | SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} 27 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 28 | 29 | # Run unit tests after a successful build 30 | unit_test_browser_switch: 31 | name: Unit Test Browser Switch 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout Repository 35 | uses: actions/checkout@v2 36 | - name: Setup Java 37 | uses: ./.github/actions/setup_java 38 | - name: Run Unit Tests 39 | run: ./ci unit_tests 40 | 41 | # Publish after successful build and unit tests 42 | publish_browser_switch: 43 | needs: [ build_aar, unit_test_browser_switch ] 44 | name: Publish To Sonatype 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout Repository 48 | uses: actions/checkout@v2 49 | - name: Setup Java 50 | uses: ./.github/actions/setup_java 51 | - name: Decode Signing Key 52 | run: ./ci decode_signing_key 53 | env: 54 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }} 55 | - name: Update Version 56 | run: ./ci update_version ${{ github.event.inputs.version }} 57 | - name: Publish Browser Switch 58 | run: ./ci publish release 59 | env: 60 | SONATYPE_NEXUS_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} 61 | SONATYPE_NEXUS_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} 62 | SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} 63 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 64 | 65 | release_finished: 66 | needs: [ publish_browser_switch ] 67 | name: Release Finished 68 | runs-on: ubuntu-latest 69 | steps: 70 | - run: echo "Release finished" 71 | 72 | bump_version: 73 | needs: [ release_finished ] 74 | name: Bump Version 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Checkout Repository 78 | uses: actions/checkout@v2 79 | - name: Setup Java 80 | uses: ./.github/actions/setup_java 81 | - name: Set GitHub User 82 | run: ./ci set_github_user_to_braintreeps 83 | - name: Update Version 84 | run: | 85 | ./ci publish_dokka_docs 86 | ./ci update_version ${{ github.event.inputs.version }} 87 | ./ci commit_and_tag_release ${{ github.event.inputs.version }} 88 | ./ci increment_snapshot_version ${{ github.event.inputs.version }} 89 | ./ci increment_demo_app_version_code 90 | 91 | git commit -am 'Prepare for development' 92 | git push origin main ${{ github.event.inputs.version }} 93 | 94 | create_github_release: 95 | needs: [ bump_version ] 96 | name: Create GitHub Release 97 | runs-on: ubuntu-latest 98 | steps: 99 | - name: Checkout Repository 100 | uses: actions/checkout@v2 101 | - name: Setup Java 102 | uses: ./.github/actions/setup_java 103 | - name: Save changelog entries to a file 104 | run: ./ci get_latest_changelog_entries > changelog_entries.md 105 | - name: Create GitHub Release 106 | id: create_release 107 | uses: actions/create-release@v1 108 | env: 109 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 110 | with: 111 | tag_name: ${{ github.event.inputs.version }} 112 | release_name: ${{ github.event.inputs.version }} 113 | body_path: changelog_entries.md 114 | draft: false 115 | prerelease: false 116 | -------------------------------------------------------------------------------- /.github/workflows/release_snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Release SNAPSHOT 2 | on: 3 | # update SNAPSHOT build whenever a push or merge occurs on master 4 | push: 5 | branches: 6 | - main 7 | - v3 8 | env: 9 | SIGNING_KEY_FILE: /home/runner/secretKey.gpg 10 | jobs: 11 | build_aar: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Repository 16 | uses: actions/checkout@v2 17 | - name: Setup Java 18 | uses: ./.github/actions/setup_java 19 | - name: Decode Signing Key 20 | run: ./ci decode_signing_key 21 | env: 22 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }} 23 | - name: Assemble 24 | run: ./ci build 25 | env: 26 | SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} 27 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 28 | 29 | # Run unit tests after a successful build 30 | unit_test_browser_switch: 31 | name: Unit Test Browser Switch 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout Repository 35 | uses: actions/checkout@v2 36 | - name: Setup Java 37 | uses: ./.github/actions/setup_java 38 | - name: Run Unit Tests 39 | run: ./ci unit_tests 40 | 41 | publish_browser_switch_snapshot: 42 | needs: [ build_aar, unit_test_browser_switch ] 43 | name: Publish SNAPSHOT 44 | runs-on: ubuntu-latest 45 | steps: 46 | - name: Checkout Repository 47 | uses: actions/checkout@v2 48 | - name: Setup Java 49 | uses: ./.github/actions/setup_java 50 | - name: Decode Signing Key 51 | run: ./ci decode_signing_key 52 | env: 53 | SIGNING_KEY: ${{ secrets.SIGNING_KEY }} 54 | - name: Publish SNAPSHOT to Sonatype 55 | run: ./ci publish snapshot 56 | env: 57 | SONATYPE_NEXUS_USERNAME: ${{ secrets.SONATYPE_NEXUS_USERNAME }} 58 | SONATYPE_NEXUS_PASSWORD: ${{ secrets.SONATYPE_NEXUS_PASSWORD }} 59 | SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} 60 | SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} 61 | 62 | release_finished: 63 | needs: [ publish_browser_switch_snapshot ] 64 | name: Release Finished 65 | runs-on: ubuntu-latest 66 | steps: 67 | - run: echo "Release finished" 68 | -------------------------------------------------------------------------------- /.github/workflows/static_analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | on: [pull_request, workflow_dispatch] 3 | concurrency: 4 | group: static-analysis-${{ github.event.number }} 5 | cancel-in-progress: true 6 | jobs: 7 | android_lint: 8 | name: Android Lint 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout Repository 12 | uses: actions/checkout@v2 13 | - name: Set up Java 17 14 | uses: actions/setup-java@v3 15 | with: 16 | java-version: '17' 17 | distribution: 'microsoft' 18 | - name: Lint 19 | run: ./ci android_lint -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [pull_request] 3 | jobs: 4 | unit_test_job: 5 | name: Unit Tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout Repository 9 | uses: actions/checkout@v2 10 | - name: Set up Java 17 11 | uses: actions/setup-java@v3 12 | with: 13 | java-version: '17' 14 | distribution: 'zulu' 15 | - name: Unit Tests 16 | run: ./ci unit_tests 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .idea 3 | build 4 | local.properties 5 | *.iml 6 | .DS_Store 7 | 8 | # vim 9 | *.swp 10 | 11 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | browser-switch-android 4 | Project browser-switch-android created by Buildship. 5 | 6 | 7 | 8 | 9 | org.eclipse.buildship.core.gradleprojectbuilder 10 | 11 | 12 | 13 | 14 | 15 | org.eclipse.buildship.core.gradleprojectnature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.settings/org.eclipse.buildship.core.prefs: -------------------------------------------------------------------------------- 1 | connection.project.dir= 2 | eclipse.preferences.version=1 3 | -------------------------------------------------------------------------------- /ACKNOWLEDGEMENTS.md: -------------------------------------------------------------------------------- 1 | Acknowledgements 2 | ---------------- 3 | 4 | Android Browser Switch uses code from the following libraries: 5 | 6 | * [Android Support Library](http://developer.android.com/tools/support-library/index.html), [Android Software Development Kit License Agreement](http://developer.android.com/sdk/terms.html) 7 | * [Gradle Nexus Staging plugin](https://github.com/Codearte/gradle-nexus-staging-plugin), Apache License Version 2.0 8 | * [JUnit](https://github.com/junit-team/junit4), Eclipse Public License v1.0 9 | * [Mockito](https://github.com/mockito/mockito), MIT License 10 | * [Robolectric](https://github.com/robolectric/robolectric), MIT License 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Braintree, a division of PayPal, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /browser-switch/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'kotlin-android' 4 | id 'org.jetbrains.dokka' 5 | } 6 | 7 | android { 8 | namespace "com.braintreepayments.api.browserswitch" 9 | compileSdk rootProject.compileSdkVersion 10 | 11 | defaultConfig { 12 | minSdkVersion rootProject.minSdkVersion 13 | targetSdkVersion rootProject.targetSdkVersion 14 | 15 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 16 | } 17 | 18 | compileOptions { 19 | sourceCompatibility versions.javaSourceCompatibility 20 | targetCompatibility versions.javaTargetCompatibility 21 | } 22 | 23 | kotlinOptions { 24 | jvmTarget = "11" 25 | } 26 | 27 | kotlin { 28 | jvmToolchain { 29 | languageVersion.set(JavaLanguageVersion.of("11")) 30 | } 31 | } 32 | 33 | // robolectric 34 | testOptions { 35 | unitTests { 36 | includeAndroidResources = true 37 | } 38 | } 39 | } 40 | 41 | dependencies { 42 | implementation deps.annotation 43 | implementation deps.appcompat 44 | implementation deps.browser 45 | 46 | testImplementation deps.junit 47 | testImplementation deps.mockitoCore 48 | testImplementation deps.jsonassert 49 | testImplementation deps.robolectric 50 | } 51 | 52 | // region signing and publishing 53 | 54 | project.ext.name = "browser-switch" 55 | project.ext.pom_name = "browser-switch" 56 | project.ext.group_id = "com.braintreepayments.api" 57 | project.ext.version = rootProject.version 58 | project.ext.pom_desc = "Android Browser Switch makes it easy to open a url in a browser or Chrome Custom Tab and receive a response as the result of user interaction, either cancel or response data from the web page." 59 | 60 | apply from: rootProject.file("gradle/gradle-publish.gradle") 61 | 62 | // endregion 63 | 64 | -------------------------------------------------------------------------------- /browser-switch/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchException.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api; 2 | 3 | import androidx.annotation.VisibleForTesting; 4 | 5 | /** 6 | * Error class thrown when browser switch returns an error. 7 | */ 8 | public class BrowserSwitchException extends Exception { 9 | 10 | @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 11 | public BrowserSwitchException(String message) { 12 | super(message); 13 | } 14 | 15 | @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 16 | BrowserSwitchException(String message, Exception reason) { 17 | super(message, reason); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchFinalResult.kt: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api 2 | 3 | import android.net.Uri 4 | import org.json.JSONObject 5 | 6 | /** 7 | * The result of a browser switch obtained from [BrowserSwitchClient.completeRequest] 8 | */ 9 | sealed class BrowserSwitchFinalResult { 10 | 11 | /** 12 | * The browser switch was successfully completed. 13 | */ 14 | class Success internal constructor( 15 | val returnUrl: Uri, 16 | val requestCode: Int, 17 | val requestUrl: Uri, 18 | val requestMetadata: JSONObject?, 19 | ) : BrowserSwitchFinalResult() { 20 | internal constructor(returnUrl: Uri, originalRequest: BrowserSwitchRequest) : this( 21 | returnUrl, 22 | originalRequest.requestCode, 23 | originalRequest.url, 24 | originalRequest.metadata 25 | ) 26 | } 27 | 28 | /** 29 | * The browser switch failed. 30 | * @property [error] Error detailing the reason for the browser switch failure. 31 | */ 32 | class Failure internal constructor(val error: BrowserSwitchException) : 33 | BrowserSwitchFinalResult() 34 | 35 | /** 36 | * No browser switch result was found. This is the expected result when a user cancels the 37 | * browser switch flow without completing by closing the browser, or navigates back to the app 38 | * without completing the browser switch flow. 39 | */ 40 | object NoResult : BrowserSwitchFinalResult() 41 | } 42 | -------------------------------------------------------------------------------- /browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchInspector.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api; 2 | 3 | import android.content.Context; 4 | import android.content.Intent; 5 | import android.net.Uri; 6 | 7 | class BrowserSwitchInspector { 8 | 9 | boolean isDeviceConfiguredForDeepLinking(Context context, String returnUrlScheme) { 10 | String browserSwitchUrl = String.format("%s://", returnUrlScheme); 11 | Intent deepLinkIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(browserSwitchUrl)); 12 | deepLinkIntent.addCategory(Intent.CATEGORY_DEFAULT); 13 | deepLinkIntent.addCategory(Intent.CATEGORY_BROWSABLE); 14 | 15 | return canResolveActivityForIntent(context, deepLinkIntent); 16 | } 17 | 18 | private boolean canResolveActivityForIntent(Context context, Intent intent) { 19 | return !context.getPackageManager().queryIntentActivities(intent, 0).isEmpty(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchOptions.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api; 2 | 3 | import android.net.Uri; 4 | 5 | import androidx.annotation.Nullable; 6 | 7 | import org.json.JSONObject; 8 | 9 | /** 10 | * Object that contains a set of browser switch parameters for use with 11 | * {@link BrowserSwitchClient#start(androidx.activity.ComponentActivity, BrowserSwitchOptions)}. 12 | */ 13 | public class BrowserSwitchOptions { 14 | 15 | private JSONObject metadata; 16 | private int requestCode; 17 | private Uri url; 18 | private String returnUrlScheme; 19 | private Uri appLinkUri; 20 | 21 | private boolean launchAsNewTask; 22 | 23 | /** 24 | * Set browser switch metadata. 25 | * 26 | * @param metadata JSONObject containing metadata that will be persisted and returned in a 27 | * {@link BrowserSwitchFinalResult} when the app has re-entered the foreground 28 | * @return {@link BrowserSwitchOptions} reference to instance to allow setter invocations to be chained 29 | */ 30 | public BrowserSwitchOptions metadata(@Nullable JSONObject metadata) { 31 | this.metadata = metadata; 32 | return this; 33 | } 34 | 35 | /** 36 | * Set browser switch request code. 37 | * 38 | * @param requestCode Request code int to associate with the browser switch request 39 | * @return {@link BrowserSwitchOptions} reference to instance to allow setter invocations to be chained 40 | */ 41 | public BrowserSwitchOptions requestCode(int requestCode) { 42 | this.requestCode = requestCode; 43 | return this; 44 | } 45 | 46 | /** 47 | * Set browser switch url. 48 | * 49 | * @param url The target url to use for browser switch 50 | * @return {@link BrowserSwitchOptions} reference to instance to allow setter invocations to be chained 51 | */ 52 | public BrowserSwitchOptions url(@Nullable Uri url) { 53 | this.url = url; 54 | return this; 55 | } 56 | 57 | /** 58 | * Set browser switch return url scheme. 59 | * 60 | * @param returnUrlScheme The return url scheme to use for deep linking back into the application 61 | * after browser switch 62 | * @return {@link BrowserSwitchOptions} reference to instance to allow setter invocations to be chained 63 | */ 64 | public BrowserSwitchOptions returnUrlScheme(@Nullable String returnUrlScheme) { 65 | this.returnUrlScheme = returnUrlScheme; 66 | return this; 67 | } 68 | 69 | /** 70 | * Set App Link [Uri]. 71 | * 72 | * @param appLinkUri The [Uri] containing the App Link URL used for navigating back into the application 73 | * after browser switch 74 | * @return {@link BrowserSwitchOptions} reference to instance to allow setter invocations to be chained 75 | */ 76 | public BrowserSwitchOptions appLinkUri(@Nullable Uri appLinkUri) { 77 | this.appLinkUri = appLinkUri; 78 | return this; 79 | } 80 | 81 | /** 82 | * @return The metadata associated with the browser switch request 83 | */ 84 | @Nullable 85 | public JSONObject getMetadata() { 86 | return metadata; 87 | } 88 | 89 | /** 90 | * @return The request code associated with the browser switch request 91 | */ 92 | public int getRequestCode() { 93 | return requestCode; 94 | } 95 | 96 | /** 97 | * @return The target url used for browser switch 98 | */ 99 | @Nullable 100 | public Uri getUrl() { 101 | return url; 102 | } 103 | 104 | /** 105 | * @return The return url scheme for deep linking back into the application after browser switch 106 | */ 107 | @Nullable 108 | public String getReturnUrlScheme() { 109 | return returnUrlScheme; 110 | } 111 | 112 | /** 113 | * @return The App Link [Uri] set for navigating back into the application after browser switch 114 | */ 115 | @Nullable 116 | public Uri getAppLinkUri() { 117 | return appLinkUri; 118 | } 119 | 120 | public boolean isLaunchAsNewTask() { 121 | return launchAsNewTask; 122 | } 123 | 124 | public BrowserSwitchOptions launchAsNewTask(boolean launchAsNewTask) { 125 | this.launchAsNewTask = launchAsNewTask; 126 | return this; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchRequest.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api; 2 | 3 | import android.net.Uri; 4 | import android.util.Base64; 5 | 6 | import androidx.annotation.NonNull; 7 | import androidx.annotation.Nullable; 8 | import androidx.annotation.RestrictTo; 9 | import androidx.annotation.VisibleForTesting; 10 | 11 | import org.json.JSONException; 12 | import org.json.JSONObject; 13 | 14 | import java.nio.charset.StandardCharsets; 15 | 16 | public class BrowserSwitchRequest { 17 | 18 | private static final String KEY_REQUEST_CODE = "requestCode"; 19 | private static final String KEY_URL = "url"; 20 | private static final String KEY_RETURN_URL_SCHEME = "returnUrlScheme"; 21 | private static final String KEY_METADATA = "metadata"; 22 | private static final String KEY_APP_LINK_URI = "appLinkUri"; 23 | 24 | private final Uri url; 25 | private final int requestCode; 26 | private final JSONObject metadata; 27 | @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 28 | public final String returnUrlScheme; 29 | private Uri appLinkUri; 30 | 31 | @NonNull 32 | static BrowserSwitchRequest fromBase64EncodedJSON(@NonNull String base64EncodedRequest) throws BrowserSwitchException { 33 | byte[] data = Base64.decode(base64EncodedRequest, Base64.DEFAULT); 34 | String requestJSONString = new String(data, StandardCharsets.UTF_8); 35 | 36 | try { 37 | JSONObject requestJSON = new JSONObject(requestJSONString); 38 | 39 | Uri appLinkUri = null; 40 | if (requestJSON.has(KEY_APP_LINK_URI)) { 41 | appLinkUri = Uri.parse(requestJSON.getString(KEY_APP_LINK_URI)); 42 | } 43 | return new BrowserSwitchRequest( 44 | requestJSON.getInt(KEY_REQUEST_CODE), 45 | Uri.parse(requestJSON.getString(KEY_URL)), 46 | requestJSON.optJSONObject(KEY_METADATA), 47 | requestJSON.optString(KEY_RETURN_URL_SCHEME), 48 | appLinkUri 49 | ); 50 | } catch (JSONException e) { 51 | throw new BrowserSwitchException("Unable to deserialize browser switch state.", e); 52 | } 53 | } 54 | 55 | @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 56 | public BrowserSwitchRequest( 57 | int requestCode, 58 | Uri url, 59 | JSONObject metadata, 60 | String returnUrlScheme, 61 | Uri appLinkUri 62 | ) { 63 | this.url = url; 64 | this.requestCode = requestCode; 65 | this.metadata = metadata; 66 | this.returnUrlScheme = returnUrlScheme; 67 | this.appLinkUri = appLinkUri; 68 | } 69 | 70 | @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 71 | public Uri getUrl() { 72 | return url; 73 | } 74 | 75 | @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 76 | public int getRequestCode() { 77 | return requestCode; 78 | } 79 | 80 | @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) 81 | public JSONObject getMetadata() { 82 | return metadata; 83 | } 84 | 85 | @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) 86 | @Nullable 87 | public Uri getAppLinkUri() { 88 | return appLinkUri; 89 | } 90 | 91 | @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) 92 | public void setAppLinkUri(@Nullable Uri appLinkUri) { 93 | this.appLinkUri = appLinkUri; 94 | } 95 | 96 | @NonNull 97 | String toBase64EncodedJSON() throws BrowserSwitchException { 98 | try { 99 | JSONObject requestJSON = new JSONObject() 100 | .put(KEY_REQUEST_CODE, requestCode) 101 | .put(KEY_URL, url.toString()) 102 | .putOpt(KEY_RETURN_URL_SCHEME, returnUrlScheme) 103 | .putOpt(KEY_METADATA, metadata) 104 | .putOpt(KEY_APP_LINK_URI, appLinkUri); 105 | 106 | byte[] requestJSONBytes = requestJSON.toString().getBytes(StandardCharsets.UTF_8); 107 | return Base64.encodeToString(requestJSONBytes, Base64.DEFAULT); 108 | } catch (JSONException e) { 109 | throw new BrowserSwitchException("Unable to serialize browser switch state.", e); 110 | } 111 | } 112 | 113 | boolean matchesDeepLinkUrlScheme(@NonNull Uri url) { 114 | return url.getScheme() != null && url.getScheme().equalsIgnoreCase(returnUrlScheme); 115 | } 116 | 117 | boolean matchesAppLinkUri(@NonNull Uri uri) { 118 | return appLinkUri != null && 119 | uri.getScheme().equals(appLinkUri.getScheme()) && 120 | uri.getHost().equals(appLinkUri.getHost()); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /browser-switch/src/main/java/com/braintreepayments/api/BrowserSwitchStartResult.kt: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api 2 | 3 | /** 4 | * The result of a browser switch obtained from [BrowserSwitchClient.start] 5 | */ 6 | sealed class BrowserSwitchStartResult { 7 | 8 | /** 9 | * The browser switch was successfully completed. Store pendingRequest String to complete 10 | * browser switch after deeplinking back into the application (see [BrowserSwitchClient.completeRequest]). 11 | */ 12 | class Started(val pendingRequest: String) : BrowserSwitchStartResult() 13 | 14 | /** 15 | * Browser switch failed with an [error]. 16 | */ 17 | class Failure(val error: Exception) : BrowserSwitchStartResult() 18 | } 19 | -------------------------------------------------------------------------------- /browser-switch/src/main/java/com/braintreepayments/api/ChromeCustomTabsInternalClient.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api; 2 | 3 | import android.content.ActivityNotFoundException; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.net.Uri; 7 | 8 | import androidx.annotation.VisibleForTesting; 9 | import androidx.browser.customtabs.CustomTabsIntent; 10 | 11 | class ChromeCustomTabsInternalClient { 12 | 13 | private final CustomTabsIntent.Builder customTabsIntentBuilder; 14 | 15 | ChromeCustomTabsInternalClient() { 16 | this(new CustomTabsIntent.Builder()); 17 | } 18 | 19 | @VisibleForTesting 20 | ChromeCustomTabsInternalClient(CustomTabsIntent.Builder builder) { 21 | this.customTabsIntentBuilder = builder; 22 | } 23 | 24 | void launchUrl(Context context, Uri url, boolean launchAsNewTask) throws ActivityNotFoundException{ 25 | CustomTabsIntent customTabsIntent = customTabsIntentBuilder.build(); 26 | if (launchAsNewTask) { 27 | customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 28 | } 29 | customTabsIntent.launchUrl(context, url); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /browser-switch/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Request code cannot be Integer.MIN_VALUE 4 | An appLinkUri or returnUrlScheme is required. 5 | No installed activities can open this URL: %1$s 6 | The return url scheme was not set up, incorrectly set up, or more than one Activity on this device defines the same url scheme in it\'s Android Manifest. See https://github.com/braintree/browser-switch-android for more information on setting up a return url scheme. 7 | 8 | -------------------------------------------------------------------------------- /browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchInspectorUnitTest.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api; 2 | 3 | import static org.mockito.ArgumentMatchers.any; 4 | import static org.mockito.ArgumentMatchers.eq; 5 | import static org.mockito.Mockito.mock; 6 | import static org.mockito.Mockito.verify; 7 | import static org.mockito.Mockito.when; 8 | 9 | import android.content.Context; 10 | import android.content.Intent; 11 | import android.content.pm.PackageManager; 12 | import android.content.pm.ResolveInfo; 13 | import android.net.Uri; 14 | 15 | import junit.framework.TestCase; 16 | 17 | import org.junit.Before; 18 | import org.junit.Test; 19 | import org.junit.runner.RunWith; 20 | import org.mockito.ArgumentCaptor; 21 | import org.robolectric.RobolectricTestRunner; 22 | 23 | import java.util.Collections; 24 | 25 | @RunWith(RobolectricTestRunner.class) 26 | public class BrowserSwitchInspectorUnitTest extends TestCase { 27 | 28 | private Context context; 29 | 30 | private PackageManager packageManager; 31 | 32 | @Before 33 | public void setUp() { 34 | context = mock(Context.class); 35 | packageManager = mock(PackageManager.class); 36 | } 37 | 38 | @Test 39 | public void isDeviceConfiguredForDeepLinking_queriesPackageManagerForDeepIntent() { 40 | when(context.getPackageName()).thenReturn("sample.package.name"); 41 | when(context.getPackageManager()).thenReturn(packageManager); 42 | when(packageManager.queryIntentActivities(any(Intent.class), eq(0))).thenReturn(Collections.emptyList()); 43 | 44 | String returnUrlScheme = "sample.package.name.browserswitch"; 45 | 46 | BrowserSwitchInspector sut = new BrowserSwitchInspector(); 47 | sut.isDeviceConfiguredForDeepLinking(context, returnUrlScheme); 48 | 49 | ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); 50 | verify(packageManager).queryIntentActivities(captor.capture(), eq(0)); 51 | 52 | Intent intent = captor.getValue(); 53 | assertEquals(Uri.parse("sample.package.name.browserswitch://"), intent.getData()); 54 | assertEquals(Intent.ACTION_VIEW, intent.getAction()); 55 | assertTrue(intent.hasCategory(Intent.CATEGORY_DEFAULT)); 56 | assertTrue(intent.hasCategory(Intent.CATEGORY_BROWSABLE)); 57 | } 58 | 59 | @Test 60 | public void isDeviceConfiguredForDeepLinking_whenNoActivityFound_returnsFalse() { 61 | when(context.getPackageName()).thenReturn("sample.package.name"); 62 | when(context.getPackageManager()).thenReturn(packageManager); 63 | when(packageManager.queryIntentActivities(any(Intent.class), eq(0))).thenReturn(Collections.emptyList()); 64 | 65 | String returnUrlScheme = "sample.package.name.browserswitch"; 66 | 67 | BrowserSwitchInspector sut = new BrowserSwitchInspector(); 68 | assertFalse(sut.isDeviceConfiguredForDeepLinking(context, returnUrlScheme)); 69 | } 70 | 71 | @Test 72 | public void isDeviceConfiguredForDeepLinking_whenActivityFound_returnsTrue() { 73 | when(context.getPackageName()).thenReturn("sample.package.name"); 74 | when(context.getPackageManager()).thenReturn(packageManager); 75 | 76 | ResolveInfo resolveInfo = new ResolveInfo(); 77 | when(packageManager.queryIntentActivities(any(Intent.class), eq(0))).thenReturn(Collections.singletonList(resolveInfo)); 78 | 79 | String returnUrlScheme = "sample.package.name.browserswitch"; 80 | 81 | BrowserSwitchInspector sut = new BrowserSwitchInspector(); 82 | assertTrue(sut.isDeviceConfiguredForDeepLinking(context, returnUrlScheme)); 83 | } 84 | } -------------------------------------------------------------------------------- /browser-switch/src/test/java/com/braintreepayments/api/BrowserSwitchRequestUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api 2 | 3 | import android.net.Uri 4 | import org.json.JSONObject 5 | import org.junit.Assert.assertEquals 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | import org.robolectric.RobolectricTestRunner 9 | 10 | @RunWith(RobolectricTestRunner::class) 11 | class BrowserSwitchRequestUnitTest { 12 | 13 | @Test 14 | fun startedConstructor_fromString_createsBrowserSwitchRequest() { 15 | val browserSwitchRequest = BrowserSwitchRequest( 16 | 1, 17 | Uri.parse("http://target-uri.com"), 18 | JSONObject().put("test_key", "test_value"), 19 | "return-url-scheme", 20 | Uri.parse("https://app-link-uri.com") 21 | ) 22 | 23 | val token = browserSwitchRequest.toBase64EncodedJSON() 24 | val sut = BrowserSwitchRequest.fromBase64EncodedJSON(token) 25 | assertEquals(browserSwitchRequest.requestCode, sut.requestCode) 26 | assertEquals("test_value", sut.metadata.getString("test_key")) 27 | assertEquals(Uri.parse("http://target-uri.com"), sut.url) 28 | assertEquals("return-url-scheme", sut.returnUrlScheme) 29 | assertEquals(Uri.parse("https://app-link-uri.com"), sut.appLinkUri) 30 | } 31 | } -------------------------------------------------------------------------------- /browser-switch/src/test/resources/org/powermock/extensions/configuration.properties: -------------------------------------------------------------------------------- 1 | mockito.mock-maker-class=mock-maker-inline 2 | 3 | -------------------------------------------------------------------------------- /browser-switch/src/test/resources/robolectric.properties: -------------------------------------------------------------------------------- 1 | # TODO: remove when Robolectric supports API level 30 (Android 11) 2 | sdk=28 3 | 4 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | maven { 6 | url = "https://plugins.gradle.org/m2/" 7 | } 8 | } 9 | 10 | def sdkTargetJavaVersion = JavaVersion.VERSION_11 11 | ext.versions = [ 12 | "javaSourceCompatibility": sdkTargetJavaVersion, 13 | "javaTargetCompatibility": sdkTargetJavaVersion, 14 | ] 15 | 16 | 17 | ext.deps = [ 18 | 'annotation' : 'androidx.annotation:annotation:1.7.0', 19 | 'appcompat' : 'androidx.appcompat:appcompat:1.6.0', 20 | 'browser' : 'androidx.browser:browser:1.7.0', 21 | 'kotlin' : 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20', 22 | 23 | // test dependencies 24 | 'junit' : 'junit:junit:4.13.2', 25 | 'mockitoCore' : 'org.mockito:mockito-core:5.7.0', 26 | 'jsonassert' : 'org.skyscreamer:jsonassert:1.5.1', 27 | 'robolectric' : 'org.robolectric:robolectric:4.11.1' 28 | ] 29 | 30 | dependencies { 31 | classpath 'com.android.tools.build:gradle:8.5.2' 32 | classpath 'org.jetbrains.dokka:dokka-gradle-plugin:1.9.10' 33 | classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0' 34 | } 35 | } 36 | 37 | plugins { 38 | id 'io.github.gradle-nexus.publish-plugin' version '1.1.0' 39 | id 'org.jetbrains.dokka' version '1.9.10' 40 | id 'org.jetbrains.kotlin.android' version '1.8.10' apply false 41 | } 42 | 43 | version = '3.0.1-SNAPSHOT' 44 | group = "com.braintreepayments" 45 | ext { 46 | compileSdkVersion = 35 47 | minSdkVersion = 23 48 | targetSdkVersion = 35 49 | versionCode = 72 50 | versionName = version 51 | } 52 | 53 | allprojects { 54 | repositories { 55 | mavenCentral() 56 | google() 57 | } 58 | } 59 | 60 | nexusPublishing { 61 | repositories { 62 | sonatype { 63 | username = System.getenv('SONATYPE_NEXUS_USERNAME') ?: '' 64 | password = System.getenv('SONATYPE_NEXUS_PASSWORD') ?: '' 65 | } 66 | } 67 | transitionCheckOptions { 68 | // give nexus sonatype more time to close the staging repository 69 | delayBetween.set(Duration.ofSeconds(20)) 70 | } 71 | } 72 | 73 | dokkaHtmlMultiModule.configure { 74 | // redirect dokka output to GitHub pages root directory 75 | outputDirectory.set(project.file("docs")) 76 | } 77 | 78 | task changeGradleReleaseVersion { 79 | doLast { 80 | def gradleFile = new File('build.gradle') 81 | def gradleFileText = gradleFile.text.replaceFirst("\\nversion = '\\d+\\.\\d+\\.\\d+(-.*)?'", "\nversion = '" + versionParam + "'") 82 | gradleFile.write(gradleFileText) 83 | } 84 | } 85 | 86 | task changeREADMEVersion { 87 | doLast { 88 | def readmeFile = new File('README.md') 89 | def readmeFileText = readmeFile.text.replaceFirst(":browser-switch:\\d+\\.\\d+\\.\\d+(-.*)?'", ":browser-switch:" + versionParam + "'") 90 | readmeFile.write(readmeFileText) 91 | } 92 | } 93 | 94 | task changeMigrationGuideVersion { 95 | doLast { 96 | def migrationGuideFile = new File('v3_MIGRATION_GUIDE.md') 97 | def migrationGuideFileText = migrationGuideFile.text.replaceAll(":\\d+\\.\\d+\\.\\d+(-.*)?'", ":" + versionParam + "'") 98 | migrationGuideFile.write(migrationGuideFileText) 99 | } 100 | } 101 | 102 | task updateCHANGELOGVersion { 103 | doLast { 104 | def changelogFile = new File('CHANGELOG.md') 105 | def changelogFileText = changelogFile.text.replaceFirst("## unreleased", "## " + versionParam) 106 | changelogFile.write(changelogFileText) 107 | } 108 | } 109 | 110 | task incrementSNAPSHOTVersion { 111 | doLast { 112 | def gradleFile = new File('build.gradle') 113 | def (major, minor, patch) = versionParam.tokenize('.') 114 | def patchInteger = patch[-1].toInteger() 115 | patchInteger++ 116 | def newPatch = patch.substring(0,patch.length()-1) + patchInteger.toString() 117 | def newVersion = "$major.$minor.$newPatch-SNAPSHOT" 118 | def gradleFileText = gradleFile.text.replaceFirst("\\nversion = '\\d+\\.\\d+\\.\\d+(-.*)?'", "\nversion = '" + newVersion + "'") 119 | gradleFile.write(gradleFileText) 120 | 121 | // update README snapshot version 122 | def readmeFile = new File('README.md') 123 | def readmeFileText = readmeFile.text.replaceFirst(":browser-switch:\\d+\\.\\d+\\.\\d+-SNAPSHOT'", ":browser-switch:" + newVersion + "'") 124 | readmeFile.write(readmeFileText) 125 | } 126 | } 127 | 128 | task incrementVersionCode { 129 | doLast { 130 | def gradleFile = new File('build.gradle') 131 | def versionText = gradleFile.text.find("versionCode = \\d+") 132 | def params = versionText.split("=") 133 | def newVersionCode = params[1].trim().toInteger() + 1 134 | def gradleFileText = gradleFile.text.replaceFirst("versionCode = \\d+", "versionCode = " + newVersionCode) 135 | gradleFile.write(gradleFileText) 136 | } 137 | } -------------------------------------------------------------------------------- /ci: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Ref: https://stackoverflow.com/a/19622569 4 | set -e 5 | 6 | command_name="$1" 7 | case $command_name in 8 | build) 9 | [ -n "${SIGNING_KEY_ID}" ] || (echo "::error:: SIGNING_KEY_ID env variable not set" && exit 1) 10 | [ -n "${SIGNING_KEY_PASSWORD}" ] || (echo "::error:: SIGNING_KEY_PASSWORD env variable not set" && exit 1) 11 | [ -n "${SIGNING_KEY_FILE}" ] || (echo "::error:: SIGNING_KEY_FILE env variable not set" && exit 1) 12 | 13 | # assemble build 14 | ./gradlew --stacktrace assemble 15 | ;; 16 | lint) 17 | ./gradlew clean lint 18 | ;; 19 | unit_tests) 20 | ./gradlew --stacktrace testRelease 21 | ;; 22 | integration_tests) 23 | ./gradlew --continue connectedAndroidTest 24 | ;; 25 | publish_to_maven_local) 26 | ./gradlew clean publishToMavenLocal 27 | ;; 28 | update_version) 29 | # guard against version updates when CHANGELOG doesn't contain an unreleased section 30 | grep "## unreleased" CHANGELOG.md || (echo "::error::No unreleased section found in CHANGELOG" && exit 1) 31 | 32 | new_version="$2" 33 | ./gradlew -PversionParam="${new_version}" changeGradleReleaseVersion 34 | ./gradlew -PversionParam="${new_version}" changeREADMEVersion 35 | ./gradlew -PversionParam="${new_version}" changeMigrationGuideVersion 36 | ./gradlew -PversionParam="${new_version}" updateCHANGELOGVersion 37 | ;; 38 | increment_snapshot_version) 39 | new_version="$2" 40 | ./gradlew -PversionParam="${new_version}" incrementSNAPSHOTVersion 41 | ;; 42 | increment_demo_app_version_code) 43 | ./gradlew incrementVersionCode 44 | ;; 45 | publish) 46 | # Ref: https://nickjanetakis.com/blog/prevent-unset-variables-in-your-shell-bash-scripts-with-set-nounset 47 | [ -n "${SONATYPE_NEXUS_USERNAME}" ] || (echo "::error:: SONATYPE_NEXUS_USERNAME env variable not set" && exit 1) 48 | [ -n "${SONATYPE_NEXUS_PASSWORD}" ] || (echo "::error:: SONATYPE_NEXUS_PASSWORD env variable not set" && exit 1) 49 | [ -n "${SIGNING_KEY_ID}" ] || (echo "::error:: SIGNING_KEY_ID env variable not set" && exit 1) 50 | [ -n "${SIGNING_KEY_PASSWORD}" ] || (echo "::error:: SIGNING_KEY_PASSWORD env variable not set" && exit 1) 51 | [ -n "${SIGNING_KEY_FILE}" ] || (echo "::error:: SIGNING_KEY_FILE env variable not set" && exit 1) 52 | 53 | release_type="$2" 54 | if [ "${release_type}" == "release" ]; then 55 | # publish release 56 | ./gradlew --stacktrace clean :browser-switch:publishToSonatype closeAndReleaseSonatypeStagingRepository 57 | else 58 | # publish SNAPSHOT 59 | ./gradlew --stacktrace clean :browser-switch:publishToSonatype 60 | fi 61 | ;; 62 | decode_signing_key) 63 | [ -n "${SIGNING_KEY}" ] || (echo "::error:: SIGNING_KEY env variable not set" && exit 1) 64 | [ -n "${SIGNING_KEY_FILE}" ] || (echo "::error:: SIGNING_KEY_FILE env variable not set" && exit 1) 65 | 66 | # write signing key to a temporary file 67 | echo "${SIGNING_KEY}" > ~/secretKey.gpg.b64 68 | base64 -d ~/secretKey.gpg.b64 > "${SIGNING_KEY_FILE}" 69 | ;; 70 | set_github_user_to_braintreeps) 71 | git config user.name braintreeps 72 | git config user.email code@getbraintree.com 73 | ;; 74 | commit_and_tag_release) 75 | new_version="$2" 76 | git add -A 77 | git commit -am "Release ${new_version}" 78 | git tag "${new_version}" -a -m "Release ${new_version}" 79 | ;; 80 | get_latest_changelog_entries) 81 | sed -e '1,/##/d' -e '/##/,$d' CHANGELOG.md 82 | ;; 83 | publish_dokka_docs) 84 | ./gradlew dokkaHtmlMultiModule 85 | ;; 86 | android_lint) 87 | ./gradlew lint 88 | ;; 89 | 90 | esac 91 | 92 | -------------------------------------------------------------------------------- /demo/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | } 5 | 6 | android { 7 | namespace "com.braintreepayments.api.demo" 8 | compileSdk rootProject.compileSdkVersion 9 | 10 | defaultConfig { 11 | applicationId "com.braintreepayments.api.browserswitch.demo" 12 | minSdkVersion rootProject.minSdkVersion 13 | targetSdkVersion rootProject.targetSdkVersion 14 | versionCode rootProject.versionCode 15 | versionName rootProject.versionName 16 | 17 | testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' 18 | } 19 | 20 | signingConfigs { 21 | debug { 22 | storeFile file('debug.keystore') 23 | storePassword 'android' 24 | keyAlias 'androiddebugkey' 25 | keyPassword 'android' 26 | } 27 | } 28 | 29 | compileOptions { 30 | sourceCompatibility versions.javaSourceCompatibility 31 | targetCompatibility versions.javaTargetCompatibility 32 | } 33 | 34 | kotlinOptions { 35 | jvmTarget = "11" 36 | } 37 | 38 | buildFeatures { 39 | compose true 40 | } 41 | composeOptions { 42 | kotlinCompilerExtensionVersion '1.4.3' 43 | } 44 | packagingOptions { 45 | resources { 46 | excludes += '/META-INF/{AL2.0,LGPL2.1}' 47 | } 48 | } 49 | } 50 | 51 | dependencies { 52 | implementation project(':browser-switch') 53 | implementation "androidx.annotation:annotation:1.7.1" 54 | implementation "androidx.appcompat:appcompat:1.6.1" 55 | implementation "androidx.fragment:fragment-ktx:1.6.2" 56 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' 57 | implementation 'androidx.activity:activity-compose:1.8.2' 58 | implementation platform('androidx.compose:compose-bom:2023.03.00') 59 | implementation 'androidx.compose.material3:material3' 60 | implementation 'androidx.core:core-ktx:1.13.1' 61 | 62 | androidTestImplementation 'androidx.test.ext:junit:1.1.5' 63 | androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' 64 | androidTestImplementation 'com.lukekorth:device-automator:1.0.0' 65 | } 66 | -------------------------------------------------------------------------------- /demo/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/braintree/browser-switch-android/91b96cf0cf605bebb8a22cb22965e7d830e074a5/demo/debug.keystore -------------------------------------------------------------------------------- /demo/src/androidTest/java/com/braintreepayments/api/demo/BrowserSwitchTest.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api.demo; 2 | 3 | import android.app.Instrumentation; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | 7 | import androidx.test.core.app.ApplicationProvider; 8 | import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; 9 | import androidx.test.platform.app.InstrumentationRegistry; 10 | import androidx.test.uiautomator.By; 11 | import androidx.test.uiautomator.UiDevice; 12 | 13 | import org.junit.Before; 14 | import org.junit.Test; 15 | import org.junit.runner.RunWith; 16 | 17 | import static androidx.test.uiautomator.Until.hasObject; 18 | import static com.lukekorth.deviceautomator.AutomatorAction.click; 19 | import static com.lukekorth.deviceautomator.DeviceAutomator.onDevice; 20 | import static com.lukekorth.deviceautomator.UiObjectMatcher.withText; 21 | import static org.junit.Assert.assertTrue; 22 | 23 | @RunWith(AndroidJUnit4ClassRunner.class) 24 | public class BrowserSwitchTest { 25 | 26 | @Before 27 | public void beforeEach() { 28 | // TODO: update device automator to use ApplicationProvider and new androidx testing libraries 29 | Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); 30 | UiDevice device = UiDevice.getInstance(instrumentation); 31 | device.pressHome(); 32 | 33 | String launcherPackage = device.getLauncherPackageName(); 34 | device.wait(hasObject(By.pkg(launcherPackage).depth(0)), 5000); 35 | 36 | String packageName = InstrumentationRegistry.getInstrumentation().getTargetContext().getPackageName(); 37 | 38 | Context context = ApplicationProvider.getApplicationContext(); 39 | Intent intent = context.getPackageManager().getLaunchIntentForPackage(packageName); 40 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 41 | .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); 42 | context.startActivity(intent); 43 | 44 | device.wait(hasObject(By.pkg(intent.getPackage()).depth(0)), 5000); 45 | } 46 | 47 | @Test(timeout = 60000) 48 | public void standardLaunchMode() { 49 | onDevice(withText("Launch Mode: Standard")).perform(click()); 50 | onDevice(withText("Start Browser Switch")).perform(click()); 51 | onDevice(withText("Red")).waitForExists().perform(click()); 52 | 53 | onDevice(withText("Browser Switch Successful")).waitForExists(); 54 | 55 | assertTrue(onDevice(withText("Browser Switch Successful")).exists()); 56 | assertTrue(onDevice(withText("Selected Color: red")).exists()); 57 | assertTrue(onDevice(withText("Metadata: test_key=test_value")).exists()); 58 | } 59 | 60 | @Test(timeout = 60000) 61 | public void singleTopLaunchMode_startWithoutMetadata() { 62 | onDevice(withText("Launch Mode: Single Top")).perform(click()); 63 | onDevice(withText("Start Browser Switch")).perform(click()); 64 | onDevice(withText("Red")).waitForExists().perform(click()); 65 | 66 | onDevice(withText("Browser Switch Successful")).waitForExists(); 67 | 68 | assertTrue(onDevice(withText("Browser Switch Successful")).exists()); 69 | assertTrue(onDevice(withText("Selected Color: red")).exists()); 70 | assertTrue(onDevice(withText("Metadata: null")).exists()); 71 | } 72 | 73 | @Test(timeout = 60000) 74 | public void singleTopLaunchMode_startWithMetadata() { 75 | onDevice(withText("Launch Mode: Single Top")).perform(click()); 76 | onDevice(withText("Start Browser Switch With Metadata")).perform(click()); 77 | onDevice(withText("Red")).waitForExists().perform(click()); 78 | 79 | onDevice(withText("Browser Switch Successful")).waitForExists(); 80 | 81 | assertTrue(onDevice(withText("Browser Switch Successful")).exists()); 82 | assertTrue(onDevice(withText("Selected Color: red")).exists()); 83 | assertTrue(onDevice(withText("Metadata: testKey=testValue")).exists()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /demo/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /demo/src/main/java/com/braintreepayments/api/browserswitch/demo/DemoActivitySingleTop.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api.browserswitch.demo; 2 | 3 | import android.content.Intent; 4 | import android.os.Bundle; 5 | import android.view.View; 6 | 7 | import androidx.annotation.Nullable; 8 | import androidx.annotation.VisibleForTesting; 9 | import androidx.appcompat.app.AppCompatActivity; 10 | import androidx.core.graphics.Insets; 11 | import androidx.core.view.ViewCompat; 12 | import androidx.core.view.WindowInsetsCompat; 13 | import androidx.fragment.app.Fragment; 14 | import androidx.fragment.app.FragmentManager; 15 | 16 | import com.braintreepayments.api.BrowserSwitchClient; 17 | import com.braintreepayments.api.BrowserSwitchFinalResult; 18 | import com.braintreepayments.api.BrowserSwitchException; 19 | import com.braintreepayments.api.BrowserSwitchOptions; 20 | import com.braintreepayments.api.BrowserSwitchStartResult; 21 | import com.braintreepayments.api.browserswitch.demo.utils.PendingRequestStore; 22 | import com.braintreepayments.api.demo.R; 23 | 24 | import java.util.Objects; 25 | 26 | public class DemoActivitySingleTop extends AppCompatActivity { 27 | 28 | private static final String FRAGMENT_TAG = DemoFragment.class.getSimpleName(); 29 | private static final String RETURN_URL_SCHEME = "my-custom-url-scheme-single-top"; 30 | 31 | @VisibleForTesting 32 | BrowserSwitchClient browserSwitchClient = null; 33 | 34 | @Override 35 | protected void onCreate(@Nullable Bundle savedInstanceState) { 36 | super.onCreate(savedInstanceState); 37 | browserSwitchClient = new BrowserSwitchClient(); 38 | 39 | FragmentManager fm = getSupportFragmentManager(); 40 | if (getDemoFragment() == null) { 41 | fm.beginTransaction() 42 | .add(android.R.id.content, new DemoFragment(), FRAGMENT_TAG) 43 | .commit(); 44 | } 45 | 46 | // Support Edge-to-Edge layout in Android 15 47 | // Ref: https://developer.android.com/develop/ui/views/layout/edge-to-edge#cutout-insets 48 | View navHostView = findViewById(android.R.id.content); 49 | ViewCompat.setOnApplyWindowInsetsListener(navHostView, (v, insets) -> { 50 | @WindowInsetsCompat.Type.InsetsType int insetTypeMask = 51 | WindowInsetsCompat.Type.systemBars() 52 | | WindowInsetsCompat.Type.displayCutout() 53 | | WindowInsetsCompat.Type.systemGestures(); 54 | Insets bars = insets.getInsets(insetTypeMask); 55 | v.setPadding(bars.left, bars.top, bars.right, bars.bottom); 56 | return WindowInsetsCompat.CONSUMED; 57 | }); 58 | } 59 | 60 | @Override 61 | protected void onNewIntent(Intent intent) { 62 | super.onNewIntent(intent); 63 | 64 | String pendingRequest = PendingRequestStore.get(this); 65 | if (pendingRequest != null) { 66 | BrowserSwitchFinalResult result = browserSwitchClient.completeRequest(intent, pendingRequest); 67 | if (result instanceof BrowserSwitchFinalResult.Success) { 68 | Objects.requireNonNull(getDemoFragment()).onBrowserSwitchResult((BrowserSwitchFinalResult.Success) result); 69 | } 70 | PendingRequestStore.clear(this); 71 | } 72 | } 73 | 74 | @Override 75 | protected void onResume() { 76 | super.onResume(); 77 | 78 | String pendingRequest = PendingRequestStore.get(this); 79 | if (pendingRequest != null) { 80 | Objects.requireNonNull(getDemoFragment()).onBrowserSwitchError(new Exception("User did not complete browser switch")); 81 | PendingRequestStore.clear(this); 82 | } 83 | } 84 | 85 | public void startBrowserSwitch(BrowserSwitchOptions options) throws BrowserSwitchException { 86 | BrowserSwitchStartResult result = browserSwitchClient.start(this, options); 87 | if (result instanceof BrowserSwitchStartResult.Started) { 88 | PendingRequestStore.put(this, ((BrowserSwitchStartResult.Started) result).getPendingRequest()); 89 | } else if (result instanceof BrowserSwitchStartResult.Failure) { 90 | Objects.requireNonNull(getDemoFragment()).onBrowserSwitchError(((BrowserSwitchStartResult.Failure) result).getError()); 91 | } 92 | } 93 | 94 | private DemoFragment getDemoFragment() { 95 | FragmentManager fm = getSupportFragmentManager(); 96 | Fragment fragment = fm.findFragmentByTag(FRAGMENT_TAG); 97 | if (fragment instanceof DemoFragment) { 98 | return ((DemoFragment) fragment); 99 | } 100 | return null; 101 | } 102 | 103 | public String getReturnUrlScheme() { 104 | return RETURN_URL_SCHEME; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /demo/src/main/java/com/braintreepayments/api/browserswitch/demo/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api.browserswitch.demo; 2 | 3 | import androidx.appcompat.app.AppCompatActivity; 4 | import androidx.core.graphics.Insets; 5 | import androidx.core.view.ViewCompat; 6 | import androidx.core.view.WindowInsetsCompat; 7 | 8 | import android.content.Intent; 9 | import android.os.Bundle; 10 | import android.view.View; 11 | import android.widget.Button; 12 | 13 | import com.braintreepayments.api.demo.R; 14 | 15 | public class MainActivity extends AppCompatActivity { 16 | 17 | @Override 18 | protected void onCreate(Bundle savedInstanceState) { 19 | super.onCreate(savedInstanceState); 20 | setContentView(R.layout.activity_main); 21 | 22 | Button standardButton = findViewById(R.id.standard_button); 23 | standardButton.setOnClickListener(this::launchStandardBrowserSwitch); 24 | 25 | Button singleTopButton = findViewById(R.id.single_top_button); 26 | singleTopButton.setOnClickListener(this::launchSingleTopBrowserSwitch); 27 | 28 | // Support Edge-to-Edge layout in Android 15 29 | // Ref: https://developer.android.com/develop/ui/views/layout/edge-to-edge#cutout-insets 30 | View navHostView = findViewById(R.id.content); 31 | ViewCompat.setOnApplyWindowInsetsListener(navHostView, (v, insets) -> { 32 | @WindowInsetsCompat.Type.InsetsType int insetTypeMask = 33 | WindowInsetsCompat.Type.systemBars() 34 | | WindowInsetsCompat.Type.displayCutout() 35 | | WindowInsetsCompat.Type.systemGestures(); 36 | Insets bars = insets.getInsets(insetTypeMask); 37 | v.setPadding(bars.left, bars.top, bars.right, bars.bottom); 38 | return WindowInsetsCompat.CONSUMED; 39 | }); 40 | } 41 | 42 | public void launchStandardBrowserSwitch(View view) { 43 | Intent intent = new Intent(this, ComposeActivity.class); 44 | startActivity(intent); 45 | } 46 | 47 | public void launchSingleTopBrowserSwitch(View view) { 48 | Intent intent = new Intent(this, DemoActivitySingleTop.class); 49 | startActivity(intent); 50 | } 51 | } -------------------------------------------------------------------------------- /demo/src/main/java/com/braintreepayments/api/browserswitch/demo/utils/PendingRequestStore.kt: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api.browserswitch.demo.utils 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | 6 | class PendingRequestStore { 7 | 8 | companion object { 9 | 10 | private const val SHARED_PREFS_KEY = "PENDING_REQUESTS" 11 | private const val PENDING_REQUEST_KEY = "BROWSER_SWITCH_REQUEST" 12 | 13 | @JvmStatic 14 | fun put(context: Context, pendingRequest: String) { 15 | val sharedPreferences: SharedPreferences = context.getSharedPreferences( 16 | SHARED_PREFS_KEY, 17 | Context.MODE_PRIVATE 18 | ) 19 | sharedPreferences.edit().putString(PENDING_REQUEST_KEY, pendingRequest).apply() 20 | } 21 | 22 | @JvmStatic 23 | fun get(context: Context): String? { 24 | val sharedPreferences: SharedPreferences = context.getSharedPreferences( 25 | SHARED_PREFS_KEY, 26 | Context.MODE_PRIVATE 27 | ) 28 | return sharedPreferences.getString(PENDING_REQUEST_KEY, null) 29 | } 30 | 31 | @JvmStatic 32 | fun clear(context: Context) { 33 | val sharedPreferences: SharedPreferences = context.getSharedPreferences( 34 | SHARED_PREFS_KEY, 35 | Context.MODE_PRIVATE 36 | ) 37 | sharedPreferences.edit().remove(PENDING_REQUEST_KEY).apply() 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /demo/src/main/java/com/braintreepayments/api/browserswitch/demo/viewmodel/BrowserSwitchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api.demo.viewmodel 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.braintreepayments.api.BrowserSwitchFinalResult 5 | import com.braintreepayments.api.browserswitch.demo.viewmodel.UiState 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.flow.asStateFlow 9 | import kotlinx.coroutines.flow.update 10 | 11 | class BrowserSwitchViewModel : ViewModel() { 12 | 13 | private val _uiState = MutableStateFlow(UiState()) 14 | val uiState: StateFlow = _uiState.asStateFlow() 15 | 16 | var browserSwitchFinalResult: BrowserSwitchFinalResult? 17 | get() = _uiState.value.browserSwitchFinalResult 18 | set(value) { 19 | _uiState.update { it.copy(browserSwitchFinalResult = value) } 20 | _uiState.update { it.copy(browserSwitchError = null) } 21 | } 22 | 23 | var browserSwitchError: Exception? 24 | get() = _uiState.value.browserSwitchError 25 | set(value) { 26 | _uiState.update { it.copy(browserSwitchError = value) } 27 | _uiState.update { it.copy(browserSwitchFinalResult = null) } 28 | } 29 | } -------------------------------------------------------------------------------- /demo/src/main/java/com/braintreepayments/api/browserswitch/demo/viewmodel/UiState.kt: -------------------------------------------------------------------------------- 1 | package com.braintreepayments.api.browserswitch.demo.viewmodel 2 | 3 | import com.braintreepayments.api.BrowserSwitchFinalResult 4 | 5 | data class UiState ( 6 | val browserSwitchFinalResult: BrowserSwitchFinalResult? = null, 7 | val browserSwitchError: Exception? = null, 8 | ) -------------------------------------------------------------------------------- /demo/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 17 | 18 |