braintree / braintree-android-drop-in

Braintree Drop-In SDK for Android
https://developers.braintreepayments.com/guides/drop-in/android/v2
MIT License
124 stars 79 forks source link

Can't Initialize Drop In Client #417

Open sawyermade opened 1 year ago

sawyermade commented 1 year ago

Braintree SDK Version

6.9.0

Environment

Sandbox

Android Version & Device

Android 12

Braintree dependencies

implementation 'com.braintreepayments.api:drop-in:6.9.0'

Describe the bug

Very simple program to test the drop in. Cant even initialize it, it says it needs a FragmenActivity or Fragment, not sure why this isn't working? The line with the ***s is the problem, says function definition doesn't exist??? I followed the guide on your website (which is a pretty bad compared to this guthub, they should just link here haha) and can't even get this thing to init. Any help would be appreciated.

***dropInClient = DropInClient(this@MainActivity, clientToken)

class MainActivity : ComponentActivity(), DropInListener { private val clientToken: String = "bob" private var dropInClient: DropInClient? = null private var dropInRequest: DropInRequest? = null private var REQUEST_CODE = 6969

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
        DropInTestTheme {
            // A surface container using the 'background' color from the theme
            Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
                Greeting("Android")
            }
        }
    }

    // Init drop in client
    dropInRequest = DropInRequest()
    dropInClient = DropInClient(this@MainActivity, clientToken)   ******** THIS LINE ************
    dropInClient!!.setListener(this)
}

override fun onDropInSuccess(dropInResult: DropInResult) {
    TODO("Not yet implemented")
}

override fun onDropInFailure(error: Exception) {
    TODO("Not yet implemented")
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Column(
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "Hello $name!",
            modifier = modifier
        )
        Button(onClick = {/*TODO*/
            dropInClient!!.launchDropIn(dropInRequest)
        }) {
            Text(text = "Drop In")
        }
    }
}
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    DropInTestTheme {
        Greeting("Android")
    }
}

}

To reproduce

Try to initialize the drop in client.

Expected behavior

It should have initialized.

Screenshots

No response

sawyermade commented 1 year ago

I switched to using the xml stuff instead of the compose and it worked. Does this not work with compose?

sshropshire commented 1 year ago

@sawyermade this looks related to this issue: https://github.com/braintree/braintree-android-drop-in/issues/413. We do not offer explicit support for JetPack Compose, but it should work. The SDK needs access to the activity to launch the DropIn Activity in the same task as the host Activity.

sawyermade commented 1 year ago

Okay, thanks for the response. I wanted to start updating are UI to using compose but maybe I will hold off on that for a while, I appreciate the response and thanks!

bolebolo commented 1 year ago

I had the same issue. Initializing DropInClient dropInClient = new DropInClient(this, "sandbox_xxx_token_key");and then calling dropInClient.launchDropIn(dropInRequest); resulted in an error and the application crashing:

java.lang.RuntimeException: Unable to start activity ComponentInfo{com.app.example....PaymentActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'void androidx.activity.result.ActivityResultLauncher.launch(java.lang.Object)' on a null object reference

From what I understand based on observations and testing, the issue lies in the fact that initializing DropInClient takes some time (likely asynchronously verifying the passed key in the background). Therefore, the line dropInClient.launchDropIn(dropInRequest); is executed before the DropInClient is fully initialized, resulting in a NullPointerException.

My solution was to move dropInClient.launchDropIn(dropInRequest); inside onResume, and the problem was resolved:

@Override
protected void onResume() {
    super.onResume();
    dropInClient.launchDropIn(dropInRequest);
}

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.payment_ly);

    createDropInRequest();
    dropInClient = new DropInClient(this, "sandbox_6mgp9yhd_cq3sptm5w7kvss4n");
    dropInClient.setListener(this);
}

This ensures that the launchDropIn method is called after the DropInClient is fully initialized, preventing the NullPointerException.

The solution is to introduce a slight delay using any technique to give the dropInClient enough time to initialize before calling launchDropIn().

I hope this will help someone because due to the desperately poor and unclear documentation, I wasted half of a day just trying to initialize dropInClient. With 10 different versions to navigate through until you reach the current one that works, and even on the official website of the current version, you don't have all the necessary information, people lose a couple of days trying to implement a simple payment, and some even give up.

