diff --git a/build.gradle b/build.gradle index a0d2190..c665db9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + ext.kotlin_version = '1.3.72' repositories { jcenter() google() @@ -10,6 +11,7 @@ buildscript { classpath 'org.robolectric:robolectric-gradle-plugin:1.1.0' classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.5' classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } @@ -19,6 +21,7 @@ allprojects { repositories { jcenter() google() + mavenCentral() } } @@ -26,9 +29,9 @@ ext { compileSdkVersion = 29 minSdkVersion = 16 targetSdkVersion = 29 - versionCode = 27 + versionCode = 18 buildToolsVersion = "29.0.2" supportLibraryVersion = "28.0.0" - versionName = "3.0.19" + versionName = "3.1.1" } \ No newline at end of file diff --git a/paystack/build.gradle b/paystack/build.gradle index 52caded..39aff98 100644 --- a/paystack/build.gradle +++ b/paystack/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' apply plugin: "com.jfrog.bintray" apply plugin: 'com.github.dcendents.android-maven' apply plugin: 'maven-publish' @@ -30,6 +31,9 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:3.14.9' implementation "com.android.support:appcompat-v7:$rootProject.ext.supportLibraryVersion" implementation 'co.paystack.android.design.widget:pinpad:1.0.4' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7" } project.afterEvaluate { diff --git a/paystack/src/main/AndroidManifest.xml b/paystack/src/main/AndroidManifest.xml index a98426b..fee01b5 100644 --- a/paystack/src/main/AndroidManifest.xml +++ b/paystack/src/main/AndroidManifest.xml @@ -1,20 +1,23 @@ + package="co.paystack.android"> + android:theme="@style/Paystack.Dialog.PinEntry" /> + android:theme="@style/Paystack.Dialog.OtpEntry" /> + android:theme="@style/Paystack.Dialog.CardEntry" /> + \ No newline at end of file diff --git a/paystack/src/main/java/co/paystack/android/TransactionManager.java b/paystack/src/main/java/co/paystack/android/TransactionManager.java index 64019c3..c978a43 100644 --- a/paystack/src/main/java/co/paystack/android/TransactionManager.java +++ b/paystack/src/main/java/co/paystack/android/TransactionManager.java @@ -22,6 +22,9 @@ import co.paystack.android.exceptions.ProcessingException; import co.paystack.android.model.Card; import co.paystack.android.model.Charge; +import co.paystack.android.ui.AddressHolder; +import co.paystack.android.ui.AddressHolder.Address; +import co.paystack.android.ui.AddressVerificationActivity; import co.paystack.android.ui.AuthActivity; import co.paystack.android.ui.AuthSingleton; import co.paystack.android.ui.CardActivity; @@ -46,6 +49,7 @@ class TransactionManager { private final PinSingleton psi = PinSingleton.getInstance(); private final OtpSingleton osi = OtpSingleton.getInstance(); private final AuthSingleton asi = AuthSingleton.getInstance(); + private final AddressHolder addressHolder = AddressHolder.getInstance(); private ChargeRequestBody chargeRequestBody; private ValidateRequestBody validateRequestBody; private ApiService apiService; @@ -97,7 +101,7 @@ void chargeCard() { try { if (charge.getCard() == null || !charge.getCard().isValid()) { final CardSingleton si = CardSingleton.getInstance(); - synchronized (si){ + synchronized (si) { si.setCard(charge.getCard()); } new CardAsyncTask().execute(); @@ -121,7 +125,6 @@ private void sendChargeToServer() { Log.e(LOG_TAG, ce.getMessage(), ce); notifyProcessingError(ce); } - } private void validate() { @@ -144,6 +147,19 @@ private void reQuery() { } + + private void chargeWithAvs(Address address) { + HashMap fields = address.toHashMap(); + fields.put("trans", transaction.getId()); + try { + Call call = apiService.submitCardAddress(fields); + call.enqueue(serverCallback); + } catch (Exception e) { + Log.e(LOG_TAG, e.getMessage(), e); + notifyProcessingError(e); + } + } + private void validateChargeOnServer() throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException { HashMap params = validateRequestBody.getParamsHashMap(); Call call = apiService.validateCharge(params); @@ -165,6 +181,12 @@ private void handleApiResponse(TransactionApiResponse transactionApiResponse) { if (transactionApiResponse == null) { transactionApiResponse = TransactionApiResponse.unknownServerResponse(); } + + // The AVS charge endpoint sends an "errors" object when address verification fails + if (transactionApiResponse.hasErrors) { + notifyProcessingError(new ChargeException(transactionApiResponse.message)); + return; + } transaction.loadFromResponse(transactionApiResponse); if (transactionApiResponse.status.equalsIgnoreCase("1") || transactionApiResponse.status.equalsIgnoreCase("success")) { @@ -173,6 +195,11 @@ private void handleApiResponse(TransactionApiResponse transactionApiResponse) { return; } + if (transactionApiResponse.status.equalsIgnoreCase("2") && transactionApiResponse.auth.equalsIgnoreCase("avs")) { + new AddressVerificationAsyncTask().execute(transactionApiResponse.avsCountryCode); + return; + } + if (transactionApiResponse.status.equalsIgnoreCase("2") || (transactionApiResponse.hasValidAuth() && (transactionApiResponse.auth.equalsIgnoreCase("pin")))) { new PinAsyncTask().execute(); return; @@ -362,4 +389,36 @@ protected void onPostExecute(String responseJson) { } } + private class AddressVerificationAsyncTask extends AsyncTask { + + + @Override + protected Address doInBackground(String... params) { + Intent i = new Intent(activity, AddressVerificationActivity.class); + i.putExtra(AddressVerificationActivity.EXTRA_COUNTRY_CODE, params[0]); + activity.startActivity(i); + synchronized (AddressHolder.getLock()) { + try { + AddressHolder.getLock().wait(); + } catch (InterruptedException e) { + notifyProcessingError(new Exception("Address entry Interrupted")); + } + } + + return addressHolder.getAddress(); + } + + @Override + protected void onPostExecute(Address address) { + super.onPostExecute(address); + + if (address != null) { + Log.e("AVS_ADDRESS", address.toString()); + chargeWithAvs(address); + + } else { + notifyProcessingError(new Exception("No address provided")); + } + } + } } \ No newline at end of file diff --git a/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java b/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java index 3b6f07f..11ea72a 100644 --- a/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java +++ b/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java @@ -13,4 +13,6 @@ public class ApiResponse extends BaseApiModel { @SerializedName("message") public String message; + @SerializedName("errors") + public boolean hasErrors = false; } diff --git a/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java b/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java index 3ee37ba..dc6e1ae 100644 --- a/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java +++ b/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java @@ -24,6 +24,9 @@ public class TransactionApiResponse extends ApiResponse implements Serializable @SerializedName("otpmessage") public String otpmessage; + @SerializedName("countryCode") + public String avsCountryCode; // Country code for Address Verification on supported international cards + public static TransactionApiResponse unknownServerResponse() { TransactionApiResponse t = new TransactionApiResponse(); t.status = "0"; diff --git a/paystack/src/main/java/co/paystack/android/api/service/ApiService.java b/paystack/src/main/java/co/paystack/android/api/service/ApiService.java index c3bc367..fe3e7c3 100644 --- a/paystack/src/main/java/co/paystack/android/api/service/ApiService.java +++ b/paystack/src/main/java/co/paystack/android/api/service/ApiService.java @@ -26,5 +26,8 @@ public interface ApiService { @GET("/requery/{trans}") Call requeryTransaction(@Path("trans") String trans); + @FormUrlEncoded + @POST("/charge/avs") + Call submitCardAddress(@FieldMap HashMap fields); } diff --git a/paystack/src/main/java/co/paystack/android/api/service/PaystackApiFactory.kt b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiFactory.kt new file mode 100644 index 0000000..2973d6f --- /dev/null +++ b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiFactory.kt @@ -0,0 +1,58 @@ +package co.paystack.android.mobilemoney.data.api + +import android.os.Build +import co.paystack.android.BuildConfig +import co.paystack.android.api.service.PaystackApiService +import co.paystack.android.api.service.converter.WrappedResponseConverter +import co.paystack.android.api.utils.TLSSocketFactory +import com.google.gson.GsonBuilder +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.security.KeyManagementException +import java.security.KeyStoreException +import java.security.NoSuchAlgorithmException +import java.util.concurrent.TimeUnit + +/* +* Generates an API client for new paystack API (https://api.paystack.co) +* */ +internal object PaystackApiFactory { + private const val BASE_URL = "https://api.paystack.co/" + + @Throws(NoSuchAlgorithmException::class, KeyManagementException::class, KeyStoreException::class) + fun createRetrofitService(): PaystackApiService { + val gson = GsonBuilder() + .setDateFormat("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'SSS'Z'") + .create() + + val tlsV1point2factory = TLSSocketFactory() + val okHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + val original = chain.request() + // Add headers so we get Android version and Paystack Library version + val builder = original.newBuilder() + .header("User-Agent", "Android_" + Build.VERSION.SDK_INT + "_Paystack_" + BuildConfig.VERSION_NAME) + .header("X-Paystack-Build", BuildConfig.VERSION_CODE.toString()) + .header("Accept", "application/json") + .method(original.method(), original.body()) + val request = builder.build() + chain.proceed(request) + } + .sslSocketFactory(tlsV1point2factory, tlsV1point2factory.x509TrustManager) + .connectTimeout(1, TimeUnit.MINUTES) + .readTimeout(1, TimeUnit.MINUTES) + .writeTimeout(1, TimeUnit.MINUTES) + .build() + + val retrofit = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(WrappedResponseConverter.Factory()) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + + return retrofit.create(PaystackApiService::class.java) + } + +} \ No newline at end of file diff --git a/paystack/src/main/java/co/paystack/android/api/service/PaystackApiService.kt b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiService.kt new file mode 100644 index 0000000..13d3a8e --- /dev/null +++ b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiService.kt @@ -0,0 +1,10 @@ +package co.paystack.android.api.service + +import co.paystack.android.model.AvsState +import retrofit2.http.GET +import retrofit2.http.Query + +internal interface PaystackApiService { + @GET("/address_verification/states") + suspend fun getAddressVerificationStates(@Query("country") countryCode: String): List +} \ No newline at end of file diff --git a/paystack/src/main/java/co/paystack/android/api/service/converter/WrappedResponseConverter.kt b/paystack/src/main/java/co/paystack/android/api/service/converter/WrappedResponseConverter.kt new file mode 100644 index 0000000..0a73699 --- /dev/null +++ b/paystack/src/main/java/co/paystack/android/api/service/converter/WrappedResponseConverter.kt @@ -0,0 +1,49 @@ +package co.paystack.android.api.service.converter + +import okhttp3.ResponseBody +import retrofit2.Converter +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +class WrappedResponseConverter( + private val delegate: Converter> +) : Converter { + override fun convert(value: ResponseBody): T? { + val response = delegate.convert(value) + return response?.data + } + + + class Factory : Converter.Factory() { + override fun responseBodyConverter( + type: Type, + annotations: Array, + retrofit: Retrofit + ): Converter? { + val wrappedType: Type = object : ParameterizedType { + override fun getRawType(): Type { + return WrappedResponse::class.java + } + + override fun getOwnerType(): Type? { + return null + } + + override fun getActualTypeArguments(): Array { + return arrayOf(type) + } + } + + val delegate = retrofit.nextResponseBodyConverter>(this, wrappedType, annotations) + return WrappedResponseConverter(delegate) + + } + } + + open class WrappedResponse( + val `data`: T, + val message: String, + val status: Boolean + ) +} \ No newline at end of file diff --git a/paystack/src/main/java/co/paystack/android/model/AvsState.kt b/paystack/src/main/java/co/paystack/android/model/AvsState.kt new file mode 100644 index 0000000..06e6813 --- /dev/null +++ b/paystack/src/main/java/co/paystack/android/model/AvsState.kt @@ -0,0 +1,7 @@ +package co.paystack.android.model + +data class AvsState( + val name: String, + val slug: String, + val abbreviation: String +) \ No newline at end of file diff --git a/paystack/src/main/java/co/paystack/android/ui/AddressHolder.java b/paystack/src/main/java/co/paystack/android/ui/AddressHolder.java new file mode 100644 index 0000000..e40eaed --- /dev/null +++ b/paystack/src/main/java/co/paystack/android/ui/AddressHolder.java @@ -0,0 +1,92 @@ +package co.paystack.android.ui; + +import java.util.HashMap; + +public class AddressHolder { + private static AddressHolder instance = new AddressHolder(); + private static Object lock = new Object(); + private Address address = null; + + private AddressHolder() { + } + + public static AddressHolder getInstance() { + return instance; + } + + public static Object getLock() { + return lock; + } + + public Address getAddress() { + return address; + } + + public void setAddress(Address address) { + this.address = address; + } + + public static class Address { + private String state = ""; + private String zipCode = ""; + private String city = ""; + private String street = ""; + + public static String FIELD_ADDRESS = "address"; + public static String FIELD_CITY = "city"; + public static String FIELD_ZIP_CODE = "zip_code"; + public static String FIELD_STATE = "state"; + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getZipCode() { + return zipCode; + } + + public void setZipCode(String zipCode) { + this.zipCode = zipCode; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + @Override + public String toString() { + return "Address{" + + "state='" + state + '\'' + + ", zipCode='" + zipCode + '\'' + + ", city='" + city + '\'' + + ", street='" + street + '\'' + + '}'; + } + + public HashMap toHashMap() { + HashMap params = new HashMap<>(); + params.put(FIELD_STATE, this.state); + params.put(FIELD_ZIP_CODE, this.zipCode); + params.put(FIELD_CITY, this.city); + params.put(FIELD_ADDRESS, this.street); + return params; + } + } + +} \ No newline at end of file diff --git a/paystack/src/main/java/co/paystack/android/ui/AddressVerificationActivity.kt b/paystack/src/main/java/co/paystack/android/ui/AddressVerificationActivity.kt new file mode 100644 index 0000000..9c94144 --- /dev/null +++ b/paystack/src/main/java/co/paystack/android/ui/AddressVerificationActivity.kt @@ -0,0 +1,159 @@ +package co.paystack.android.ui + +import android.os.Bundle +import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.ListPopupWindow +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.widget.* +import co.paystack.android.R +import co.paystack.android.mobilemoney.data.api.PaystackApiFactory +import co.paystack.android.model.AvsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.properties.Delegates + +class AddressVerificationActivity : AppCompatActivity(), CoroutineScope { + private lateinit var job: Job + override val coroutineContext: CoroutineContext + get() = job + Dispatchers.Main + + private val addressHolder = AddressHolder.getInstance() + private val lock = AddressHolder.getLock() + + + private val paystackApiService by lazy { + PaystackApiFactory.createRetrofitService() + } + + private val etState by lazy { findViewById(R.id.etState) } + private val etStreet by lazy { findViewById(R.id.etStreet) } + private val etCity by lazy { findViewById(R.id.etCity) } + private val etZipCode by lazy { findViewById(R.id.etZipCode) } + private val tvError by lazy { findViewById(R.id.tvError) } + private val btnRetry by lazy { findViewById