From 3a2c851481794e372a102ecddee480421972e55e Mon Sep 17 00:00:00 2001 From: Jason Atwood Date: Wed, 3 Jul 2024 17:28:26 -0400 Subject: [PATCH] Add V2 to sample app --- gradle/libs.versions.toml | 6 + sample/README.md | 2 +- sample/build.gradle.kts | 13 +- sample/src/main/AndroidManifest.xml | 3 +- .../com/example/AfterpayV2SampleActivity.kt | 234 ++++++++++++++++++ .../com/example/AfterpayV3SampleActivity.kt | 2 +- .../src/main/java/com/example/SampleData.kt | 7 +- .../example/api/GetConfigurationResponse.kt | 36 +++ .../java/com/example/api/GetTokenRequest.kt | 31 +++ .../java/com/example/api/GetTokenResponse.kt | 24 ++ .../main/java/com/example/api/MerchantApi.kt | 162 ++++++++++++ .../main/res/layout/afterpay_v2_layout.xml | 49 ++++ .../main/res/xml/network_security_config.xml | 14 ++ 13 files changed, 575 insertions(+), 8 deletions(-) create mode 100644 sample/src/main/java/com/example/AfterpayV2SampleActivity.kt create mode 100644 sample/src/main/java/com/example/api/GetConfigurationResponse.kt create mode 100644 sample/src/main/java/com/example/api/GetTokenRequest.kt create mode 100644 sample/src/main/java/com/example/api/GetTokenResponse.kt create mode 100644 sample/src/main/java/com/example/api/MerchantApi.kt create mode 100644 sample/src/main/res/layout/afterpay_v2_layout.xml create mode 100644 sample/src/main/res/xml/network_security_config.xml diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02e8bc7d..4106d4eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,8 @@ ktlint = "0.48.2" material = "1.12.0" maven_publish_plugin = "0.17.0" mockk = "1.13.5" +moshi = "1.14.0" # latest version that is compatible with kotlin 1.7.x +retrofit = "2.9.0" # latest version that is compatible with kotlin 1.7.x secretsGradlePlugin = "2.0.1" tools_desugar_sdk = "1.1.5" @@ -38,6 +40,10 @@ app-cash-paykit = "app.cash.paykit:core:2.3.0" junit = { module = "junit:junit", version.ref = "junit" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } mockK = { module = "io.mockk:mockk", version.ref = "mockk" } +moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } +moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" } +retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } +retrofit-converter-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" } [plugins] android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } diff --git a/sample/README.md b/sample/README.md index 93670a0a..c5606aea 100644 --- a/sample/README.md +++ b/sample/README.md @@ -8,7 +8,7 @@ Think of the variants as different approaches (with benefits and drawbacks) to h | Variant | Afterpay | Afterpay via CashApp | |-----------------------|--------------------------|-------------------------| | V1 "Standard Checkout" | WIP | N/A | -| V2 "Express Checkout" | WIP | WIP | +| V2 "Express Checkout" | AfterpayV2SampleActivity | WIP | | V3 | AfterpayV3SampleActivity | CashAppV3SampleActivity | To toggle which variant to display, update the `AndroidManifest.xml` to launch one specific `Activity`. diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index 3ac5a191..792e4a99 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -56,8 +56,8 @@ android { dependencies { // toggle between using Maven artifact and local module - // implementation(projects.afterpay) - implementation(libs.afterpay.android) + implementation(projects.afterpay) + // implementation(libs.afterpay.android) implementation(libs.app.cash.paykit) @@ -65,6 +65,15 @@ dependencies { implementation(libs.androidxCoreKtx) implementation(libs.androidxLifecycleRuntimeKtx) implementation(libs.material) + + /** + * Usage of retrofit / moshi is entirely preference to interact with + * sample Merchant API + */ + implementation(libs.moshi) + implementation(libs.moshi.kotlin) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.moshi) } secrets { diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index 7e4b11df..4d202895 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:networkSecurityConfig="@xml/network_security_config" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.AppCompat" @@ -13,7 +14,7 @@ diff --git a/sample/src/main/java/com/example/AfterpayV2SampleActivity.kt b/sample/src/main/java/com/example/AfterpayV2SampleActivity.kt new file mode 100644 index 00000000..2ceb7c1f --- /dev/null +++ b/sample/src/main/java/com/example/AfterpayV2SampleActivity.kt @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2024 Afterpay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example + +import android.app.Activity +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.afterpay.android.Afterpay +import com.afterpay.android.AfterpayCheckoutV2Handler +import com.afterpay.android.AfterpayCheckoutV2Options +import com.afterpay.android.model.ShippingAddress +import com.afterpay.android.model.ShippingOption +import com.afterpay.android.model.ShippingOptionUpdateResult +import com.afterpay.android.model.ShippingOptionsResult +import com.example.api.CheckoutMode +import com.example.api.GetConfigurationResponse +import com.example.api.GetTokenRequest +import com.example.api.merchantApi +import com.example.databinding.AfterpayV2LayoutBinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale + +/** + * Activity showing the Afterpay V2 checkout flow + */ +class AfterpayV2SampleActivity : AppCompatActivity() { + private lateinit var bindings: AfterpayV2LayoutBinding + + private val activityResultLauncher = + registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result: ActivityResult -> + result.data?.let { data -> + /** + * Step 6: Afterpay flow will complete and return result to your app. If checkout was + * successful you will receive an order token which you pass back to your server + * for final processing. + */ + when (result.resultCode) { + Activity.RESULT_OK -> { + val orderToken = Afterpay.parseCheckoutSuccessResponse(data) + val message = "Checkout Complete, Received order token: $orderToken" + Log.d(tag, message) + showToast(this, message) + } + + Activity.RESULT_CANCELED -> { + val status = Afterpay.parseCheckoutCancellationResponse(data) + showToast(this, "Checkout Cancelled: $status") + } + } + } + } + + private val checkoutHandler = + object : AfterpayCheckoutV2Handler { + override fun didCommenceCheckout(onTokenLoaded: (Result) -> Unit) { + Log.d(tag, "didCommenceCheckout") + /** + * Step 4: After starting Afterpay flow, Afterpay SDK will call back asking you to + * load your token. You will need to make another network request to your own server + * to fetch this token. + * + * You can fetch this token *before* customer clicks button and "have it ready". + * However you will need to re-request this token any time order amount changes. + * (e.g. customer adds items to cart) . + * + * Here in the sample app we request token from a sample merchant API / server. + * You must first have the sample server running: + * https://github.com/afterpay/sdk-example-server + */ + CoroutineScope(Dispatchers.IO).launch { + Log.d(tag, "Getting token from merchant server") + merchantApi() + .getToken( + GetTokenRequest( + email = customerEmail, + amount = "12.00", + // Our example server uses the same endpoint to get configuration for + // both V1 and V2. Server calls them "standard" and "express" respectively. + mode = CheckoutMode.STANDARD, + isCashAppPay = false, + ), + ) + /** + * Step 5: Pass that token back to Afterpay SDK via the supplied + * [onTokenLoaded] callback + */ + .onSuccess { response -> + // Because the example server uses the same endpoint for both V1 and V2 + // it returns more data than we need. Ideally your server would return + // only the token. (i.e. response.url is not needed here) + Log.d(tag, "Token received and loaded") + onTokenLoaded(Result.success(response.token)) + } + .onFailure { + // If fetching token failed for any reason you need to tell Afterpay + // so it can correctly return to your app + val msg = "Failed to fetch token" + Log.e(tag, msg) + onTokenLoaded(Result.failure(Throwable(msg))) + } + } + } + + override fun shippingAddressDidChange( + address: ShippingAddress, + onProvideShippingOptions: (ShippingOptionsResult) -> Unit, + ) { + TODO("Not yet implemented") + } + + override fun shippingOptionDidChange( + shippingOption: ShippingOption, + onProvideShippingOption: (ShippingOptionUpdateResult?) -> Unit, + ) { + TODO("Not yet implemented") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindings = AfterpayV2LayoutBinding.inflate(LayoutInflater.from(this)) + val view = bindings.root + setContentView(view) + + bindings.afterpayButton.setOnClickListener { + /** + * Step 3: Once configuration is set, Afterpay SDK will enable the button, allowing + * customer to start checkout flow. + * + * Create [AfterpayCheckoutV2Options], create an [android.content.Intent], and start flow. + * + * Here we use Android's ActivityResult APIs but you can use older [startActivityForResult] + */ + val afterpayCheckoutV2Options = + AfterpayCheckoutV2Options( + pickup = bindings.pickup.isChecked, + buyNow = bindings.buyNow.isChecked, + shippingOptionRequired = bindings.shippingOptionRequiredCheckbox.isChecked, + enableSingleShippingOptionUpdate = true, + ) + + val intent = + Afterpay.createCheckoutV2Intent( + context = this@AfterpayV2SampleActivity, + options = afterpayCheckoutV2Options, + ) + + Log.d(tag, "Launching intent") + activityResultLauncher.launch(intent) + } + + /** + * Step 0: Periodically check for new configurations from your own server. Doesn't need + * to happen before *every* transaction. Does need to happen before *first* transaction + * Use the tool of your choice to enable async non-main-thread network request + * (i.e. coroutines, rxjava, retrofit) + * + * Here in the sample app we request configuration from a sample merchant API / server. + * You must first have the sample server running: + * https://github.com/afterpay/sdk-example-server + * + * Using [lifecycleScope] is a quick-and-dirty example. Ideally you would not tie this network + * request to an Activity's lifecycle. + */ + lifecycleScope.launch { + getConfiguration() + } + + /** + * Step 2: Set a handler which manages callbacks between your app and Afterpay SDK flow + */ + Afterpay.setCheckoutV2Handler(checkoutHandler) + } + + private fun getConfiguration() { + CoroutineScope(Dispatchers.IO).launch { + merchantApi().getConfiguration().apply { + onFailure { error -> + val msg = "Failed to get configuration" + showToastFromBackground(this@AfterpayV2SampleActivity, msg) + Log.e(tag, msg, error) + } + + onSuccess { response: GetConfigurationResponse -> + withContext(Dispatchers.Main) { + Log.d(tag, "Fetched and setting configs") + /** + * Step 1: Pass configuration to Afterpay + * + * It is up to you to save this Configuration (e.g. local storage) to avoid repeat calls + * to fetch them. You will need to pass Configuration to Afterpay on each app + * restart (before first transaction) by calling .setConfiguration() + * + * Once this function is called, Afterpay will enable the button. + */ + Afterpay.setConfiguration( + minimumAmount = response.minimumAmount?.amount, + maximumAmount = response.maximumAmount.amount, + currencyCode = response.maximumAmount.currency, + locale = + Locale(response.locale.language, response.locale.country), + environment = AFTERPAY_ENVIRONMENT, + ) + } + } + } + } + } +} + +private val tag = AfterpayV2SampleActivity::class.java.simpleName diff --git a/sample/src/main/java/com/example/AfterpayV3SampleActivity.kt b/sample/src/main/java/com/example/AfterpayV3SampleActivity.kt index f573108d..60920dd3 100644 --- a/sample/src/main/java/com/example/AfterpayV3SampleActivity.kt +++ b/sample/src/main/java/com/example/AfterpayV3SampleActivity.kt @@ -133,7 +133,7 @@ class AfterpayV3SampleActivity : AppCompatActivity() { * to fetch them. You will need to pass Configuration to Afterpay on each app * restart (before first transaction) by calling .setConfiguration() * - * Only once this function is called, will Afterpay enable the button. + * Once this function is called, Afterpay will enable the button. */ Log.d(tag, "Fetched merchant configs") Afterpay.setConfigurationV3(merchantConfiguration) diff --git a/sample/src/main/java/com/example/SampleData.kt b/sample/src/main/java/com/example/SampleData.kt index cdfb25f9..9c115f8e 100644 --- a/sample/src/main/java/com/example/SampleData.kt +++ b/sample/src/main/java/com/example/SampleData.kt @@ -70,7 +70,7 @@ internal fun createConsumer(): CheckoutV3Consumer { override val givenNames: String? get() = "Bob" override val phoneNumber: String? - get() = "4041234567" + get() = customerPhonenumber override val shippingInformation: CheckoutV3Contact? get() = createShippingInfo() override val surname: String? @@ -99,7 +99,7 @@ internal fun createBillingInfo(): CheckoutV3Contact? { get() = "Bob" set(value) {} override var phoneNumber: String? - get() = null + get() = customerPhonenumber set(value) {} override var postcode: String? get() = "post code" @@ -131,7 +131,7 @@ internal fun createShippingInfo(): CheckoutV3Contact? { get() = "Bob" set(value) {} override var phoneNumber: String? - get() = null + get() = customerPhonenumber set(value) {} override var postcode: String? get() = "post code" @@ -143,3 +143,4 @@ internal fun createShippingInfo(): CheckoutV3Contact? { } val customerEmail = "example@squareup.com" +val customerPhonenumber = "4045551234" diff --git a/sample/src/main/java/com/example/api/GetConfigurationResponse.kt b/sample/src/main/java/com/example/api/GetConfigurationResponse.kt new file mode 100644 index 00000000..07133e3d --- /dev/null +++ b/sample/src/main/java/com/example/api/GetConfigurationResponse.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2024 Afterpay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.api + +/** + * Required format to work with https://github.com/afterpay/sdk-example-server + */ +data class GetConfigurationResponse( + val minimumAmount: Money?, + val maximumAmount: Money, + val locale: Locale, +) { + data class Money( + val amount: String, + val currency: String, + ) + + data class Locale( + val identifier: String, + val language: String, + val country: String, + ) +} diff --git a/sample/src/main/java/com/example/api/GetTokenRequest.kt b/sample/src/main/java/com/example/api/GetTokenRequest.kt new file mode 100644 index 00000000..6ca3f0ee --- /dev/null +++ b/sample/src/main/java/com/example/api/GetTokenRequest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 Afterpay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.api + +/** + * Required format to work with https://github.com/afterpay/sdk-example-server + */ +data class GetTokenRequest( + val email: String, + val amount: String, + val mode: CheckoutMode, + val isCashAppPay: Boolean = false, +) + +enum class CheckoutMode(val string: String) { + STANDARD("standard"), + EXPRESS("express"), +} diff --git a/sample/src/main/java/com/example/api/GetTokenResponse.kt b/sample/src/main/java/com/example/api/GetTokenResponse.kt new file mode 100644 index 00000000..cafaa21b --- /dev/null +++ b/sample/src/main/java/com/example/api/GetTokenResponse.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 Afterpay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.api + +/** + * Required format to work with https://github.com/afterpay/sdk-example-server + */ +data class GetTokenResponse( + val url: String, + val token: String, +) diff --git a/sample/src/main/java/com/example/api/MerchantApi.kt b/sample/src/main/java/com/example/api/MerchantApi.kt new file mode 100644 index 00000000..94dd2663 --- /dev/null +++ b/sample/src/main/java/com/example/api/MerchantApi.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2024 Afterpay + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.example.api + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import okhttp3.Request +import okio.Timeout +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Callback +import retrofit2.HttpException +import retrofit2.Response +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import java.io.IOException +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +/** + * The use of Retrofit + Moshi here is entirely a convenience. Use the API stack you are + * most comfortable with. + */ +interface MerchantApi { + // TODO @jatwood handle success/error results, not just response + + @GET("configuration") + suspend fun getConfiguration(): Result + + @POST("checkout") + suspend fun getToken( + @Body request: GetTokenRequest, + ): Result +} + +fun merchantApi() = + Retrofit.Builder() + .baseUrl(MERCHANT_API_BASE_URL) + .addConverterFactory( + MoshiConverterFactory.create( + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build(), + ), + ) + .addCallAdapterFactory(ResultCallAdapterFactory()) + .build() + .create(MerchantApi::class.java) + +const val MERCHANT_API_BASE_URL = "http://10.0.2.2:3000" // 10.0.2.2 if using emulator + +// Call adapter to get API to return kotlin.Result +class ResultCallAdapterFactory : CallAdapter.Factory() { + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + if (getRawType(returnType) != Call::class.java || returnType !is ParameterizedType) { + return null + } + val upperBound = getParameterUpperBound(0, returnType) + + return if (upperBound is ParameterizedType && upperBound.rawType == Result::class.java) { + object : CallAdapter>> { + override fun responseType(): Type = getParameterUpperBound(0, upperBound) + + override fun adapt(call: Call): Call> = + ResultCall(call) as Call> + } + } else { + null + } + } +} + +class ResultCall(val delegate: Call) : + Call> { + + override fun enqueue(callback: Callback>) { + delegate.enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + callback.onResponse( + this@ResultCall, + Response.success( + response.code(), + Result.success(response.body()!!), + ), + ) + } else { + callback.onResponse( + this@ResultCall, + Response.success( + Result.failure( + HttpException(response), + ), + ), + ) + } + } + + override fun onFailure(call: Call, t: Throwable) { + val errorMessage = when (t) { + is IOException -> "No internet connection" + is HttpException -> "Something went wrong!" + else -> t.localizedMessage + } + callback.onResponse( + this@ResultCall, + Response.success(Result.failure(RuntimeException(errorMessage, t))), + ) + } + }, + ) + } + + override fun isExecuted(): Boolean { + return delegate.isExecuted + } + + override fun execute(): Response> { + return Response.success(Result.success(delegate.execute().body()!!)) + } + + override fun cancel() { + delegate.cancel() + } + + override fun isCanceled(): Boolean { + return delegate.isCanceled + } + + override fun clone(): Call> { + return ResultCall(delegate.clone()) + } + + override fun request(): Request { + return delegate.request() + } + + override fun timeout(): Timeout { + return delegate.timeout() + } +} diff --git a/sample/src/main/res/layout/afterpay_v2_layout.xml b/sample/src/main/res/layout/afterpay_v2_layout.xml new file mode 100644 index 00000000..4b477ca8 --- /dev/null +++ b/sample/src/main/res/layout/afterpay_v2_layout.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/xml/network_security_config.xml b/sample/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..c4617673 --- /dev/null +++ b/sample/src/main/res/xml/network_security_config.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + 10.0.2.2 + +