sshropshire commented 1 year ago

@bolebolo thanks for your writeup of what you're experiencing. You're correct in your understanding, originally we made an implicit assumption that launchDropIn() would be called in response to some user interaction made within the Merchant's host activity. When called in response to a user action, the Activity it presumably in the onResume() state.

There are several reasons why the SDK API is the way it is today, and we are currently at work to improve the SDK so that you won't need this workaround. We're working on a next-major-version beta of our braintree/braintree_android core SDK. Once the Core SDK beta is released, we will subsequently release new beta versions of our DropIn library to remove these restrictions. We don't have a specific timeline for when these new versions will be available, but we are aware of challenges like these and we're planning changes that will make the SDK less opinonated.

Feedback like this helps improve the SDK tremendously. If there's anything else you'd like to see change, please feel free to open a discussion with a detailed description of a feature (or pattern) that you'd like us to consider in the next major version.

sawyermade commented 1 year ago

I was able to get it to work in compose by creating a compose activity then changing the inherited class from component activity to fragment activity and I can then launch the drop in from a composable. Works pretty well but yeah, it’s a little hacky.

sawyermade commented 1 year ago

This is how I was able to get it working. Another thing you guys need to do is fix the Client Token Provider to be able to work with Ktor too instead of just retrofit, I had to write a little retrofit for this specific module where the rest of them are using the Ktor for my API calls. Not terrible but definitely not so cut and dry when using full kotlin with kotlin Gradle, compose, code, etc. Still works great though atm as long as you know how to get it to pop up bc your documentation pretty much sucks, no offense.

package com.sjursolutions.creditcard

import android.content.Context
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.FragmentActivity
import com.braintreepayments.api.ClientTokenProvider
import com.braintreepayments.api.DropInClient
import com.braintreepayments.api.DropInListener
import com.braintreepayments.api.DropInRequest
import com.braintreepayments.api.DropInResult
import com.sjursolutions.creditcard.ui.theme.Vip2Theme
import com.sjursolutions.myprefs.DataStoreManager
import kotlinx.coroutines.runBlocking
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

// Constants
private const val TAG = "CreditCardActivity"

class CreditCardActivity : FragmentActivity(), DropInListener {

    // Class vars
    private lateinit var dropInClient: DropInClient
    private lateinit var dropInRequest: DropInRequest
    private lateinit var mContext: Context
    private lateinit var dataStore: DataStoreManager
    private lateinit var baseUrl: String
    private lateinit var spToken: String
    private lateinit var retrofit: Retrofit
    private lateinit var firstname: String
    private lateinit var lastname: String
    private lateinit var address1: String
    private lateinit var address2: String
    private lateinit var city: String
    private lateinit var state: String
    private lateinit var zip: String
    private lateinit var phone: String
    private lateinit var email: String
    private var contractorId: Int = -1
    private val buttonLoadingString = "Loading, Please Wait..."
    private val buttonLiveString = "Try Again"

    // Dynamic vars for composable
    private val buttonLoading = mutableStateOf(true)
    private val buttonText = mutableStateOf(buttonLoadingString)
    private val inDropIn = mutableStateOf(false)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Log start
        Log.d(TAG, "onCreate STARTED")

        // Load Context, DataStore, and vars needed
        mContext = this
        dataStore = DataStoreManager(mContext)
        runBlocking {
            baseUrl = dataStore.getBaseUrl()
            spToken = dataStore.getSpToken()
            firstname = dataStore.getFirstname()
            lastname = dataStore.getLastname()
            email = dataStore.getEmail()
            phone = dataStore.getPhoneNumber()
            address1 = ""
            address2 = ""
            city = ""
            state = ""
            zip = ""
            contractorId = dataStore.getContractorId()
        }
        Log.d(TAG, "baseUrl, spToken: ${baseUrl}, $spToken")

        // Retrofit builder setup with base url
        retrofit = Retrofit.Builder()
            .baseUrl(baseUrl)
            .addConverterFactory(GsonConverterFactory.create())
            .build()

        // Setup drop in and listener
        dropInRequest = DropInRequest()
        dropInClient = DropInClient(this, getClientTokenProvider())
        dropInClient.setListener(this)

        // Start drop in
        dropInClient.launchDropIn(dropInRequest)

        // Composable start
        setContent {
            Vip2Theme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    // Calls main composable function
                    Main()
                }
            }
        }
    }

    // Main functions UI
    @Composable
    private fun Main() {
        // Local vars
        val uriHandler = LocalUriHandler.current

        // Main UIX column on screen
        LazyColumn(
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,

        ) {

            // Try again, loading button and spinner
            item {
                // Font size
                val fontSize = 28.sp

                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.Center

                ) {

                    // Loading text button
                    if (buttonLoading.value && !inDropIn.value) {
                        TextButton(
                            onClick = {},
                            enabled = true

                        ) {
                            Text(text = buttonText.value, fontSize = fontSize)
                        }

                    } else if (!inDropIn.value) {
                        // Try again button, relaunches drop in
                        Button(onClick = {
                            // Change button and launch drop in
                            buttonText.value = buttonLoadingString
                            buttonLoading.value = true
                            inDropIn.value = true
                            dropInClient.launchDropIn(dropInRequest)

                        },
                        modifier = Modifier
                            .weight(1f)
                            .padding(start = 40.dp, end = 40.dp, top = 10.dp)

                        ) {
                            Text(text = buttonText.value, fontSize = fontSize)
                        }
                    }
                }
            }

            // Call for help button
            item {
                // Font size and spacer
                val fontSize = 28.sp
                Spacer(modifier = Modifier.height(40.dp))

                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.Center

                ) {
                    // Call button if not loading
                    if (!buttonLoading.value && !inDropIn.value) {
                        // Text button to call for support
                        Button(onClick = {
                            val uriPhone = "tel:18888201180"
                            uriHandler.openUri(uriPhone)

                        },
                        modifier = Modifier
                            .weight(1f)
                            .padding(start = 40.dp, end = 40.dp, top = 10.dp)

                        ) {
                            Text(text = "Call", fontSize = fontSize)
                        }

                    } else if (!inDropIn.value) {
                        // Circular indicator if loading drop in
                        CircularProgressIndicator()
                    }
                }
            }
        }
    }

    // Client token provider async call to get nonce from Braintree
    private fun getClientTokenProvider(): ClientTokenProvider {
        return ClientTokenProvider {
            // Creates request interface
            val request = retrofit.create(VipApiRF::class.java)

            // Setup async call with spToken
            val call: Call<String> = request.getClientToken(
                "application/json",
                "Bearer $spToken"
            )

            // Queue async call and get client token from VIP API servers
            call.enqueue(object : Callback<String?> {
                override fun onResponse(call: Call<String?>, response: Response<String?>) {
                    // Response http status code
                    val status: Int = response.code()

                    // DEBUG
                    Log.d(TAG, "getClientToken onResponse: status, response: $status, $response")

                    // If request successful, sends token to drop-in client callback
                    if (response.isSuccessful) {
                        inDropIn.value = true
                        buttonLoading.value = false
                        buttonText.value = buttonLiveString
                        it.onSuccess(response.body()!!)
                        Log.d(TAG, "clientToken: ${response.body()}")
                    } else {
                        inDropIn.value = false
                        buttonLoading.value = false
                        buttonText.value = buttonLiveString
                        Toast.makeText(mContext, "Getting Nonce Failed", Toast.LENGTH_LONG).show()
                    }
                }

                // Server connect fail
                override fun onFailure(call: Call<String?>, t: Throwable) {
                    inDropIn.value = false
                    buttonLoading.value = false
                    buttonText.value = buttonLiveString
                    Toast.makeText(mContext, "Getting Nonce Failed", Toast.LENGTH_LONG).show()
                }
            })
        }
    }

    // Drop in success on Braintree side, tries to posts to VIP API
    override fun onDropInSuccess(dropInResult: DropInResult) {
        //TODO("Not yet implemented")

        // Get payment method nonce
        val paymentMethodNonce = dropInResult.paymentMethodNonce?.string
        Log.d(TAG, "paymentMethodNonce: $paymentMethodNonce")

        if (paymentMethodNonce != null) {
            // Post data class for VPI API
            val postBraintree = PostBraintree(
                firstname,
                lastname,
                address1,
                address2,
                city,
                state,
                zip,
                phone,
                email,
                paymentMethodNonce,
                contractorId
            )

            // Create VIP API interface
            val postAPIData = retrofit.create(VipApiRF::class.java)

            // Send to VIP API call
            val call = postAPIData.postBraintree(
                "application/json",
                "Bearer $spToken",
                postBraintree
            )

            // Queue async call and get client token from VIP API servers
            call.enqueue(object : Callback<ResponseBody?> {
                override fun onResponse(call: Call<ResponseBody?>, response: Response<ResponseBody?>) {
                    // Response http status code
                    val status: Int = response.code()

                    // DEBUG
                    Log.d(TAG, "PostBraintree status, response: $status, $response")

                    // Completed show message and finish activity
                    if (status == 200) {
                        Toast.makeText(mContext, "Card Added Successfully!", Toast.LENGTH_SHORT)
                            .show()
                        this@CreditCardActivity.finish()

                    } else {

                        // Failed, show error messages
                        Toast.makeText(mContext, "PostBraintree Failed: $status", Toast.LENGTH_LONG).show()

                        // Change button to try again if fails
                        buttonLoading.value = false
                        buttonText.value = buttonLiveString
                        inDropIn.value = false
                    }
                }

                // Server connect fail
                override fun onFailure(call: Call<ResponseBody?>, t: Throwable) {
                    // Error message
                    Toast.makeText(mContext, "PostBraintree Failed", Toast.LENGTH_LONG).show()

                    // Change button to try again if fails
                    buttonLoading.value = false
                    buttonText.value = buttonLiveString
                    inDropIn.value = false
                }
            })

        } else {
            // Error message
            Toast.makeText(this, "Adding Card Failed", Toast.LENGTH_SHORT).show()

            // Change button to try again if fails
            buttonLoading.value = false
            buttonText.value = buttonLiveString
            inDropIn.value = false
        }
    }

    // Drop in failed to connect or start up
    override fun onDropInFailure(error: Exception) {
        // Error message
        Toast.makeText(this, "Adding Card Failed", Toast.LENGTH_SHORT).show()

        // Change button to try again if it fails
        buttonLoading.value = false
        buttonText.value = buttonLiveString
        inDropIn.value = false
    }
}
sawyermade commented 1 year ago

Here's my retrofit code I call for the api too for anyone that is having probs...

package com.sjursolutions.creditcard

import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST

interface VipApiRF {
    @GET("api/Braintree/token")
    fun getClientToken(
        @Header("Content-Type") contentType: String,
        @Header("Authorization") authorization: String
    ): Call<String>

    @POST("api/Braintree/SetUpCustomer")
    fun postBraintree(
        @Header("Content-Type") contentType: String,
        @Header("Authorization") authorization: String,
        @Body postBraintree: PostBraintree
    ): Call<ResponseBody>
}

// PostBraintree data class for post to VIP
data class PostBraintree(
    private var firstName: String,
    private var lastName: String,
    private var address1: String,
    private var address2: String,
    private var city: String,
    private var state: String,
    private var zip: String,
    private var phone: String,
    private var email: String,
    private var paymentMethodNonce: String,
    private var contractorId: Int
)
bolebolo commented 11 months ago

Hi @sshropshire, and Thank you for submitted work and effort to facilitate the use of this library whose documentation is so confusing (so many "Migration documentation", inactive and outdated versions, but still present through links and documentation even official).

I have a question that is not directly related to this problem, and i will delete a comment later if necessary

The Question is : Is there a way to refresh (reinitialize - recreate) a DropInClient that has already initialized? (without recreating activity)

The situation is :

I initialized a DropInClient successfully, performed the transaction for the first time when the user did not have saved payment methods and customer Id.

Got "customer Id" from first successfully performed transaction response (save that id in DBase for that user), and it works perfectly,

 JSONObject transactionJsonObj = new JSONObject(transactionString);
 String customerId = transactionJsonObj.getJSONObject("customer").getString("id");

but if I stay on that activity and want to perform another transaction, when displaying a new Drop-in, the previously entered method won't be suggested because Drop-in was initialized without a customer ID. Is there a way to refresh the DropInClient after a successful transaction without recreating activity (recreate());?

I tried to reinitialize DropInClient and listener, but in that case, the application crashes because it is already connected to the lifecycle of the activity.

 dropInClient =null; 
dropInClient = new DropInClient(this, new BraintreeClientTokenProvider(customerId));
 dropInClient.setListener(this);

But this results in a error.

What is the right way to recreate DropInClient after receiving the customer id?

Thank you in advance

sawyermade commented 11 months ago

Don’t create a new dropInClient and just use the one you already have and call dropInClient.launchDropIn(dropInRequest) again, should work.

bolebolo commented 11 months ago

Thanks for your reply.

That is how I currently operate.... calling again dropInClient.launchDropIn(dropInRequest)

However, after a successful first transaction, the payment method, I already used, doesn't appear unless I exit or recreate the activity. If I exit and re-enter the activity or recreate it, everything works as expected, and the payment method I used for the first payment appears.

I think the problem lies in the fact that when the user first initialize Drop-in client the customerId is an empty string because it doesn't exist in the database yet.

customerId = ""

dropInClient = new DropInClient(this, new BraintreeClientTokenProvider(customerId));
dropInClient.setListener(this);

And after the first transaction, I get the customerId:

JSONObject transactionJsonObj = new JSONObject(transactionString);
String customerId = transactionJsonObj.getJSONObject("customer").getString("id");

so now customerId is some number :

customerId = "some_customer_id_11111"

When I launch dropInClient.launchDropIn(dropInRequest) again, it doesn't return that method because dropInClient is already initialized with an empty customerId , and he doesn't know the new customerId.

I think that at that moment I should somehow inform and recreate dropInClientthat with that new customerId = "some_customer_id_11111"

If I reload the page, dropInClient is initialized with the customerId pulled from the database:

customerId = "some_customer_id_11111"

In that case, it works as expected.

So, the problem only occurs if I stay on the same activity after the first successful transaction.

sawyermade commented 11 months ago

Instead of setting the old one to null, have you tried just creating a new dropInClient2 or something, then getting the client token again, and launching it from there?

bolebolo commented 11 months ago

Yes yes of course, I tried with setting to null and without.... in both cases I get the same error:

java.lang.IllegalStateException: LifecycleOwner com.app.xxxxxx.activities.PaymentActivity@2da6df8 is attempting to register while current state is RESUMED. LifecycleOwners must call register before they are STARTED.

sawyermade commented 11 months ago

I’m not sure then bud, I did mine all in kotlin with compose, the views stuff drives me bonkers but that’s definitely an asynchronous type problem with the views lifecycle. Did you try calling register for activity or whatever it’s called?

https://stackoverflow.com/questions/64476827/how-to-resolve-the-error-lifecycleowners-must-call-register-before-they-are-sta

bolebolo commented 11 months ago

Thank you for trying to help me, but i will have to wait for @sshropshire to reply or someone who works at braintree.

Until then, my solution is to check is customerId an empty string after the transaction is completed, and if yes, then recreate Activity :)

sawyermade commented 11 months ago

Okay, I reopened this, it was set to closed haha.

jktestaccount commented 2 months ago

In 20 years of programming I have never come across an SDK so poorly designed and dysfunctional.

Had to jump through massive hoops to get this to work. If you have any other option, use another payment provider.

sshropshire commented 2 months ago

In 20 years of programming I have never come across an SDK so poorly designed and dysfunctional.

Had to jump through massive hoops to get this to work. If you have any other option, use another payment provider.

@jktestaccount As you know within the past 10 years the Android landscape has changed significantly. We are constantly evolving our SDK and seeking the best possible solution that fits the myriad of possible architectures that exist today. Feedback is the only way forward, and if you could share the pain points of your integration it would be greatly appreciated. Otherwise thank you for your business and we hope you consider our product in the future.

sshropshire commented 2 months ago

@bolebolo @sawyermade apologies for such a late response and thank you for your patience in general. This issue seems to have gotten swept up in the weeds. We are looking to deprecate ClientTokenProvider in an upcoming release. There are some logistical hurdles we need to clear, but we know now through trial and error that it's better to decrease the opinionated nature of our SDKs to enable more integration flexibility. We don't have an exact timeline, but the next major version of our Core SDK is in beta. A DropIn update will soon follow.